//! 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::*, grid::Dir, 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) } fn count(app: &App, me: Entity, item: &str) -> u32 { app.world().get::(me).unwrap().count(item) } #[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_eq!(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 one tile. send(&mut app, SetMoveDir(Some(Dir::East))); send(&mut app, SetMoveDir(None)); for _ in 0..5 { app.update(); } let pos = *app.world().get::(me).unwrap(); assert_eq!(pos, GridPos::new(start.x + 1, start.y), "start {start:?}"); // 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(|(_, p)| *p == target)); 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::() } #[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 = 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)), } }; 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(); } }