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 <db-stem>.<world>.tiled/,
saves/<world>/ or saves/world/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-22 01:01:04 +02:00
parent 232305490d
commit 823f0f76e6
9 changed files with 428 additions and 54 deletions

View File

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

View File

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

View File

@ -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", {

View File

@ -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, MapError> {
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, MapError> {
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, MapError> {
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<Self, MapError> {
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,7 +136,16 @@ impl MapLayout {
))
})?;
let image_path = image.source.canonicalize().unwrap_or(image.source.clone());
let rel = image_path.strip_prefix(&assets_canon).map_err(|_| {
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()

View File

@ -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,
),
(

View File

@ -174,13 +174,57 @@ fn overlay<T: Serialize + DeserializeOwned>(
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 <this directory>` (or `import <this directory>` 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::<Storage>().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<PathBuf, MapIoError> {
let mut save = persistence::snapshot(world);
let data = world.resource::<GameData>();
@ -203,12 +247,57 @@ pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
}
}
}
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<PathBuf, MapIoError> {
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::<GameData>())?;
save.characters = persistence::snapshot(world).characters;
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
@ -311,7 +400,20 @@ fn write_properties(xml: &mut Xml, fields: &BTreeMap<String, Value>) -> 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<PathBuf, MapIoError> {
write_map(save, path, ImageSources::Assets)
}
fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathBuf, MapIoError> {
let path = path.with_extension("tmx");
let layout = save
.layout
@ -368,7 +470,12 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
&[
(
"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();

View File

@ -536,9 +536,7 @@ pub struct Storage {
impl Storage {
pub fn new(runtime: Arc<Runtime>, db: Database, world: WorldInfo) -> Self {
let map_export_path = std::path::PathBuf::from("saves")
.join(world.id.to_string())
.join("world.tmx");
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))
}

View File

@ -73,7 +73,8 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
.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 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::<Position>(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::<WorldLayout>().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::<Vec<_>>()
};
assert_eq!(
images(&app.world().resource::<WorldLayout>().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::<Position>(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]

View File

@ -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"\)/);
});