From 823f0f76e64d5ec302f7b524e64270b062933b03 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Thu, 22 Oct 2026 01:01:04 +0200 Subject: [PATCH] feat(server)!: export worlds as ready-to-use Tiled projects `export [DIR]` now writes a project directory containing world.tmx, a station.tiled-project that loads the bundled extensions, a copy of the extensions, relative tileset image copies and a README. An existing project file is kept on re-export. `import` accepts the directory or a map file; copied tileset images map back to the identical asset paths. Add the Tiled action "Station: Convert All Project Maps", which converts painted gameplay tiles in every map of the opened project at once and leaves the results open for review. BREAKING CHANGE: the export path is now a directory instead of a .tmx file, and default export locations changed to ..tiled/, saves// or saves/world/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +- README.md | 48 +++++--- assets/tiled/station.js | 67 ++++++++++- crates/game_core/src/map.rs | 37 ++++-- crates/game_server/src/commands.rs | 8 +- crates/game_server/src/map_io.rs | 152 ++++++++++++++++++++++++- crates/game_server/src/persistence.rs | 6 +- crates/game_server/tests/simulation.rs | 69 ++++++++--- tools/tiled.test.cjs | 87 ++++++++++++++ 9 files changed, 428 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2dbc8..acb3bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Breaking:** `export [DIR]` writes a ready-to-use Tiled project directory + (`world.tmx`, `station.tiled-project`, the Station extensions and relative + tileset image copies) instead of a single map file; `import` accepts that + directory or a map file. - Tiled exports and newly created conversion layers place Objects above Ground Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing hidden by default in the editor without affecting gameplay. ### Added +- **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay + tiles in every map of the opened project at once. - A Station Object Brush in Tiled with per-tile brush property settings and independently editable wall/floor objects, drag previews and stroke undo. Open the bundled `assets/station.tiled-project` to load Station extensions @@ -26,7 +32,7 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). removing floors exposes the map's configurable default terrain. - A bundled Tiled extension to convert painted gameplay tiles into editable objects, preserving properties and flips with conflict checks and undo support. -- Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of +- Admin `export` and `import` 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. - Native chat text editing with word navigation, keyboard/mouse selection, system diff --git a/README.md b/README.md index 853cc31..b5aefb9 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately. | `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights | | `rejected` | admins | Recently rejected logins (with player ids) | | `save` | admins | Save the world now | -| `export [FILE]` | admins | Export terrain, buildings and ground items to Tiled | -| `import [FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters | +| `export [DIR]` | admins | Export terrain, buildings and ground items as a Tiled project | +| `import [DIR\|FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters | | `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` | | `stop` | console, host | Save and shut down | @@ -454,6 +454,13 @@ Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer overlaps abort the entire conversion. Conversion is a single undoable operation, and running it again does not duplicate objects. +To convert every map at once, choose **Map > Station: Convert All Project Maps**. +It opens each `.tmx` file in the project's folders (including subfolders), +converts it as above and reports how many maps and tiles changed, plus any maps +that failed. Converted maps stay open and unsaved so you can review or undo them +per map; use **File > Save All** to keep the result. Maps without painted +gameplay tiles are closed again unless they were already open. + For custom tiles, set `proto` to a building ID and add the desired `state.*` or `machine.*` defaults on the tileset tile. Floor tiles should include `state.layer` as the string `"floor"` (with JSON quotes) so the extension can check occupancy. @@ -465,19 +472,29 @@ and coordinate conventions; conversion is optional before loading a map. #### Editing a running world's map -1. Run `/export` in chat, or `export` in the server terminal. The reply shows the - server-local file path. With SQLite it defaults to `..tmx` - beside the database. With PostgreSQL it defaults to `saves//world.tmx`; - without saving, `saves/world.tmx`. An explicit path, including spaces, may follow - either command; relative paths are relative to the server's working directory. - 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 +1. Run `/export` in chat, or `export` in the server terminal. It writes a + ready-to-use Tiled project directory and replies with the path of its + `station.tiled-project` file (a server-local path). With SQLite the directory + defaults to `..tiled/` beside the database. With + PostgreSQL it defaults to `saves//`; without saving, `saves/world/`. + An explicit directory, including spaces, may follow either command; relative + paths are relative to the server's working directory. The directory contains: + - `world.tmx`: the exported map; + - `station.tiled-project`: a Tiled project that loads the bundled extensions; + - `extensions/`: a copy of `assets/tiled/` (refreshed on every export); + - `tilesets/`: copies of the tileset images, referenced relatively, so the + directory can be copied to another machine and opened there; + - `README.txt`: short usage instructions. + + Re-exporting into the same directory overwrites the map, extensions, images and + README but keeps an existing project file, so project settings survive. +2. Open `station.tiled-project` in Tiled 1.10.1 or newer (**Project > Open + Project**), click **Enable Extensions** when asked, then open `world.tmx` + from the Project panel. Edit 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. + Terrain layers and collision properties are included. The tileset image copies + are for display only: on import they map back to the server's identical asset + paths, and editing them does not change the game. 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 @@ -488,7 +505,8 @@ and coordinate conventions; conversion is optional before loading a map. hidden layers still load into the game. 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. Copy the directory back to the server if you edited it elsewhere, then run + `/import` (or `/import DIR`, or `/import FILE` for a single `.tmx` 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. diff --git a/assets/tiled/station.js b/assets/tiled/station.js index 3b63170..d681a76 100644 --- a/assets/tiled/station.js +++ b/assets/tiled/station.js @@ -1,4 +1,4 @@ -/* global tiled, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */ +/* global tiled, File, FileInfo, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */ "use strict"; function stationGameplayProperties(properties) { @@ -168,6 +168,70 @@ const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles", }); stationConvertAction.text = "Station: Convert Painted Tiles to Objects"; +function stationProjectMapFiles(project) { + if (!project || !project.fileName || project.folders.length === 0) + throw new Error("Open a Station project first (Project > Open Project...)."); + const files = new Set(); + function visit(dir) { + const fileFilter = File.Files | File.NoSymLinks; + for (const name of File.directoryEntries(dir, fileFilter)) { + if (name.toLowerCase().endsWith(".tmx")) + files.add(FileInfo.cleanPath(dir + "/" + name)); + } + // Symlinked folders are skipped so link cycles cannot loop forever. + const dirFilter = File.Dirs | File.NoDotAndDotDot | File.NoSymLinks; + for (const name of File.directoryEntries(dir, dirFilter)) + visit(dir + "/" + name); + } + for (const folder of project.folders) + visit(folder); + return [...files].sort(); +} + +// Converted maps stay open and unsaved so the result can be reviewed and undone per map. +function stationConvertProject() { + const files = stationProjectMapFiles(tiled.project); + const previous = tiled.activeAsset; + const report = {maps: 0, tiles: 0, failures: []}; + for (const file of files) { + const wasOpen = tiled.openAssets.some(asset => FileInfo.cleanPath(asset.fileName) === file); + const asset = tiled.open(file); + try { + if (!asset) + throw new Error("could not open the map"); + const count = stationConvert(asset); + if (count > 0) { + report.maps++; + report.tiles += count; + } else if (!wasOpen) { + tiled.close(asset); + } + } catch (error) { + report.failures.push(file + ": " + error.message); + if (asset && !wasOpen && !asset.modified) + tiled.close(asset); + } + } + if (previous && tiled.openAssets.includes(previous)) + tiled.activeAsset = previous; + return report; +} + +const stationConvertProjectAction = tiled.registerAction("StationConvertProjectMaps", function () { + try { + const report = stationConvertProject(); + let message = "Station: converted " + report.tiles + " painted tiles in " + report.maps + + " maps. Review them and use File > Save All."; + if (report.failures.length > 0) + message += "\n\nNot converted:\n" + report.failures.join("\n"); + tiled.log(message); + tiled.alert(message); + } catch (error) { + tiled.alert("Station conversion failed: " + error.message); + } +}); +stationConvertProjectAction.text = "Station: Convert All Project Maps"; + const stationBrushProperties = new Map(); function stationBrushSettings(tile) { @@ -343,6 +407,7 @@ stationPropertiesAction.text = "Station: Brush Properties"; if (tiled.mapEditor) { tiled.extendMenu("Map", [ {action: "StationConvertPaintedTiles"}, + {action: "StationConvertProjectMaps"}, {action: "StationBrushProperties"}, ]); tiled.registerTool("StationObjectBrush", { diff --git a/crates/game_core/src/map.rs b/crates/game_core/src/map.rs index 5c02a03..f4c0859 100644 --- a/crates/game_core/src/map.rs +++ b/crates/game_core/src/map.rs @@ -87,18 +87,29 @@ pub enum MapError { impl MapLayout { /// Loads an asset-relative or absolute map path. pub fn load(assets_dir: &Path, map_path: &str) -> Result { - Self::load_inner(assets_dir, map_path, true) + Self::load_inner(assets_dir, map_path, true, None) + } + + /// Like [`Self::load`], but tileset images inside `mirror` stand for the asset at the same + /// relative path, so exported projects carrying image copies stay portable. + pub fn load_with_image_mirror( + assets_dir: &Path, + map_path: &str, + mirror: &Path, + ) -> Result { + Self::load_inner(assets_dir, map_path, true, Some(mirror)) } /// Legacy saves have entity state but no terrain snapshot; do not reseed their entities. pub fn load_legacy_terrain(assets_dir: &Path, map_path: &str) -> Result { - Self::load_inner(assets_dir, map_path, false) + Self::load_inner(assets_dir, map_path, false, None) } fn load_inner( assets_dir: &Path, map_path: &str, convert_painted: bool, + mirror: Option<&Path>, ) -> Result { let full = assets_dir.join(map_path); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); @@ -115,6 +126,7 @@ impl MapLayout { let assets_canon = assets_dir .canonicalize() .unwrap_or_else(|_| assets_dir.into()); + let mirror_canon = mirror.and_then(|m| m.canonicalize().ok()); let mut tilesets = Vec::new(); for ts in map.tilesets() { let image = ts.image.as_ref().ok_or_else(|| { @@ -124,12 +136,21 @@ impl MapLayout { )) })?; let image_path = image.source.canonicalize().unwrap_or(image.source.clone()); - let rel = image_path.strip_prefix(&assets_canon).map_err(|_| { - unsupported(format!( - "tileset image `{}` must be inside the assets directory", - image.source.display() - )) - })?; + let rel = image_path + .strip_prefix(&assets_canon) + .ok() + .or_else(|| { + mirror_canon + .as_deref() + .and_then(|m| image_path.strip_prefix(m).ok()) + .filter(|rel| assets_canon.join(rel).is_file()) + }) + .ok_or_else(|| { + unsupported(format!( + "tileset image `{}` must be inside the assets directory", + image.source.display() + )) + })?; tilesets.push(TilesetInfo { image: rel.to_string_lossy().replace('\\', "/"), image_width: image.width as u32, diff --git a/crates/game_server/src/commands.rs b/crates/game_server/src/commands.rs index 11f0b28..9602d97 100644 --- a/crates/game_server/src/commands.rs +++ b/crates/game_server/src/commands.rs @@ -82,13 +82,13 @@ const COMMANDS: &[(&str, &str, Permission)] = &[ ("rejected", "recently rejected logins", Permission::Admin), ("save", "save the world now", Permission::Admin), ( - "export [FILE]", - "export the world to a Tiled map", + "export [DIR]", + "export the world to a Tiled project directory", Permission::Admin, ), ( - "import [FILE]", - "replace the world from a Tiled map (keeps characters)", + "import [DIR|FILE]", + "replace the world from a Tiled project or map (keeps characters)", Permission::Admin, ), ( diff --git a/crates/game_server/src/map_io.rs b/crates/game_server/src/map_io.rs index c188368..77674c8 100644 --- a/crates/game_server/src/map_io.rs +++ b/crates/game_server/src/map_io.rs @@ -174,13 +174,57 @@ fn overlay( Ok(()) } +/// Map file inside an exported project directory. +pub const PROJECT_MAP: &str = "world.tmx"; +pub const PROJECT_FILE: &str = "station.tiled-project"; +const PROJECT: &str = r#"{ + "automappingRulesFile": "", + "commands": [], + "extensionsPath": "extensions", + "folders": [ + "." + ], + "propertyTypes": [] +} +"#; +const PROJECT_README: &str = "\ +Station world export + +1. Open station.tiled-project in Tiled 1.10.1 or newer (Project > Open Project...). +2. Click \"Enable Extensions\" when Tiled asks, then open world.tmx. +3. Edit the map. Use the Map menu entries starting with \"Station:\" to set brush + properties, convert painted tiles to objects, or convert every map in the project. +4. Save, then run `/import ` (or `import ` on the + server console). Importing replaces terrain, buildings and ground items. + +Tileset images are copies for display; changing them does not change the game. +"; + +/// Default export directory; import reads [`PROJECT_MAP`] inside it. pub fn default_path(world: &World) -> PathBuf { world.get_resource::().map_or_else( - || PathBuf::from("saves/world.tmx"), + || PathBuf::from("saves/world"), |storage| storage.map_export_path.clone(), ) } +/// A `.tmx` path names the map file directly; anything else is the project directory. +fn project_paths(path: &Path) -> (PathBuf, PathBuf) { + if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("tmx")) + { + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + (dir.to_path_buf(), path.to_path_buf()) + } else { + (path.to_path_buf(), path.join(PROJECT_MAP)) + } +} + +/// Exports to a ready-to-use Tiled project and returns the path of its project file. pub fn export(world: &mut World, path: &Path) -> Result { let mut save = persistence::snapshot(world); let data = world.resource::(); @@ -203,12 +247,57 @@ pub fn export(world: &mut World, path: &Path) -> Result { } } } - write(&save, path) + write_project(&save, path) } +/// Writes the map with the tileset images, Station extension and project file next to it. +pub fn write_project(save: &SaveGame, path: &Path) -> Result { + let (dir, map) = project_paths(path); + let layout = save + .layout + .as_ref() + .ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?; + std::fs::create_dir_all(&dir)?; + let assets = game_core::assets_dir(); + for ts in &layout.tilesets { + let target = dir.join(&ts.image); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(assets.join(&ts.image), target)?; + } + let extensions = dir.join("extensions"); + std::fs::create_dir_all(&extensions)?; + for entry in std::fs::read_dir(assets.join("tiled"))? { + let entry = entry?; + if entry.file_type()?.is_file() { + std::fs::copy(entry.path(), extensions.join(entry.file_name()))?; + } + } + // Keep admin customizations such as custom property types across re-exports. + let project = dir.join(PROJECT_FILE); + if !project.exists() { + std::fs::write(&project, PROJECT)?; + } + std::fs::write(dir.join("README.txt"), PROJECT_README)?; + write_map(save, &map, ImageSources::Relative)?; + Ok(project) +} + +/// Accepts a project directory (reads its [`PROJECT_MAP`]) or a map file. pub fn import(world: &mut World, path: &Path) -> Result<(), MapIoError> { + let path = if path.is_dir() { + path.join(PROJECT_MAP) + } else { + path.to_path_buf() + }; let path = path.canonicalize()?; - let layout = MapLayout::load(&game_core::assets_dir(), &path.to_string_lossy())?; + let mirror = path.parent().unwrap_or(Path::new(".")); + let layout = MapLayout::load_with_image_mirror( + &game_core::assets_dir(), + &path.to_string_lossy(), + mirror, + )?; let mut save = decode(layout, world.resource::())?; save.characters = persistence::snapshot(world).characters; if let Some(mut storage) = world.get_resource_mut::() { @@ -311,7 +400,20 @@ fn write_properties(xml: &mut Xml, fields: &BTreeMap) -> Result<( Ok(()) } +#[derive(Clone, Copy)] +enum ImageSources { + /// Absolute paths into the assets directory. + Assets, + /// Asset-relative paths resolved next to the map, where [`write_project`] copies images. + Relative, +} + +/// Writes a standalone map file that references the assets directory. pub fn write(save: &SaveGame, path: &Path) -> Result { + write_map(save, path, ImageSources::Assets) +} + +fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result { let path = path.with_extension("tmx"); let layout = save .layout @@ -368,7 +470,12 @@ pub fn write(save: &SaveGame, path: &Path) -> Result { &[ ( "source", - assets.join(&ts.image).to_string_lossy().replace('\\', "/"), + match images { + ImageSources::Assets => { + assets.join(&ts.image).to_string_lossy().replace('\\', "/") + } + ImageSources::Relative => ts.image.clone(), + }, ), ("width", ts.image_width.to_string()), ("height", ts.image_height.to_string()), @@ -586,6 +693,43 @@ mod tests { std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4())) } + #[test] + fn project_export_is_portable_and_keeps_admin_project_settings() { + let (_, save) = seeded(); + let dir = path().with_extension("project"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(PROJECT_FILE), "{\"custom\": true}").unwrap(); + let project = write_project(&save, &dir.join("custom.TMX")).unwrap(); + assert_eq!(project, dir.join(PROJECT_FILE)); + assert_eq!( + std::fs::read_to_string(&project).unwrap(), + "{\"custom\": true}" + ); + let map = dir.join("custom.tmx"); + assert!(map.is_file()); + assert!(dir.join("extensions/station.js").is_file()); + assert!(dir.join("README.txt").is_file()); + let assets = game_core::assets_dir(); + let loaded = + MapLayout::load_with_image_mirror(&assets, map.to_str().unwrap(), &dir).unwrap(); + assert_eq!( + loaded.tilesets[0].image, + save.layout.unwrap().tilesets[0].image + ); + let unknown = dir.join("tilesets/unknown.png"); + std::fs::rename(dir.join(&loaded.tilesets[0].image), &unknown).unwrap(); + let text = std::fs::read_to_string(&map).unwrap(); + std::fs::write( + &map, + text.replace(&loaded.tilesets[0].image, "tilesets/unknown.png"), + ) + .unwrap(); + let error = + MapLayout::load_with_image_mirror(&assets, map.to_str().unwrap(), &dir).unwrap_err(); + assert!(error.to_string().contains("inside the assets directory")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn writer_uses_tmx_extension_and_separates_object_categories() { let (data, save) = seeded(); diff --git a/crates/game_server/src/persistence.rs b/crates/game_server/src/persistence.rs index 123425a..4306088 100644 --- a/crates/game_server/src/persistence.rs +++ b/crates/game_server/src/persistence.rs @@ -536,9 +536,7 @@ pub struct Storage { impl Storage { pub fn new(runtime: Arc, db: Database, world: WorldInfo) -> Self { - let map_export_path = std::path::PathBuf::from("saves") - .join(world.id.to_string()) - .join("world.tmx"); + let map_export_path = std::path::PathBuf::from("saves").join(world.id.to_string()); Self { runtime, db, @@ -577,7 +575,7 @@ impl Storage { 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)); + storage.map_export_path = path.with_extension(format!("{}.tiled", storage.world.id)); } Ok((storage, save)) } diff --git a/crates/game_server/tests/simulation.rs b/crates/game_server/tests/simulation.rs index 0b34f4a..1642a30 100644 --- a/crates/game_server/tests/simulation.rs +++ b/crates/game_server/tests/simulation.rs @@ -73,7 +73,8 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma .query_filtered::>() .single(app.world()) .unwrap(); - let file = std::env::temp_dir().join(format!("station-import-{}.tmx", uuid::Uuid::new_v4())); + let dir = std::env::temp_dir().join(format!("station-import-{}", uuid::Uuid::new_v4())); + let file = dir.join(map_io::PROJECT_MAP); let filename = file.to_str().unwrap(); let ordinary_player = CommandSource::Player { client: ClientId::Server, @@ -83,10 +84,10 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma let denied = commands::execute( app.world_mut(), ordinary_player, - &format!("export {filename}"), + &format!("export {}", dir.display()), ); assert!(denied[0].contains("not allowed")); - assert!(!file.exists()); + assert!(!dir.exists()); let denied = commands::execute( app.world_mut(), ordinary_player, @@ -96,17 +97,21 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma let reply = commands::execute( app.world_mut(), CommandSource::Console, - &format!("export {}", file.with_extension("backup").display()), + &format!("export {}", dir.display()), ); assert!(reply[0].contains("exported"), "{reply:?}"); - assert!(reply[0].ends_with(filename), "{reply:?}"); - assert!(!file.with_extension("backup").exists()); + assert!( + reply[0].ends_with(&dir.join(map_io::PROJECT_FILE).display().to_string()), + "{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 mut map = + game_core::map::MapLayout::load_with_image_mirror(&game_core::assets_dir(), filename, &dir) + .unwrap(); let door = map .objects .iter_mut() @@ -132,7 +137,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma let reply = commands::execute( app.world_mut(), CommandSource::Console, - &format!("import {filename}"), + &format!("import {}", dir.display()), ); assert!(reply[0].contains("imported"), "{reply:?}"); let imported = persistence::snapshot(app.world_mut()); @@ -205,7 +210,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma 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(); + std::fs::remove_dir_all(dir).unwrap(); } fn count(app: &App, me: Entity, item: &str) -> u32 { @@ -321,8 +326,32 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai send(&mut app, SetMovement { x: 0, y: 0 }); assert!(app.world().get::(me).unwrap().0.x < target.x as f32); - map_io::export(app.world_mut(), &file).unwrap(); - map_io::import(app.world_mut(), &file).unwrap(); + let project = file.with_extension("project"); + let project_file = map_io::export(app.world_mut(), &project).unwrap(); + assert_eq!(project_file, project.join(map_io::PROJECT_FILE)); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&project_file).unwrap()).unwrap(); + assert_eq!(settings["extensionsPath"], "extensions"); + assert!(project.join("extensions/station.js").is_file()); + assert!(project.join("extensions/brush.svg").is_file()); + let map = std::fs::read_to_string(project.join(map_io::PROJECT_MAP)).unwrap(); + assert!( + map.contains(r#"source="tilesets/"#), + "images must be project-relative" + ); + let tilesets = app.world().resource::().0.tilesets.clone(); + for tileset in &tilesets { + assert!(project.join(&tileset.image).is_file()); + } + map_io::import(app.world_mut(), &project).unwrap(); + let images = |tilesets: &[game_core::map::TilesetInfo]| { + tilesets.iter().map(|t| t.image.clone()).collect::>() + }; + assert_eq!( + images(&app.world().resource::().0.tilesets), + images(&tilesets), + "copied images must resolve back to the shipped assets" + ); assert!( layers(&mut app).is_empty(), "removed floors must not be reseeded" @@ -343,6 +372,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai send(&mut app, SetMovement { x: 0, y: 0 }); assert!(app.world().get::(me).unwrap().0.x > target.x as f32 + 0.3); std::fs::remove_file(file).unwrap(); + std::fs::remove_dir_all(project).unwrap(); } #[test] @@ -773,17 +803,22 @@ mod network { 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())); + let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4())); + let file = dir.join(map_io::PROJECT_MAP); host.world_mut().client_trigger(Chat { - text: format!("/export {}", file.display()), + text: format!("/export {}", dir.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(); + let mut map = game_core::map::MapLayout::load_with_image_mirror( + &assets, + file.to_str().unwrap(), + &dir, + ) + .unwrap(); assert!(!map.is_solid(GridPos::new(0, 0))); map.layers[1].cells[0] = Some(game_core::map::TileCell { tileset: 0, @@ -795,7 +830,7 @@ mod network { 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()), + text: format!("/import {}", dir.display()), }); let imported = |app: &mut App| { let mut maps = app.world_mut().query::<&WorldMap>(); @@ -813,7 +848,7 @@ mod network { ); pump(&mut [&mut newcomer], imported); server.stop(); - std::fs::remove_file(file).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); } #[test] diff --git a/tools/tiled.test.cjs b/tools/tiled.test.cjs index f4cd938..1f98b5d 100644 --- a/tools/tiled.test.cjs +++ b/tools/tiled.test.cjs @@ -461,3 +461,90 @@ test("bundled project loads the shipped extension directory", () => { for (const folder of project.folders) assert.ok(require("node:fs").statSync(join(__dirname, "../assets", folder)).isDirectory()); }); + +function projectHarness(maps) { + const h = harness(); + const {context} = h; + const entries = { + "/p": {files: ["a.tmx", "notes.txt"], dirs: ["sub", "extensions"]}, + "/p/sub": {files: ["B.TMX", "c.tmx"], dirs: []}, + "/p/extensions": {files: ["station.js"], dirs: []}, + }; + context.File = { + Files: 2, Dirs: 1, NoSymLinks: 8, NoDotAndDotDot: 0x6000, + directoryEntries(dir, filter) { + assert.ok(filter & context.File.NoSymLinks); + const entry = entries[dir] || {files: [], dirs: []}; + return filter & context.File.Files ? entry.files : entry.dirs; + }, + }; + context.FileInfo = {cleanPath: path => path.replace(/\/+/g, "/")}; + const original = h.map; + original.fileName = "/p/a.tmx"; + original.modified = false; + const closed = []; + context.tiled.project = {fileName: "/p/station.tiled-project", folders: ["/p"]}; + context.tiled.openAssets = [original]; + context.tiled.open = file => { + let asset = context.tiled.openAssets.find(a => a.fileName === file); + if (!asset && maps[file]) { + asset = maps[file]; + asset.fileName = file; + context.tiled.openAssets.push(asset); + } + if (asset) + context.tiled.activeAsset = asset; + return asset || null; + }; + context.tiled.close = asset => { + closed.push(asset.fileName); + context.tiled.openAssets = context.tiled.openAssets.filter(a => a !== asset); + return true; + }; + return {...h, closed}; +} + +test("project action converts every project map and reports failures per map", () => { + const sample = harness().map; + function clone(entries, extra = {}) { + return Object.assign(Object.create(sample), {layers: [layer(entries)], macros: 0, modified: false}, extra); + } + const wall = tile({proto: "wall", "state.layer": '"structure"'}); + const converted = clone([[0, 0, wall], [1, 1, wall]]); + const untouched = clone([[0, 0, tile({solid: true})]]); + const broken = clone([[0, 0, wall]], {infinite: true}); + const {context, closed, map} = projectHarness({ + "/p/sub/B.TMX": converted, "/p/sub/c.tmx": untouched, + }); + map.layers.push(layer([[0, 0, wall]])); + context.tiled.openAssets.push(broken); + broken.fileName = "/p/missing.tmx"; + const report = context.stationConvertProject(); + assert.equal(report.maps, 2); + assert.equal(report.tiles, 3); + assert.equal(converted.macros, 1); + assert.equal(map.macros, 1); + assert.deepEqual(closed, ["/p/sub/c.tmx"]); + assert.ok(context.tiled.openAssets.includes(converted)); + assert.equal(context.tiled.activeAsset, map); + assert.equal(report.failures.length, 0); + + const {context: other, actions: otherActions, messages: otherMessages, closed: otherClosed} = + projectHarness({"/p/sub/B.TMX": clone([[0, 0, wall]], {infinite: true})}); + otherActions.StationConvertProjectMaps(); + assert.match(otherMessages.at(-1), /converted 0 painted tiles in 0 maps/); + assert.match(otherMessages.at(-1), /B\.TMX: Station supports finite/); + assert.match(otherMessages.at(-1), /c\.tmx: could not open/); + assert.deepEqual(otherClosed, ["/p/sub/B.TMX"]); + assert.equal(other.tiled.openAssets.length, 1); + + other.tiled.project = {fileName: "", folders: []}; + otherActions.StationConvertProjectMaps(); + assert.match(otherMessages.at(-1), /Open a Station project first/); +}); + +test("exported projects load the copied extension directory", () => { + const source = readFileSync(join(__dirname, "../crates/game_server/src/map_io.rs"), "utf8"); + assert.match(source, /"extensionsPath": "extensions"/); + assert.match(source, /join\("extensions"\)/); +});