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:
parent
0c9136eaae
commit
43051c9f5d
12
CHANGELOG.md
12
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
|
||||
|
|
|
|||
|
|
@ -3136,6 +3136,7 @@ dependencies = [
|
|||
"clap",
|
||||
"ctrlc",
|
||||
"game_core",
|
||||
"quick-xml 0.41.0",
|
||||
"rsn",
|
||||
"rune",
|
||||
"serde",
|
||||
|
|
|
|||
52
README.md
52
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 `<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +131,7 @@ fn spawn_map(
|
|||
"map `{}` displayed ({}x{})",
|
||||
map.path, layout.width, layout.height
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_local_character(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue