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
|
### 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
|
- Persistent-world station game with singleplayer, hosting (`--host`) and
|
||||||
joining (`--connect`), plus a dedicated `game-server`.
|
joining (`--connect`), plus a dedicated `game-server`.
|
||||||
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.
|
- 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
|
### 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
|
- Characters move freely instead of tile by tile: diagonal movement, sliding
|
||||||
along walls and around corners. Their exact position is saved.
|
along walls and around corners. Their exact position is saved.
|
||||||
- **Breaking:** network protocol version 4. Logins carry a player id and
|
- **Breaking:** network protocol version 5. Authoritative terrain is replicated,
|
||||||
password, and movement is sent as a direction vector, so older clients and
|
logins carry a player id and password, and movement is sent as a direction vector,
|
||||||
servers can't connect. Characters saved by name
|
so older clients and servers can't connect. Characters saved by name
|
||||||
in existing worlds are not carried over to player ids.
|
in existing worlds are not carried over to player ids.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
|
||||||
|
|
@ -3136,6 +3136,7 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"game_core",
|
"game_core",
|
||||||
|
"quick-xml 0.41.0",
|
||||||
"rsn",
|
"rsn",
|
||||||
"rune",
|
"rune",
|
||||||
"serde",
|
"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 |
|
| `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights |
|
||||||
| `rejected` | admins | Recently rejected logins (with player ids) |
|
| `rejected` | admins | Recently rejected logins (with player ids) |
|
||||||
| `save` | admins | Save the world now |
|
| `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` |
|
| `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 |
|
| `stop` | console, host | Save and shut down |
|
||||||
|
|
||||||
|
|
@ -344,20 +346,58 @@ pub fn toggle(ctx) {
|
||||||
|
|
||||||
### Maps (Tiled)
|
### Maps (Tiled)
|
||||||
|
|
||||||
Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one
|
Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or
|
||||||
game tile (drawn at 32×32; 32 px tilesets look best).
|
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
|
- 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`.
|
property `solid = true` in its tileset, or its layer has `solid = true`.
|
||||||
- Object layers spawn gameplay entities by object **class**:
|
- Object layers spawn gameplay entities by object **class**:
|
||||||
- `spawn` – player spawn point.
|
- `spawn` – player spawn point.
|
||||||
- `building` – custom property `proto` = building id; any integer properties
|
- `building` – custom property `proto` = building id. `state.health`,
|
||||||
override the building's `vars`.
|
`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`.
|
- `item` – custom property `proto` = item id and optional `count`.
|
||||||
- Objects are placed on the tile containing their position (point objects work well).
|
- 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
|
A default map seeds a new world; after that the database stores the authoritative
|
||||||
authoritative. Start a new save game (or world) to get a fresh copy of the map.
|
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.
|
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ use game_core::{
|
||||||
GameData,
|
GameData,
|
||||||
components::*,
|
components::*,
|
||||||
grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
|
grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
|
||||||
map::MapLayout,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::session::SessionInfo;
|
use crate::session::SessionInfo;
|
||||||
|
|
@ -16,11 +15,11 @@ impl Plugin for RenderPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<LocalCharacter>()
|
app.init_resource::<LocalCharacter>()
|
||||||
.add_systems(Startup, setup)
|
.add_systems(Startup, setup)
|
||||||
.add_observer(spawn_map)
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
find_local_character,
|
find_local_character,
|
||||||
|
spawn_map,
|
||||||
building_visuals,
|
building_visuals,
|
||||||
item_visuals,
|
item_visuals,
|
||||||
character_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
|
/// 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).
|
/// of the map entity, so they disappear with it (e.g. on disconnect).
|
||||||
fn spawn_map(
|
fn spawn_map(
|
||||||
add: On<Add, WorldMap>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
maps: Query<&WorldMap>,
|
maps: Query<(Entity, &WorldMap), Changed<WorldMap>>,
|
||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
|
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||||
) {
|
) {
|
||||||
let Ok(map) = maps.get(add.entity) else {
|
for (entity, map) in &maps {
|
||||||
return;
|
let layout = &map.layout;
|
||||||
};
|
commands.entity(entity).despawn_children();
|
||||||
let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) {
|
|
||||||
Ok(layout) => layout,
|
|
||||||
Err(e) => {
|
|
||||||
error!("can't display map: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let atlases: Vec<_> = layout
|
let atlases: Vec<_> = layout
|
||||||
.tilesets
|
.tilesets
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -92,7 +83,7 @@ fn spawn_map(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.entity(add.entity)
|
.entity(entity)
|
||||||
.insert((Transform::default(), Visibility::default()));
|
.insert((Transform::default(), Visibility::default()));
|
||||||
for (layer_index, layer) in layout.layers.iter().enumerate() {
|
for (layer_index, layer) in layout.layers.iter().enumerate() {
|
||||||
let z = layer_index as f32 * 0.01;
|
let z = layer_index as f32 * 0.01;
|
||||||
|
|
@ -106,19 +97,33 @@ fn spawn_map(
|
||||||
(i as u32 / layout.width) as i32,
|
(i as u32 / layout.width) as i32,
|
||||||
);
|
);
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ChildOf(add.entity),
|
ChildOf(entity),
|
||||||
Sprite {
|
Sprite {
|
||||||
image: image.clone(),
|
image: image.clone(),
|
||||||
texture_atlas: Some(TextureAtlas {
|
texture_atlas: Some(TextureAtlas {
|
||||||
layout: atlas.clone(),
|
layout: atlas.clone(),
|
||||||
index: cell.id as usize,
|
index: cell.id as usize,
|
||||||
}),
|
}),
|
||||||
flip_x: cell.flip_h,
|
flip_x: if cell.flip_d {
|
||||||
flip_y: cell.flip_v,
|
!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)),
|
custom_size: Some(Vec2::splat(TILE_SIZE)),
|
||||||
..default()
|
..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
|
map.path, layout.width, layout.height
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn find_local_character(
|
fn find_local_character(
|
||||||
mut local: ResMut<LocalCharacter>,
|
mut local: ResMut<LocalCharacter>,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use bevy::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
data::{GameData, Ingredient, MachineDef, MachineRecipeDef},
|
data::{BuildingDef, GameData, Ingredient, MachineDef, MachineRecipeDef},
|
||||||
grid::Dir,
|
grid::Dir,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -236,6 +236,17 @@ pub struct BuildingState {
|
||||||
pub vars: BTreeMap<String, i64>,
|
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.
|
/// Which slot of a machine an action refers to.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum MachineSlot {
|
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)]
|
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorldMap {
|
pub struct WorldMap {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub layout: crate::map::MapLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub use data::GameData;
|
||||||
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
||||||
pub const DEFAULT_PORT: u16 = 5000;
|
pub const DEFAULT_PORT: u16 = 5000;
|
||||||
/// Bump when the protocol changes in incompatible ways.
|
/// Bump when the protocol changes in incompatible ways.
|
||||||
pub const PROTOCOL_ID: u64 = 4;
|
pub const PROTOCOL_ID: u64 = 5;
|
||||||
|
|
||||||
/// Locates the `assets` directory.
|
/// Locates the `assets` directory.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,18 @@
|
||||||
//! - `building`: building with the custom property `proto` (a building id).
|
//! - `building`: building with the custom property `proto` (a building id).
|
||||||
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
|
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
|
||||||
//!
|
//!
|
||||||
//! Any other custom properties are passed through in [`MapObject::properties`]
|
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
|
||||||
//! (buildings copy integer properties into their script variables).
|
//! are decoded by the server for both new worlds and imports.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, BTreeSet},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::components::GridPos;
|
use crate::components::GridPos;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TilesetInfo {
|
pub struct TilesetInfo {
|
||||||
/// Image path relative to the assets directory.
|
/// Image path relative to the assets directory.
|
||||||
pub image: String,
|
pub image: String,
|
||||||
|
|
@ -30,24 +31,27 @@ pub struct TilesetInfo {
|
||||||
pub tile_count: u32,
|
pub tile_count: u32,
|
||||||
pub spacing: u32,
|
pub spacing: u32,
|
||||||
pub margin: 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 struct TileCell {
|
||||||
pub tileset: usize,
|
pub tileset: usize,
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub flip_h: bool,
|
pub flip_h: bool,
|
||||||
pub flip_v: bool,
|
pub flip_v: bool,
|
||||||
|
pub flip_d: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TileLayerData {
|
pub struct TileLayerData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub solid: bool,
|
||||||
/// Row-major cells, `width * height` entries.
|
/// Row-major cells, `width * height` entries.
|
||||||
pub cells: Vec<Option<TileCell>>,
|
pub cells: Vec<Option<TileCell>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MapObject {
|
pub struct MapObject {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub class: String,
|
pub class: String,
|
||||||
|
|
@ -56,7 +60,7 @@ pub struct MapObject {
|
||||||
pub properties: BTreeMap<String, String>,
|
pub properties: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MapLayout {
|
pub struct MapLayout {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
|
|
@ -77,7 +81,7 @@ pub enum MapError {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MapLayout {
|
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> {
|
pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
|
||||||
let full = assets_dir.join(map_path);
|
let full = assets_dir.join(map_path);
|
||||||
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
|
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
|
||||||
|
|
@ -116,10 +120,28 @@ impl MapLayout {
|
||||||
tile_count: ts.tilecount,
|
tile_count: ts.tilecount,
|
||||||
spacing: ts.spacing,
|
spacing: ts.spacing,
|
||||||
margin: ts.margin,
|
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);
|
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 layers = Vec::new();
|
||||||
let mut solid = vec![false; (width * height) as usize];
|
let mut solid = vec![false; (width * height) as usize];
|
||||||
let mut objects = Vec::new();
|
let mut objects = Vec::new();
|
||||||
|
|
@ -147,6 +169,7 @@ impl MapLayout {
|
||||||
id: t.id(),
|
id: t.id(),
|
||||||
flip_h: t.flip_h,
|
flip_h: t.flip_h,
|
||||||
flip_v: t.flip_v,
|
flip_v: t.flip_v,
|
||||||
|
flip_d: t.flip_d,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cells.push(cell);
|
cells.push(cell);
|
||||||
|
|
@ -154,6 +177,7 @@ impl MapLayout {
|
||||||
}
|
}
|
||||||
layers.push(TileLayerData {
|
layers.push(TileLayerData {
|
||||||
name: layer.name.clone(),
|
name: layer.name.clone(),
|
||||||
|
solid: layer_solid,
|
||||||
cells,
|
cells,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ rune = { workspace = true }
|
||||||
tiled = { workspace = true }
|
tiled = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
quick-xml = "0.41"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use bevy_replicon::prelude::*;
|
||||||
use game_core::{components::Character, protocol::GameLog};
|
use game_core::{components::Character, protocol::GameLog};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config, persistence,
|
config, map_io, persistence,
|
||||||
players::{self, Controller, PlayerId, Players, RejectedLogins},
|
players::{self, Controller, PlayerId, Players, RejectedLogins},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -81,6 +81,16 @@ const COMMANDS: &[(&str, &str, Permission)] = &[
|
||||||
("admin list", "list admins", Permission::Admin),
|
("admin list", "list admins", Permission::Admin),
|
||||||
("rejected", "recently rejected logins", Permission::Admin),
|
("rejected", "recently rejected logins", Permission::Admin),
|
||||||
("save", "save the world now", 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",
|
"config get KEY",
|
||||||
"show a setting, e.g. `config get access.whitelist`",
|
"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),
|
"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" => {
|
"stop" => {
|
||||||
info!("{actor} stopped the server");
|
info!("{actor} stopped the server");
|
||||||
world.write_message(AppExit::Success);
|
world.write_message(AppExit::Success);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub mod console;
|
||||||
pub mod crafting;
|
pub mod crafting;
|
||||||
pub mod embedded;
|
pub mod embedded;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
|
pub mod map_io;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod persistence;
|
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 bevy::prelude::*;
|
||||||
use game_core::{GameData, components::*, grid::Dir};
|
use game_core::{GameData, components::*, grid::Dir, map::MapLayout};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{AnyPool, AssertSqlSafe, Row, any::AnyPoolOptions};
|
use sqlx::{AnyPool, AssertSqlSafe, Row, any::AnyPoolOptions};
|
||||||
use tokio::{runtime::Runtime, task::JoinHandle};
|
use tokio::{runtime::Runtime, task::JoinHandle};
|
||||||
|
|
@ -27,11 +27,12 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bump together with a new entry in [`MIGRATIONS`].
|
/// 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`.
|
/// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`.
|
||||||
/// Only portable SQL (SQLite and PostgreSQL) may be used.
|
/// Only portable SQL (SQLite and PostgreSQL) may be used.
|
||||||
const MIGRATIONS: &[&[&str]] = &[&[
|
const MIGRATIONS: &[&[&str]] = &[
|
||||||
|
&[
|
||||||
"CREATE TABLE worlds (
|
"CREATE TABLE worlds (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|
@ -63,7 +64,9 @@ const MIGRATIONS: &[&[&str]] = &[&[
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
PRIMARY KEY (world_id, player_id)
|
PRIMARY KEY (world_id, player_id)
|
||||||
)",
|
)",
|
||||||
]];
|
],
|
||||||
|
&["ALTER TABLE worlds ADD COLUMN layout TEXT"],
|
||||||
|
];
|
||||||
|
|
||||||
const DELETE_WORLD_ROWS: [&str; 3] = [
|
const DELETE_WORLD_ROWS: [&str; 3] = [
|
||||||
"DELETE FROM buildings WHERE world_id = $1",
|
"DELETE FROM buildings WHERE world_id = $1",
|
||||||
|
|
@ -81,6 +84,8 @@ pub enum StorageError {
|
||||||
TooNew(i64),
|
TooNew(i64),
|
||||||
#[error("world {0} not found")]
|
#[error("world {0} not found")]
|
||||||
NoSuchWorld(Uuid),
|
NoSuchWorld(Uuid),
|
||||||
|
#[error("could not initialize world: {0}")]
|
||||||
|
Initialization(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -114,6 +119,7 @@ pub struct SavedItem {
|
||||||
/// Full state of a world.
|
/// Full state of a world.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct SaveGame {
|
pub struct SaveGame {
|
||||||
|
pub layout: Option<MapLayout>,
|
||||||
pub buildings: Vec<SavedBuilding>,
|
pub buildings: Vec<SavedBuilding>,
|
||||||
pub items: Vec<SavedItem>,
|
pub items: Vec<SavedItem>,
|
||||||
/// All characters (online and offline), by player id.
|
/// 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> {
|
pub async fn load_world(&self, id: Uuid) -> Result<SaveGame, StorageError> {
|
||||||
let world = id.to_string();
|
let world = id.to_string();
|
||||||
let mut save = SaveGame::default();
|
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 =
|
let rows =
|
||||||
sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1")
|
sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1")
|
||||||
.bind(world.clone())
|
.bind(world.clone())
|
||||||
|
|
@ -390,8 +403,14 @@ impl Database {
|
||||||
const BATCH: usize = 100;
|
const BATCH: usize = 100;
|
||||||
let world = id.to_string();
|
let world = id.to_string();
|
||||||
let mut tx = self.pool.begin().await?;
|
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(now())
|
||||||
|
.bind(
|
||||||
|
save.layout
|
||||||
|
.as_ref()
|
||||||
|
.map(serde_json::to_string)
|
||||||
|
.transpose()?,
|
||||||
|
)
|
||||||
.bind(world.clone())
|
.bind(world.clone())
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -511,44 +530,56 @@ pub struct Storage {
|
||||||
pub runtime: Arc<Runtime>,
|
pub runtime: Arc<Runtime>,
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub world: WorldInfo,
|
pub world: WorldInfo,
|
||||||
|
pub map_export_path: std::path::PathBuf,
|
||||||
pending: Option<JoinHandle<()>>,
|
pending: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Storage {
|
impl Storage {
|
||||||
pub fn new(runtime: Arc<Runtime>, db: Database, world: WorldInfo) -> Self {
|
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 {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
|
map_export_path,
|
||||||
pending: None,
|
pending: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens (or creates) the configured world and loads its state. Returns
|
/// Initializes new worlds before registering them; existing worlds never use the seed.
|
||||||
/// `None` as the save for newly created worlds.
|
|
||||||
pub fn open(
|
pub fn open(
|
||||||
settings: &StorageSettings,
|
settings: &StorageSettings,
|
||||||
map: &str,
|
map: &str,
|
||||||
) -> Result<(Self, Option<SaveGame>), StorageError> {
|
initialize: impl FnOnce() -> Result<SaveGame, String>,
|
||||||
|
) -> Result<(Self, SaveGame), StorageError> {
|
||||||
let runtime = runtime();
|
let runtime = runtime();
|
||||||
let (db, world, save) = runtime.block_on(async {
|
let (db, world, save) = runtime.block_on(async {
|
||||||
let db = Database::connect(&settings.database_url).await?;
|
let db = Database::connect(&settings.database_url).await?;
|
||||||
match db.find_world(&settings.world).await? {
|
match db.find_world(&settings.world).await? {
|
||||||
Some(world) => {
|
Some(world) => {
|
||||||
let save = db.load_world(world.id).await?;
|
let save = db.load_world(world.id).await?;
|
||||||
Ok::<_, StorageError>((db, world, Some(save)))
|
Ok::<_, StorageError>((db, world, save))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
let save = initialize().map_err(StorageError::Initialization)?;
|
||||||
let (id, name) = match &settings.world {
|
let (id, name) = match &settings.world {
|
||||||
WorldSelector::Id(id) => (*id, id.to_string()),
|
WorldSelector::Id(id) => (*id, id.to_string()),
|
||||||
WorldSelector::Name(name) => (Uuid::new_v4(), name.clone()),
|
WorldSelector::Name(name) => (Uuid::new_v4(), name.clone()),
|
||||||
};
|
};
|
||||||
let world = db.create_world(id, &name, map).await?;
|
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) {
|
fn finish_pending(&mut self) {
|
||||||
|
|
@ -671,6 +702,9 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
SaveGame {
|
SaveGame {
|
||||||
|
layout: world
|
||||||
|
.get_resource::<crate::world::WorldLayout>()
|
||||||
|
.map(|l| (*l.0).clone()),
|
||||||
buildings,
|
buildings,
|
||||||
items,
|
items,
|
||||||
characters,
|
characters,
|
||||||
|
|
@ -725,7 +759,12 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn sample() -> SaveGame {
|
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();
|
let mut inventory = Inventory::default();
|
||||||
inventory.slots.push(ItemStack::new("apple", 3));
|
inventory.slots.push(ItemStack::new("apple", 3));
|
||||||
let mut skills = Skills::default();
|
let mut skills = Skills::default();
|
||||||
|
|
@ -874,4 +913,53 @@ mod tests {
|
||||||
WorldSelector::Name("main".into())
|
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,
|
map::MapLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{ServerSettings, persistence};
|
use crate::{ServerSettings, map_io, persistence};
|
||||||
|
|
||||||
/// The loaded map, used for collision and spawn points.
|
/// The loaded map, used for collision and spawn points.
|
||||||
#[derive(Resource, Clone)]
|
#[derive(Resource, Clone)]
|
||||||
|
|
@ -22,31 +22,16 @@ pub struct WorldLayout(pub Arc<MapLayout>);
|
||||||
pub struct SpawnPoint(pub GridPos);
|
pub struct SpawnPoint(pub GridPos);
|
||||||
|
|
||||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||||
let assets = game_core::assets_dir();
|
let seed = || {
|
||||||
let layout = match MapLayout::load(&assets, &settings.map) {
|
MapLayout::load(&game_core::assets_dir(), &settings.map)
|
||||||
Ok(layout) => layout,
|
.map_err(map_io::MapIoError::from)
|
||||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
.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;
|
let mut save = None;
|
||||||
if let Some(storage) = &settings.storage {
|
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,
|
Ok(opened) => opened,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return crate::fail(
|
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);
|
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||||
if loaded.is_some() && storage.world.map != settings.map {
|
if storage.world.map != settings.map {
|
||||||
warn!(
|
warn!(
|
||||||
"world was created on map `{}`, now running `{}`",
|
"world was created on map `{}`, now running `{}`",
|
||||||
storage.world.map, settings.map
|
storage.world.map, settings.map
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
save = loaded;
|
save = Some(loaded);
|
||||||
commands.insert_resource(storage);
|
commands.insert_resource(storage);
|
||||||
}
|
}
|
||||||
match save {
|
let (layout, save) = match save {
|
||||||
Some(save) => {
|
Some(mut save) => {
|
||||||
info!(
|
info!(
|
||||||
"restoring persistent world: {} buildings, {} items, {} characters",
|
"restoring persistent world: {} buildings, {} items, {} characters",
|
||||||
save.buildings.len(),
|
save.buildings.len(),
|
||||||
save.items.len(),
|
save.items.len(),
|
||||||
save.characters.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 => {
|
None => {
|
||||||
info!("creating a new world from `{}`", settings.map);
|
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)));
|
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.
|
/// Spawns a building with its default state. `vars` override the prototype's variables.
|
||||||
pub fn spawn_building(
|
pub fn spawn_building(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
|
|
@ -128,12 +97,7 @@ pub fn spawn_building(
|
||||||
vars: BTreeMap<String, i64>,
|
vars: BTreeMap<String, i64>,
|
||||||
) -> Option<Entity> {
|
) -> Option<Entity> {
|
||||||
let def = data.buildings.get(proto)?;
|
let def = data.buildings.get(proto)?;
|
||||||
let mut state = BuildingState {
|
let mut state = BuildingState::new(def);
|
||||||
solid: def.solid,
|
|
||||||
variant: None,
|
|
||||||
health: def.max_health,
|
|
||||||
vars: def.vars.clone(),
|
|
||||||
};
|
|
||||||
state.vars.extend(vars);
|
state.vars.extend(vars);
|
||||||
let machine = data.machines.get(proto).map(Machine::new);
|
let machine = data.machines.get(proto).map(Machine::new);
|
||||||
Some(spawn_building_with_state(
|
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)
|
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 {
|
fn count(app: &App, me: Entity, item: &str) -> u32 {
|
||||||
app.world().get::<Inventory>(me).unwrap().count(item)
|
app.world().get::<Inventory>(me).unwrap().count(item)
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +554,78 @@ mod network {
|
||||||
app.world().resource::<Inbox>()
|
app.world().resource::<Inbox>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_server(
|
||||||
|
settings: ServerSettings,
|
||||||
|
config: ServerConfig,
|
||||||
|
) -> (EmbeddedServer, SocketAddr) {
|
||||||
|
let server = EmbeddedServer::start(settings, config, None, Ipv4Addr::LOCALHOST.into());
|
||||||
|
let start = Instant::now();
|
||||||
|
let addr = loop {
|
||||||
|
match server.poll() {
|
||||||
|
Some(ServerStatus::Started(addr)) => break addr,
|
||||||
|
Some(other) => panic!("{other:?}"),
|
||||||
|
None if start.elapsed() > Duration::from_secs(30) => panic!("server did not start"),
|
||||||
|
None => std::thread::sleep(Duration::from_millis(10)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(server, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiled_import_replicates_terrain_to_existing_and_joining_clients() {
|
||||||
|
use game_server::map_io;
|
||||||
|
|
||||||
|
let mut config = ServerConfig {
|
||||||
|
port: 0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
config.database.save = false;
|
||||||
|
let settings = ServerSettings {
|
||||||
|
owner: Some(HOST.into()),
|
||||||
|
..config.settings()
|
||||||
|
};
|
||||||
|
let (server, addr) = start_server(settings, config);
|
||||||
|
let mut host = client(addr);
|
||||||
|
join(&mut host, HOST, "Host", "");
|
||||||
|
let mut guest = client(addr);
|
||||||
|
join(&mut guest, GUEST, "Guest", "");
|
||||||
|
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]
|
#[test]
|
||||||
fn password_admins_and_bans() {
|
fn password_admins_and_bans() {
|
||||||
let mut config = ServerConfig {
|
let mut config = ServerConfig {
|
||||||
|
|
@ -426,16 +638,7 @@ mod network {
|
||||||
owner: Some(HOST.into()),
|
owner: Some(HOST.into()),
|
||||||
..config.settings()
|
..config.settings()
|
||||||
};
|
};
|
||||||
let server = EmbeddedServer::start(settings, config, None, Ipv4Addr::LOCALHOST.into());
|
let (server, addr) = start_server(settings, config);
|
||||||
let start = Instant::now();
|
|
||||||
let addr = loop {
|
|
||||||
match server.poll() {
|
|
||||||
Some(ServerStatus::Started(addr)) => break addr,
|
|
||||||
Some(other) => panic!("{other:?}"),
|
|
||||||
None if start.elapsed() > Duration::from_secs(30) => panic!("server did not start"),
|
|
||||||
None => std::thread::sleep(Duration::from_millis(10)),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut intruder = client(addr);
|
let mut intruder = client(addr);
|
||||||
join(&mut intruder, GUEST, "Guest", "wrong");
|
join(&mut intruder, GUEST, "Guest", "wrong");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue