import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { mapSchema } from "@nengine/core"; import type { MapDef } from "@nengine/core"; import { parse, stringify } from "yaml"; import { describe, expect, it } from "vitest"; import { DEFAULT_ERASE_TILE, TILE_SIZE, UNKNOWN_TILE_COLOR, addTileId, collectTileIds, createMapModel, erase, isWalkableTile, markSaved, paint, pointToTile, serialize, setSpawn, tileColor, toggleWalkable, } from "../src/renderer/map-model.js"; import type { MapModel } from "../src/renderer/map-model.js"; const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); function grid(width: number, height: number, fill = "grass"): string[][] { return Array.from({ length: height }, () => Array.from({ length: width }, () => fill)); } function makeDef(overrides: Partial = {}): MapDef { const def: MapDef = { id: "t", width: 4, height: 4, tilesetRef: "ts", spawn: { x: 1, y: 1 }, layers: [{ name: "ground", data: grid(4, 4) }], ...overrides, }; return def; } function model(def: MapDef): MapModel { const m = createMapModel(def); if (m === null) throw new Error("expected a valid model"); return m; } describe("createMapModel", () => { it("builds a model from a valid def without mutating the input", () => { const def = makeDef(); const snapshot = JSON.stringify(def); const m = model(def); paint(m, 0, 0, "water"); setSpawn(m, 2, 2); toggleWalkable(m, "grass"); erase(m, 1, 1); expect(JSON.stringify(def)).toBe(snapshot); expect(m.dirty).toBe(true); }); it("rejects corrupt defs", () => { expect(createMapModel({ id: 42 })).toBeNull(); const badRows = makeDef({ layers: [{ name: "ground", data: grid(4, 2) }] }); expect(createMapModel(badRows)).toBeNull(); }); it("seeds the palette from ids on the map", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "tilled") }] }); def.layers[0].data[0] = ["water", "grass", "tilled", "grass"]; const m = model(def); expect(m.palette).toEqual(["water", "grass", "tilled"]); }); }); describe("paint", () => { it("writes ground-layer data and marks dirty", () => { const m = model(makeDef()); expect(m.dirty).toBe(false); paint(m, 2, 3, "tilled"); expect(m.def.layers[0].data[3][2]).toBe("tilled"); expect(m.dirty).toBe(true); }); it("uses the layer named ground", () => { const def = makeDef(); def.layers = [ { name: "base", data: grid(4, 4, "rock") }, { name: "ground", data: grid(4, 4) }, ]; const m = model(def); paint(m, 1, 1, "water"); expect(m.def.layers[1].data[1][1]).toBe("water"); expect(m.def.layers[0].data[1][1]).toBe("rock"); }); it("falls back to the first layer when ground is absent", () => { const def = makeDef({ layers: [{ name: "base", data: grid(4, 4) }] }); const m = model(def); paint(m, 2, 2, "water"); expect(m.def.layers[0].data[2][2]).toBe("water"); }); it("ignores out-of-bounds cells", () => { const m = model(makeDef()); paint(m, -1, 0, "water"); paint(m, 0, -1, "water"); paint(m, 99, 0, "water"); paint(m, 0, 99, "water"); expect(m.def.layers[0].data[0][0]).toBe("grass"); expect(m.dirty).toBe(false); }); it("adds a new tile id to the palette", () => { const m = model(makeDef()); paint(m, 0, 0, "rock"); expect(m.palette).toContain("rock"); expect(collectTileIds(m)).toContain("rock"); }); }); describe("erase", () => { it("restores the default grass tile when the map uses it", () => { const m = model(makeDef()); paint(m, 0, 0, "water"); erase(m, 0, 0); expect(m.def.layers[0].data[0][0]).toBe(DEFAULT_ERASE_TILE); }); it("falls back to the first palette id when grass is absent", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "water") }] }); const m = model(def); paint(m, 1, 1, "tilled"); erase(m, 1, 1); expect(m.def.layers[0].data[1][1]).toBe("water"); }); it("writes an empty string when the palette is empty", () => { const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "") }] }); const m = model(def); erase(m, 2, 2); expect(m.def.layers[0].data[2][2]).toBe(""); }); }); describe("setSpawn", () => { it("moves the spawn point", () => { const m = model(makeDef()); setSpawn(m, 3, 2); expect(m.def.spawn).toEqual({ x: 3, y: 2 }); expect(m.dirty).toBe(true); }); it("clamps out-of-bounds coordinates", () => { const m = model(makeDef()); setSpawn(m, -5, 2); expect(m.def.spawn.x).toBe(0); setSpawn(m, 100, 100); expect(m.def.spawn).toEqual({ x: 3, y: 3 }); }); it("rejects non-integer coordinates", () => { const m = model(makeDef()); setSpawn(m, Number.NaN, 1); setSpawn(m, 1.5, 1); expect(m.def.spawn).toEqual({ x: 1, y: 1 }); expect(m.dirty).toBe(false); }); }); describe("toggleWalkable", () => { it("sets absent ids to false", () => { const m = model(makeDef()); expect(m.def.walkable).toBeUndefined(); toggleWalkable(m, "grass"); expect(m.def.walkable?.grass).toBe(false); expect(m.dirty).toBe(true); }); it("deletes the key when already false (restores fallback)", () => { const m = model(makeDef()); toggleWalkable(m, "grass"); toggleWalkable(m, "grass"); expect(m.def.walkable).toBeUndefined(); }); it("sets true entries to false", () => { const def = makeDef(); def.walkable = { grass: true }; const m = model(def); toggleWalkable(m, "grass"); expect(m.def.walkable?.grass).toBe(false); }); it("keeps unrelated entries when deleting one", () => { const def = makeDef(); def.walkable = { water: false, tilled: false }; const m = model(def); toggleWalkable(m, "water"); expect(m.def.walkable).toEqual({ tilled: false }); }); it("ignores the empty tile id", () => { const m = model(makeDef()); toggleWalkable(m, ""); expect(m.def.walkable).toBeUndefined(); expect(m.dirty).toBe(false); }); }); describe("addTileId", () => { it("registers a palette id without touching the def", () => { const m = model(makeDef()); const defBefore = JSON.stringify(m.def); addTileId(m, "sand"); expect(m.palette).toContain("sand"); expect(JSON.stringify(m.def)).toBe(defBefore); expect(m.dirty).toBe(false); addTileId(m, "sand"); expect(m.palette.filter((id) => id === "sand")).toHaveLength(1); addTileId(m, ""); expect(m.palette).not.toContain(""); }); }); describe("serialize", () => { it("roundtrips model → def → model identity for the same def", () => { const def = makeDef({ walkable: { water: false }, tiles: { grass: "grass-1" } }); const m = model(def); const out = serialize(m); expect(out).not.toBeNull(); expect(out).toEqual(def); expect(out).not.toBe(m.def); expect(mapSchema.safeParse(out as MapDef).success).toBe(true); }); it("returns a fresh object reflecting edits", () => { const m = model(makeDef()); paint(m, 0, 0, "tilled"); setSpawn(m, 0, 0); const out = serialize(m); expect(out?.layers[0].data[0][0]).toBe("tilled"); expect(out?.spawn).toEqual({ x: 0, y: 0 }); }); it("rejects an invalid working copy", () => { const m = model(makeDef()); m.def.layers[0].data[0].length = 99; expect(serialize(m)).toBeNull(); }); }); describe("collectTileIds", () => { it("collects unique ids from all layers in stable order", () => { const def: MapDef = { id: "t", width: 2, height: 2, tilesetRef: "ts", spawn: { x: 0, y: 0 }, layers: [ { name: "ground", data: [["water", "grass"], ["grass", "tilled"]] }, { name: "deco", data: [["rock", "grass"], ["", "rock"]] }, ], }; const m = model(def); expect(collectTileIds(m)).toEqual(["water", "grass", "tilled", "rock"]); }); }); describe("tileColor", () => { it("keeps known tiles stable and special-cased", () => { expect(tileColor("grass")).toBe("#7cb342"); expect(tileColor("water")).toBe("#4fc3f7"); expect(tileColor("tilled")).toBe("#8d6e63"); expect(tileColor("")).toBe(UNKNOWN_TILE_COLOR); }); it("is stable for unknown ids and format is a hue", () => { expect(tileColor("rock")).toBe(tileColor("rock")); expect(tileColor("rock")).toMatch(/^hsl\(\d+ 55% 55%\)$/); expect(tileColor("rock")).not.toBe(tileColor("sand")); }); }); describe("isWalkableTile", () => { it("honours declared walkable entries", () => { const def = makeDef(); def.walkable = { water: false, tilled: true }; const m = model(def); expect(isWalkableTile(m, "water")).toBe(false); expect(isWalkableTile(m, "tilled")).toBe(true); }); it("falls back to the engine's UNWALKABLE_TILES", () => { const m = model(makeDef()); expect(isWalkableTile(m, "water")).toBe(false); expect(isWalkableTile(m, "grass")).toBe(true); expect(isWalkableTile(m, "rock")).toBe(true); }); }); describe("pointToTile", () => { it("maps pixel offsets to grid tiles", () => { expect(pointToTile(0, 0)).toEqual({ x: 0, y: 0 }); expect(pointToTile(31, 31)).toEqual({ x: 0, y: 0 }); expect(pointToTile(32, 64)).toEqual({ x: 1, y: 2 }); expect(pointToTile(-5, 3)).toEqual({ x: -1, y: 0 }); expect(TILE_SIZE).toBe(32); }); }); describe("dirty lifecycle", () => { it("tracks edits and clears on markSaved", () => { const m = model(makeDef()); expect(m.dirty).toBe(false); paint(m, 0, 0, "water"); expect(m.dirty).toBe(true); markSaved(m); expect(m.dirty).toBe(false); erase(m, 0, 0); expect(m.dirty).toBe(true); markSaved(m); setSpawn(m, 2, 2); expect(m.dirty).toBe(true); markSaved(m); toggleWalkable(m, "grass"); expect(m.dirty).toBe(true); markSaved(m); addTileId(m, "sand"); expect(m.dirty).toBe(false); }); }); describe("real map fixture end-to-end", () => { it("edits a real farm.yaml through parse → model → serialize → stringify → parse", async () => { const text = await readFile(join(fixturesDir, "farm.yaml"), "utf8"); const m = createMapModel(parse(text)); expect(m).not.toBeNull(); if (m === null) throw new Error("fixture must be a valid map"); expect(mapSchema.safeParse(m.def).success).toBe(true); expect(m.def.width).toBe(16); expect(m.def.spawn).toEqual({ x: 2, y: 4 }); paint(m, 0, 0, "grass"); setSpawn(m, 4, 4); toggleWalkable(m, "tilled"); const out = serialize(m); expect(out).not.toBeNull(); if (out === null) throw new Error("edited fixture must serialize"); const yaml = stringify(out); const reparsed = parse(yaml); expect(reparsed).toEqual(out); expect(mapSchema.safeParse(reparsed).success).toBe(true); const m2 = createMapModel(reparsed); expect(m2).not.toBeNull(); expect(m2?.def.layers[0].data[0][0]).toBe("grass"); expect(m2?.def.spawn).toEqual({ x: 4, y: 4 }); expect(m2?.def.walkable).toEqual({ water: false, tilled: false }); expect(m2?.def.tiles?.water).toBe("water-1"); expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text); }); });