station/tools/tiled.test.cjs

464 lines
20 KiB
JavaScript

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