"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; } properties() { return this.props; } } const messages = []; const actions = {}; const context = { MapObject, ObjectGroup, TileMap: {Orthogonal: "orthogonal"}, tiled: { mapEditor: {}, registerAction(name, callback) { actions[name] = callback; return {}; }, 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.equal(name, "Convert Station painted tiles to objects"); 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; return {map, context, actions, messages}; } 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/); });