//! Login/logout and the mapping between network clients and characters. use std::collections::{BTreeMap, HashMap}; use bevy::prelude::*; use bevy_replicon::prelude::*; use game_core::{ GameData, components::*, protocol::{GameLog, Login}, }; use crate::{ 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); /// Online characters by client. #[derive(Resource, Default)] pub struct Players { pub by_client: HashMap, } /// Characters that are currently offline, by name. 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 == '-') } #[allow(clippy::too_many_arguments)] pub fn on_login( login: On>, mut commands: Commands, mut players: ResMut, mut store: ResMut, mut disconnects: MessageWriter, data: Res, spawn: Res, layout: Res, characters: Query<&Character>, buildings: Query<(&GridPos, &BuildingState), (With, Without)>, ) { let client = login.client_id; let name = login.message.name.clone(); if players.by_client.contains_key(&client) { return; } let reject = |commands: &mut Commands, disconnects: &mut MessageWriter, reason: &str| { tell(commands, client, reason); if let ClientId::Client(entity) = client { disconnects.write(DisconnectRequest { client: entity }); } }; if !valid_name(&name) { reject(&mut commands, &mut disconnects, "Invalid character name."); return; } if characters.iter().any(|c| c.name == name) { reject( &mut commands, &mut disconnects, "That character is already online.", ); return; } let (saved, returning) = match store.0.remove(&name) { Some(saved) => (saved, true), None => (new_character(&data, spawn.0), false), }; let pos = find_free_tile(saved.pos, &layout.0, &buildings); let entity = commands .spawn(( Replicated, Character { name: name.clone() }, pos, Facing(saved.facing), saved.vitals, saved.inventory, saved.skills, Controller(client), MoveIntent::default(), )) .id(); players.by_client.insert(client, entity); info!("{name} logged in ({client:?})"); let greeting = if returning { format!("Welcome back, {name}.") } else { format!("Welcome to the station, {name}. Press H for help.") }; tell(&mut commands, client, greeting); } fn new_character(data: &GameData, spawn: GridPos) -> SavedCharacter { let mut inventory = Inventory::default(); if data.items.contains_key("apple") { inventory.add("apple", 2, data); } SavedCharacter { 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, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>, ) { let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else { return; }; if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) { info!("{} logged out", c.name); store.0.insert( c.name.clone(), SavedCharacter { pos: *pos, facing: facing.0, vitals: vitals.clone(), inventory: inventory.clone(), skills: skills.clone(), }, ); } commands.entity(entity).despawn(); } #[cfg(test)] mod tests { use super::valid_name; #[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))); } }