fix(tiled): enforce tmx exports and group objects by category

Normalize export filenames to .tmx and return the actual saved path in command replies. Separate floors, structures, ground items and spawn points into named object layers, with a reserved wiring/plumbing layer. Reuse the same categories in the Tiled converter and preserve undo behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 22:20:13 +02:00
parent 8f24528304
commit 29275c38fc
7 changed files with 297 additions and 60 deletions

View File

@ -59,6 +59,9 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed ### Changed
- Tiled exports always use the `.tmx` extension and separate floors, structures,
ground items and spawn points into named object layers, with a reserved
wiring/plumbing layer. The Tiled converter uses the same layer organization.
- Tiled property overrides apply to new-world seeds as well as imported maps. - Tiled property overrides apply to new-world seeds as well as imported maps.
Terrain is persisted with world state and replicated to connected clients. Terrain is persisted with world state and replicated to connected clients.
Save databases migrate automatically to schema version 2. Save databases migrate automatically to schema version 2.

View File

@ -417,7 +417,9 @@ copy `station.js` there, then enable/reload it (or restart Tiled).
Paint the marked tiles normally, then choose **Map > Station: Convert Painted Paint the marked tiles normally, then choose **Map > Station: Convert Painted
Tiles to Objects**. The extension replaces all marked painted cells with selected Tiles to Objects**. The extension replaces all marked painted cells with selected
`building` point objects in a new **Station gameplay** object layer. Select one `building` point objects in separate **Floors** and **Objects** layers. Existing
layers with those names are reused. The converter also creates **Wiring/Plumbing**,
**Ground Items** and **Triggers** layers when missing. Select an object
to edit its health, solidity or other custom properties in the Properties panel. to edit its health, solidity or other custom properties in the Properties panel.
Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer
overlaps abort the entire conversion. Conversion is a single undoable operation, overlaps abort the entire conversion. Conversion is a single undoable operation,
@ -439,11 +441,20 @@ and coordinate conventions; conversion is optional before loading a map.
beside the database. With PostgreSQL it defaults to `saves/<world-uuid>/world.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 without saving, `saves/world.tmx`. An explicit path, including spaces, may follow
either command; relative paths are relative to the server's working directory. either command; relative paths are relative to the server's working directory.
Exports always use `.tmx`: a missing extension is added and any other extension
is replaced (for example, `/export repair.backup` saves `repair.tmx`). The reply
reports the actual filename; use that filename when importing.
2. Open that file in Tiled, edit it and save. Buildings and ground items are point 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. objects with their prototype and full mutable state in custom properties.
Terrain layers and collision properties are included. Tilesets are embedded Terrain layers and collision properties are included. Tilesets are embedded
with absolute image references into the server's assets directory; when editing with absolute image references into the server's assets directory; when editing
on another machine, relink the images to that machine's matching assets. on another machine, relink the images to that machine's matching assets.
Objects are organized into **Floors**, **Objects** (walls, machines and other
structures), **Ground Items**, and **Triggers** (currently spawn points).
**Wiring/Plumbing** is an empty organizational placeholder; it does not add
wiring, plumbing or trigger mechanics. Hide or lock individual layers in Tiled
to edit one category without the others getting in the way. Object classes and
properties determine gameplay, not the editor layer names.
3. Run `/import` (or `/import FILE`). **Import replaces all terrain, buildings and 3. Run `/import` (or `/import FILE`). **Import replaces all terrain, buildings and
ground items, including deletions.** It does not merge objects or change online ground items, including deletions.** It does not merge objects or change online
or offline characters, their positions or inventories. Export a separate backup or offline characters, their positions or inventories. Export a separate backup

View File

@ -103,10 +103,28 @@ function stationConvert(map) {
if (planned.length === 0) if (planned.length === 0)
return 0; return 0;
const names = ["Floors", "Wiring/Plumbing", "Objects", "Ground Items", "Triggers"];
const destinations = new Map();
for (const name of names) {
const matches = layers.filter(layer => layer.isObjectLayer && layer.name === name);
if (matches.length > 1)
throw new Error("Multiple object layers named " + name + "; rename them before converting.");
if (matches.length === 1) {
if (matches[0].locked)
throw new Error("Unlock object layer before converting: " + name);
destinations.set(name, matches[0]);
}
}
map.macro("Convert Station painted tiles to objects", function () { map.macro("Convert Station painted tiles to objects", function () {
const objects = new ObjectGroup(); for (const name of names) {
objects.name = "Station gameplay"; if (!destinations.has(name)) {
map.addLayer(objects); const objects = new ObjectGroup();
objects.name = name;
map.addLayer(objects);
destinations.set(name, objects);
}
}
const selected = [];
const edits = new Map(); const edits = new Map();
for (const entry of planned) { for (const entry of planned) {
const object = new MapObject(); const object = new MapObject();
@ -116,14 +134,16 @@ function stationConvert(map) {
object.x = (entry.x + 0.5) * map.tileWidth; object.x = (entry.x + 0.5) * map.tileWidth;
object.y = (entry.y + 0.5) * map.tileHeight; object.y = (entry.y + 0.5) * map.tileHeight;
object.setProperties(entry.properties); object.setProperties(entry.properties);
objects.addObject(object); const name = stationLayer(entry.properties, prototypeLayers) === "floor" ? "Floors" : "Objects";
destinations.get(name).addObject(object);
selected.push(object);
if (!edits.has(entry.layer)) if (!edits.has(entry.layer))
edits.set(entry.layer, entry.layer.edit()); edits.set(entry.layer, entry.layer.edit());
edits.get(entry.layer).setTile(entry.x, entry.y, null); edits.get(entry.layer).setTile(entry.x, entry.y, null);
} }
for (const edit of edits.values()) for (const edit of edits.values())
edit.apply(); edit.apply();
map.selectedObjects = objects.objects; map.selectedObjects = selected;
}); });
return planned.length; return planned.length;
} }

View File

@ -229,10 +229,10 @@ pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec<Stri
let result = if command == "export" { let result = if command == "export" {
map_io::export(world, &path) map_io::export(world, &path)
} else { } else {
map_io::import(world, &path) map_io::import(world, &path).map(|()| path.clone())
}; };
match result { match result {
Ok(()) => { Ok(path) => {
info!("{actor}: {command} {}", path.display()); info!("{actor}: {command} {}", path.display());
let mut message = format!("World {command}ed: {}", path.display()); let mut message = format!("World {command}ed: {}", path.display());
if command == "import" && !world.contains_resource::<persistence::Storage>() { if command == "import" && !world.contains_resource::<persistence::Storage>() {

View File

@ -181,7 +181,7 @@ pub fn default_path(world: &World) -> PathBuf {
) )
} }
pub fn export(world: &mut World, path: &Path) -> Result<(), MapIoError> { pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
let mut save = persistence::snapshot(world); let mut save = persistence::snapshot(world);
let data = world.resource::<GameData>(); let data = world.resource::<GameData>();
if let Some(layout) = &mut save.layout { if let Some(layout) = &mut save.layout {
@ -311,7 +311,8 @@ fn write_properties(xml: &mut Xml, fields: &BTreeMap<String, Value>) -> Result<(
Ok(()) Ok(())
} }
pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> { pub fn write(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
let path = path.with_extension("tmx");
let layout = save let layout = save
.layout .layout
.as_ref() .as_ref()
@ -442,67 +443,89 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> {
end(&mut xml, "data")?; end(&mut xml, "data")?;
end(&mut xml, "layer")?; end(&mut xml, "layer")?;
} }
tag(
&mut xml,
"objectgroup",
&[
("id", (layout.layers.len() + 1).to_string()),
("name", "World".into()),
],
false,
)?;
let mut id = 1; let mut id = 1;
for object in layout.objects.iter().filter(|o| o.class == "spawn") { for (index, name) in [
write_object( "Floors",
"Wiring/Plumbing",
"Objects",
"Ground Items",
"Triggers",
]
.iter()
.enumerate()
{
tag(
&mut xml, &mut xml,
id, "objectgroup",
"spawn", &[
&object.name, ("id", (layout.layers.len() + index + 1).to_string()),
object.pos, ("name", (*name).into()),
layout, ("draworder", "topdown".into()),
&BTreeMap::new(), ],
false,
)?; )?;
id += 1; for b in save.buildings.iter().filter(|b| {
} if b.state.layer == game_core::data::BuildingLayer::Floor {
for b in &save.buildings { *name == "Floors"
let mut fields = properties(&b.state, "state.")?; } else {
fields.insert("proto".into(), Value::String(b.proto.clone())); *name == "Objects"
if let Some(machine) = &b.machine { }
fields.extend(properties(machine, "machine.")?); }) {
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;
} }
write_object(&mut xml, id, "building", &b.proto, b.pos, layout, &fields)?; if *name == "Ground Items" {
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;
}
}
if *name == "Triggers" {
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;
}
}
end(&mut xml, "objectgroup")?;
} }
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")?; end(&mut xml, "map")?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&temporary, xml.into_inner())?; std::fs::write(&temporary, xml.into_inner())?;
if let Err(error) = std::fs::rename(&temporary, path) { if let Err(error) = std::fs::rename(&temporary, &path) {
if let Err(cleanup) = std::fs::remove_file(&temporary) { if let Err(cleanup) = std::fs::remove_file(&temporary) {
warn!("failed to remove {}: {cleanup}", temporary.display()); warn!("failed to remove {}: {cleanup}", temporary.display());
} }
return Err(error.into()); return Err(error.into());
} }
Ok(()) Ok(path)
} }
fn write_object( fn write_object(
@ -554,6 +577,130 @@ mod tests {
std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4())) std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4()))
} }
#[test]
fn writer_uses_tmx_extension_and_separates_object_categories() {
let (_, save) = seeded();
for extension in ["", "backup", "TMX", "tmx"] {
let requested = path().with_extension(extension);
let saved = write(&save, &requested).unwrap();
assert_eq!(saved, requested.with_extension("tmx"));
assert!(saved.is_file());
if requested != saved {
assert!(!requested.exists());
}
let mut reader = quick_xml::Reader::from_file(&saved).unwrap();
let mut buffer = Vec::new();
let mut groups = BTreeMap::<String, usize>::new();
let mut current = None;
let mut layer_ids = BTreeSet::new();
let mut object_ids = BTreeSet::new();
loop {
match reader.read_event_into(&mut buffer).unwrap() {
Event::Start(element) if element.name().as_ref() == b"objectgroup" => {
let mut name = None;
for attribute in element.attributes() {
let attribute = attribute.unwrap();
let value = attribute
.decoded_and_normalized_value(
quick_xml::XmlVersion::Implicit1_0,
reader.decoder(),
)
.unwrap()
.into_owned();
match attribute.key.as_ref() {
b"name" => name = Some(value),
b"id" => assert!(layer_ids.insert(value)),
_ => {}
}
}
let name = name.unwrap();
assert!(groups.insert(name.clone(), 0).is_none());
current = Some(name);
}
Event::Start(element) if element.name().as_ref() == b"object" => {
*groups.get_mut(current.as_ref().unwrap()).unwrap() += 1;
for attribute in element.attributes() {
let attribute = attribute.unwrap();
if attribute.key.as_ref() == b"id" {
assert!(
object_ids.insert(
attribute
.decoded_and_normalized_value(
quick_xml::XmlVersion::Implicit1_0,
reader.decoder()
)
.unwrap()
.into_owned()
)
);
}
}
}
Event::End(element) if element.name().as_ref() == b"objectgroup" => {
current = None
}
Event::Empty(element) if element.name().as_ref() == b"property" => {
let attributes: BTreeMap<_, _> = element
.attributes()
.map(|attribute| {
let attribute = attribute.unwrap();
(
attribute.key.as_ref().to_vec(),
attribute
.decoded_and_normalized_value(
quick_xml::XmlVersion::Implicit1_0,
reader.decoder(),
)
.unwrap()
.into_owned(),
)
})
.collect();
if attributes
.get(b"name".as_slice())
.is_some_and(|name| name == "state.layer")
&& current.is_some()
{
let expected = if attributes[b"value".as_slice()] == "\"floor\"" {
"Floors"
} else {
"Objects"
};
assert_eq!(current.as_deref(), Some(expected));
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let floors = save
.buildings
.iter()
.filter(|b| b.state.layer == game_core::data::BuildingLayer::Floor)
.count();
assert!(floors > 0);
assert_eq!(
groups,
BTreeMap::from([
("Floors".into(), floors),
("Wiring/Plumbing".into(), 0),
("Objects".into(), save.buildings.len() - floors),
("Ground Items".into(), save.items.len()),
(
"Triggers".into(),
save.layout
.as_ref()
.unwrap()
.objects_of_class("spawn")
.count()
),
])
);
std::fs::remove_file(saved).unwrap();
}
}
fn fixture(layers: &str, properties: &str) -> Result<MapLayout, game_core::map::MapError> { fn fixture(layers: &str, properties: &str) -> Result<MapLayout, game_core::map::MapError> {
let file = path(); let file = path();
let tileset = game_core::assets_dir().join("tilesets/station.tsx"); let tileset = game_core::assets_dir().join("tilesets/station.tsx");
@ -757,7 +904,10 @@ mod tests {
let file = path(); let file = path();
write(&save, &file).unwrap(); write(&save, &file).unwrap();
let loaded = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(); let loaded = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap();
let roundtrip = decode(loaded, &data).unwrap(); let mut roundtrip = decode(loaded, &data).unwrap();
let key = |b: &SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
roundtrip.buildings.sort_by_key(key);
save.buildings.sort_by_key(key);
assert_eq!(roundtrip.buildings, save.buildings); assert_eq!(roundtrip.buildings, save.buildings);
assert_eq!(roundtrip.items, save.items); assert_eq!(roundtrip.items, save.items);
let loaded = roundtrip.layout.unwrap(); let loaded = roundtrip.layout.unwrap();

View File

@ -96,9 +96,11 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
let reply = commands::execute( let reply = commands::execute(
app.world_mut(), app.world_mut(),
CommandSource::Console, CommandSource::Console,
&format!("export {filename}"), &format!("export {}", file.with_extension("backup").display()),
); );
assert!(reply[0].contains("exported"), "{reply:?}"); assert!(reply[0].contains("exported"), "{reply:?}");
assert!(reply[0].ends_with(filename), "{reply:?}");
assert!(!file.with_extension("backup").exists());
let original = std::fs::read_to_string(&file).unwrap(); let original = std::fs::read_to_string(&file).unwrap();
assert!( assert!(
!original.contains("MapAdmin"), !original.contains("MapAdmin"),
@ -182,11 +184,14 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
map_io::write(&expected, &file).unwrap(); map_io::write(&expected, &file).unwrap();
map_io::import(app.world_mut(), &file).unwrap(); map_io::import(app.world_mut(), &file).unwrap();
let storage = app.world().resource::<persistence::Storage>(); let storage = app.world().resource::<persistence::Storage>();
let persisted = storage let mut persisted = storage
.runtime .runtime
.block_on(storage.db.load_world(storage.world.id)) .block_on(storage.db.load_world(storage.world.id))
.unwrap(); .unwrap();
expected.characters = imported.characters; expected.characters = imported.characters;
let key = |b: &persistence::SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
persisted.buildings.sort_by_key(key);
expected.buildings.sort_by_key(key);
assert_eq!(persisted.buildings, expected.buildings); assert_eq!(persisted.buildings, expected.buildings);
assert_eq!(persisted.items, expected.items); assert_eq!(persisted.items, expected.items);
assert_eq!(persisted.characters, expected.characters); assert_eq!(persisted.characters, expected.characters);

View File

@ -51,10 +51,13 @@ function harness() {
const layers = [...this.layers]; const layers = [...this.layers];
const cells = layers.filter(layer => layer.isTileLayer) const cells = layers.filter(layer => layer.isTileLayer)
.map(layer => [layer, new Map(layer.cells)]); .map(layer => [layer, new Map(layer.cells)]);
const objects = layers.filter(layer => layer.isObjectLayer)
.map(layer => [layer, [...layer.objects]]);
callback(); callback();
this.undo = () => { this.undo = () => {
this.layers = layers; this.layers = layers;
for (const [layer, snapshot] of cells) layer.cells = snapshot; for (const [layer, snapshot] of cells) layer.cells = snapshot;
for (const [layer, snapshot] of objects) layer.objects = snapshot;
}; };
}, },
}; };
@ -98,7 +101,12 @@ test("converts floors and walls together, retaining properties, flags and decora
map.layers.push(ground, wall); map.layers.push(ground, wall);
assert.equal(context.stationConvert(map), 2); assert.equal(context.stationConvert(map), 2);
assert.equal(map.macros, 1); assert.equal(map.macros, 1);
const objects = map.layers[2].objects; assert.deepEqual(map.layers.slice(2).map(layer => layer.name),
["Floors", "Wiring/Plumbing", "Objects", "Ground Items", "Triggers"]);
const objects = [map.layers[2].objects[0], map.layers[4].objects[0]];
assert.equal(map.layers[2].objects.length, 1);
assert.equal(map.layers[3].objects.length, 0);
assert.equal(map.layers[4].objects.length, 1);
assert.equal(objects[0].className, "building"); assert.equal(objects[0].className, "building");
assert.equal(objects[0].shape, "point"); assert.equal(objects[0].shape, "point");
assert.equal(objects[0].x, 16); assert.equal(objects[0].x, 16);
@ -115,7 +123,7 @@ test("converts floors and walls together, retaining properties, flags and decora
assert.equal(ground.tileAt(0, 0), null); assert.equal(ground.tileAt(0, 0), null);
assert.notEqual(ground.tileAt(1, 0), null); assert.notEqual(ground.tileAt(1, 0), null);
assert.equal(wall.tileAt(0, 0), null); assert.equal(wall.tileAt(0, 0), null);
assert.equal(map.selectedObjects, objects); assert.deepEqual([...map.selectedObjects], objects);
assert.equal(context.stationConvert(map), 0); assert.equal(context.stationConvert(map), 0);
assert.equal(map.macros, 1); assert.equal(map.macros, 1);
map.undo(); map.undo();
@ -124,6 +132,46 @@ test("converts floors and walls together, retaining properties, flags and decora
assert.notEqual(wall.tileAt(0, 0), null); assert.notEqual(wall.tileAt(0, 0), null);
}); });
test("reuses categorized object layers and undoes additions without changing existing objects", () => {
const {map, context} = harness();
const floors = new context.ObjectGroup();
floors.name = "Floors";
const existing = {className: "building", x: 48, y: 48, tile: null,
properties: () => ({proto: "floor", "state.layer": '"floor"'})};
floors.addObject(existing);
const objects = new context.ObjectGroup();
objects.name = "Objects";
map.layers.push(floors, objects, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]]));
assert.equal(context.stationConvert(map), 1);
assert.equal(map.layers.filter(layer => layer.name === "Floors").length, 1);
assert.equal(floors.objects.length, 2);
assert.equal(floors.objects[0], existing);
assert.equal(objects.objects.length, 0);
map.undo();
assert.equal(floors.objects.length, 1);
assert.equal(floors.objects[0], existing);
assert.equal(map.layers.length, 3);
});
test("refuses ambiguous or locked destination layers before modifying the map", () => {
for (const duplicate of [false, true]) {
const {map, context} = harness();
const floors = new context.ObjectGroup();
floors.name = "Floors";
map.layers.push(floors, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]]));
if (duplicate) {
const second = new context.ObjectGroup();
second.name = "Floors";
map.layers.push(second);
} else {
floors.locked = true;
}
assert.throws(() => context.stationConvert(map), duplicate ? /Multiple object layers/ : /Unlock object layer/);
assert.equal(map.macros, 0);
assert.equal(floors.objects.length, 0);
}
});
test("rejects same-layer duplicates before making changes", () => { test("rejects same-layer duplicates before making changes", () => {
for (const properties of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) { for (const properties of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) {
const {map, context} = harness(); const {map, context} = harness();