station/crates/game_server/src/players.rs

584 lines
19 KiB
Rust
Raw Normal View History

//! 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<ClientId, Entity>,
}
/// Characters that are currently offline, by player id. 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 == '-')
}
/// 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<String>);
const REJECTED_HISTORY: usize = 20;
/// Tells a client why, then disconnects it.
pub fn disconnect(commands: &mut Commands, client: ClientId, reason: impl Into<String>) {
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<Admin>>();
let admins: Vec<ClientId> = 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<String> {
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::<CharacterStore>()
.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<String> {
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::<CharacterStore>()
.0
.iter()
.find(|(_, c)| c.name.eq_ignore_ascii_case(name))
.map(|(id, _)| id.clone())
}
pub fn on_login(login: On<FromClient<Login>>, 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::<Players>().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::<ServerSettings>().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::<Players>().by_client.len(),
id_online,
new_character,
name_taken,
};
if let Err(reason) = check_access(world.resource::<ServerConfig>(), &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::<RejectedLogins>();
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::<CharacterStore>().0.remove(&player_id) {
Some(saved) => saved,
None => {
let spawn = world.resource::<SpawnPoint>().0;
new_character_data(world.resource::<GameData>(), &name, spawn)
}
};
let mut buildings =
world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
let pos = find_free_tile(
saved.pos,
&world.resource::<WorldLayout>().0,
&buildings.query(world),
);
// The saved spot is only kept if the tile is still free.
let position = saved
.position
.filter(|p| p.tile() == pos)
.unwrap_or(Position::center_of(pos));
let config = world.resource::<ServerConfig>().clone();
let owner_id = world.resource::<ServerSettings>().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,
position,
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::<Players>()
.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<FromClient<Chat>>, 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::<Players>().by_client.get(&client) else {
return;
};
if text.is_empty() {
return;
}
if let Some(command) = text.strip_prefix('/') {
let player_id = world.get::<PlayerId>(entity).map(|id| id.0.clone());
let owner = client == ClientId::Server
|| player_id
.is_some_and(|id| world.resource::<ServerSettings>().owner.as_deref() == Some(&id));
let source = CommandSource::Player {
client,
admin: world.get::<Admin>(entity).is_some(),
owner,
};
let name = world
.get::<Character>(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::<Character>(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::<ServerConfig>().clone();
let owner = world.resource::<ServerSettings>().owner.clone();
let mut q = world.query::<(Entity, &Character, &PlayerId, &Controller, Has<Admin>)>();
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::<Admin>();
}
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,
position: None,
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,
&PlayerId,
&GridPos,
&Position,
&Facing,
&Vitals,
&Inventory,
&Skills,
)>,
) {
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
return;
};
if let Ok((c, id, pos, position, 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,
position: Some(*position),
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)));
}
}