station/tools/tiled.test.cjs

255 lines
11 KiB
JavaScript
Raw Normal View History

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