station/crates/game_server/src/players.rs

173 lines
5.0 KiB
Rust
Raw Normal View History

//! 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<ClientId, Entity>,
}
/// Characters that are currently offline, by name. Persisted with the world.
#[derive(Resource, Default)]
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
pub const MAX_NAME_LEN: usize = 24;
pub fn tell(commands: &mut Commands, client: ClientId, text: impl Into<String>) {
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<FromClient<Login>>,
mut commands: Commands,
mut players: ResMut<Players>,
mut store: ResMut<CharacterStore>,
mut disconnects: MessageWriter<DisconnectRequest>,
data: Res<GameData>,
spawn: Res<SpawnPoint>,
layout: Res<WorldLayout>,
characters: Query<&Character>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
) {
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<DisconnectRequest>,
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<Remove, ConnectedClient>,
mut commands: Commands,
mut players: ResMut<Players>,
mut store: ResMut<CharacterStore>,
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)));
}
}