//! Login/logout and the mapping between network clients and characters. use std::collections::{BTreeMap, HashMap, VecDeque}; use bevy::prelude::*; use bevy_replicon::prelude::*; use game_core::{ GameData, components::*, protocol::{AdminStatus, Chat, GameLog, Login, Rejected, Welcome}, }; use crate::{ ServerSettings, commands::{self, CommandSource}, config::ServerConfig, movement::MoveIntent, persistence::SavedCharacter, world::{SpawnPoint, WorldLayout, find_free_tile}, }; /// Which client controls a character. Server-only. #[derive(Component, Clone, Copy, Debug)] pub struct Controller(pub ClientId); /// The player owning a character (a UUID sent by the client). Server-only. #[derive(Component, Clone, Debug, PartialEq, Eq)] pub struct PlayerId(pub String); /// Marks characters of players with admin rights. Server-only. #[derive(Component, Clone, Copy, Debug)] pub struct Admin; /// Online characters by client. #[derive(Resource, Default)] pub struct Players { pub by_client: HashMap, } /// Characters that are currently offline, by player id. Persisted with the world. #[derive(Resource, Default)] pub struct CharacterStore(pub BTreeMap); pub const MAX_NAME_LEN: usize = 24; pub fn tell(commands: &mut Commands, client: ClientId, text: impl Into) { commands.server_trigger(ToClients { targets: SendTargets::Single(client), message: GameLog { text: text.into() }, }); } pub fn valid_name(name: &str) -> bool { let len = name.chars().count(); (1..=MAX_NAME_LEN).contains(&len) && name.trim() == name && name .chars() .all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-') } /// Everything [`check_access`] needs to know about a login attempt. #[derive(Clone, Copy, Debug)] pub struct AccessRequest<'a> { pub player_id: &'a str, /// Character name (the stored one for returning players). pub name: &'a str, pub password: &'a str, /// Host of an embedded server or a local client; bypasses all restrictions. pub owner: bool, pub online: usize, pub id_online: bool, pub new_character: bool, /// Another player's character already uses this name. pub name_taken: bool, } /// Whether a player may join. Pure so the rules can be tested in isolation. pub fn check_access(config: &ServerConfig, req: &AccessRequest) -> Result<(), String> { if uuid::Uuid::parse_str(req.player_id).is_err() { return Err("Invalid player id.".into()); } let admin = req.owner || is_listed(&config.access.admins, req.player_id, req.name); if !req.owner { if !config.password.is_empty() && req.password != config.password { return Err(if req.password.is_empty() { "This server requires a password.".into() } else { "Wrong password.".into() }); } if is_listed(&config.access.banned, req.player_id, req.name) { return Err("You are banned from this server.".into()); } if config.access.whitelist_enabled && !admin && !is_listed(&config.access.whitelist, req.player_id, req.name) { return Err("You are not on the whitelist.".into()); } if req.online >= config.max_players && !admin { return Err("The server is full.".into()); } } if req.id_online { return Err("You are already logged in.".into()); } if req.new_character { if !valid_name(req.name) { return Err(format!( "Invalid character name. Use 1-{MAX_NAME_LEN} letters, digits, spaces, `_` or `-`." )); } if req.name_taken { return Err("That name belongs to another player.".into()); } } Ok(()) } /// Whether a ban/whitelist/admin entry matches a player id or (case-insensitively) a name. pub fn is_listed(list: &[String], player_id: &str, name: &str) -> bool { list.iter() .any(|e| e == player_id || (!name.is_empty() && e.eq_ignore_ascii_case(name))) } /// Whether a player has admin rights. pub fn is_admin( world_owner: Option<&str>, config: &ServerConfig, client: ClientId, id: &str, ) -> bool { client == ClientId::Server || world_owner == Some(id) || config.access.admins.iter().any(|a| a == id) } /// Recent rejected logins, for admins. #[derive(Resource, Default)] pub struct RejectedLogins(pub VecDeque); const REJECTED_HISTORY: usize = 20; /// Tells a client why, then disconnects it. pub fn disconnect(commands: &mut Commands, client: ClientId, reason: impl Into) { commands.server_trigger(ToClients { targets: SendTargets::Single(client), message: Rejected { reason: reason.into(), }, }); if let ClientId::Client(entity) = client { commands.write_message(DisconnectRequest { client: entity }); } } /// Sends a message to every online admin. pub fn tell_admins(world: &mut World, text: &str) { let mut q = world.query_filtered::<&Controller, With>(); let admins: Vec = q.iter(world).map(|c| c.0).collect(); let mut commands = world.commands(); for client in admins { tell(&mut commands, client, text); } world.flush(); } /// Name of the character a player id owns in this world, online or stored. pub fn character_name(world: &mut World, player_id: &str) -> Option { let mut q = world.query::<(&Character, &PlayerId)>(); if let Some((c, _)) = q.iter(world).find(|(_, id)| id.0 == player_id) { return Some(c.name.clone()); } world .resource::() .0 .get(player_id) .map(|c| c.name.clone()) } /// Player id owning a character name (case-insensitive), online or stored. pub fn find_player_by_name(world: &mut World, name: &str) -> Option { let mut q = world.query::<(&Character, &PlayerId)>(); if let Some((_, id)) = q .iter(world) .find(|(c, _)| c.name.eq_ignore_ascii_case(name)) { return Some(id.0.clone()); } world .resource::() .0 .iter() .find(|(_, c)| c.name.eq_ignore_ascii_case(name)) .map(|(id, _)| id.clone()) } pub fn on_login(login: On>, mut commands: Commands) { let client = login.client_id; let login = login.message.clone(); commands.queue(move |world: &mut World| handle_login(world, client, login)); } fn handle_login(world: &mut World, client: ClientId, login: Login) { if world.resource::().by_client.contains_key(&client) { return; } let player_id = login.player_id.trim().to_lowercase(); let stored_name = character_name(world, &player_id); let new_character = stored_name.is_none(); let name = stored_name.unwrap_or_else(|| login.name.trim().to_owned()); let name_taken = new_character && find_player_by_name(world, &name).is_some_and(|owner| owner != player_id); let owner = world.resource::().owner.as_deref() == Some(player_id.as_str()) || client == ClientId::Server; let id_online = world .query::<&PlayerId>() .iter(world) .any(|id| id.0 == player_id); let request = AccessRequest { player_id: &player_id, name: &name, password: &login.password, owner, online: world.resource::().by_client.len(), id_online, new_character, name_taken, }; if let Err(reason) = check_access(world.resource::(), &request) { let entry = format!("{name} ({player_id}): {reason}"); warn!("rejected login of {entry}"); tell_admins(world, &format!("[admin] Rejected {entry}")); let mut log = world.resource_mut::(); if log.0.len() >= REJECTED_HISTORY { log.0.pop_front(); } log.0.push_back(entry); disconnect(&mut world.commands(), client, reason); world.flush(); return; } let saved = match world.resource_mut::().0.remove(&player_id) { Some(saved) => saved, None => { let spawn = world.resource::().0; new_character_data(world.resource::(), &name, spawn) } }; let mut buildings = world.query_filtered::<(&GridPos, &BuildingState), (With, Without)>(); let pos = find_free_tile( saved.pos, &world.resource::().0, &buildings.query(world), ); let config = world.resource::().clone(); let owner_id = world.resource::().owner.clone(); let admin = is_admin(owner_id.as_deref(), &config, client, &player_id); let mut entity = world.spawn(( Replicated, Character { name: name.clone() }, pos, Facing(saved.facing), saved.vitals, saved.inventory, saved.skills, Controller(client), PlayerId(player_id.clone()), MoveIntent::default(), )); if admin { entity.insert(Admin); } let entity = entity.id(); world .resource_mut::() .by_client .insert(client, entity); info!( "{name} logged in (player id {player_id}, {client:?}{})", if admin { ", admin" } else { "" } ); let greeting = if new_character { format!("Welcome to the station, {name}. Press H for help.") } else { format!("Welcome back, {name}.") }; let mut commands = world.commands(); commands.server_trigger(ToClients { targets: SendTargets::Single(client), message: Welcome { server_name: config.name.clone(), motd: config.motd.clone(), character: name, admin, }, }); tell(&mut commands, client, greeting); if !config.motd.is_empty() { tell(&mut commands, client, config.motd); } world.flush(); } /// Longest accepted chat message, in characters. pub const MAX_CHAT_LEN: usize = 200; /// Chat messages and `/commands` from players. pub fn on_chat(chat: On>, mut commands: Commands) { let client = chat.client_id; let text: String = chat .message .text .chars() .filter(|c| !c.is_control()) .take(MAX_CHAT_LEN) .collect(); commands.queue(move |world: &mut World| handle_chat(world, client, text.trim())); } fn handle_chat(world: &mut World, client: ClientId, text: &str) { let Some(&entity) = world.resource::().by_client.get(&client) else { return; }; if text.is_empty() { return; } if let Some(command) = text.strip_prefix('/') { let player_id = world.get::(entity).map(|id| id.0.clone()); let owner = client == ClientId::Server || player_id .is_some_and(|id| world.resource::().owner.as_deref() == Some(&id)); let source = CommandSource::Player { client, admin: world.get::(entity).is_some(), owner, }; let name = world .get::(entity) .map(|c| c.name.clone()) .unwrap_or_default(); info!("{name} ran `/{command}`"); let reply = commands::execute(world, source, command); let mut cmds = world.commands(); for line in reply { tell(&mut cmds, client, line); } world.flush(); return; } let Some(name) = world.get::(entity).map(|c| c.name.clone()) else { return; }; let line = format!("<{name}> {text}"); info!(target: "chat", "{line}"); world.commands().server_trigger(ToClients { targets: SendTargets::All, message: GameLog { text: line }, }); world.flush(); } /// Applies access list changes to online players: kicks banned and no longer /// whitelisted players and updates admin rights. pub fn enforce_access(world: &mut World) { let config = world.resource::().clone(); let owner = world.resource::().owner.clone(); let mut q = world.query::<(Entity, &Character, &PlayerId, &Controller, Has)>(); let online: Vec<_> = q .iter(world) .map(|(e, c, id, ctrl, admin)| (e, c.name.clone(), id.0.clone(), ctrl.0, admin)) .collect(); for (entity, name, id, client, was_admin) in online { let admin = is_admin(owner.as_deref(), &config, client, &id); let privileged = admin || client == ClientId::Server || owner.as_deref() == Some(id.as_str()); let reason = if privileged { None } else if is_listed(&config.access.banned, &id, &name) { Some("You have been banned from this server.") } else if config.access.whitelist_enabled && !is_listed(&config.access.whitelist, &id, &name) { Some("You are not on the whitelist.") } else { None }; let mut commands = world.commands(); if let Some(reason) = reason { info!("disconnecting {name}: {reason}"); disconnect(&mut commands, client, reason); } else if admin != was_admin { if admin { commands.entity(entity).insert(Admin); } else { commands.entity(entity).remove::(); } commands.server_trigger(ToClients { targets: SendTargets::Single(client), message: AdminStatus { admin }, }); tell( &mut commands, client, if admin { "You are now an admin." } else { "You are no longer an admin." }, ); } world.flush(); } } pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter { let mut inventory = Inventory::default(); if data.items.contains_key("apple") { inventory.add("apple", 2, data); } SavedCharacter { name: name.to_owned(), pos: spawn, facing: Default::default(), vitals: Vitals::default(), inventory, skills: Skills::default(), } } /// Stores the character of a disconnecting client and removes it from the world. pub fn on_disconnect( remove: On, mut commands: Commands, mut players: ResMut, mut store: ResMut, characters: Query<( &Character, &PlayerId, &GridPos, &Facing, &Vitals, &Inventory, &Skills, )>, ) { let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else { return; }; if let Ok((c, id, pos, facing, vitals, inventory, skills)) = characters.get(entity) { info!("{} logged out", c.name); store.0.insert( id.0.clone(), SavedCharacter { name: c.name.clone(), pos: *pos, facing: facing.0, vitals: vitals.clone(), inventory: inventory.clone(), skills: skills.clone(), }, ); } commands.entity(entity).despawn(); } #[cfg(test)] mod tests { use super::*; const ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001"; fn request<'a>() -> AccessRequest<'a> { AccessRequest { player_id: ID, name: "Jane", password: "", owner: false, online: 0, id_online: false, new_character: true, name_taken: false, } } #[test] fn access_rules() { let mut config = ServerConfig::default(); assert_eq!(check_access(&config, &request()), Ok(())); let bad_id = AccessRequest { player_id: "nope", ..request() }; assert!(check_access(&config, &bad_id).is_err()); config.password = "secret".into(); assert!(check_access(&config, &request()).is_err()); let with_password = AccessRequest { password: "secret", ..request() }; assert_eq!(check_access(&config, &with_password), Ok(())); let owner = AccessRequest { owner: true, ..request() }; assert_eq!(check_access(&config, &owner), Ok(())); config.password.clear(); config.access.banned = vec!["JANE".into()]; assert!(check_access(&config, &request()).is_err()); config.access.banned = vec![ID.into()]; assert!(check_access(&config, &request()).is_err()); config.access.banned.clear(); config.access.whitelist_enabled = true; assert!(check_access(&config, &request()).is_err()); config.access.whitelist = vec!["jane".into()]; assert_eq!(check_access(&config, &request()), Ok(())); config.access.whitelist.clear(); config.access.admins = vec![ID.into()]; assert_eq!( check_access(&config, &request()), Ok(()), "admins bypass the whitelist" ); config.access.whitelist_enabled = false; config.access.admins.clear(); config.max_players = 1; let full = AccessRequest { online: 1, ..request() }; assert!(check_access(&config, &full).is_err()); config.max_players = 32; for req in [ AccessRequest { id_online: true, ..request() }, AccessRequest { name_taken: true, ..request() }, AccessRequest { name: "bad;name", ..request() }, ] { assert!(check_access(&config, &req).is_err(), "{req:?}"); } let returning = AccessRequest { name_taken: true, new_character: false, ..request() }; assert_eq!(check_access(&config, &returning), Ok(())); } #[test] fn names() { assert!(valid_name("Jane Doe")); assert!(valid_name("x_1-2")); assert!(!valid_name("")); assert!(!valid_name(" padded")); assert!(!valid_name("semi;colon")); assert!(!valid_name(&"a".repeat(25))); } }