| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- 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> = {}): 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);
- });
- });
|