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
|
||||
|
||||
- **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
|
||||
|
|
|
|||
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 |
|
||||
| `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.
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue