"use strict"; const assert = require("node:assert/strict"); const {readFileSync} = require("node:fs"); const {join} = require("node:path"); const {test} = require("node:test"); const vm = require("node:vm"); function harness() { class ObjectGroup { constructor() { this.isObjectLayer = true; this.offset = {x: 0, y: 0}; this.objects = []; } addObject(object) { this.objects.push(object); } } class MapObject { static Point = "point"; setProperties(properties) { this.props = properties; } setProperty(name, value) { this.props ||= {}; this.props[name] = value; } properties() { return this.props; } } const messages = []; const actions = {}; const tools = {}; const dialogState = {values: {}, apply: true}; class Dialog { static SingleWidgetRows = 1; constructor() { this.inputs = {}; this.buttons = {}; } addLabel() {} addCheckBox(name) { return this.inputs[name] = {checked: false}; } addTextInput(name, text) { return this.inputs[name] = {text}; } addButton(name) { return {clicked: {connect: callback => { this.buttons[name] = callback; }}}; } accept() { this.accepted = true; } reject() { this.accepted = false; } exec() { for (const [name, value] of Object.entries(dialogState.values)) { if (typeof value === "boolean") this.inputs[name].checked = value; else this.inputs[name].text = value; } this.buttons[dialogState.apply ? "Apply" : "Cancel"](); } } class TileMap { static Orthogonal = "orthogonal"; constructor() { this.layers = []; } setSize(width, height) { this.width = width; this.height = height; } setTileSize(width, height) { this.tileWidth = width; this.tileHeight = height; } addLayer(layer) { this.layers.push(layer); } addTileset() {} } class TileLayer { constructor() { this.cells = new Map(); } edit() { return { setTile: (x, y, tile) => { this.cells.set(`${x},${y}`, tile); }, apply() {}, }; } } const context = { MapObject, ObjectGroup, TileMap, TileLayer, Dialog, Qt: {LeftButton: 1, RightButton: 2, Key_Escape: 27}, tiled: { mapEditor: {tilesetsView: {selectedTiles: []}}, registerAction(name, callback) { actions[name] = callback; return {}; }, registerTool(name, tool) { tools[name] = tool; return tool; }, extendMenu(name, entries) { assert.equal(name, "Map"); assert.equal(entries[0].action, "StationConvertPaintedTiles"); }, log(text) { messages.push(text); }, alert(text) { messages.push(text); }, }, }; vm.createContext(context); vm.runInContext(readFileSync(join(__dirname, "../assets/tiled/station.js"), "utf8"), context); const map = { isTileMap: true, orientation: "orthogonal", infinite: false, width: 2, height: 2, tileWidth: 32, tileHeight: 32, layers: [], tilesets: [], macros: 0, get layerCount() { return this.layers.length; }, layerAt(index) { return this.layers[index]; }, addLayer(layer) { this.layers.push(layer); }, macro(name, callback) { assert.ok(["Convert Station painted tiles to objects", "Paint Station objects"].includes(name)); this.macros++; const layers = [...this.layers]; const cells = layers.filter(layer => layer.isTileLayer) .map(layer => [layer, new Map(layer.cells)]); const objects = layers.filter(layer => layer.isObjectLayer) .map(layer => [layer, [...layer.objects]]); callback(); this.undo = () => { this.layers = layers; for (const [layer, snapshot] of cells) layer.cells = snapshot; for (const [layer, snapshot] of objects) layer.objects = snapshot; }; }, }; context.tiled.activeAsset = map; tools.StationObjectBrush.map = map; for (const name of ["mousePressed", "mouseReleased", "mouseMoved"]) { const callback = tools.StationObjectBrush[name]; tools.StationObjectBrush[name] = function (...args) { const index = name === "mouseMoved" ? 0 : 1; this.tilePosition = { x: Math.floor(args[index] / map.tileWidth), y: Math.floor(args[index + 1] / map.tileHeight), }; return callback.apply(this, args); }; } return {map, context, actions, messages, tools, dialogState}; } function tile(properties) { return {property: name => properties[name], properties: () => properties}; } function layer(entries) { return { name: "Paint", offset: {x: 0, y: 0}, isTileLayer: true, locked: false, cells: new Map(entries.map(([x, y, tile, flags = {}]) => [`${x},${y}`, {tile, flags}])), tileAt(x, y) { return this.cells.get(`${x},${y}`)?.tile || null; }, cellAt(x, y) { return this.cells.get(`${x},${y}`).flags; }, edit() { const pending = []; return { setTile(x, y, value) { assert.equal(value, null); pending.push(`${x},${y}`); }, apply: () => { for (const key of pending) this.cells.delete(key); }, }; }, }; } test("converts floors and walls together, retaining properties, flags and decorative terrain", () => { const {map, context} = harness(); const floor = tile({ proto: "floor", "state.layer": '"floor"', "state.health": 17, "state.vars": '{"stock":2}', name: "metadata", solid: false, "state.flip_h": false, "state.flip_v": false, "state.flip_d": false, "machine.progress": 1.25, legacy: 3, }); const ground = layer([ [0, 0, floor, {flippedHorizontally: true, flippedVertically: true, flippedAntiDiagonally: true}], [1, 0, tile({solid: true})], ]); const wall = layer([[0, 0, tile({proto: "wall", "state.layer": '"structure"', "state.health": 300})]]); map.layers.push(ground, wall); assert.equal(context.stationConvert(map), 2); assert.equal(map.macros, 1); assert.deepEqual(map.layers.slice(2).map(layer => layer.name), ["Floors", "Wiring/Plumbing", "Triggers", "Ground Items", "Objects"]); assert.deepEqual(map.layers.slice(2).map(layer => layer.visible), [false, false, true, true, true]); const objects = [map.layers[2].objects[0], map.layers[6].objects[0]]; assert.equal(map.layers[2].objects.length, 1); assert.equal(map.layers[3].objects.length, 0); assert.equal(map.layers[6].objects.length, 1); assert.equal(objects[0].className, "building"); assert.equal(objects[0].shape, "point"); assert.equal(objects[0].x, 16); assert.equal(objects[0].y, 16); assert.equal(objects[0].props["state.health"], 17); assert.equal(objects[0].props["state.vars"], '{"stock":2}'); assert.equal(objects[0].props["machine.progress"], 1.25); assert.equal(objects[0].props.legacy, 3); assert.equal(objects[0].props["state.flip_h"], true); assert.equal(objects[0].props["state.flip_v"], true); assert.equal(objects[0].props["state.flip_d"], true); assert.equal(objects[0].props.name, undefined); assert.equal(objects[0].props.solid, undefined); assert.equal(ground.tileAt(0, 0), null); assert.notEqual(ground.tileAt(1, 0), null); assert.equal(wall.tileAt(0, 0), null); assert.deepEqual([...map.selectedObjects], objects); assert.equal(context.stationConvert(map), 0); assert.equal(map.macros, 1); map.undo(); assert.equal(map.layers.length, 2); assert.equal(ground.tileAt(0, 0), floor); assert.notEqual(wall.tileAt(0, 0), null); }); test("reuses categorized object layers and undoes additions without changing existing objects", () => { const {map, context} = harness(); const floors = new context.ObjectGroup(); floors.name = "Floors"; floors.visible = true; const existing = {className: "building", x: 48, y: 48, tile: null, properties: () => ({proto: "floor", "state.layer": '"floor"'})}; floors.addObject(existing); const objects = new context.ObjectGroup(); objects.name = "Objects"; objects.visible = false; map.layers.push(floors, objects, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]])); assert.equal(context.stationConvert(map), 1); assert.equal(map.layers.filter(layer => layer.name === "Floors").length, 1); assert.equal(floors.objects.length, 2); assert.equal(floors.objects[0], existing); assert.equal(objects.objects.length, 0); assert.equal(floors.visible, true); assert.equal(objects.visible, false); map.undo(); assert.equal(floors.objects.length, 1); assert.equal(floors.objects[0], existing); assert.equal(map.layers.length, 3); }); test("refuses ambiguous or locked destination layers before modifying the map", () => { for (const duplicate of [false, true]) { const {map, context} = harness(); const floors = new context.ObjectGroup(); floors.name = "Floors"; map.layers.push(floors, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]])); if (duplicate) { const second = new context.ObjectGroup(); second.name = "Floors"; map.layers.push(second); } else { floors.locked = true; } assert.throws(() => context.stationConvert(map), duplicate ? /Multiple object layers/ : /Unlock object layer/); assert.equal(map.macros, 0); assert.equal(floors.objects.length, 0); } }); test("rejects same-layer duplicates before making changes", () => { for (const properties of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) { const {map, context} = harness(); map.layers.push(layer([[0, 0, tile(properties)]]), layer([[0, 0, tile(properties)]])); assert.throws(() => context.stationConvert(map), /Two gameplay objects/); assert.equal(map.macros, 0); assert.notEqual(map.layers[0].tileAt(0, 0), null); } }); test("detects conflicts with existing tile objects and point objects", () => { for (const isTile of [false, true]) { const {map, context} = harness(); const objects = new context.ObjectGroup(); objects.addObject({ className: "building", x: 16, y: isTile ? 32 : 16, tile: isTile ? tile({proto: "wall"}) : null, properties: () => ({proto: "wall"}), }); map.layers.push(objects, layer([[0, 0, tile({proto: "wall"})]])); assert.throws(() => context.stationConvert(map), /Two gameplay objects/); assert.equal(map.macros, 0); } }); test("uses tileset prototype layers for existing objects with omitted defaults", () => { for (const painted of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) { const {map, context} = harness(); map.tilesets.push({tiles: [tile({proto: "floor", "state.layer": '"floor"'})]}); const objects = new context.ObjectGroup(); objects.addObject({ className: "building", x: 16, y: 16, tile: null, properties: () => ({proto: "floor"}), }); map.layers.push(objects, layer([[0, 0, tile(painted)]])); if (painted.proto === "wall") assert.equal(context.stationConvert(map), 1); else assert.throws(() => context.stationConvert(map), /Two gameplay objects/); } }); test("walks groups and refuses unsupported maps, offsets, locks and invalid layer properties", () => { const {map, context} = harness(); const ground = layer([[1, 1, tile({proto: "plating", "state.layer": '"floor"'})]]); map.layers.push({isGroupLayer: true, offset: {x: 0, y: 0}, layerCount: 1, layerAt: () => ground}); assert.equal(context.stationConvert(map), 1); assert.equal(map.layers[1].objects[0].x, 48); for (const mutate of [ map => { map.infinite = true; }, map => { map.orientation = "isometric"; }, map => { map.width = 0; }, map => { map.width = 1000001; }, map => { map.layers[0].offset.x = 32; }, map => { map.layers[0].locked = true; }, map => { map.layers[0] = layer([[0, 0, tile({proto: "floor", "state.layer": '"wrong"'})]]); }, map => { map.layers[0] = layer([[0, 0, tile({proto: ""})]]); }, ]) { const {map, context} = harness(); map.layers.push(layer([[0, 0, tile({proto: "floor"})]])); mutate(map); assert.throws(() => context.stationConvert(map)); assert.equal(map.macros, 0); } }); test("menu action reports conversion and validation failures explicitly", () => { const {map, actions, messages} = harness(); actions.StationConvertPaintedTiles(); assert.match(messages[0], /converted 0/); map.infinite = true; actions.StationConvertPaintedTiles(); assert.match(messages[1], /conversion failed/); }); test("brush configures future objects without changing tileset or previous object properties", () => { const {map, context, actions, dialogState, tools} = harness(); const defaults = {proto: "wall", "state.layer": '"structure"', "state.health": 300, "state.solid": true, "state.vars": "{}", "state.variant": "null"}; const wall = tile(defaults); const brush = tools.StationObjectBrush; brush.selectedTile = wall; context.tiled.mapEditor.tilesetsView.selectedTiles = [wall]; dialogState.values = {"state.health": "17", "state.solid": false, "state.vars": '{"stock":3}'}; actions.StationBrushProperties(); brush.mousePressed(1, 1, 1); assert.equal(map.macros, 0); assert.equal(brush.preview.layers[0].cells.size, 1); brush.mouseReleased(1, 1, 1); const object = map.layers[0].objects[0]; assert.equal(object.props["state.health"], 17); assert.equal(object.props["state.solid"], false); assert.equal(object.props["state.vars"], '{"stock":3}'); assert.equal(object.tile, wall); assert.equal(object.className, "building"); assert.equal(object.x, 0); assert.equal(object.y, 32); assert.equal(object.width, 32); assert.equal(defaults["state.health"], 300); dialogState.values = {"state.health": "42"}; actions.StationBrushProperties(); brush.mousePressed(1, 33, 1); brush.mouseReleased(1, 33, 1); assert.equal(map.layers[0].objects[1].props["state.health"], 42); assert.equal(object.props["state.health"], 17); map.undo(); assert.equal(map.layers[0].objects.length, 1); }); test("brush interpolates fast drags, deduplicates cells and undoes the entire stroke", () => { const {map, tools} = harness(); map.width = 8; const brush = tools.StationObjectBrush; brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"', "state.health": 100}); brush.mousePressed(1, 1, 1); brush.mouseMoved(225, 1); brush.mouseMoved(1, 1); brush.mouseReleased(1, 225, 1); assert.equal(map.macros, 1); assert.equal(map.layers[0].name, "Floors"); assert.equal(map.layers[0].visible, false); assert.equal(map.layers[0].objects.length, 8); assert.equal(map.selectedObjects.length, 8); assert.equal(brush.stroke, null); map.undo(); assert.equal(map.layers.length, 0); }); test("brush refuses occupied, painted, locked, duplicate and invalid maps without partial edits", () => { for (const scenario of ["object", "painted", "locked", "duplicate", "offset", "infinite"]) { const {map, context, tools, messages} = harness(); const wall = tile({proto: "wall", "state.layer": '"structure"'}); const group = new context.ObjectGroup(); group.name = "Objects"; map.layers.push(group); if (scenario === "object") { const object = new context.MapObject(); Object.assign(object, {className: "building", x: 16, y: 16}); object.setProperties({proto: "wall"}); group.addObject(object); } else if (scenario === "painted") map.layers.push(layer([[1, 0, wall]])); else if (scenario === "locked") group.locked = true; else if (scenario === "duplicate") map.layers.push(Object.assign(new context.ObjectGroup(), {name: "Objects"})); else if (scenario === "offset") group.offset.x = 10; else if (scenario === "infinite") map.infinite = true; const brush = tools.StationObjectBrush; brush.selectedTile = wall; context.tiled.alert = () => { assert.fail("Mouse callbacks must not show modal dialogs"); }; context.tiled.log = text => { assert.equal(brush.stroke, null); brush.mouseReleased(1, 33, 1); messages.push(text); }; brush.mousePressed(1, 1, 1); brush.mouseReleased(1, 33, 1); assert.equal(map.macros, 0, scenario); assert.equal(messages.length, 1, scenario); assert.match(messages[0], /Station brush:/); assert.equal(brush.statusInfo, messages[0]); } }); test("brush allows floor and structure overlaps and cancels on Escape or map change", () => { const {map, tools} = harness(); const brush = tools.StationObjectBrush; brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"'}); brush.mousePressed(1, 1, 1); brush.keyPressed(27); brush.mouseReleased(1, 1, 1); assert.equal(map.macros, 0); brush.mousePressed(1, 1, 1); brush.mapChanged(); brush.mouseReleased(1, 1, 1); assert.equal(map.macros, 0); brush.mousePressed(2, 1, 1); assert.equal(map.macros, 0); brush.mousePressed(1, 1, 1); brush.mouseReleased(1, 1, 1); brush.selectedTile = tile({proto: "wall", "state.layer": '"structure"'}); brush.mousePressed(1, 1, 1); brush.mouseReleased(1, 1, 1); assert.equal(map.layers[0].objects.length, 1); assert.equal(map.layers[1].objects.length, 1); }); test("brush reports invalid input and cancellation preserves configured defaults", () => { const {context, actions, tools, messages, dialogState} = harness(); actions.StationBrushProperties(); assert.match(messages.pop(), /Select exactly one/); const wall = tile({proto: "wall", "state.health": 300, "state.vars": "{}"}); context.tiled.mapEditor.tilesetsView.selectedTiles = [wall]; for (const value of ["", "NaN", "1.5", "9007199254740992", "2147483648", "-2147483649"]) { dialogState.values = {"state.health": value}; actions.StationBrushProperties(); assert.match(messages.pop(), /valid 32-bit integer/); } dialogState.values = {"state.vars": "invalid json"}; actions.StationBrushProperties(); assert.match(messages.pop(), /Invalid brush properties/); dialogState.values = {"state.health": "17"}; dialogState.apply = false; actions.StationBrushProperties(); assert.equal(context.stationBrushSettings(wall)["state.health"], 300); dialogState.apply = true; dialogState.values = {"state.health": "2147483647"}; actions.StationBrushProperties(); assert.equal(context.stationBrushSettings(wall)["state.health"], 2147483647); tools.StationObjectBrush.selectedTile = tile({name: "water"}); tools.StationObjectBrush.mousePressed(1, 1, 1); assert.match(messages.pop(), /Select one gameplay/); }); test("brush supports fractional machine progress even when its default is zero", () => { const {context, actions, dialogState} = harness(); const machine = tile({proto: "furnace", "machine.progress": 0}); context.tiled.mapEditor.tilesetsView.selectedTiles = [machine]; dialogState.values = {"machine.progress": "1.25"}; actions.StationBrushProperties(); assert.equal(context.stationBrushSettings(machine)["machine.progress"], 1.25); }); test("bundled project loads the shipped extension directory", () => { const project = JSON.parse(readFileSync(join(__dirname, "../assets/station.tiled-project"), "utf8")); const extension = join(__dirname, "../assets", project.extensionsPath, "station.js"); assert.match(readFileSync(extension, "utf8"), /StationObjectBrush/); 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"\)/); });