//! Runs the full server simulation headless, the same way singleplayer does: //! client events are triggered locally and handled by the server observers. use std::time::Duration; use bevy::{prelude::*, state::app::StatesPlugin, time::TimeUpdateStrategy}; use bevy_replicon::prelude::*; use game_core::{CorePlugin, components::*, protocol::*}; use game_server::{GameServerPlugin, ServerSettings}; #[derive(Resource, Default)] struct Log(Vec); fn app() -> App { let mut app = App::new(); app.add_plugins(( MinimalPlugins, StatesPlugin, RepliconPlugins, CorePlugin, GameServerPlugin { settings: ServerSettings::default(), }, )) .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis( 50, ))) .init_resource::() .add_observer(|ev: On, mut log: ResMut| log.0.push(ev.text.clone())); // Normally done by `App::run`; replicon builds its message systems in `finish`. app.finish(); app.cleanup(); app.update(); app } fn send(app: &mut App, ev: impl Event) { app.world_mut().client_trigger(ev); // Resent locally in PostUpdate, triggered on the server in the next PreUpdate. app.update(); app.update(); } const PLAYER_ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001"; fn login(name: &str) -> Login { Login { player_id: PLAYER_ID.into(), name: name.into(), password: String::new(), } } fn take_log(app: &mut App) -> Vec { // Server events also go through a local resend before the client observer sees them. app.update(); std::mem::take(&mut app.world_mut().resource_mut::().0) } #[test] fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_maps() { use game_server::{ commands::{self, CommandSource}, map_io, persistence, world::WorldLayout, }; let mut app = app(); send(&mut app, login("MapAdmin")); let mut expected = persistence::snapshot(app.world_mut()); let character = app .world_mut() .query_filtered::>() .single(app.world()) .unwrap(); let dir = std::env::temp_dir().join(format!("station-import-{}", uuid::Uuid::new_v4())); let file = dir.join(map_io::PROJECT_MAP); let filename = file.to_str().unwrap(); let ordinary_player = CommandSource::Player { client: ClientId::Server, admin: false, owner: false, }; let denied = commands::execute( app.world_mut(), ordinary_player, &format!("export {}", dir.display()), ); assert!(denied[0].contains("not allowed")); assert!(!dir.exists()); let denied = commands::execute( app.world_mut(), ordinary_player, &format!("import {filename}"), ); assert!(denied[0].contains("not allowed")); let reply = commands::execute( app.world_mut(), CommandSource::Console, &format!("export {}", dir.display()), ); assert!(reply[0].contains("exported"), "{reply:?}"); assert!( reply[0].ends_with(&dir.join(map_io::PROJECT_FILE).display().to_string()), "{reply:?}" ); let original = std::fs::read_to_string(&file).unwrap(); assert!( !original.contains("MapAdmin"), "characters must not be exported" ); let mut map = game_core::map::MapLayout::load_with_image_mirror(&game_core::assets_dir(), filename, &dir) .unwrap(); let door = map .objects .iter_mut() .find(|o| o.class == "building" && o.properties["proto"] == "door") .unwrap(); door.properties.insert("state.health".into(), "11".into()); map.layers[1].cells[0] = Some(game_core::map::TileCell { tileset: 0, id: 4, flip_h: false, flip_v: false, flip_d: false, }); let mut edited = map_io::decode(map, app.world().resource::()).unwrap(); edited.items.truncate(1); edited.buildings.retain(|b| b.proto != "tree"); map_io::write(&edited, &file).unwrap(); edited = map_io::decode( game_core::map::MapLayout::load(&game_core::assets_dir(), filename).unwrap(), app.world().resource::(), ) .unwrap(); let reply = commands::execute( app.world_mut(), CommandSource::Console, &format!("import {}", dir.display()), ); assert!(reply[0].contains("imported"), "{reply:?}"); let imported = persistence::snapshot(app.world_mut()); assert!( app.world() .resource::() .0 .is_solid(GridPos::new(0, 0)) ); assert_eq!(imported.buildings, edited.buildings); assert_eq!(imported.items, edited.items); assert_eq!(imported.characters, expected.characters); assert!( app.world().get::(character).is_some(), "online entity must survive" ); assert_eq!( app.world().resource::().0.as_ref(), edited.layout.as_ref().unwrap() ); let mut maps = app.world_mut().query::<&WorldMap>(); assert_eq!( &maps.single(app.world()).unwrap().layout, edited.layout.as_ref().unwrap() ); std::fs::write( &file, original.replace( "proto\" type=\"string\" value=\"door", "proto\" type=\"string\" value=\"missing", ), ) .unwrap(); let reply = commands::execute( app.world_mut(), CommandSource::Console, &format!("import {filename}"), ); assert!(reply[0].contains("failed"), "{reply:?}"); assert_eq!(persistence::snapshot(app.world_mut()), imported); let settings = persistence::StorageSettings { database_url: "sqlite::memory:".into(), world: persistence::WorldSelector::Name("persistent".into()), }; let (storage, _) = persistence::Storage::open(&settings, game_core::DEFAULT_MAP, || Ok(imported.clone())) .unwrap(); app.world_mut().insert_resource(storage); map_io::write(&expected, &file).unwrap(); map_io::import(app.world_mut(), &file).unwrap(); let storage = app.world().resource::(); let mut persisted = storage .runtime .block_on(storage.db.load_world(storage.world.id)) .unwrap(); expected.characters = imported.characters; let key = |b: &persistence::SavedBuilding| (b.state.layer, b.pos.x, b.pos.y); persisted.buildings.sort_by_key(key); expected.buildings.sort_by_key(key); assert_eq!(persisted.buildings, expected.buildings); assert_eq!(persisted.items, expected.items); assert_eq!(persisted.characters, expected.characters); assert_eq!( persisted.layout.as_ref().unwrap().layers, expected.layout.as_ref().unwrap().layers ); let before_failure = persistence::snapshot(app.world_mut()); let storage = app.world().resource::(); storage.runtime.block_on(storage.db.close()); map_io::write(&edited, &file).unwrap(); assert!(map_io::import(app.world_mut(), &file).is_err()); assert_eq!(persistence::snapshot(app.world_mut()), before_failure); std::fs::remove_dir_all(dir).unwrap(); } fn count(app: &App, me: Entity, item: &str) -> u32 { app.world().get::(me).unwrap().count(item) } #[test] fn floors_and_structures_build_and_deconstruct_independently_over_default_terrain() { use game_core::data::BuildingLayer; use game_server::{ map_io, persistence, world::{self, WorldLayout}, }; let mut app = app(); send(&mut app, login("Builder")); let me = app .world_mut() .query_filtered::>() .single(app.world()) .unwrap(); let pos = *app.world().get::(me).unwrap(); let target = GridPos::new(pos.x + 1, pos.y); let mut save = persistence::snapshot(app.world_mut()); save.buildings.retain(|b| b.pos != target); save.layout .as_mut() .unwrap() .default_tile .as_mut() .unwrap() .id = 4; let file = std::env::temp_dir().join(format!("station-floors-{}.tmx", uuid::Uuid::new_v4())); map_io::write(&save, &file).unwrap(); map_io::import(app.world_mut(), &file).unwrap(); let data = app.world().resource::().clone(); { let mut inventory = app.world_mut().get_mut::(me).unwrap(); inventory.slots.clear(); inventory.add("iron_plate", 8, &data); inventory.add("wrench", 1, &data); } let blocked = |app: &mut App| { let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>(); world::is_blocked( target, &app.world().resource::().0, q.iter(app.world()), ) }; let layers = |app: &mut App| { let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>(); let mut layers: Vec<_> = q .iter(app.world()) .filter(|(p, _)| **p == target) .map(|(_, state)| state.layer) .collect(); layers.sort(); layers }; assert!(blocked(&mut app)); send( &mut app, Construct { building: "wall".into(), target, }, ); assert_eq!(count(&app, me, "iron_plate"), 8); send( &mut app, Construct { building: "floor".into(), target, }, ); assert_eq!(layers(&mut app), [BuildingLayer::Floor]); assert!(!blocked(&mut app)); send( &mut app, Construct { building: "plating".into(), target, }, ); assert_eq!(count(&app, me, "iron_plate"), 7); send( &mut app, Construct { building: "wall".into(), target, }, ); assert_eq!( layers(&mut app), [BuildingLayer::Floor, BuildingLayer::Structure] ); assert!(blocked(&mut app)); send(&mut app, Interact { target }); assert!(take_log(&mut app).iter().any(|message| message == "Wall")); send(&mut app, Deconstruct { target }); assert_eq!(layers(&mut app), [BuildingLayer::Floor]); assert!(!blocked(&mut app)); send(&mut app, Deconstruct { target }); assert!(layers(&mut app).is_empty()); assert!(blocked(&mut app)); assert_eq!(count(&app, me, "iron_plate"), 8); send(&mut app, SetMovement { x: 1, y: 0 }); for _ in 0..6 { app.update(); } send(&mut app, SetMovement { x: 0, y: 0 }); assert!(app.world().get::(me).unwrap().0.x < target.x as f32); let project = file.with_extension("project"); let project_file = map_io::export(app.world_mut(), &project).unwrap(); assert_eq!(project_file, project.join(map_io::PROJECT_FILE)); let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&project_file).unwrap()).unwrap(); assert_eq!(settings["extensionsPath"], "extensions"); assert!(project.join("extensions/station.js").is_file()); assert!(project.join("extensions/brush.svg").is_file()); let map = std::fs::read_to_string(project.join(map_io::PROJECT_MAP)).unwrap(); assert!( map.contains(r#"source="tilesets/"#), "images must be project-relative" ); let tilesets = app.world().resource::().0.tilesets.clone(); for tileset in &tilesets { assert!(project.join(&tileset.image).is_file()); } map_io::import(app.world_mut(), &project).unwrap(); let images = |tilesets: &[game_core::map::TilesetInfo]| { tilesets.iter().map(|t| t.image.clone()).collect::>() }; assert_eq!( images(&app.world().resource::().0.tilesets), images(&tilesets), "copied images must resolve back to the shipped assets" ); assert!( layers(&mut app).is_empty(), "removed floors must not be reseeded" ); assert!(blocked(&mut app)); send( &mut app, Construct { building: "floor".into(), target, }, ); assert!(!blocked(&mut app)); send(&mut app, SetMovement { x: 1, y: 0 }); for _ in 0..4 { app.update(); } send(&mut app, SetMovement { x: 0, y: 0 }); assert!(app.world().get::(me).unwrap().0.x > target.x as f32 + 0.3); std::fs::remove_file(file).unwrap(); std::fs::remove_dir_all(project).unwrap(); } #[test] fn painted_walls_are_deconstructible_without_leaving_static_collision() { let mut app = app(); send(&mut app, login("Demolition")); let me = app .world_mut() .query_filtered::>() .single(app.world()) .unwrap(); let target = GridPos::new(0, 0); let wall = app .world_mut() .query::<(Entity, &Building, &GridPos)>() .iter(app.world()) .find(|(_, b, p)| b.proto == "wall" && **p == target) .map(|(entity, _, _)| entity) .unwrap(); assert_eq!(app.world().get::(wall).unwrap().health, 300); app.world_mut() .entity_mut(me) .insert((GridPos::new(0, 1), Position::center_of(GridPos::new(0, 1)))); let data = app.world().resource::().clone(); app.world_mut() .get_mut::(me) .unwrap() .add("wrench", 1, &data); send(&mut app, Deconstruct { target }); assert!(app.world().get_entity(wall).is_err()); let mut buildings = app.world_mut().query::<(&GridPos, &BuildingState)>(); assert!(!game_server::world::is_blocked( target, &app.world().resource::().0, buildings.iter(app.world()) )); assert_eq!(count(&app, me, "iron_plate"), 2); } #[test] fn login_move_and_interact() { let mut app = app(); // The map defines buildings and items. let mut buildings = app.world_mut().query::<&Building>(); assert!(buildings.iter(app.world()).count() > 19); send(&mut app, login("Tester")); let me = app .world_mut() .query_filtered::>() .single(app.world()) .expect("one character"); let start = *app.world().get::(me).unwrap(); let log = take_log(&mut app); assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}"); assert_eq!(count(&app, me, "apple"), 2); // A second login of the same client is ignored. send(&mut app, login("Tester")); let mut characters = app.world_mut().query::<&Character>(); assert_eq!(characters.iter(app.world()).count(), 1); // The local client is the owner and may use admin commands. send( &mut app, Chat { text: "/whitelist add Someone".into(), }, ); let log = take_log(&mut app); assert!( log.iter().any(|t| t.contains("whitelisted Someone")), "{log:?}" ); let config = app.world().resource::(); assert_eq!(config.access.whitelist, ["Someone"]); send( &mut app, Chat { text: "hello there".into(), }, ); let log = take_log(&mut app); assert!(log.iter().any(|t| t == " hello there"), "{log:?}"); // Walk east until the character crosses into the next tile. let start_x = app.world().get::(me).unwrap().0.x; send(&mut app, SetMovement { x: 1, y: 0 }); for _ in 0..20 { if app.world().get::(me).unwrap().x != start.x { break; } app.update(); } send(&mut app, SetMovement::default()); let pos = *app.world().get::(me).unwrap(); let position = *app.world().get::(me).unwrap(); assert_eq!(pos, GridPos::new(start.x + 1, start.y), "start {start:?}"); assert_eq!(position.tile(), pos); assert!(position.0.x > start_x + 0.5, "{position:?}"); for _ in 0..3 { app.update(); } assert_eq!( app.world().get::(me).unwrap(), &position, "stopped" ); // Eat an apple through the Rune script. app.world_mut().get_mut::(me).unwrap().nutrition = 50.0; send(&mut app, UseItem { slot: 0 }); let vitals = app.world().get::(me).unwrap(); assert!(vitals.nutrition > 50.0, "{vitals:?}"); assert_eq!(count(&app, me, "apple"), 1); assert!(take_log(&mut app).iter().any(|t| t.contains("Delicious"))); // Build a table next to us. let target = GridPos::new(pos.x, pos.y + 1); let data = app.world().resource::().clone(); app.world_mut() .get_mut::(me) .unwrap() .add("plank", 2, &data); send( &mut app, Construct { building: "table".into(), target, }, ); let log = take_log(&mut app); let mut q = app.world_mut().query::<(&Building, &GridPos)>(); assert!( q.iter(app.world()) .any(|(b, p)| b.proto == "table" && *p == target), "{log:?}" ); assert_eq!(count(&app, me, "plank"), 0); // Doors toggle via script. let door_pos = GridPos::new(pos.x + 1, pos.y + 1); app.world_mut().spawn(( Building { proto: "door".into(), }, door_pos, BuildingState { solid: true, ..default() }, )); send(&mut app, Interact { target: door_pos }); let mut q = app .world_mut() .query::<(&Building, &GridPos, &BuildingState)>(); let (_, _, state) = q .iter(app.world()) .find(|(b, p, _)| b.proto == "door" && **p == door_pos) .unwrap(); assert!(!state.solid); assert_eq!(state.variant.as_deref(), Some("open")); // Deconstruction refunds the materials. send(&mut app, Deconstruct { target }); let mut q = app.world_mut().query::<(&Building, &GridPos)>(); assert!( !q.iter(app.world()) .any(|(b, p)| *p == target && b.proto == "door") ); assert_eq!(count(&app, me, "plank"), 2); // Drop and pick up again. let slot = app .world() .get::(me) .unwrap() .slots .iter() .position(|s| s.item == "plank") .unwrap(); send(&mut app, DropItem { slot }); assert_eq!(count(&app, me, "plank"), 0); send(&mut app, PickUp { target: pos }); assert_eq!(count(&app, me, "plank"), 2); // Crafting without a station fails with a reason. take_log(&mut app); send( &mut app, Craft { recipe: "saw_planks".into(), }, ); let log = take_log(&mut app); assert!(log.iter().any(|t| t.contains("workbench")), "{log:?}"); } #[test] fn machines_process_items() { let mut app = app(); send(&mut app, login("Smith")); let me = app .world_mut() .query_filtered::>() .single(app.world()) .unwrap(); let pos = *app.world().get::(me).unwrap(); let target = GridPos::new(pos.x + 1, pos.y); let data = app.world().resource::().clone(); // Ignore anything the map placed there. let mut q = app.world_mut().query::<(Entity, &GridPos)>(); let blocking: Vec<_> = q .iter(app.world()) .filter(|(e, p)| **p == target && *e != me) .map(|(e, _)| e) .collect(); for e in blocking { app.world_mut().despawn(e); } { let mut inventory = app.world_mut().get_mut::(me).unwrap(); inventory.slots.clear(); inventory.add("iron_ore", 5, &data); inventory.add("log", 1, &data); inventory.add("plank", 1, &data); inventory.add("screwdriver", 1, &data); inventory.add("iron_plate", 4, &data); } send( &mut app, Construct { building: "furnace".into(), target, }, ); let furnace = app .world_mut() .query::<(Entity, &Building, &GridPos)>() .iter(app.world()) .find(|(_, b, p)| b.proto == "furnace" && **p == target) .map(|(e, _, _)| e); let log = take_log(&mut app); let Some(furnace) = furnace else { panic!("furnace not built: {log:?}"); }; assert!(app.world().get::(furnace).is_some()); { let mut inventory = app.world_mut().get_mut::(me).unwrap(); inventory.add("iron_ore", 4, &data); inventory.add("log", 1, &data); } let slot_of = |app: &App, item: &str| { app.world() .get::(me) .unwrap() .slots .iter() .position(|s| s.item == item) .unwrap() }; for item in ["iron_ore", "log"] { let slot = slot_of(&app, item); send( &mut app, MachineInsert { target, inventory_slot: slot, machine_slot: None, count: u32::MAX, }, ); } // 1 ore left over from construction plus 4 added. assert_eq!(count(&app, me, "iron_ore"), 0); let machine = app.world().get::(furnace).unwrap(); assert_eq!(machine.input_count("iron_ore"), 5); assert_eq!(machine.recipe.as_deref(), Some("smelt_iron")); let state = app.world().get::(furnace).unwrap(); assert_eq!(state.variant.as_deref(), Some("lit")); // 4 seconds of 50 ms steps. for _ in 0..90 { app.update(); } let machine = app.world().get::(furnace).unwrap().clone(); assert_eq!(machine.input_count("iron_ore"), 1, "{machine:?}"); assert_eq!(machine.outputs[0], Some(ItemStack::new("iron_plate", 2))); assert_eq!(machine.recipe, None); let state = app.world().get::(furnace).unwrap(); assert_eq!(state.variant, None); send( &mut app, MachineTake { target, slot: MachineSlot::Output(0), count: u32::MAX, }, ); assert_eq!(count(&app, me, "iron_plate"), 6); // Taking a machine apart returns its contents. send(&mut app, Deconstruct { target }); send(&mut app, PickUp { target }); assert_eq!(count(&app, me, "iron_ore"), 5); } mod network { use std::{ net::{Ipv4Addr, SocketAddr}, time::Instant, }; use bevy_replicon_renet::RepliconRenetPlugins; use game_server::{ config::ServerConfig, embedded::{EmbeddedServer, ServerStatus}, network::create_client, }; use super::*; const HOST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000aa"; const GUEST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000bb"; #[derive(Resource, Default)] struct Inbox { welcome: Option, rejected: Option, log: Vec, } fn client(addr: SocketAddr) -> App { let mut app = App::new(); app.add_plugins(( MinimalPlugins, StatesPlugin, RepliconPlugins, RepliconRenetPlugins, CorePlugin, )) .init_resource::() .add_observer(|ev: On, mut inbox: ResMut| { inbox.welcome = Some(ev.event().clone()) }) .add_observer(|ev: On, mut inbox: ResMut| { inbox.rejected = Some(ev.reason.clone()) }) .add_observer(|ev: On, mut inbox: ResMut| inbox.log.push(ev.text.clone())); app.finish(); app.cleanup(); let channels = app.world().resource::(); let (client, transport) = create_client(channels, addr).unwrap(); app.insert_resource(client).insert_resource(transport); app } /// Updates the clients until `done` returns true for the first one. fn pump(apps: &mut [&mut App], done: impl Fn(&mut App) -> bool) { let start = Instant::now(); loop { for app in apps.iter_mut() { app.update(); } if done(apps[0]) { return; } assert!(start.elapsed() < Duration::from_secs(20), "timed out"); std::thread::sleep(Duration::from_millis(5)); } } fn connected(app: &mut App) -> bool { *app.world().resource::>() == ClientState::Connected } fn join(app: &mut App, id: &str, name: &str, password: &str) { pump(&mut [app], connected); app.world_mut().client_trigger(Login { player_id: id.into(), name: name.into(), password: password.into(), }); pump(&mut [app], |app| { let inbox = app.world().resource::(); inbox.welcome.is_some() || inbox.rejected.is_some() }); } fn inbox(app: &App) -> &Inbox { app.world().resource::() } fn start_server( settings: ServerSettings, config: ServerConfig, ) -> (EmbeddedServer, SocketAddr) { let server = EmbeddedServer::start(settings, config, None, Ipv4Addr::LOCALHOST.into()); let start = Instant::now(); let addr = loop { match server.poll() { Some(ServerStatus::Started(addr)) => break addr, Some(other) => panic!("{other:?}"), None if start.elapsed() > Duration::from_secs(30) => panic!("server did not start"), None => std::thread::sleep(Duration::from_millis(10)), } }; (server, addr) } #[test] fn tiled_import_replicates_terrain_to_existing_and_joining_clients() { use game_server::map_io; let mut config = ServerConfig { port: 0, ..default() }; config.database.save = false; let settings = ServerSettings { owner: Some(HOST.into()), ..config.settings() }; let (server, addr) = start_server(settings, config); let mut host = client(addr); join(&mut host, HOST, "Host", ""); let mut guest = client(addr); join(&mut guest, GUEST, "Guest", ""); let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4())); let file = dir.join(map_io::PROJECT_MAP); host.world_mut().client_trigger(Chat { text: format!("/export {}", dir.display()), }); pump(&mut [&mut host, &mut guest], |app| { inbox(app).log.iter().any(|l| l.contains("World exported:")) }); let assets = game_core::assets_dir(); let data = game_core::GameData::load_dir(&assets.join("data")).unwrap(); let mut map = game_core::map::MapLayout::load_with_image_mirror( &assets, file.to_str().unwrap(), &dir, ) .unwrap(); assert!(!map.is_solid(GridPos::new(0, 0))); map.layers[1].cells[0] = Some(game_core::map::TileCell { tileset: 0, id: 4, flip_h: false, flip_v: false, flip_d: false, }); let edited = map_io::decode(map, &data).unwrap(); map_io::write(&edited, &file).unwrap(); host.world_mut().client_trigger(Chat { text: format!("/import {}", dir.display()), }); let imported = |app: &mut App| { let mut maps = app.world_mut().query::<&WorldMap>(); maps.iter(app.world()) .any(|map| map.layout.is_solid(GridPos::new(0, 0))) }; pump(&mut [&mut host, &mut guest], imported); pump(&mut [&mut guest, &mut host], imported); let mut newcomer = client(addr); join( &mut newcomer, "4f1c0c56-1111-4a5e-9a1e-0000000000cc", "Newcomer", "", ); pump(&mut [&mut newcomer], imported); server.stop(); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn password_admins_and_bans() { let mut config = ServerConfig { port: 0, password: "letmein".into(), ..Default::default() }; config.database.save = false; let settings = ServerSettings { owner: Some(HOST.into()), ..config.settings() }; let (server, addr) = start_server(settings, config); let mut intruder = client(addr); join(&mut intruder, GUEST, "Guest", "wrong"); assert_eq!( inbox(&intruder).rejected.as_deref(), Some("Wrong password.") ); // The owner needs no password and is always admin. let mut host = client(addr); join(&mut host, HOST, "Host", ""); let welcome = inbox(&host).welcome.clone().expect("host welcomed"); assert!(welcome.admin); assert_eq!(welcome.character, "Host"); let mut guest = client(addr); join(&mut guest, GUEST, "Guest", "letmein"); assert!( !inbox(&guest) .welcome .as_ref() .expect("guest welcomed") .admin ); guest.world_mut().client_trigger(Chat { text: "/kick Host".into(), }); pump(&mut [&mut guest, &mut host], |app| { inbox(app).log.iter().any(|t| t.contains("not allowed")) }); host.world_mut().client_trigger(Chat { text: "/ban Guest".into(), }); pump(&mut [&mut guest, &mut host], |app| { inbox(app).rejected.is_some() }); assert_eq!( inbox(&guest).rejected.as_deref(), Some("You have been banned from this server.") ); pump(&mut [&mut host], |app| { inbox(app).log.iter().any(|t| t.contains("banned Guest")) }); drop(guest); let mut again = client(addr); join(&mut again, GUEST, "Guest", "letmein"); assert_eq!( inbox(&again).rejected.as_deref(), Some("You are banned from this server.") ); pump(&mut [&mut host], |app| { inbox(app) .log .iter() .any(|t| t.contains("[admin] Rejected Guest")) }); server.stop(); } }