diff --git a/CHANGELOG.md b/CHANGELOG.md index 19956fe..822aadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of + terrain, buildings and ground items, including health, script variables, machine + contents and processing state. Imports replace the map while preserving characters. - Persistent-world station game with singleplayer, hosting (`--host`) and joining (`--connect`), plus a dedicated `game-server`. - Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`. @@ -49,11 +52,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Tiled property overrides apply to new-world seeds as well as imported maps. + Terrain is persisted with world state and replicated to connected clients. + Save databases migrate automatically to schema version 2. - Characters move freely instead of tile by tile: diagonal movement, sliding along walls and around corners. Their exact position is saved. -- **Breaking:** network protocol version 4. Logins carry a player id and - password, and movement is sent as a direction vector, so older clients and - servers can't connect. Characters saved by name +- **Breaking:** network protocol version 5. Authoritative terrain is replicated, + logins carry a player id and password, and movement is sent as a direction vector, + so older clients and servers can't connect. Characters saved by name in existing worlds are not carried over to player ids. ### Removed diff --git a/Cargo.lock b/Cargo.lock index 521db8a..2ce960c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3136,6 +3136,7 @@ dependencies = [ "clap", "ctrlc", "game_core", + "quick-xml 0.41.0", "rsn", "rune", "serde", diff --git a/README.md b/README.md index 78f2736..8841f23 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately. | `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights | | `rejected` | admins | Recently rejected logins (with player ids) | | `save` | admins | Save the world now | +| `export [FILE]` | admins | Export terrain, buildings and ground items to Tiled | +| `import [FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters | | `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` | | `stop` | console, host | Save and shut down | @@ -344,20 +346,58 @@ pub fn toggle(ctx) { ### Maps (Tiled) -Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one -game tile (drawn at 32×32; 32 px tilesets look best). +Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or +external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best). - Tile layers are rendered in order. A tile is solid if the tile has the boolean custom property `solid = true` in its tileset, or its layer has `solid = true`. - Object layers spawn gameplay entities by object **class**: - `spawn` – player spawn point. - - `building` – custom property `proto` = building id; any integer properties - override the building's `vars`. + - `building` – custom property `proto` = building id. `state.health`, + `state.solid`, `state.variant` and `state.vars` override the prototype defaults. + Integer custom properties without a `state.`/`machine.` prefix also override + individual script variables, for compatibility with existing maps. + Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and + `machine.progress`. - `item` – custom property `proto` = item id and optional `count`. - Objects are placed on the tile containing their position (point objects work well). -A map is only used to create a new world; after that the saved world is -authoritative. Start a new save game (or world) to get a fresh copy of the map. +A default map seeds a new world; after that the database stores the authoritative +terrain and entity state. Existing saves acquire their terrain snapshot on their +next save. To apply a map to an existing world, use the admin `import` command. + +#### Editing a running world's map + +1. Run `/export` in chat, or `export` in the server terminal. The reply shows the + server-local file path. With SQLite it defaults to `..tmx` + beside the database. With PostgreSQL it defaults to `saves//world.tmx`; + without saving, `saves/world.tmx`. An explicit path, including spaces, may follow + either command; relative paths are relative to the server's working directory. +2. Open that file in Tiled, edit it and save. Buildings and ground items are point + objects with their prototype and full mutable state in custom properties. + Terrain layers and collision properties are included. Tilesets are embedded + with absolute image references into the server's assets directory; when editing + on another machine, relink the images to that machine's matching assets. +3. Run `/import` (or `/import FILE`). **Import replaces all terrain, buildings and + ground items, including deletions.** It does not merge objects or change online + or offline characters, their positions or inventories. Export a separate backup + before importing, and avoid editing a stale export while players keep building. + +Booleans and small integers use native Tiled property types. Other state values +use JSON in string properties: for example `state.variant` is `"open"` (including +the quotes) or `null`, `state.vars` is `{"stock":2}`, and `machine.inputs` is +`[{"item":"iron_ore","count":4},null]`. `machine.recipe` is `"smelt_iron"` or `null`; +`machine.progress` is elapsed seconds. Large integers use JSON strings to avoid +Tiled's 32-bit integer limit. These same overrides work in default maps, not just +exports; omitted properties retain prototype defaults. + +Import validates the whole map before changing anything, and commits it to the +database before applying it to the simulation. Invalid prototypes, properties, +positions or machine state abort the import. Saving-disabled worlds can still +import, but changes are only in memory. Terrain changes are replicated immediately +to connected clients and retained across restarts; remote clients need matching +sprite assets, not a copy of the exported map. Characters are left at their current +positions, so avoid enclosing them in newly solid terrain. `tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map. diff --git a/crates/game_client/src/render.rs b/crates/game_client/src/render.rs index 048e2d6..bb3b29f 100644 --- a/crates/game_client/src/render.rs +++ b/crates/game_client/src/render.rs @@ -5,7 +5,6 @@ use game_core::{ GameData, components::*, grid::{Dir, TILE_SIZE, grid_to_world, position_to_world}, - map::MapLayout, }; use crate::session::SessionInfo; @@ -16,11 +15,11 @@ impl Plugin for RenderPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, setup) - .add_observer(spawn_map) .add_systems( Update, ( find_local_character, + spawn_map, building_visuals, item_visuals, character_visuals, @@ -59,73 +58,80 @@ fn setup(mut commands: Commands) { /// Builds sprites for every tile when the world map arrives. Tiles are children /// of the map entity, so they disappear with it (e.g. on disconnect). fn spawn_map( - add: On, mut commands: Commands, - maps: Query<&WorldMap>, + maps: Query<(Entity, &WorldMap), Changed>, asset_server: Res, mut layouts: ResMut>, ) { - let Ok(map) = maps.get(add.entity) else { - return; - }; - let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) { - Ok(layout) => layout, - Err(e) => { - error!("can't display map: {e}"); - return; - } - }; - let atlases: Vec<_> = layout - .tilesets - .iter() - .map(|ts| { - let rows = ts.tile_count.div_ceil(ts.columns.max(1)); - let atlas = TextureAtlasLayout::from_grid( - UVec2::new(ts.tile_width, ts.tile_height), - ts.columns, - rows, - Some(UVec2::splat(ts.spacing)), - Some(UVec2::splat(ts.margin)), - ); - (asset_server.load(ts.image.clone()), layouts.add(atlas)) - }) - .collect(); + for (entity, map) in &maps { + let layout = &map.layout; + commands.entity(entity).despawn_children(); + let atlases: Vec<_> = layout + .tilesets + .iter() + .map(|ts| { + let rows = ts.tile_count.div_ceil(ts.columns.max(1)); + let atlas = TextureAtlasLayout::from_grid( + UVec2::new(ts.tile_width, ts.tile_height), + ts.columns, + rows, + Some(UVec2::splat(ts.spacing)), + Some(UVec2::splat(ts.margin)), + ); + (asset_server.load(ts.image.clone()), layouts.add(atlas)) + }) + .collect(); - commands - .entity(add.entity) - .insert((Transform::default(), Visibility::default())); - for (layer_index, layer) in layout.layers.iter().enumerate() { - let z = layer_index as f32 * 0.01; - for (i, cell) in layer.cells.iter().enumerate() { - let Some(cell) = cell else { continue }; - let Some((image, atlas)) = atlases.get(cell.tileset) else { - continue; - }; - let pos = GridPos::new( - (i as u32 % layout.width) as i32, - (i as u32 / layout.width) as i32, - ); - commands.spawn(( - ChildOf(add.entity), - Sprite { - image: image.clone(), - texture_atlas: Some(TextureAtlas { - layout: atlas.clone(), - index: cell.id as usize, - }), - flip_x: cell.flip_h, - flip_y: cell.flip_v, - custom_size: Some(Vec2::splat(TILE_SIZE)), - ..default() - }, - Transform::from_translation(grid_to_world(pos).extend(z)), - )); + commands + .entity(entity) + .insert((Transform::default(), Visibility::default())); + for (layer_index, layer) in layout.layers.iter().enumerate() { + let z = layer_index as f32 * 0.01; + for (i, cell) in layer.cells.iter().enumerate() { + let Some(cell) = cell else { continue }; + let Some((image, atlas)) = atlases.get(cell.tileset) else { + continue; + }; + let pos = GridPos::new( + (i as u32 % layout.width) as i32, + (i as u32 / layout.width) as i32, + ); + commands.spawn(( + ChildOf(entity), + Sprite { + image: image.clone(), + texture_atlas: Some(TextureAtlas { + layout: atlas.clone(), + index: cell.id as usize, + }), + flip_x: if cell.flip_d { + !cell.flip_v + } else { + cell.flip_h + }, + flip_y: if cell.flip_d { + cell.flip_h + } else { + cell.flip_v + }, + custom_size: Some(Vec2::splat(TILE_SIZE)), + ..default() + }, + Transform::from_translation(grid_to_world(pos).extend(z)).with_rotation( + Quat::from_rotation_z(if cell.flip_d { + -std::f32::consts::FRAC_PI_2 + } else { + 0.0 + }), + ), + )); + } } + info!( + "map `{}` displayed ({}x{})", + map.path, layout.width, layout.height + ); } - info!( - "map `{}` displayed ({}x{})", - map.path, layout.width, layout.height - ); } fn find_local_character( diff --git a/crates/game_core/src/components.rs b/crates/game_core/src/components.rs index 1501031..057f6a3 100644 --- a/crates/game_core/src/components.rs +++ b/crates/game_core/src/components.rs @@ -6,7 +6,7 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ - data::{GameData, Ingredient, MachineDef, MachineRecipeDef}, + data::{BuildingDef, GameData, Ingredient, MachineDef, MachineRecipeDef}, grid::Dir, }; @@ -236,6 +236,17 @@ pub struct BuildingState { pub vars: BTreeMap, } +impl BuildingState { + pub fn new(def: &BuildingDef) -> Self { + Self { + solid: def.solid, + variant: None, + health: def.max_health, + vars: def.vars.clone(), + } + } +} + /// Which slot of a machine an action refers to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MachineSlot { @@ -427,10 +438,11 @@ impl Machine { } } -/// The Tiled map the world is built on. Path is relative to the assets directory. +/// Authoritative terrain; clients only need local assets for its tileset images. #[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorldMap { pub path: String, + pub layout: crate::map::MapLayout, } #[cfg(test)] diff --git a/crates/game_core/src/lib.rs b/crates/game_core/src/lib.rs index 507b1f6..fad770f 100644 --- a/crates/game_core/src/lib.rs +++ b/crates/game_core/src/lib.rs @@ -16,7 +16,7 @@ pub use data::GameData; pub const DEFAULT_MAP: &str = "maps/station.tmx"; pub const DEFAULT_PORT: u16 = 5000; /// Bump when the protocol changes in incompatible ways. -pub const PROTOCOL_ID: u64 = 4; +pub const PROTOCOL_ID: u64 = 5; /// Locates the `assets` directory. /// diff --git a/crates/game_core/src/map.rs b/crates/game_core/src/map.rs index 5bf403c..c18443e 100644 --- a/crates/game_core/src/map.rs +++ b/crates/game_core/src/map.rs @@ -8,17 +8,18 @@ //! - `building`: building with the custom property `proto` (a building id). //! - `item`: ground item with the property `proto` (an item id) and optional `count`. //! -//! Any other custom properties are passed through in [`MapObject::properties`] -//! (buildings copy integer properties into their script variables). +//! State overrides (`state.*`, `machine.*`) and legacy integer script variables +//! are decoded by the server for both new worlds and imports. use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, }; use crate::components::GridPos; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TilesetInfo { /// Image path relative to the assets directory. pub image: String, @@ -30,24 +31,27 @@ pub struct TilesetInfo { pub tile_count: u32, pub spacing: u32, pub margin: u32, + pub solid_tiles: BTreeSet, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TileCell { pub tileset: usize, pub id: u32, pub flip_h: bool, pub flip_v: bool, + pub flip_d: bool, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TileLayerData { pub name: String, + pub solid: bool, /// Row-major cells, `width * height` entries. pub cells: Vec>, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MapObject { pub id: u32, pub class: String, @@ -56,7 +60,7 @@ pub struct MapObject { pub properties: BTreeMap, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MapLayout { pub width: u32, pub height: u32, @@ -77,7 +81,7 @@ pub enum MapError { } impl MapLayout { - /// Loads `map_path` (relative to `assets_dir`). + /// Loads an asset-relative or absolute map path. pub fn load(assets_dir: &Path, map_path: &str) -> Result { let full = assets_dir.join(map_path); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); @@ -116,10 +120,28 @@ impl MapLayout { tile_count: ts.tilecount, spacing: ts.spacing, margin: ts.margin, + solid_tiles: ts + .tiles() + .filter(|(_, tile)| bool_prop(&tile.properties, "solid")) + .map(|(id, _)| id) + .collect(), }); } let (width, height) = (map.width, map.height); + if width == 0 || height == 0 || map.tile_width == 0 || map.tile_height == 0 { + return Err(unsupported( + "map and tile dimensions must be nonzero".into(), + )); + } + if width > i32::MAX as u32 + || height > i32::MAX as u32 + || u64::from(width) * u64::from(height) > 1_000_000 + { + return Err(unsupported( + "maps may contain at most 1,000,000 cells".into(), + )); + } let mut layers = Vec::new(); let mut solid = vec![false; (width * height) as usize]; let mut objects = Vec::new(); @@ -147,6 +169,7 @@ impl MapLayout { id: t.id(), flip_h: t.flip_h, flip_v: t.flip_v, + flip_d: t.flip_d, } }); cells.push(cell); @@ -154,6 +177,7 @@ impl MapLayout { } layers.push(TileLayerData { name: layer.name.clone(), + solid: layer_solid, cells, }); } diff --git a/crates/game_server/Cargo.toml b/crates/game_server/Cargo.toml index 97c5860..24db549 100644 --- a/crates/game_server/Cargo.toml +++ b/crates/game_server/Cargo.toml @@ -18,6 +18,7 @@ rune = { workspace = true } tiled = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } +quick-xml = "0.41" uuid = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } diff --git a/crates/game_server/src/commands.rs b/crates/game_server/src/commands.rs index 2aa52f3..712439d 100644 --- a/crates/game_server/src/commands.rs +++ b/crates/game_server/src/commands.rs @@ -6,7 +6,7 @@ use bevy_replicon::prelude::*; use game_core::{components::Character, protocol::GameLog}; use crate::{ - config, persistence, + config, map_io, persistence, players::{self, Controller, PlayerId, Players, RejectedLogins}, }; @@ -81,6 +81,16 @@ const COMMANDS: &[(&str, &str, Permission)] = &[ ("admin list", "list admins", Permission::Admin), ("rejected", "recently rejected logins", Permission::Admin), ("save", "save the world now", Permission::Admin), + ( + "export [FILE]", + "export the world to a Tiled map", + Permission::Admin, + ), + ( + "import [FILE]", + "replace the world from a Tiled map (keeps characters)", + Permission::Admin, + ), ( "config get KEY", "show a setting, e.g. `config get access.whitelist`", @@ -210,6 +220,32 @@ pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec config_command(world, rest), + "export" | "import" => { + let path = if rest.is_empty() { + map_io::default_path(world) + } else { + std::path::PathBuf::from(rest) + }; + let result = if command == "export" { + map_io::export(world, &path) + } else { + map_io::import(world, &path) + }; + match result { + Ok(()) => { + info!("{actor}: {command} {}", path.display()); + let mut message = format!("World {command}ed: {}", path.display()); + if command == "import" && !world.contains_resource::() { + message.push_str(" (Saving is disabled; changes are not persistent.)"); + } + vec![message] + } + Err(e) => { + error!("{command} {} failed: {e}", path.display()); + vec![format!("{command} failed: {e}")] + } + } + } "stop" => { info!("{actor} stopped the server"); world.write_message(AppExit::Success); diff --git a/crates/game_server/src/lib.rs b/crates/game_server/src/lib.rs index a943b24..de0e3e0 100644 --- a/crates/game_server/src/lib.rs +++ b/crates/game_server/src/lib.rs @@ -11,6 +11,7 @@ pub mod console; pub mod crafting; pub mod embedded; pub mod machines; +pub mod map_io; pub mod movement; pub mod network; pub mod persistence; diff --git a/crates/game_server/src/map_io.rs b/crates/game_server/src/map_io.rs new file mode 100644 index 0000000..1197276 --- /dev/null +++ b/crates/game_server/src/map_io.rs @@ -0,0 +1,610 @@ +//! Editable Tiled snapshots of terrain, buildings and ground items. + +use std::{ + collections::{BTreeMap, BTreeSet}, + io, + path::{Path, PathBuf}, + sync::Arc, +}; + +use bevy::prelude::*; +use game_core::{GameData, components::*, map::MapLayout}; +use quick_xml::{ + Writer, + events::{BytesEnd, BytesStart, BytesText, Event}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use crate::{ + persistence::{self, SaveGame, SavedBuilding, SavedItem, Storage}, + world::{SpawnPoint, WorldLayout, spawn_building_with_state, spawn_ground_item}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum MapIoError { + #[error("{0}")] + Map(#[from] game_core::map::MapError), + #[error("map I/O error: {0}")] + Io(#[from] io::Error), + #[error("invalid map state: {0}")] + Invalid(String), + #[error("invalid property JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("could not persist imported world: {0}")] + Storage(#[from] persistence::StorageError), +} + +/// Uses prototype defaults only for properties not supplied by the map. +pub fn decode(layout: MapLayout, data: &GameData) -> Result { + let mut save = SaveGame::default(); + let mut occupied = BTreeSet::new(); + for object in &layout.objects { + let invalid = |message: String| { + MapIoError::Invalid(format!( + "object {} (`{}`): {message}", + object.id, object.name + )) + }; + if !layout.in_bounds(object.pos) { + return Err(invalid("position is outside the map".into())); + } + if object.class == "spawn" { + continue; + } + let proto = object + .properties + .get("proto") + .ok_or_else(|| invalid("missing `proto`".into()))?; + match object.class.as_str() { + "building" => { + let def = data + .buildings + .get(proto) + .ok_or_else(|| invalid(format!("unknown building `{proto}`")))?; + let mut state = BuildingState::new(def); + overlay(&mut state, "state.", &object.properties) + .map_err(|e| invalid(e.to_string()))?; + for (key, value) in &object.properties { + if key != "proto" && !key.starts_with("state.") && !key.starts_with("machine.") + { + let value = value.parse::().map_err(|_| { + invalid(format!("script variable `{key}` must be an integer")) + })?; + state.vars.insert(key.clone(), value); + } + } + if let Some(variant) = &state.variant + && !def.variants.contains_key(variant) + { + return Err(invalid(format!("unknown building variant `{variant}`"))); + } + let machine = if let Some(def) = data.machines.get(proto) { + let mut machine = Machine::new(def); + overlay(&mut machine, "machine.", &object.properties) + .map_err(|e| invalid(e.to_string()))?; + if machine.inputs.len() != def.input_slots + || machine.outputs.len() != def.output_slots + || !machine.progress.is_finite() + || machine.progress < 0.0 + || machine + .recipe + .as_ref() + .is_some_and(|r| def.recipe(r).is_none()) + { + return Err(invalid("invalid machine slots, recipe or progress".into())); + } + for stack in machine.contents() { + validate_stack(stack, data).map_err(&invalid)?; + if stack.count > data.max_stack(&stack.item) { + return Err(invalid(format!( + "machine stack `{}` exceeds its stack limit", + stack.item + ))); + } + } + Some(machine) + } else { + if object.properties.keys().any(|k| k.starts_with("machine.")) { + return Err(invalid("machine properties on a non-machine".into())); + } + None + }; + if !occupied.insert((object.pos.x, object.pos.y)) { + return Err(invalid("two buildings occupy the same tile".into())); + } + save.buildings.push(SavedBuilding { + proto: proto.clone(), + pos: object.pos, + state, + machine, + }); + } + "item" => { + let count = object.properties.get("count").map_or(Ok(1), |v| { + v.parse::() + .map_err(|_| invalid("invalid item count".into())) + })?; + let stack = ItemStack::new(proto, count); + validate_stack(&stack, data).map_err(invalid)?; + save.items.push(SavedItem { + stack, + pos: object.pos, + }); + } + class => return Err(invalid(format!("unsupported class `{class}`"))), + } + } + let mut terrain = layout; + terrain.objects.retain(|o| o.class == "spawn"); + save.layout = Some(terrain); + Ok(save) +} + +fn validate_stack(stack: &ItemStack, data: &GameData) -> Result<(), String> { + if !data.items.contains_key(&stack.item) || stack.count == 0 { + return Err(format!("unknown item or zero count: `{}`", stack.item)); + } + Ok(()) +} + +fn overlay( + state: &mut T, + prefix: &str, + properties: &BTreeMap, +) -> Result<(), MapIoError> { + let mut value = serde_json::to_value(&*state)?; + for (key, text) in properties { + if let Some(field) = key.strip_prefix(prefix) { + let target = value + .get_mut(field) + .ok_or_else(|| MapIoError::Invalid(format!("unknown property `{key}`")))?; + *target = serde_json::from_str(text) + .map_err(|e| MapIoError::Invalid(format!("`{key}`: {e}")))?; + } + } + *state = serde_json::from_value(value)?; + Ok(()) +} + +pub fn default_path(world: &World) -> PathBuf { + world.get_resource::().map_or_else( + || PathBuf::from("saves/world.tmx"), + |storage| storage.map_export_path.clone(), + ) +} + +pub fn export(world: &mut World, path: &Path) -> Result<(), MapIoError> { + let save = persistence::snapshot(world); + write(&save, path) +} + +pub fn import(world: &mut World, path: &Path) -> Result<(), MapIoError> { + let path = path.canonicalize()?; + let layout = MapLayout::load(&game_core::assets_dir(), &path.to_string_lossy())?; + let mut save = decode(layout, world.resource::())?; + save.characters = persistence::snapshot(world).characters; + if let Some(mut storage) = world.get_resource_mut::() { + // Wait for any old autosave before committing the replacement. + storage.save_blocking(&save)?; + } + let layout = save.layout.take().expect("decoded map has terrain"); + let spawn = spawn_point(&layout); + let mut query = world.query_filtered::, With)>>(); + let entities: Vec<_> = query.iter(world).collect(); + for entity in entities { + world.despawn(entity); + } + let mut maps = world.query::<&mut WorldMap>(); + for mut map in maps.iter_mut(world) { + map.layout = layout.clone(); + } + world.insert_resource(SpawnPoint(spawn)); + world.insert_resource(WorldLayout(Arc::new(layout))); + let mut commands = world.commands(); + for b in save.buildings { + spawn_building_with_state(&mut commands, &b.proto, b.pos, b.state, b.machine); + } + for item in save.items { + spawn_ground_item(&mut commands, item.stack, item.pos); + } + world.flush(); + Ok(()) +} + +pub fn spawn_point(layout: &MapLayout) -> GridPos { + layout.objects_of_class("spawn").next().map_or_else( + || GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2), + |o| o.pos, + ) +} + +/// Nested/optional values use JSON; numbers and booleans use native Tiled types. +fn properties( + state: &T, + prefix: &str, +) -> Result, MapIoError> { + let Value::Object(fields) = serde_json::to_value(state)? else { + return Err(MapIoError::Invalid("state must be an object".into())); + }; + Ok(fields + .into_iter() + .map(|(k, v)| (format!("{prefix}{k}"), v)) + .collect()) +} + +type Xml = Writer>; + +fn tag(xml: &mut Xml, name: &str, attrs: &[(&str, String)], empty: bool) -> io::Result<()> { + let mut element = BytesStart::new(name); + for (key, value) in attrs { + element.push_attribute((*key, value.as_str())); + } + xml.write_event(if empty { + Event::Empty(element) + } else { + Event::Start(element) + }) +} + +fn end(xml: &mut Xml, name: &str) -> io::Result<()> { + xml.write_event(Event::End(BytesEnd::new(name))) +} + +fn write_properties(xml: &mut Xml, fields: &BTreeMap) -> Result<(), MapIoError> { + tag(xml, "properties", &[], false)?; + for (name, value) in fields { + let kind = match value { + Value::Bool(_) => "bool", + Value::Number(n) if n.as_i64().is_some_and(|n| i32::try_from(n).is_ok()) => "int", + Value::Number(n) if n.is_i64() || n.is_u64() => "string", + Value::Number(_) => "float", + _ => "string", + }; + let text = if name == "proto" { + value + .as_str() + .ok_or_else(|| MapIoError::Invalid("proto must be a string".into()))? + .to_owned() + } else { + serde_json::to_string(value)? + }; + tag( + xml, + "property", + &[ + ("name", name.clone()), + ("type", kind.into()), + ("value", text), + ], + true, + )?; + } + end(xml, "properties")?; + Ok(()) +} + +pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> { + let layout = save + .layout + .as_ref() + .ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?; + let assets = game_core::assets_dir().canonicalize()?; + let mut xml = Writer::new_with_indent(Vec::new(), b' ', 1); + tag( + &mut xml, + "map", + &[ + ("version", "1.10".into()), + ("orientation", "orthogonal".into()), + ("renderorder", "right-down".into()), + ("infinite", "0".into()), + ("width", layout.width.to_string()), + ("height", layout.height.to_string()), + ("tilewidth", layout.tile_width.to_string()), + ("tileheight", layout.tile_height.to_string()), + ], + false, + )?; + let mut gids = Vec::new(); + let mut gid = 1u32; + for (i, ts) in layout.tilesets.iter().enumerate() { + gids.push(gid); + tag( + &mut xml, + "tileset", + &[ + ("firstgid", gid.to_string()), + ("name", format!("tileset_{i}")), + ("tilewidth", ts.tile_width.to_string()), + ("tileheight", ts.tile_height.to_string()), + ("tilecount", ts.tile_count.to_string()), + ("columns", ts.columns.to_string()), + ("spacing", ts.spacing.to_string()), + ("margin", ts.margin.to_string()), + ], + false, + )?; + tag( + &mut xml, + "image", + &[ + ( + "source", + assets.join(&ts.image).to_string_lossy().replace('\\', "/"), + ), + ("width", ts.image_width.to_string()), + ("height", ts.image_height.to_string()), + ], + true, + )?; + for id in &ts.solid_tiles { + tag(&mut xml, "tile", &[("id", id.to_string())], false)?; + write_properties( + &mut xml, + &BTreeMap::from([("solid".into(), Value::Bool(true))]), + )?; + end(&mut xml, "tile")?; + } + end(&mut xml, "tileset")?; + gid = gid + .checked_add(ts.tile_count) + .filter(|g| *g < 0x1000_0000) + .ok_or_else(|| MapIoError::Invalid("too many tiles".into()))?; + } + for (i, layer) in layout.layers.iter().enumerate() { + tag( + &mut xml, + "layer", + &[ + ("id", (i + 1).to_string()), + ("name", layer.name.clone()), + ("width", layout.width.to_string()), + ("height", layout.height.to_string()), + ], + false, + )?; + if layer.solid { + write_properties( + &mut xml, + &BTreeMap::from([("solid".into(), Value::Bool(true))]), + )?; + } + tag(&mut xml, "data", &[("encoding", "csv".into())], false)?; + let cells = layer + .cells + .iter() + .map(|cell| { + cell.map_or(0, |cell| { + (gids[cell.tileset] + cell.id) + | if cell.flip_h { 0x8000_0000 } else { 0 } + | if cell.flip_v { 0x4000_0000 } else { 0 } + | if cell.flip_d { 0x2000_0000 } else { 0 } + }) + .to_string() + }) + .collect::>() + .join(","); + xml.write_event(Event::Text(BytesText::new(&cells)))?; + end(&mut xml, "data")?; + end(&mut xml, "layer")?; + } + tag( + &mut xml, + "objectgroup", + &[ + ("id", (layout.layers.len() + 1).to_string()), + ("name", "World".into()), + ], + false, + )?; + let mut id = 1; + for object in layout.objects.iter().filter(|o| o.class == "spawn") { + write_object( + &mut xml, + id, + "spawn", + &object.name, + object.pos, + layout, + &BTreeMap::new(), + )?; + id += 1; + } + for b in &save.buildings { + let mut fields = properties(&b.state, "state.")?; + fields.insert("proto".into(), Value::String(b.proto.clone())); + if let Some(machine) = &b.machine { + fields.extend(properties(machine, "machine.")?); + } + write_object(&mut xml, id, "building", &b.proto, b.pos, layout, &fields)?; + id += 1; + } + for item in &save.items { + let fields = BTreeMap::from([ + ("proto".into(), Value::String(item.stack.item.clone())), + ("count".into(), Value::from(item.stack.count)), + ]); + write_object( + &mut xml, + id, + "item", + &item.stack.item, + item.pos, + layout, + &fields, + )?; + id += 1; + } + end(&mut xml, "objectgroup")?; + end(&mut xml, "map")?; + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent)?; + } + let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&temporary, xml.into_inner())?; + if let Err(error) = std::fs::rename(&temporary, path) { + if let Err(cleanup) = std::fs::remove_file(&temporary) { + warn!("failed to remove {}: {cleanup}", temporary.display()); + } + return Err(error.into()); + } + Ok(()) +} + +fn write_object( + xml: &mut Xml, + id: u32, + class: &str, + name: &str, + pos: GridPos, + layout: &MapLayout, + fields: &BTreeMap, +) -> Result<(), MapIoError> { + tag( + xml, + "object", + &[ + ("id", id.to_string()), + ("type", class.into()), + ("name", name.into()), + ( + "x", + (i64::from(pos.x) * i64::from(layout.tile_width)).to_string(), + ), + ( + "y", + (i64::from(pos.y) * i64::from(layout.tile_height)).to_string(), + ), + ], + false, + )?; + write_properties(xml, fields)?; + tag(xml, "point", &[], true)?; + end(xml, "object")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seeded() -> (GameData, SaveGame) { + let assets = game_core::assets_dir(); + let data = GameData::load_dir(&assets.join("data")).unwrap(); + let map = MapLayout::load(&assets, game_core::DEFAULT_MAP).unwrap(); + let save = decode(map, &data).unwrap(); + (data, save) + } + + fn path() -> PathBuf { + std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4())) + } + + #[test] + fn tiled_roundtrip_preserves_every_saved_object_property_and_terrain() { + let (data, mut save) = seeded(); + for b in &mut save.buildings { + b.state.health = 17; + b.state.solid = false; + b.state.vars = BTreeMap::from([ + ("escaped<&\"".into(), i64::MAX), + ("negative".into(), i64::MIN), + ]); + if b.proto == "door" { + b.state.variant = Some("open".into()); + } + if b.proto == "furnace" { + b.state.variant = Some("lit".into()); + b.machine = Some(Machine { + inputs: vec![ + Some(ItemStack::new("iron_ore", 4)), + Some(ItemStack::new("log", 2)), + ], + outputs: vec![Some(ItemStack::new("iron_plate", 3))], + recipe: Some("smelt_iron".into()), + progress: 1.2345678, + }); + } + } + save.items[0].stack.count = u32::MAX; + let layout = save.layout.as_mut().unwrap(); + layout.layers[0].name = "Terrain & \"test\"".into(); + let cell = layout.layers[0].cells[0].as_mut().unwrap(); + cell.flip_h = true; + cell.flip_v = true; + cell.flip_d = true; + let file = path(); + write(&save, &file).unwrap(); + let loaded = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(); + let roundtrip = decode(loaded, &data).unwrap(); + assert_eq!(roundtrip.buildings, save.buildings); + assert_eq!(roundtrip.items, save.items); + let loaded = roundtrip.layout.unwrap(); + let expected = save.layout.unwrap(); + assert_eq!(loaded.layers, expected.layers); + assert_eq!(loaded.tilesets, expected.tilesets); + assert_eq!(loaded.width, expected.width); + assert_eq!(loaded.height, expected.height); + assert_eq!(spawn_point(&loaded), spawn_point(&expected)); + for y in 0..loaded.height as i32 { + for x in 0..loaded.width as i32 { + let pos = GridPos::new(x, y); + assert_eq!(loaded.is_solid(pos), expected.is_solid(pos)); + } + } + std::fs::remove_file(file).unwrap(); + } + + #[test] + fn map_properties_override_defaults_and_invalid_overrides_fail() { + let (data, _) = seeded(); + let mut layout = MapLayout::load(&game_core::assets_dir(), game_core::DEFAULT_MAP).unwrap(); + layout + .objects + .retain(|o| o.class == "building" && o.properties["proto"] == "furnace"); + assert_eq!(layout.objects.len(), 1); + layout.objects[0].properties.extend(BTreeMap::from([ + ("state.health".into(), "7".into()), + ("state.solid".into(), "false".into()), + ("state.variant".into(), "\"lit\"".into()), + ("state.vars".into(), "{\"stock\":2}".into()), + ("legacy".into(), "9".into()), + ( + "machine.inputs".into(), + "[{\"item\":\"log\",\"count\":3},null]".into(), + ), + ( + "machine.outputs".into(), + "[{\"item\":\"iron_plate\",\"count\":2}]".into(), + ), + ("machine.recipe".into(), "\"smelt_iron\"".into()), + ("machine.progress".into(), "2.25".into()), + ])); + let decoded = decode(layout.clone(), &data).unwrap(); + let b = &decoded.buildings[0]; + assert_eq!(b.state.health, 7); + assert!(!b.state.solid); + assert_eq!(b.state.variant.as_deref(), Some("lit")); + assert_eq!(b.state.vars["stock"], 2); + assert_eq!(b.state.vars["legacy"], 9); + let machine = b.machine.as_ref().unwrap(); + assert_eq!(machine.inputs[0], Some(ItemStack::new("log", 3))); + assert_eq!(machine.outputs[0], Some(ItemStack::new("iron_plate", 2))); + assert_eq!(machine.progress, 2.25); + for (key, value) in [ + ("state.health", "\"bad\""), + ("state.typo", "1"), + ("machine.inputs", "[]"), + ("machine.recipe", "\"unknown\""), + ("machine.progress", "-1"), + ("machine.outputs", "[{\"item\":\"unknown\",\"count\":1}]"), + ] { + let mut invalid = layout.clone(); + invalid.objects[0] + .properties + .insert(key.into(), value.into()); + assert!(decode(invalid, &data).is_err(), "{key}"); + } + } +} diff --git a/crates/game_server/src/persistence.rs b/crates/game_server/src/persistence.rs index 7f3fd87..41aa2f5 100644 --- a/crates/game_server/src/persistence.rs +++ b/crates/game_server/src/persistence.rs @@ -15,7 +15,7 @@ use std::{ }; use bevy::prelude::*; -use game_core::{GameData, components::*, grid::Dir}; +use game_core::{GameData, components::*, grid::Dir, map::MapLayout}; use serde::{Deserialize, Serialize}; use sqlx::{AnyPool, AssertSqlSafe, Row, any::AnyPoolOptions}; use tokio::{runtime::Runtime, task::JoinHandle}; @@ -27,19 +27,20 @@ use crate::{ }; /// Bump together with a new entry in [`MIGRATIONS`]. -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 2; /// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`. /// Only portable SQL (SQLite and PostgreSQL) may be used. -const MIGRATIONS: &[&[&str]] = &[&[ - "CREATE TABLE worlds ( +const MIGRATIONS: &[&[&str]] = &[ + &[ + "CREATE TABLE worlds ( id TEXT PRIMARY KEY, name TEXT NOT NULL, map TEXT NOT NULL, created_at BIGINT NOT NULL, saved_at BIGINT NOT NULL )", - "CREATE TABLE buildings ( + "CREATE TABLE buildings ( world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE, proto TEXT NOT NULL, x BIGINT NOT NULL, @@ -47,23 +48,25 @@ const MIGRATIONS: &[&[&str]] = &[&[ state TEXT NOT NULL, machine TEXT )", - "CREATE INDEX buildings_world ON buildings (world_id)", - "CREATE TABLE ground_items ( + "CREATE INDEX buildings_world ON buildings (world_id)", + "CREATE TABLE ground_items ( world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE, item TEXT NOT NULL, count BIGINT NOT NULL, x BIGINT NOT NULL, y BIGINT NOT NULL )", - "CREATE INDEX ground_items_world ON ground_items (world_id)", - "CREATE TABLE characters ( + "CREATE INDEX ground_items_world ON ground_items (world_id)", + "CREATE TABLE characters ( world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE, player_id TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (world_id, player_id) )", -]]; + ], + &["ALTER TABLE worlds ADD COLUMN layout TEXT"], +]; const DELETE_WORLD_ROWS: [&str; 3] = [ "DELETE FROM buildings WHERE world_id = $1", @@ -81,6 +84,8 @@ pub enum StorageError { TooNew(i64), #[error("world {0} not found")] NoSuchWorld(Uuid), + #[error("could not initialize world: {0}")] + Initialization(String), } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -114,6 +119,7 @@ pub struct SavedItem { /// Full state of a world. #[derive(Clone, Debug, Default, PartialEq)] pub struct SaveGame { + pub layout: Option, pub buildings: Vec, pub items: Vec, /// All characters (online and offline), by player id. @@ -346,6 +352,13 @@ impl Database { pub async fn load_world(&self, id: Uuid) -> Result { let world = id.to_string(); let mut save = SaveGame::default(); + let row = sqlx::query("SELECT layout FROM worlds WHERE id = $1") + .bind(world.clone()) + .fetch_optional(&self.pool) + .await? + .ok_or(StorageError::NoSuchWorld(id))?; + let layout: Option = row.try_get(0)?; + save.layout = layout.as_deref().map(serde_json::from_str).transpose()?; let rows = sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1") .bind(world.clone()) @@ -390,8 +403,14 @@ impl Database { const BATCH: usize = 100; let world = id.to_string(); let mut tx = self.pool.begin().await?; - let updated = sqlx::query("UPDATE worlds SET saved_at = $1 WHERE id = $2") + let updated = sqlx::query("UPDATE worlds SET saved_at = $1, layout = $2 WHERE id = $3") .bind(now()) + .bind( + save.layout + .as_ref() + .map(serde_json::to_string) + .transpose()?, + ) .bind(world.clone()) .execute(&mut *tx) .await?; @@ -511,44 +530,56 @@ pub struct Storage { pub runtime: Arc, pub db: Database, pub world: WorldInfo, + pub map_export_path: std::path::PathBuf, pending: Option>, } impl Storage { pub fn new(runtime: Arc, db: Database, world: WorldInfo) -> Self { + let map_export_path = std::path::PathBuf::from("saves") + .join(world.id.to_string()) + .join("world.tmx"); Self { runtime, db, world, + map_export_path, pending: None, } } - /// Opens (or creates) the configured world and loads its state. Returns - /// `None` as the save for newly created worlds. + /// Initializes new worlds before registering them; existing worlds never use the seed. pub fn open( settings: &StorageSettings, map: &str, - ) -> Result<(Self, Option), StorageError> { + initialize: impl FnOnce() -> Result, + ) -> Result<(Self, SaveGame), StorageError> { let runtime = runtime(); let (db, world, save) = runtime.block_on(async { let db = Database::connect(&settings.database_url).await?; match db.find_world(&settings.world).await? { Some(world) => { let save = db.load_world(world.id).await?; - Ok::<_, StorageError>((db, world, Some(save))) + Ok::<_, StorageError>((db, world, save)) } None => { + let save = initialize().map_err(StorageError::Initialization)?; let (id, name) = match &settings.world { WorldSelector::Id(id) => (*id, id.to_string()), WorldSelector::Name(name) => (Uuid::new_v4(), name.clone()), }; let world = db.create_world(id, &name, map).await?; - Ok((db, world, None)) + db.save_world(id, &save).await?; + Ok((db, world, save)) } } })?; - Ok((Self::new(runtime, db, world), save)) + let mut storage = Self::new(runtime, db, world); + if let Some(path) = settings.database_url.strip_prefix("sqlite://") { + let path = Path::new(path.split('?').next().unwrap_or(path)); + storage.map_export_path = path.with_extension(format!("{}.tmx", storage.world.id)); + } + Ok((storage, save)) } fn finish_pending(&mut self) { @@ -671,6 +702,9 @@ pub fn snapshot(world: &mut World) -> SaveGame { }) .collect(); SaveGame { + layout: world + .get_resource::() + .map(|l| (*l.0).clone()), buildings, items, characters, @@ -725,7 +759,12 @@ mod tests { use super::*; fn sample() -> SaveGame { - let mut save = SaveGame::default(); + let mut save = SaveGame { + layout: Some( + MapLayout::load(&game_core::assets_dir(), game_core::DEFAULT_MAP).unwrap(), + ), + ..default() + }; let mut inventory = Inventory::default(); inventory.slots.push(ItemStack::new("apple", 3)); let mut skills = Skills::default(); @@ -874,4 +913,53 @@ mod tests { WorldSelector::Name("main".into()) ); } + + #[test] + fn migrates_v1_and_only_initializes_new_worlds() { + let rt = runtime(); + rt.block_on(async { + let db = Database::connect("sqlite::memory:").await.unwrap(); + sqlx::query("ALTER TABLE worlds DROP COLUMN layout") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("UPDATE station_schema SET version = 1") + .execute(&db.pool) + .await + .unwrap(); + let world = db + .create_world(Uuid::new_v4(), "old", "maps/station.tmx") + .await + .unwrap(); + db.migrate().await.unwrap(); + let old = db.load_world(world.id).await.unwrap(); + assert!(old.layout.is_none()); + db.save_world(world.id, &sample()).await.unwrap(); + assert!(db.load_world(world.id).await.unwrap().layout.is_some()); + db.close().await; + }); + let file = std::env::temp_dir().join(format!("station-init-{}.sqlite", Uuid::new_v4())); + let settings = StorageSettings { + database_url: sqlite_url(&file), + world: WorldSelector::Name("test".into()), + }; + assert!( + Storage::open(&settings, game_core::DEFAULT_MAP, || Err( + "invalid seed".into() + )) + .is_err() + ); + let expected = sample(); + let (storage, save) = + Storage::open(&settings, game_core::DEFAULT_MAP, || Ok(expected.clone())).unwrap(); + assert_eq!(save, expected); + drop(storage); + let (storage, save) = Storage::open(&settings, "missing.tmx", || { + panic!("existing world must not load its seed") + }) + .unwrap(); + assert_eq!(save, expected); + drop(storage); + std::fs::remove_file(file).unwrap(); + } } diff --git a/crates/game_server/src/world.rs b/crates/game_server/src/world.rs index 9f5555c..89e31e9 100644 --- a/crates/game_server/src/world.rs +++ b/crates/game_server/src/world.rs @@ -12,7 +12,7 @@ use game_core::{ map::MapLayout, }; -use crate::{ServerSettings, persistence}; +use crate::{ServerSettings, map_io, persistence}; /// The loaded map, used for collision and spawn points. #[derive(Resource, Clone)] @@ -22,31 +22,16 @@ pub struct WorldLayout(pub Arc); pub struct SpawnPoint(pub GridPos); pub fn setup_world(mut commands: Commands, settings: Res, data: Res) { - let assets = game_core::assets_dir(); - let layout = match MapLayout::load(&assets, &settings.map) { - Ok(layout) => layout, - Err(e) => return crate::fail(&mut commands, e.to_string()), + let seed = || { + MapLayout::load(&game_core::assets_dir(), &settings.map) + .map_err(map_io::MapIoError::from) + .and_then(|layout| map_io::decode(layout, &data)) }; - let spawn = layout - .objects_of_class("spawn") - .next() - .map(|o| o.pos) - .unwrap_or_else(|| { - warn!("map has no `spawn` object, using the map center"); - GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2) - }); - commands.insert_resource(SpawnPoint(spawn)); - - commands.spawn(( - Replicated, - WorldMap { - path: settings.map.clone(), - }, - )); - let mut save = None; if let Some(storage) = &settings.storage { - let (storage, loaded) = match persistence::Storage::open(storage, &settings.map) { + let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || { + seed().map_err(|e| e.to_string()) + }) { Ok(opened) => opened, Err(e) => { return crate::fail( @@ -56,69 +41,53 @@ pub fn setup_world(mut commands: Commands, settings: Res, data: } }; info!("world `{}` ({})", storage.world.name, storage.world.id); - if loaded.is_some() && storage.world.map != settings.map { + if storage.world.map != settings.map { warn!( "world was created on map `{}`, now running `{}`", storage.world.map, settings.map ); } - save = loaded; + save = Some(loaded); commands.insert_resource(storage); } - match save { - Some(save) => { + let (layout, save) = match save { + Some(mut save) => { info!( "restoring persistent world: {} buildings, {} items, {} characters", save.buildings.len(), save.items.len(), save.characters.len() ); - persistence::restore(&mut commands, &data, save); + let layout = match save.layout.take() { + Some(layout) => layout, + None => match MapLayout::load(&game_core::assets_dir(), &settings.map) { + Ok(layout) => layout, + Err(e) => return crate::fail(&mut commands, e.to_string()), + }, + }; + (layout, save) } None => { info!("creating a new world from `{}`", settings.map); - spawn_map_objects(&mut commands, &data, &layout); + let mut save = match seed() { + Ok(save) => save, + Err(e) => return crate::fail(&mut commands, e.to_string()), + }; + (save.layout.take().expect("decoded map has terrain"), save) } - } + }; + commands.insert_resource(SpawnPoint(map_io::spawn_point(&layout))); + commands.spawn(( + Replicated, + WorldMap { + path: settings.map.clone(), + layout: layout.clone(), + }, + )); + persistence::restore(&mut commands, &data, save); commands.insert_resource(WorldLayout(Arc::new(layout))); } -fn spawn_map_objects(commands: &mut Commands, data: &GameData, layout: &MapLayout) { - for obj in &layout.objects { - let proto = obj.properties.get("proto"); - match (obj.class.as_str(), proto) { - ("spawn", _) => {} - ("building", Some(proto)) => { - // Integer custom properties override the building's script variables. - let vars = obj - .properties - .iter() - .filter_map(|(k, v)| Some((k.clone(), v.parse::().ok()?))) - .collect(); - if spawn_building(commands, data, proto, obj.pos, vars).is_none() { - warn!("map object {}: unknown building `{proto}`", obj.id); - } - } - ("item", Some(proto)) => { - let count = obj - .properties - .get("count") - .and_then(|c| c.parse().ok()) - .unwrap_or(1); - if data.items.contains_key(proto) { - spawn_ground_item(commands, ItemStack::new(proto, count), obj.pos); - } else { - warn!("map object {}: unknown item `{proto}`", obj.id); - } - } - (class, _) => warn!( - "map object {} (`{}`): unsupported class `{class}` or missing `proto` property", - obj.id, obj.name - ), - } - } -} - /// Spawns a building with its default state. `vars` override the prototype's variables. pub fn spawn_building( commands: &mut Commands, @@ -128,12 +97,7 @@ pub fn spawn_building( vars: BTreeMap, ) -> Option { let def = data.buildings.get(proto)?; - let mut state = BuildingState { - solid: def.solid, - variant: None, - health: def.max_health, - vars: def.vars.clone(), - }; + let mut state = BuildingState::new(def); state.vars.extend(vars); let machine = data.machines.get(proto).map(Machine::new); Some(spawn_building_with_state( diff --git a/crates/game_server/tests/simulation.rs b/crates/game_server/tests/simulation.rs index be36151..1cd1ed6 100644 --- a/crates/game_server/tests/simulation.rs +++ b/crates/game_server/tests/simulation.rs @@ -57,6 +57,146 @@ fn take_log(app: &mut App) -> Vec { 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 file = std::env::temp_dir().join(format!("station-import-{}.tmx", uuid::Uuid::new_v4())); + 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 {filename}"), + ); + assert!(denied[0].contains("not allowed")); + assert!(!file.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 {filename}"), + ); + assert!(reply[0].contains("exported"), "{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(&game_core::assets_dir(), filename).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] = None; + 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 {filename}"), + ); + 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 persisted = storage + .runtime + .block_on(storage.db.load_world(storage.world.id)) + .unwrap(); + expected.characters = imported.characters; + 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_file(file).unwrap(); +} + fn count(app: &App, me: Entity, item: &str) -> u32 { app.world().get::(me).unwrap().count(item) } @@ -414,6 +554,78 @@ mod network { 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 file = + std::env::temp_dir().join(format!("station-network-{}.tmx", uuid::Uuid::new_v4())); + host.world_mut().client_trigger(Chat { + text: format!("/export {}", file.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(&assets, file.to_str().unwrap()).unwrap(); + assert!(map.is_solid(GridPos::new(0, 0))); + map.layers[1].cells[0] = None; + let edited = map_io::decode(map, &data).unwrap(); + map_io::write(&edited, &file).unwrap(); + host.world_mut().client_trigger(Chat { + text: format!("/import {}", file.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_file(file).unwrap(); + } + #[test] fn password_admins_and_bans() { let mut config = ServerConfig { @@ -426,16 +638,7 @@ mod network { 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 (server, addr) = start_server(settings, config); let mut intruder = client(addr); join(&mut intruder, GUEST, "Guest", "wrong");