map-model.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import { readFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { mapSchema } from "@nengine/core";
  5. import type { MapDef } from "@nengine/core";
  6. import { parse, stringify } from "yaml";
  7. import { describe, expect, it } from "vitest";
  8. import {
  9. DEFAULT_ERASE_TILE,
  10. TILE_SIZE,
  11. UNKNOWN_TILE_COLOR,
  12. addTileId,
  13. collectTileIds,
  14. createMapModel,
  15. erase,
  16. isWalkableTile,
  17. markSaved,
  18. paint,
  19. pointToTile,
  20. serialize,
  21. setSpawn,
  22. tileColor,
  23. toggleWalkable,
  24. } from "../src/renderer/map-model.js";
  25. import type { MapModel } from "../src/renderer/map-model.js";
  26. const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
  27. function grid(width: number, height: number, fill = "grass"): string[][] {
  28. return Array.from({ length: height }, () => Array.from({ length: width }, () => fill));
  29. }
  30. function makeDef(overrides: Partial<MapDef> = {}): MapDef {
  31. const def: MapDef = {
  32. id: "t",
  33. width: 4,
  34. height: 4,
  35. tilesetRef: "ts",
  36. spawn: { x: 1, y: 1 },
  37. layers: [{ name: "ground", data: grid(4, 4) }],
  38. ...overrides,
  39. };
  40. return def;
  41. }
  42. function model(def: MapDef): MapModel {
  43. const m = createMapModel(def);
  44. if (m === null) throw new Error("expected a valid model");
  45. return m;
  46. }
  47. describe("createMapModel", () => {
  48. it("builds a model from a valid def without mutating the input", () => {
  49. const def = makeDef();
  50. const snapshot = JSON.stringify(def);
  51. const m = model(def);
  52. paint(m, 0, 0, "water");
  53. setSpawn(m, 2, 2);
  54. toggleWalkable(m, "grass");
  55. erase(m, 1, 1);
  56. expect(JSON.stringify(def)).toBe(snapshot);
  57. expect(m.dirty).toBe(true);
  58. });
  59. it("rejects corrupt defs", () => {
  60. expect(createMapModel({ id: 42 })).toBeNull();
  61. const badRows = makeDef({ layers: [{ name: "ground", data: grid(4, 2) }] });
  62. expect(createMapModel(badRows)).toBeNull();
  63. });
  64. it("seeds the palette from ids on the map", () => {
  65. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "tilled") }] });
  66. def.layers[0].data[0] = ["water", "grass", "tilled", "grass"];
  67. const m = model(def);
  68. expect(m.palette).toEqual(["water", "grass", "tilled"]);
  69. });
  70. });
  71. describe("paint", () => {
  72. it("writes ground-layer data and marks dirty", () => {
  73. const m = model(makeDef());
  74. expect(m.dirty).toBe(false);
  75. paint(m, 2, 3, "tilled");
  76. expect(m.def.layers[0].data[3][2]).toBe("tilled");
  77. expect(m.dirty).toBe(true);
  78. });
  79. it("uses the layer named ground", () => {
  80. const def = makeDef();
  81. def.layers = [
  82. { name: "base", data: grid(4, 4, "rock") },
  83. { name: "ground", data: grid(4, 4) },
  84. ];
  85. const m = model(def);
  86. paint(m, 1, 1, "water");
  87. expect(m.def.layers[1].data[1][1]).toBe("water");
  88. expect(m.def.layers[0].data[1][1]).toBe("rock");
  89. });
  90. it("falls back to the first layer when ground is absent", () => {
  91. const def = makeDef({ layers: [{ name: "base", data: grid(4, 4) }] });
  92. const m = model(def);
  93. paint(m, 2, 2, "water");
  94. expect(m.def.layers[0].data[2][2]).toBe("water");
  95. });
  96. it("ignores out-of-bounds cells", () => {
  97. const m = model(makeDef());
  98. paint(m, -1, 0, "water");
  99. paint(m, 0, -1, "water");
  100. paint(m, 99, 0, "water");
  101. paint(m, 0, 99, "water");
  102. expect(m.def.layers[0].data[0][0]).toBe("grass");
  103. expect(m.dirty).toBe(false);
  104. });
  105. it("adds a new tile id to the palette", () => {
  106. const m = model(makeDef());
  107. paint(m, 0, 0, "rock");
  108. expect(m.palette).toContain("rock");
  109. expect(collectTileIds(m)).toContain("rock");
  110. });
  111. });
  112. describe("erase", () => {
  113. it("restores the default grass tile when the map uses it", () => {
  114. const m = model(makeDef());
  115. paint(m, 0, 0, "water");
  116. erase(m, 0, 0);
  117. expect(m.def.layers[0].data[0][0]).toBe(DEFAULT_ERASE_TILE);
  118. });
  119. it("falls back to the first palette id when grass is absent", () => {
  120. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "water") }] });
  121. const m = model(def);
  122. paint(m, 1, 1, "tilled");
  123. erase(m, 1, 1);
  124. expect(m.def.layers[0].data[1][1]).toBe("water");
  125. });
  126. it("writes an empty string when the palette is empty", () => {
  127. const def = makeDef({ layers: [{ name: "ground", data: grid(4, 4, "") }] });
  128. const m = model(def);
  129. erase(m, 2, 2);
  130. expect(m.def.layers[0].data[2][2]).toBe("");
  131. });
  132. });
  133. describe("setSpawn", () => {
  134. it("moves the spawn point", () => {
  135. const m = model(makeDef());
  136. setSpawn(m, 3, 2);
  137. expect(m.def.spawn).toEqual({ x: 3, y: 2 });
  138. expect(m.dirty).toBe(true);
  139. });
  140. it("clamps out-of-bounds coordinates", () => {
  141. const m = model(makeDef());
  142. setSpawn(m, -5, 2);
  143. expect(m.def.spawn.x).toBe(0);
  144. setSpawn(m, 100, 100);
  145. expect(m.def.spawn).toEqual({ x: 3, y: 3 });
  146. });
  147. it("rejects non-integer coordinates", () => {
  148. const m = model(makeDef());
  149. setSpawn(m, Number.NaN, 1);
  150. setSpawn(m, 1.5, 1);
  151. expect(m.def.spawn).toEqual({ x: 1, y: 1 });
  152. expect(m.dirty).toBe(false);
  153. });
  154. });
  155. describe("toggleWalkable", () => {
  156. it("sets absent ids to false", () => {
  157. const m = model(makeDef());
  158. expect(m.def.walkable).toBeUndefined();
  159. toggleWalkable(m, "grass");
  160. expect(m.def.walkable?.grass).toBe(false);
  161. expect(m.dirty).toBe(true);
  162. });
  163. it("deletes the key when already false (restores fallback)", () => {
  164. const m = model(makeDef());
  165. toggleWalkable(m, "grass");
  166. toggleWalkable(m, "grass");
  167. expect(m.def.walkable).toBeUndefined();
  168. });
  169. it("sets true entries to false", () => {
  170. const def = makeDef();
  171. def.walkable = { grass: true };
  172. const m = model(def);
  173. toggleWalkable(m, "grass");
  174. expect(m.def.walkable?.grass).toBe(false);
  175. });
  176. it("keeps unrelated entries when deleting one", () => {
  177. const def = makeDef();
  178. def.walkable = { water: false, tilled: false };
  179. const m = model(def);
  180. toggleWalkable(m, "water");
  181. expect(m.def.walkable).toEqual({ tilled: false });
  182. });
  183. it("ignores the empty tile id", () => {
  184. const m = model(makeDef());
  185. toggleWalkable(m, "");
  186. expect(m.def.walkable).toBeUndefined();
  187. expect(m.dirty).toBe(false);
  188. });
  189. });
  190. describe("addTileId", () => {
  191. it("registers a palette id without touching the def", () => {
  192. const m = model(makeDef());
  193. const defBefore = JSON.stringify(m.def);
  194. addTileId(m, "sand");
  195. expect(m.palette).toContain("sand");
  196. expect(JSON.stringify(m.def)).toBe(defBefore);
  197. expect(m.dirty).toBe(false);
  198. addTileId(m, "sand");
  199. expect(m.palette.filter((id) => id === "sand")).toHaveLength(1);
  200. addTileId(m, "");
  201. expect(m.palette).not.toContain("");
  202. });
  203. });
  204. describe("serialize", () => {
  205. it("roundtrips model → def → model identity for the same def", () => {
  206. const def = makeDef({ walkable: { water: false }, tiles: { grass: "grass-1" } });
  207. const m = model(def);
  208. const out = serialize(m);
  209. expect(out).not.toBeNull();
  210. expect(out).toEqual(def);
  211. expect(out).not.toBe(m.def);
  212. expect(mapSchema.safeParse(out as MapDef).success).toBe(true);
  213. });
  214. it("returns a fresh object reflecting edits", () => {
  215. const m = model(makeDef());
  216. paint(m, 0, 0, "tilled");
  217. setSpawn(m, 0, 0);
  218. const out = serialize(m);
  219. expect(out?.layers[0].data[0][0]).toBe("tilled");
  220. expect(out?.spawn).toEqual({ x: 0, y: 0 });
  221. });
  222. it("rejects an invalid working copy", () => {
  223. const m = model(makeDef());
  224. m.def.layers[0].data[0].length = 99;
  225. expect(serialize(m)).toBeNull();
  226. });
  227. });
  228. describe("collectTileIds", () => {
  229. it("collects unique ids from all layers in stable order", () => {
  230. const def: MapDef = {
  231. id: "t",
  232. width: 2,
  233. height: 2,
  234. tilesetRef: "ts",
  235. spawn: { x: 0, y: 0 },
  236. layers: [
  237. { name: "ground", data: [["water", "grass"], ["grass", "tilled"]] },
  238. { name: "deco", data: [["rock", "grass"], ["", "rock"]] },
  239. ],
  240. };
  241. const m = model(def);
  242. expect(collectTileIds(m)).toEqual(["water", "grass", "tilled", "rock"]);
  243. });
  244. });
  245. describe("tileColor", () => {
  246. it("keeps known tiles stable and special-cased", () => {
  247. expect(tileColor("grass")).toBe("#7cb342");
  248. expect(tileColor("water")).toBe("#4fc3f7");
  249. expect(tileColor("tilled")).toBe("#8d6e63");
  250. expect(tileColor("")).toBe(UNKNOWN_TILE_COLOR);
  251. });
  252. it("is stable for unknown ids and format is a hue", () => {
  253. expect(tileColor("rock")).toBe(tileColor("rock"));
  254. expect(tileColor("rock")).toMatch(/^hsl\(\d+ 55% 55%\)$/);
  255. expect(tileColor("rock")).not.toBe(tileColor("sand"));
  256. });
  257. });
  258. describe("isWalkableTile", () => {
  259. it("honours declared walkable entries", () => {
  260. const def = makeDef();
  261. def.walkable = { water: false, tilled: true };
  262. const m = model(def);
  263. expect(isWalkableTile(m, "water")).toBe(false);
  264. expect(isWalkableTile(m, "tilled")).toBe(true);
  265. });
  266. it("falls back to the engine's UNWALKABLE_TILES", () => {
  267. const m = model(makeDef());
  268. expect(isWalkableTile(m, "water")).toBe(false);
  269. expect(isWalkableTile(m, "grass")).toBe(true);
  270. expect(isWalkableTile(m, "rock")).toBe(true);
  271. });
  272. });
  273. describe("pointToTile", () => {
  274. it("maps pixel offsets to grid tiles", () => {
  275. expect(pointToTile(0, 0)).toEqual({ x: 0, y: 0 });
  276. expect(pointToTile(31, 31)).toEqual({ x: 0, y: 0 });
  277. expect(pointToTile(32, 64)).toEqual({ x: 1, y: 2 });
  278. expect(pointToTile(-5, 3)).toEqual({ x: -1, y: 0 });
  279. expect(TILE_SIZE).toBe(32);
  280. });
  281. });
  282. describe("dirty lifecycle", () => {
  283. it("tracks edits and clears on markSaved", () => {
  284. const m = model(makeDef());
  285. expect(m.dirty).toBe(false);
  286. paint(m, 0, 0, "water");
  287. expect(m.dirty).toBe(true);
  288. markSaved(m);
  289. expect(m.dirty).toBe(false);
  290. erase(m, 0, 0);
  291. expect(m.dirty).toBe(true);
  292. markSaved(m);
  293. setSpawn(m, 2, 2);
  294. expect(m.dirty).toBe(true);
  295. markSaved(m);
  296. toggleWalkable(m, "grass");
  297. expect(m.dirty).toBe(true);
  298. markSaved(m);
  299. addTileId(m, "sand");
  300. expect(m.dirty).toBe(false);
  301. });
  302. });
  303. describe("real map fixture end-to-end", () => {
  304. it("edits a real farm.yaml through parse → model → serialize → stringify → parse", async () => {
  305. const text = await readFile(join(fixturesDir, "farm.yaml"), "utf8");
  306. const m = createMapModel(parse(text));
  307. expect(m).not.toBeNull();
  308. if (m === null) throw new Error("fixture must be a valid map");
  309. expect(mapSchema.safeParse(m.def).success).toBe(true);
  310. expect(m.def.width).toBe(16);
  311. expect(m.def.spawn).toEqual({ x: 2, y: 4 });
  312. paint(m, 0, 0, "grass");
  313. setSpawn(m, 4, 4);
  314. toggleWalkable(m, "tilled");
  315. const out = serialize(m);
  316. expect(out).not.toBeNull();
  317. if (out === null) throw new Error("edited fixture must serialize");
  318. const yaml = stringify(out);
  319. const reparsed = parse(yaml);
  320. expect(reparsed).toEqual(out);
  321. expect(mapSchema.safeParse(reparsed).success).toBe(true);
  322. const m2 = createMapModel(reparsed);
  323. expect(m2).not.toBeNull();
  324. expect(m2?.def.layers[0].data[0][0]).toBe("grass");
  325. expect(m2?.def.spawn).toEqual({ x: 4, y: 4 });
  326. expect(m2?.def.walkable).toEqual({ water: false, tilled: false });
  327. expect(m2?.def.tiles?.water).toBe("water-1");
  328. expect(await readFile(join(fixturesDir, "farm.yaml"), "utf8")).toBe(text);
  329. });
  330. });