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:
parent
232305490d
commit
823f0f76e6
|
|
@ -7,12 +7,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
### Changed
|
### 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
|
- Tiled exports and newly created conversion layers place Objects above Ground
|
||||||
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
|
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
|
||||||
hidden by default in the editor without affecting gameplay.
|
hidden by default in the editor without affecting gameplay.
|
||||||
|
|
||||||
### Added
|
### 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
|
- A Station Object Brush in Tiled with per-tile brush property settings and
|
||||||
independently editable wall/floor objects, drag previews and stroke undo.
|
independently editable wall/floor objects, drag previews and stroke undo.
|
||||||
Open the bundled `assets/station.tiled-project` to load Station extensions
|
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.
|
removing floors exposes the map's configurable default terrain.
|
||||||
- A bundled Tiled extension to convert painted gameplay tiles into editable
|
- A bundled Tiled extension to convert painted gameplay tiles into editable
|
||||||
objects, preserving properties and flips with conflict checks and undo support.
|
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
|
terrain, buildings and ground items, including health, script variables, machine
|
||||||
contents and processing state. Imports replace the map while preserving characters.
|
contents and processing state. Imports replace the map while preserving characters.
|
||||||
- Native chat text editing with word navigation, keyboard/mouse selection, system
|
- Native chat text editing with word navigation, keyboard/mouse selection, system
|
||||||
|
|
|
||||||
48
README.md
48
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 |
|
| `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 |
|
| `export [DIR]` | admins | Export terrain, buildings and ground items as a Tiled project |
|
||||||
| `import [FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters |
|
| `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` |
|
| `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 |
|
||||||
|
|
||||||
|
|
@ -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,
|
overlaps abort the entire conversion. Conversion is a single undoable operation,
|
||||||
and running it again does not duplicate objects.
|
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
|
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`
|
`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.
|
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
|
#### Editing a running world's map
|
||||||
|
|
||||||
1. Run `/export` in chat, or `export` in the server terminal. The reply shows the
|
1. Run `/export` in chat, or `export` in the server terminal. It writes a
|
||||||
server-local file path. With SQLite it defaults to `<database-stem>.<world-uuid>.tmx`
|
ready-to-use Tiled project directory and replies with the path of its
|
||||||
beside the database. With PostgreSQL it defaults to `saves/<world-uuid>/world.tmx`;
|
`station.tiled-project` file (a server-local path). With SQLite the directory
|
||||||
without saving, `saves/world.tmx`. An explicit path, including spaces, may follow
|
defaults to `<database-stem>.<world-uuid>.tiled/` beside the database. With
|
||||||
either command; relative paths are relative to the server's working directory.
|
PostgreSQL it defaults to `saves/<world-uuid>/`; without saving, `saves/world/`.
|
||||||
Exports always use `.tmx`: a missing extension is added and any other extension
|
An explicit directory, including spaces, may follow either command; relative
|
||||||
is replaced (for example, `/export repair.backup` saves `repair.tmx`). The reply
|
paths are relative to the server's working directory. The directory contains:
|
||||||
reports the actual filename; use that filename when importing.
|
- `world.tmx`: the exported map;
|
||||||
2. Open that file in Tiled, edit it and save. Buildings and ground items are point
|
- `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.
|
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. The tileset image copies
|
||||||
with absolute image references into the server's assets directory; when editing
|
are for display only: on import they map back to the server's identical asset
|
||||||
on another machine, relink the images to that machine's matching assets.
|
paths, and editing them does not change the game.
|
||||||
Objects are organized into **Floors**, **Objects** (walls, machines and other
|
Objects are organized into **Floors**, **Objects** (walls, machines and other
|
||||||
structures), **Ground Items**, and **Triggers** (currently spawn points).
|
structures), **Ground Items**, and **Triggers** (currently spawn points).
|
||||||
**Wiring/Plumbing** is an empty organizational placeholder; it does not add
|
**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
|
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
|
to edit one category without the others getting in the way. Object classes and
|
||||||
properties determine gameplay, not the editor layer names.
|
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
|
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
|
||||||
before importing, and avoid editing a stale export while players keep building.
|
before importing, and avoid editing a stale export while players keep building.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* global tiled, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */
|
/* global tiled, File, FileInfo, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function stationGameplayProperties(properties) {
|
function stationGameplayProperties(properties) {
|
||||||
|
|
@ -168,6 +168,70 @@ const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles",
|
||||||
});
|
});
|
||||||
stationConvertAction.text = "Station: Convert Painted Tiles to Objects";
|
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();
|
const stationBrushProperties = new Map();
|
||||||
|
|
||||||
function stationBrushSettings(tile) {
|
function stationBrushSettings(tile) {
|
||||||
|
|
@ -343,6 +407,7 @@ stationPropertiesAction.text = "Station: Brush Properties";
|
||||||
if (tiled.mapEditor) {
|
if (tiled.mapEditor) {
|
||||||
tiled.extendMenu("Map", [
|
tiled.extendMenu("Map", [
|
||||||
{action: "StationConvertPaintedTiles"},
|
{action: "StationConvertPaintedTiles"},
|
||||||
|
{action: "StationConvertProjectMaps"},
|
||||||
{action: "StationBrushProperties"},
|
{action: "StationBrushProperties"},
|
||||||
]);
|
]);
|
||||||
tiled.registerTool("StationObjectBrush", {
|
tiled.registerTool("StationObjectBrush", {
|
||||||
|
|
|
||||||
|
|
@ -87,18 +87,29 @@ pub enum MapError {
|
||||||
impl MapLayout {
|
impl MapLayout {
|
||||||
/// Loads an asset-relative or absolute map path.
|
/// 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> {
|
||||||
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.
|
/// 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> {
|
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(
|
fn load_inner(
|
||||||
assets_dir: &Path,
|
assets_dir: &Path,
|
||||||
map_path: &str,
|
map_path: &str,
|
||||||
convert_painted: bool,
|
convert_painted: bool,
|
||||||
|
mirror: Option<&Path>,
|
||||||
) -> Result<Self, MapError> {
|
) -> 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);
|
||||||
|
|
@ -115,6 +126,7 @@ impl MapLayout {
|
||||||
let assets_canon = assets_dir
|
let assets_canon = assets_dir
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| assets_dir.into());
|
.unwrap_or_else(|_| assets_dir.into());
|
||||||
|
let mirror_canon = mirror.and_then(|m| m.canonicalize().ok());
|
||||||
let mut tilesets = Vec::new();
|
let mut tilesets = Vec::new();
|
||||||
for ts in map.tilesets() {
|
for ts in map.tilesets() {
|
||||||
let image = ts.image.as_ref().ok_or_else(|| {
|
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 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!(
|
unsupported(format!(
|
||||||
"tileset image `{}` must be inside the assets directory",
|
"tileset image `{}` must be inside the assets directory",
|
||||||
image.source.display()
|
image.source.display()
|
||||||
|
|
|
||||||
|
|
@ -82,13 +82,13 @@ const COMMANDS: &[(&str, &str, Permission)] = &[
|
||||||
("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 [DIR]",
|
||||||
"export the world to a Tiled map",
|
"export the world to a Tiled project directory",
|
||||||
Permission::Admin,
|
Permission::Admin,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"import [FILE]",
|
"import [DIR|FILE]",
|
||||||
"replace the world from a Tiled map (keeps characters)",
|
"replace the world from a Tiled project or map (keeps characters)",
|
||||||
Permission::Admin,
|
Permission::Admin,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,57 @@ fn overlay<T: Serialize + DeserializeOwned>(
|
||||||
Ok(())
|
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 {
|
pub fn default_path(world: &World) -> PathBuf {
|
||||||
world.get_resource::<Storage>().map_or_else(
|
world.get_resource::<Storage>().map_or_else(
|
||||||
|| PathBuf::from("saves/world.tmx"),
|
|| PathBuf::from("saves/world"),
|
||||||
|storage| storage.map_export_path.clone(),
|
|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> {
|
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>();
|
||||||
|
|
@ -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> {
|
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 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>())?;
|
let mut save = decode(layout, world.resource::<GameData>())?;
|
||||||
save.characters = persistence::snapshot(world).characters;
|
save.characters = persistence::snapshot(world).characters;
|
||||||
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
|
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(())
|
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> {
|
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 path = path.with_extension("tmx");
|
||||||
let layout = save
|
let layout = save
|
||||||
.layout
|
.layout
|
||||||
|
|
@ -368,7 +470,12 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||||
&[
|
&[
|
||||||
(
|
(
|
||||||
"source",
|
"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()),
|
("width", ts.image_width.to_string()),
|
||||||
("height", ts.image_height.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()))
|
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]
|
#[test]
|
||||||
fn writer_uses_tmx_extension_and_separates_object_categories() {
|
fn writer_uses_tmx_extension_and_separates_object_categories() {
|
||||||
let (data, save) = seeded();
|
let (data, save) = seeded();
|
||||||
|
|
|
||||||
|
|
@ -536,9 +536,7 @@ pub struct Storage {
|
||||||
|
|
||||||
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")
|
let map_export_path = std::path::PathBuf::from("saves").join(world.id.to_string());
|
||||||
.join(world.id.to_string())
|
|
||||||
.join("world.tmx");
|
|
||||||
Self {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
db,
|
db,
|
||||||
|
|
@ -577,7 +575,7 @@ impl Storage {
|
||||||
let mut storage = Self::new(runtime, db, world);
|
let mut storage = Self::new(runtime, db, world);
|
||||||
if let Some(path) = settings.database_url.strip_prefix("sqlite://") {
|
if let Some(path) = settings.database_url.strip_prefix("sqlite://") {
|
||||||
let path = Path::new(path.split('?').next().unwrap_or(path));
|
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))
|
Ok((storage, save))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
.query_filtered::<Entity, With<Character>>()
|
.query_filtered::<Entity, With<Character>>()
|
||||||
.single(app.world())
|
.single(app.world())
|
||||||
.unwrap();
|
.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 filename = file.to_str().unwrap();
|
||||||
let ordinary_player = CommandSource::Player {
|
let ordinary_player = CommandSource::Player {
|
||||||
client: ClientId::Server,
|
client: ClientId::Server,
|
||||||
|
|
@ -83,10 +84,10 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
let denied = commands::execute(
|
let denied = commands::execute(
|
||||||
app.world_mut(),
|
app.world_mut(),
|
||||||
ordinary_player,
|
ordinary_player,
|
||||||
&format!("export {filename}"),
|
&format!("export {}", dir.display()),
|
||||||
);
|
);
|
||||||
assert!(denied[0].contains("not allowed"));
|
assert!(denied[0].contains("not allowed"));
|
||||||
assert!(!file.exists());
|
assert!(!dir.exists());
|
||||||
let denied = commands::execute(
|
let denied = commands::execute(
|
||||||
app.world_mut(),
|
app.world_mut(),
|
||||||
ordinary_player,
|
ordinary_player,
|
||||||
|
|
@ -96,17 +97,21 @@ 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 {}", file.with_extension("backup").display()),
|
&format!("export {}", dir.display()),
|
||||||
);
|
);
|
||||||
assert!(reply[0].contains("exported"), "{reply:?}");
|
assert!(reply[0].contains("exported"), "{reply:?}");
|
||||||
assert!(reply[0].ends_with(filename), "{reply:?}");
|
assert!(
|
||||||
assert!(!file.with_extension("backup").exists());
|
reply[0].ends_with(&dir.join(map_io::PROJECT_FILE).display().to_string()),
|
||||||
|
"{reply:?}"
|
||||||
|
);
|
||||||
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"),
|
||||||
"characters must not be exported"
|
"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
|
let door = map
|
||||||
.objects
|
.objects
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
|
|
@ -132,7 +137,7 @@ 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!("import {filename}"),
|
&format!("import {}", dir.display()),
|
||||||
);
|
);
|
||||||
assert!(reply[0].contains("imported"), "{reply:?}");
|
assert!(reply[0].contains("imported"), "{reply:?}");
|
||||||
let imported = persistence::snapshot(app.world_mut());
|
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();
|
map_io::write(&edited, &file).unwrap();
|
||||||
assert!(map_io::import(app.world_mut(), &file).is_err());
|
assert!(map_io::import(app.world_mut(), &file).is_err());
|
||||||
assert_eq!(persistence::snapshot(app.world_mut()), before_failure);
|
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 {
|
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 });
|
send(&mut app, SetMovement { x: 0, y: 0 });
|
||||||
assert!(app.world().get::<Position>(me).unwrap().0.x < target.x as f32);
|
assert!(app.world().get::<Position>(me).unwrap().0.x < target.x as f32);
|
||||||
|
|
||||||
map_io::export(app.world_mut(), &file).unwrap();
|
let project = file.with_extension("project");
|
||||||
map_io::import(app.world_mut(), &file).unwrap();
|
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!(
|
assert!(
|
||||||
layers(&mut app).is_empty(),
|
layers(&mut app).is_empty(),
|
||||||
"removed floors must not be reseeded"
|
"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 });
|
send(&mut app, SetMovement { x: 0, y: 0 });
|
||||||
assert!(app.world().get::<Position>(me).unwrap().0.x > target.x as f32 + 0.3);
|
assert!(app.world().get::<Position>(me).unwrap().0.x > target.x as f32 + 0.3);
|
||||||
std::fs::remove_file(file).unwrap();
|
std::fs::remove_file(file).unwrap();
|
||||||
|
std::fs::remove_dir_all(project).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -773,17 +803,22 @@ mod network {
|
||||||
join(&mut host, HOST, "Host", "");
|
join(&mut host, HOST, "Host", "");
|
||||||
let mut guest = client(addr);
|
let mut guest = client(addr);
|
||||||
join(&mut guest, GUEST, "Guest", "");
|
join(&mut guest, GUEST, "Guest", "");
|
||||||
let file =
|
let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4()));
|
||||||
std::env::temp_dir().join(format!("station-network-{}.tmx", uuid::Uuid::new_v4()));
|
let file = dir.join(map_io::PROJECT_MAP);
|
||||||
host.world_mut().client_trigger(Chat {
|
host.world_mut().client_trigger(Chat {
|
||||||
text: format!("/export {}", file.display()),
|
text: format!("/export {}", dir.display()),
|
||||||
});
|
});
|
||||||
pump(&mut [&mut host, &mut guest], |app| {
|
pump(&mut [&mut host, &mut guest], |app| {
|
||||||
inbox(app).log.iter().any(|l| l.contains("World exported:"))
|
inbox(app).log.iter().any(|l| l.contains("World exported:"))
|
||||||
});
|
});
|
||||||
let assets = game_core::assets_dir();
|
let assets = game_core::assets_dir();
|
||||||
let data = game_core::GameData::load_dir(&assets.join("data")).unwrap();
|
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)));
|
assert!(!map.is_solid(GridPos::new(0, 0)));
|
||||||
map.layers[1].cells[0] = Some(game_core::map::TileCell {
|
map.layers[1].cells[0] = Some(game_core::map::TileCell {
|
||||||
tileset: 0,
|
tileset: 0,
|
||||||
|
|
@ -795,7 +830,7 @@ mod network {
|
||||||
let edited = map_io::decode(map, &data).unwrap();
|
let edited = map_io::decode(map, &data).unwrap();
|
||||||
map_io::write(&edited, &file).unwrap();
|
map_io::write(&edited, &file).unwrap();
|
||||||
host.world_mut().client_trigger(Chat {
|
host.world_mut().client_trigger(Chat {
|
||||||
text: format!("/import {}", file.display()),
|
text: format!("/import {}", dir.display()),
|
||||||
});
|
});
|
||||||
let imported = |app: &mut App| {
|
let imported = |app: &mut App| {
|
||||||
let mut maps = app.world_mut().query::<&WorldMap>();
|
let mut maps = app.world_mut().query::<&WorldMap>();
|
||||||
|
|
@ -813,7 +848,7 @@ mod network {
|
||||||
);
|
);
|
||||||
pump(&mut [&mut newcomer], imported);
|
pump(&mut [&mut newcomer], imported);
|
||||||
server.stop();
|
server.stop();
|
||||||
std::fs::remove_file(file).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -461,3 +461,90 @@ test("bundled project loads the shipped extension directory", () => {
|
||||||
for (const folder of project.folders)
|
for (const folder of project.folders)
|
||||||
assert.ok(require("node:fs").statSync(join(__dirname, "../assets", folder)).isDirectory());
|
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"\)/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue