station/crates/game_server/tests/simulation.rs

1135 lines
37 KiB
Rust
Raw Permalink Normal View History

//! 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<String>);
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::<Log>()
.add_observer(|ev: On<GameLog>, mut log: ResMut<Log>| 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(),
}
}
/// Level id of [`game_core::DEFAULT_MAP`].
const MAIN: &str = "station.tmx";
fn main_level() -> game_server::world::Level {
game_server::world::Level(MAIN.into())
}
/// Decodes an exported project with all its levels.
fn decode_project(
dir: &std::path::Path,
data: &game_core::GameData,
) -> game_server::persistence::SaveGame {
game_server::map_io::decode_world(MAIN, data, |id| {
Ok(game_core::map::MapLayout::load_with_image_mirror(
&game_core::assets_dir(),
dir.join(id).to_str().unwrap(),
dir,
)?)
})
.unwrap()
}
fn take_log(app: &mut App) -> Vec<String> {
// 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::<Log>().0)
}
#[test]
fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_maps() {
use game_server::{
commands::{self, CommandSource},
map_io, persistence,
world::{Level, Levels},
};
let mut app = app();
send(&mut app, login("MapAdmin"));
let mut expected = persistence::snapshot(app.world_mut());
let character = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let dir = std::env::temp_dir().join(format!("station-import-{}", uuid::Uuid::new_v4()));
let file = dir.join(MAIN);
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::<game_core::GameData>()).unwrap();
edited.items.truncate(1);
edited.buildings.retain(|b| b.proto != "tree");
map_io::write(&edited, MAIN, &file).unwrap();
edited = decode_project(&dir, app.world().resource::<game_core::GameData>());
assert!(
edited.levels.contains_key("cellar.tmx"),
"levels next to the main map are imported"
);
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::<Levels>()
.main_layout()
.is_solid(GridPos::new(0, 0))
);
let key = |b: &persistence::SavedBuilding| (b.level.clone(), b.state.layer, b.pos.x, b.pos.y);
let sorted = |mut buildings: Vec<persistence::SavedBuilding>| {
buildings.sort_by_key(key);
buildings
};
let sorted_items = |mut items: Vec<persistence::SavedItem>| {
items.sort_by_key(|i| (i.level.clone(), i.pos.x, i.pos.y, i.stack.item.clone()));
items
};
assert_eq!(
sorted(imported.buildings.clone()),
sorted(edited.buildings.clone())
);
assert_eq!(
sorted_items(imported.items.clone()),
sorted_items(edited.items.clone())
);
assert_eq!(imported.levels, edited.levels);
assert_eq!(imported.characters, expected.characters);
assert!(
app.world().get::<Character>(character).is_some(),
"online entity must survive"
);
assert_eq!(
app.world().resource::<Levels>().main_layout(),
edited.layout.as_ref().unwrap()
);
let mut maps = app.world_mut().query::<(&WorldMap, &Level)>();
let maps: Vec<_> = maps.iter(app.world()).collect();
assert_eq!(maps.len(), 2, "one terrain entity per level");
let (main_map, _) = maps
.iter()
.find(|(_, level)| **level == main_level())
.unwrap();
assert_eq!(&main_map.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, MAIN, &file).unwrap();
map_io::import(app.world_mut(), &file).unwrap();
let storage = app.world().resource::<persistence::Storage>();
let mut persisted = storage
.runtime
.block_on(storage.db.load_world(storage.world.id))
.unwrap();
expected.characters = imported.characters;
persisted.buildings.sort_by_key(key);
expected.buildings.sort_by_key(key);
assert_eq!(persisted.buildings, expected.buildings);
assert_eq!(sorted_items(persisted.items), sorted_items(expected.items));
let without_ids = |levels: &std::collections::BTreeMap<String, game_core::map::MapLayout>| {
let mut levels = levels.clone();
for layout in levels.values_mut() {
layout.objects.iter_mut().for_each(|o| o.id = 0);
}
levels
};
assert_eq!(
without_ids(&persisted.levels),
without_ids(&expected.levels)
);
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::<persistence::Storage>();
storage.runtime.block_on(storage.db.close());
map_io::write(&edited, MAIN, &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::<Inventory>(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, Level, Levels},
};
let mut app = app();
send(&mut app, login("Builder"));
let me = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let pos = *app.world().get::<GridPos>(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 dir = std::env::temp_dir().join(format!("station-floors-{}", uuid::Uuid::new_v4()));
let file = dir.join("edited.tmx");
map_io::write(&save, MAIN, &file).unwrap();
map_io::import(app.world_mut(), &file).unwrap();
let data = app.world().resource::<game_core::GameData>().clone();
{
let mut inventory = app.world_mut().get_mut::<Inventory>(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, &Level)>();
world::is_blocked(
target,
app.world().resource::<Levels>().main_layout(),
q.iter(app.world())
.filter(|(_, _, l)| **l == main_level())
.map(|(p, s, _)| (p, s)),
)
};
let layers = |app: &mut App| {
let mut q = app
.world_mut()
.query::<(&GridPos, &BuildingState, &Level)>();
let mut layers: Vec<_> = q
.iter(app.world())
.filter(|(p, _, l)| **p == target && **l == main_level())
.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::<Position>(me).unwrap().0.x < target.x as f32);
let project = dir.join("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(MAIN)).unwrap();
assert!(
map.contains(r#"source="tilesets/"#),
"images must be project-relative"
);
let tilesets = app
.world()
.resource::<Levels>()
.main_layout()
.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::<Vec<_>>()
};
assert_eq!(
images(&app.world().resource::<Levels>().main_layout().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::<Position>(me).unwrap().0.x > target.x as f32 + 0.3);
std::fs::remove_dir_all(dir).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::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let target = GridPos::new(0, 0);
let wall = app
.world_mut()
.query::<(Entity, &Building, &GridPos, &game_server::world::Level)>()
.iter(app.world())
.find(|(_, b, p, l)| b.proto == "wall" && **p == target && **l == main_level())
.map(|(entity, _, _, _)| entity)
.unwrap();
assert_eq!(app.world().get::<BuildingState>(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::<game_core::GameData>().clone();
app.world_mut()
.get_mut::<Inventory>(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, &game_server::world::Level)>();
assert!(!game_server::world::is_blocked(
target,
app.world()
.resource::<game_server::world::Levels>()
.main_layout(),
buildings
.iter(app.world())
.filter(|(_, _, l)| **l == main_level())
.map(|(p, s, _)| (p, s))
));
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::<Entity, With<Character>>()
.single(app.world())
.expect("one character");
let start = *app.world().get::<GridPos>(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::<game_server::config::ServerConfig>();
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 == "<Tester> hello there"), "{log:?}");
// Walk east until the character crosses into the next tile.
let start_x = app.world().get::<Position>(me).unwrap().0.x;
send(&mut app, SetMovement { x: 1, y: 0 });
for _ in 0..20 {
if app.world().get::<GridPos>(me).unwrap().x != start.x {
break;
}
app.update();
}
send(&mut app, SetMovement::default());
let pos = *app.world().get::<GridPos>(me).unwrap();
let position = *app.world().get::<Position>(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::<Position>(me).unwrap(),
&position,
"stopped"
);
// Eat an apple through the Rune script.
app.world_mut().get_mut::<Vitals>(me).unwrap().nutrition = 50.0;
send(&mut app, UseItem { slot: 0 });
let vitals = app.world().get::<Vitals>(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::<game_core::GameData>().clone();
app.world_mut()
.get_mut::<Inventory>(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,
main_level(),
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::<Inventory>(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:?}");
}
/// Places the character in the middle of `pos` on its current level.
fn place(app: &mut App, me: Entity, pos: GridPos) {
let mut entity = app.world_mut().entity_mut(me);
entity.insert((Position::center_of(pos), pos));
app.update();
}
/// Walks one step in `dx` until the tile or level changes.
fn step(app: &mut App, me: Entity, dx: i8) {
use game_server::world::Level;
let before = (
*app.world().get::<GridPos>(me).unwrap(),
app.world().get::<Level>(me).unwrap().clone(),
);
send(app, SetMovement { x: dx, y: 0 });
for _ in 0..20 {
let now = (
*app.world().get::<GridPos>(me).unwrap(),
app.world().get::<Level>(me).unwrap().clone(),
);
if now != before {
break;
}
app.update();
}
send(app, SetMovement::default());
}
#[test]
fn stairs_teleport_between_levels_that_all_keep_running() {
use game_server::world::Level;
let cellar = Level("cellar.tmx".into());
let stairs_down = GridPos::new(16, 12);
let stairs_up = GridPos::new(2, 2);
let cellar_furnace = GridPos::new(11, 2);
let mut app = app();
let mut q = app.world_mut().query::<(&Building, &GridPos, &Level)>();
let placed: Vec<_> = q
.iter(app.world())
.map(|(b, p, l)| (b.proto.clone(), *p, l.clone()))
.collect();
assert!(placed.contains(&("stairs_down".into(), stairs_down, main_level())));
assert!(placed.contains(&("stairs_up".into(), stairs_up, cellar.clone())));
assert!(placed.contains(&("furnace".into(), cellar_furnace, cellar.clone())));
send(&mut app, login("Climber"));
let me = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.unwrap();
place(&mut app, me, GridPos::new(15, 12));
step(&mut app, me, 1);
assert_eq!(app.world().get::<Level>(me), Some(&cellar));
assert_eq!(app.world().get::<GridPos>(me), Some(&stairs_up));
// Arriving on the up stairs doesn't bounce straight back.
for _ in 0..5 {
app.update();
}
assert_eq!(app.world().get::<Level>(me), Some(&cellar));
// Interactions only reach the character's own level.
let data = app.world().resource::<game_core::GameData>().clone();
let beside = GridPos::new(3, 2);
app.world_mut().spawn((
GroundItem(ItemStack::new("medkit", 1)),
main_level(),
beside,
));
app.world_mut()
.get_mut::<Inventory>(me)
.unwrap()
.slots
.clear();
send(&mut app, PickUp { target: beside });
assert_eq!(count(&app, me, "medkit"), 0);
// Fuel the cellar furnace, then leave it running unattended.
place(&mut app, me, GridPos::new(10, 2));
{
let mut inventory = app.world_mut().get_mut::<Inventory>(me).unwrap();
inventory.add("iron_ore", 4, &data);
inventory.add("log", 1, &data);
}
for item in ["iron_ore", "log"] {
let slot = app
.world()
.get::<Inventory>(me)
.unwrap()
.slots
.iter()
.position(|s| s.item == item)
.unwrap();
send(
&mut app,
MachineInsert {
target: cellar_furnace,
inventory_slot: slot,
machine_slot: None,
count: u32::MAX,
},
);
}
assert_eq!(count(&app, me, "iron_ore"), 0, "{:?}", take_log(&mut app));
place(&mut app, me, GridPos::new(3, 2));
step(&mut app, me, -1);
assert_eq!(app.world().get::<Level>(me), Some(&main_level()));
assert_eq!(app.world().get::<GridPos>(me), Some(&stairs_down));
for _ in 0..90 {
app.update();
}
let mut furnaces = app.world_mut().query::<(&Machine, &GridPos, &Level)>();
let (machine, _, _) = furnaces
.iter(app.world())
.find(|(_, p, l)| **p == cellar_furnace && **l == cellar)
.unwrap();
assert_eq!(machine.outputs[0], Some(ItemStack::new("iron_plate", 2)));
}
#[test]
fn machines_process_items() {
let mut app = app();
send(&mut app, login("Smith"));
let me = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let pos = *app.world().get::<GridPos>(me).unwrap();
let target = GridPos::new(pos.x + 1, pos.y);
let data = app.world().resource::<game_core::GameData>().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::<Inventory>(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::<Machine>(furnace).is_some());
{
let mut inventory = app.world_mut().get_mut::<Inventory>(me).unwrap();
inventory.add("iron_ore", 4, &data);
inventory.add("log", 1, &data);
}
let slot_of = |app: &App, item: &str| {
app.world()
.get::<Inventory>(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::<Machine>(furnace).unwrap();
assert_eq!(machine.input_count("iron_ore"), 5);
assert_eq!(machine.recipe.as_deref(), Some("smelt_iron"));
let state = app.world().get::<BuildingState>(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::<Machine>(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::<BuildingState>(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<Welcome>,
rejected: Option<String>,
log: Vec<String>,
}
fn client(addr: SocketAddr) -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins,
RepliconRenetPlugins,
CorePlugin,
))
.init_resource::<Inbox>()
.add_observer(|ev: On<Welcome>, mut inbox: ResMut<Inbox>| {
inbox.welcome = Some(ev.event().clone())
})
.add_observer(|ev: On<Rejected>, mut inbox: ResMut<Inbox>| {
inbox.rejected = Some(ev.reason.clone())
})
.add_observer(|ev: On<GameLog>, mut inbox: ResMut<Inbox>| inbox.log.push(ev.text.clone()));
app.finish();
app.cleanup();
let channels = app.world().resource::<RepliconChannels>();
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::<State<ClientState>>() == 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>();
inbox.welcome.is_some() || inbox.rejected.is_some()
});
}
fn inbox(app: &App) -> &Inbox {
app.world().resource::<Inbox>()
}
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", "");
// Only the level the character is on replicates.
pump(&mut [&mut guest, &mut host], |app| {
app.world_mut()
.query::<&Character>()
.iter(app.world())
.any(|c| c.name == "Guest")
});
let mut levels = guest.world_mut().query::<&game_server::world::Level>();
let levels: std::collections::BTreeSet<_> =
levels.iter(guest.world()).map(|l| l.0.clone()).collect();
assert_eq!(levels, [super::MAIN.to_owned()].into());
let mut maps = guest.world_mut().query::<&WorldMap>();
assert_eq!(maps.iter(guest.world()).count(), 1);
let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4()));
let file = dir.join(super::MAIN);
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, super::MAIN, &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();
}
}