feat(world)!: add lossless Tiled export and import

Preserve building state, machine contents and progress in editable Tiled properties. Share property decoding between seeds and imports; replace terrain and objects atomically while keeping characters. Persist terrain in schema version 2 and replicate it to existing and joining clients.

BREAKING CHANGE: network protocol version 5 replicates authoritative terrain; older clients and servers cannot connect.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 20:22:13 +02:00
parent 0c9136eaae
commit 43051c9f5d
14 changed files with 1176 additions and 184 deletions

View File

@ -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

1
Cargo.lock generated
View File

@ -3136,6 +3136,7 @@ dependencies = [
"clap",
"ctrlc",
"game_core",
"quick-xml 0.41.0",
"rsn",
"rune",
"serde",

View File

@ -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 `<database-stem>.<world-uuid>.tmx`
beside the database. With PostgreSQL it defaults to `saves/<world-uuid>/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.

View File

@ -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::<LocalCharacter>()
.add_systems(Startup, setup)
.add_observer(spawn_map)
.add_systems(
Update,
(
find_local_character,
spawn_map,
building_visuals,
item_visuals,
character_visuals,
@ -59,22 +58,14 @@ 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<Add, WorldMap>,
mut commands: Commands,
maps: Query<&WorldMap>,
maps: Query<(Entity, &WorldMap), Changed<WorldMap>>,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
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;
}
};
for (entity, map) in &maps {
let layout = &map.layout;
commands.entity(entity).despawn_children();
let atlases: Vec<_> = layout
.tilesets
.iter()
@ -92,7 +83,7 @@ fn spawn_map(
.collect();
commands
.entity(add.entity)
.entity(entity)
.insert((Transform::default(), Visibility::default()));
for (layer_index, layer) in layout.layers.iter().enumerate() {
let z = layer_index as f32 * 0.01;
@ -106,19 +97,33 @@ fn spawn_map(
(i as u32 / layout.width) as i32,
);
commands.spawn((
ChildOf(add.entity),
ChildOf(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,
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)),
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
}),
),
));
}
}
@ -127,6 +132,7 @@ fn spawn_map(
map.path, layout.width, layout.height
);
}
}
fn find_local_character(
mut local: ResMut<LocalCharacter>,

View File

@ -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<String, i64>,
}
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)]

View File

@ -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.
///

View File

@ -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<u32>,
}
#[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<Option<TileCell>>,
}
#[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<String, String>,
}
#[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<Self, MapError> {
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,
});
}

View File

@ -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 }

View File

@ -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<Stri
}
}
"config" => 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::<persistence::Storage>() {
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);

View File

@ -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;

View File

@ -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<SaveGame, MapIoError> {
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::<i64>().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::<u32>()
.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<T: Serialize + DeserializeOwned>(
state: &mut T,
prefix: &str,
properties: &BTreeMap<String, String>,
) -> 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::<Storage>().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::<GameData>())?;
save.characters = persistence::snapshot(world).characters;
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
// 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::<Entity, Or<(With<Building>, With<GroundItem>)>>();
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<T: Serialize>(
state: &T,
prefix: &str,
) -> Result<BTreeMap<String, Value>, 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<Vec<u8>>;
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<String, Value>) -> 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::<Vec<_>>()
.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<String, Value>,
) -> 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 & <ground> \"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}");
}
}
}

View File

@ -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,11 +27,12 @@ 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]] = &[&[
const MIGRATIONS: &[&[&str]] = &[
&[
"CREATE TABLE worlds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@ -63,7 +64,9 @@ const MIGRATIONS: &[&[&str]] = &[&[
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<MapLayout>,
pub buildings: Vec<SavedBuilding>,
pub items: Vec<SavedItem>,
/// All characters (online and offline), by player id.
@ -346,6 +352,13 @@ impl Database {
pub async fn load_world(&self, id: Uuid) -> Result<SaveGame, StorageError> {
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<String> = 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<Runtime>,
pub db: Database,
pub world: WorldInfo,
pub map_export_path: std::path::PathBuf,
pending: Option<JoinHandle<()>>,
}
impl Storage {
pub fn new(runtime: Arc<Runtime>, 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<SaveGame>), StorageError> {
initialize: impl FnOnce() -> Result<SaveGame, String>,
) -> 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::<crate::world::WorldLayout>()
.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();
}
}

View File

@ -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<MapLayout>);
pub struct SpawnPoint(pub GridPos);
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
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<ServerSettings>, 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::<i64>().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<String, i64>,
) -> Option<Entity> {
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(

View File

@ -57,6 +57,146 @@ fn take_log(app: &mut App) -> Vec<String> {
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::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::<Entity, With<Character>>()
.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::<game_core::GameData>()).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::<game_core::GameData>(),
)
.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::<WorldLayout>()
.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>(character).is_some(),
"online entity must survive"
);
assert_eq!(
app.world().resource::<WorldLayout>().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::<persistence::Storage>();
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::<persistence::Storage>();
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::<Inventory>(me).unwrap().count(item)
}
@ -414,6 +554,78 @@ mod network {
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", "");
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");