scene-model.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import { readFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { buildingSchema, sceneSchema } from "@nengine/core-schema";
  5. import type { SceneDef, SceneEntity } from "@nengine/core-schema";
  6. import { parse, stringify } from "yaml";
  7. import { describe, expect, it } from "vitest";
  8. import {
  9. ANIMAL_COLOR,
  10. DEFAULT_SCENE_ID,
  11. NPC_COLOR,
  12. addAnimal,
  13. addBuilding,
  14. addNpc,
  15. createSceneModel,
  16. entityAt,
  17. entityColor,
  18. entityFootprint,
  19. entityIndexAt,
  20. markSaved,
  21. moveEntity,
  22. removeEntity,
  23. serialize,
  24. } from "../src/renderer/scene-model.js";
  25. import type { SceneFootprint, SceneModel } from "../src/renderer/scene-model.js";
  26. const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
  27. const FOOTPRINTS: Record<string, SceneFootprint> = {
  28. house: { w: 2, h: 2 },
  29. shed: { w: 2, h: 2 },
  30. well: { w: 1, h: 1 },
  31. };
  32. function model(mapWidth = 10, mapHeight = 10, footprints: Record<string, SceneFootprint> = FOOTPRINTS): SceneModel {
  33. const m = createSceneModel(
  34. { id: "farm", entities: [] },
  35. { mapWidth, mapHeight, footprints },
  36. );
  37. if (m === null) throw new Error("expected a valid model");
  38. return m;
  39. }
  40. function entities(m: SceneModel): SceneEntity[] {
  41. return m.def.entities;
  42. }
  43. describe("createSceneModel", () => {
  44. it("builds a model from a valid def without mutating the input", () => {
  45. const def: SceneDef = {
  46. id: "farm",
  47. entities: [{ type: "building", buildingId: "well", x: 1, y: 1 }],
  48. };
  49. const snapshot = JSON.stringify(def);
  50. const m = createSceneModel(def, { mapWidth: 10, mapHeight: 10, footprints: FOOTPRINTS });
  51. expect(m).not.toBeNull();
  52. if (m === null) throw new Error("expected a valid model");
  53. expect(m.dirty).toBe(false);
  54. addNpc(m, "mayor", 3, 3);
  55. expect(JSON.stringify(def)).toBe(snapshot);
  56. expect(m.dirty).toBe(true);
  57. });
  58. it("rejects corrupt defs", () => {
  59. expect(createSceneModel({ id: 42 }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
  60. expect(createSceneModel({ id: "farm", entities: [{ type: "npc", x: 1 }] }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
  61. expect(
  62. createSceneModel({ id: "farm", entities: [{ type: "building", buildingId: "well", x: -1, y: 0 }] }, { mapWidth: 10, mapHeight: 10 }),
  63. ).toBeNull();
  64. expect(
  65. createSceneModel({ id: "farm", entities: [{ type: "silo", x: 1, y: 1 }] }, { mapWidth: 10, mapHeight: 10 }),
  66. ).toBeNull();
  67. });
  68. it("accepts an empty entities array", () => {
  69. const m = createSceneModel({ id: "farm", entities: [] }, { mapWidth: 10, mapHeight: 10 });
  70. expect(m?.def.entities).toEqual([]);
  71. });
  72. it("creates a new empty scene from undefined input with the default id", () => {
  73. const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10 });
  74. expect(m).not.toBeNull();
  75. expect(m?.def).toEqual({ id: DEFAULT_SCENE_ID, entities: [] });
  76. expect(m?.dirty).toBe(false);
  77. });
  78. it("honours a custom default id for new scenes", () => {
  79. const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10, defaultId: "farm" });
  80. expect(m?.def.id).toBe("farm");
  81. });
  82. it("deep-clones the footprints table", () => {
  83. const footprints: Record<string, SceneFootprint> = { house: { w: 2, h: 2 } };
  84. const m = model(10, 10, footprints);
  85. m.footprints.house = { w: 5, h: 5 };
  86. expect(footprints.house).toEqual({ w: 2, h: 2 });
  87. });
  88. });
  89. describe("addBuilding", () => {
  90. it("places a building and marks dirty", () => {
  91. const m = model();
  92. expect(m.dirty).toBe(false);
  93. const result = addBuilding(m, "well", 3, 4);
  94. expect(result).toEqual({ ok: true });
  95. expect(entities(m)).toEqual([{ type: "building", buildingId: "well", x: 3, y: 4 }]);
  96. expect(m.dirty).toBe(true);
  97. });
  98. it("clamps out-of-bounds coordinates footprint-aware", () => {
  99. const m = model(10, 10);
  100. addBuilding(m, "house", 9, 9);
  101. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 8, y: 8 });
  102. addBuilding(m, "well", -3, -3);
  103. expect(entities(m)[1]).toEqual({ type: "building", buildingId: "well", x: 0, y: 0 });
  104. });
  105. it("rejects non-integer coordinates", () => {
  106. const m = model();
  107. expect(addBuilding(m, "well", 1.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  108. expect(addBuilding(m, "well", Number.NaN, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  109. expect(entities(m)).toHaveLength(0);
  110. expect(m.dirty).toBe(false);
  111. });
  112. it("rejects an unknown building id", () => {
  113. const m = model();
  114. const result = addBuilding(m, "silo", 1, 1);
  115. expect(result).toEqual({ ok: false, reason: expect.stringContaining('unknown building "silo"') });
  116. expect(entities(m)).toHaveLength(0);
  117. });
  118. it("rejects an empty building id", () => {
  119. const m = model();
  120. expect(addBuilding(m, "", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("must not be empty") });
  121. });
  122. it("rejects overlap with another building's footprint", () => {
  123. const m = model();
  124. addBuilding(m, "house", 0, 0);
  125. const result = addBuilding(m, "well", 1, 1);
  126. expect(result.ok).toBe(false);
  127. if (!result.ok) expect(result.reason).toContain("overlaps");
  128. expect(entities(m)).toHaveLength(1);
  129. expect(m.dirty).toBe(true);
  130. });
  131. it("rejects a duplicate building id at the same coordinates", () => {
  132. const m = model();
  133. addBuilding(m, "well", 2, 2);
  134. const result = addBuilding(m, "well", 2, 2);
  135. expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate building "well" at (2,2)') });
  136. expect(entities(m)).toHaveLength(1);
  137. });
  138. it("rejects overlap with a placed npc or animal", () => {
  139. const m = model();
  140. addNpc(m, "mayor", 2, 2);
  141. expect(addBuilding(m, "house", 1, 1).ok).toBe(false);
  142. addAnimal(m, "chicken", 5, 5);
  143. expect(addBuilding(m, "house", 4, 4).ok).toBe(false);
  144. expect(entities(m).filter((e) => e.type === "building")).toHaveLength(0);
  145. });
  146. it("allows adjacent placement that only touches edges", () => {
  147. const m = model();
  148. addBuilding(m, "house", 0, 0);
  149. const result = addBuilding(m, "well", 2, 0);
  150. expect(result).toEqual({ ok: true });
  151. expect(entities(m)).toHaveLength(2);
  152. });
  153. it("rejects a footprint larger than the map", () => {
  154. const m = model(1, 1);
  155. const result = addBuilding(m, "house", 0, 0);
  156. expect(result.ok).toBe(false);
  157. if (!result.ok) expect(result.reason).toContain("does not fit");
  158. expect(entities(m)).toHaveLength(0);
  159. });
  160. });
  161. describe("addNpc", () => {
  162. it("places an npc with optional dialogue and label", () => {
  163. const m = model();
  164. const result = addNpc(m, "mayor", 4, 4, { dialogue: "intro", label: "Mayor" });
  165. expect(result).toEqual({ ok: true });
  166. expect(entities(m)).toEqual([
  167. { type: "npc", npcId: "mayor", x: 4, y: 4, dialogue: "intro", label: "Mayor" },
  168. ]);
  169. expect(m.dirty).toBe(true);
  170. });
  171. it("omits empty dialogue and label", () => {
  172. const m = model();
  173. addNpc(m, "mayor", 4, 4, { dialogue: "", label: "" });
  174. expect(entities(m)).toEqual([{ type: "npc", npcId: "mayor", x: 4, y: 4 }]);
  175. });
  176. it("rejects a duplicate npc id", () => {
  177. const m = model();
  178. addNpc(m, "mayor", 1, 1);
  179. const result = addNpc(m, "mayor", 5, 5);
  180. expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate npc id "mayor"') });
  181. expect(entities(m)).toHaveLength(1);
  182. });
  183. it("clamps out-of-bounds coordinates", () => {
  184. const m = model(10, 10);
  185. addNpc(m, "mayor", -5, 99);
  186. expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 9 });
  187. });
  188. it("rejects placement inside a building footprint", () => {
  189. const m = model();
  190. addBuilding(m, "house", 0, 0);
  191. const result = addNpc(m, "mayor", 1, 1);
  192. expect(result.ok).toBe(false);
  193. if (!result.ok) expect(result.reason).toContain("overlaps");
  194. expect(entities(m)).toHaveLength(1);
  195. });
  196. });
  197. describe("addAnimal", () => {
  198. it("places an animal and marks dirty", () => {
  199. const m = model();
  200. const result = addAnimal(m, "chicken", 3, 3);
  201. expect(result).toEqual({ ok: true });
  202. expect(entities(m)).toEqual([{ type: "animal", animalId: "chicken", x: 3, y: 3 }]);
  203. expect(m.dirty).toBe(true);
  204. });
  205. it("clamps out-of-bounds coordinates", () => {
  206. const m = model(10, 10);
  207. addAnimal(m, "chicken", 10, -2);
  208. expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 9, y: 0 });
  209. });
  210. it("allows multiple animals with the same animal id", () => {
  211. const m = model();
  212. addAnimal(m, "chicken", 1, 1);
  213. addAnimal(m, "chicken", 3, 3);
  214. expect(entities(m)).toHaveLength(2);
  215. });
  216. it("rejects overlap with a building", () => {
  217. const m = model();
  218. addBuilding(m, "house", 0, 0);
  219. expect(addAnimal(m, "chicken", 0, 0).ok).toBe(false);
  220. expect(entities(m)).toHaveLength(1);
  221. });
  222. });
  223. describe("removeEntity", () => {
  224. it("removes the entity at the index and marks dirty", () => {
  225. const m = model();
  226. addBuilding(m, "well", 1, 1);
  227. addNpc(m, "mayor", 3, 3);
  228. const result = removeEntity(m, 0);
  229. expect(result).toEqual({ ok: true });
  230. expect(entities(m)).toHaveLength(1);
  231. expect(entities(m)[0]?.type).toBe("npc");
  232. expect(m.dirty).toBe(true);
  233. });
  234. it("rejects an out-of-range index", () => {
  235. const m = model();
  236. expect(removeEntity(m, 0)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  237. addAnimal(m, "chicken", 1, 1);
  238. expect(removeEntity(m, 5)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  239. expect(entities(m)).toHaveLength(1);
  240. });
  241. });
  242. describe("moveEntity", () => {
  243. it("moves an entity and marks dirty", () => {
  244. const m = model();
  245. addNpc(m, "mayor", 1, 1);
  246. const result = moveEntity(m, 0, 4, 5);
  247. expect(result).toEqual({ ok: true });
  248. expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 5 });
  249. expect(m.dirty).toBe(true);
  250. });
  251. it("re-clamps out-of-bounds targets footprint-aware", () => {
  252. const m = model(10, 10);
  253. addBuilding(m, "house", 1, 1);
  254. moveEntity(m, 0, -5, 99);
  255. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 0, y: 8 });
  256. });
  257. it("rejects a move into another entity's footprint without mutating", () => {
  258. const m = model();
  259. addBuilding(m, "house", 0, 0);
  260. addNpc(m, "mayor", 6, 6);
  261. const before = JSON.stringify(m.def);
  262. const result = moveEntity(m, 0, 6, 6);
  263. expect(result.ok).toBe(false);
  264. if (!result.ok) expect(result.reason).toContain("overlaps");
  265. expect(JSON.stringify(m.def)).toBe(before);
  266. expect(m.dirty).toBe(true);
  267. });
  268. it("rejects a move into its own footprint (same entity excluded from overlap)", () => {
  269. const m = model();
  270. addBuilding(m, "house", 0, 0);
  271. addNpc(m, "mayor", 6, 6);
  272. const result = moveEntity(m, 0, 1, 1);
  273. expect(result).toEqual({ ok: true });
  274. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 1, y: 1 });
  275. });
  276. it("rejects an invalid index and non-integer coordinates", () => {
  277. const m = model();
  278. expect(moveEntity(m, 0, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  279. addAnimal(m, "chicken", 1, 1);
  280. expect(moveEntity(m, 0, 2.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  281. expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 1, y: 1 });
  282. });
  283. });
  284. describe("serialize", () => {
  285. it("roundtrips a valid scene as a fresh object", () => {
  286. const m = model();
  287. addBuilding(m, "house", 0, 0);
  288. addNpc(m, "mayor", 3, 3, { dialogue: "intro" });
  289. const out = serialize(m);
  290. expect(out).not.toBeNull();
  291. expect(out).toEqual({
  292. id: "farm",
  293. entities: [
  294. { type: "building", buildingId: "house", x: 0, y: 0 },
  295. { type: "npc", npcId: "mayor", x: 3, y: 3, dialogue: "intro" },
  296. ],
  297. });
  298. expect(out).not.toBe(m.def);
  299. expect(out?.entities[0]).not.toBe(m.def.entities[0]);
  300. expect(sceneSchema.safeParse(out as SceneDef).success).toBe(true);
  301. });
  302. it("rejects an invalid working copy", () => {
  303. const m = model();
  304. addAnimal(m, "chicken", 1, 1);
  305. const invalid = entities(m)[0] as { animalId: string };
  306. delete invalid.animalId;
  307. expect(serialize(m)).toBeNull();
  308. });
  309. it("still serializes after a rejected op (invalid ops never corrupt)", () => {
  310. const m = model();
  311. addBuilding(m, "house", 0, 0);
  312. addBuilding(m, "well", 1, 1);
  313. addNpc(m, "mayor", 1, 1);
  314. const out = serialize(m);
  315. expect(out).not.toBeNull();
  316. expect(out?.entities).toHaveLength(1);
  317. });
  318. it("serializes an empty scene", () => {
  319. const m = model();
  320. const out = serialize(m);
  321. expect(out).toEqual({ id: "farm", entities: [] });
  322. });
  323. });
  324. describe("entityAt / entityIndexAt", () => {
  325. it("finds entities by any footprint tile", () => {
  326. const m = model();
  327. addBuilding(m, "house", 0, 0);
  328. addNpc(m, "mayor", 5, 5);
  329. expect(entityIndexAt(m, 0, 0)).toBe(0);
  330. expect(entityIndexAt(m, 1, 1)).toBe(0);
  331. expect(entityIndexAt(m, 0, 1)).toBe(0);
  332. expect(entityIndexAt(m, 5, 5)).toBe(1);
  333. expect(entityIndexAt(m, 3, 3)).toBe(-1);
  334. expect(entityAt(m, 1, 1)?.type).toBe("building");
  335. expect(entityAt(m, 3, 3)).toBeUndefined();
  336. });
  337. it("returns the topmost entity for overlapping loaded defs", () => {
  338. const m = model();
  339. addBuilding(m, "house", 0, 0);
  340. const raw = m.def as SceneDef;
  341. raw.entities.push({ type: "npc", npcId: "mayor", x: 1, y: 1 });
  342. expect(entityIndexAt(m, 1, 1)).toBe(1);
  343. expect(entityAt(m, 1, 1)?.type).toBe("npc");
  344. });
  345. });
  346. describe("entityFootprint / entityColor", () => {
  347. it("uses the footprint table for buildings and 1x1 for others", () => {
  348. const m = model();
  349. addBuilding(m, "house", 0, 0);
  350. addNpc(m, "mayor", 5, 5);
  351. addAnimal(m, "chicken", 8, 8);
  352. expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 2, h: 2 });
  353. expect(entityFootprint(m, entities(m)[1] as SceneEntity)).toEqual({ w: 1, h: 1 });
  354. expect(entityFootprint(m, entities(m)[2] as SceneEntity)).toEqual({ w: 1, h: 1 });
  355. });
  356. it("falls back to 1x1 for a building with no footprint entry", () => {
  357. const m = model(10, 10, {});
  358. const raw = m.def as SceneDef;
  359. raw.entities.push({ type: "building", buildingId: "silo", x: 0, y: 0 });
  360. expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 1, h: 1 });
  361. });
  362. it("colors npc amber, animal red, buildings a stable hash hue", () => {
  363. expect(entityColor({ type: "npc", npcId: "mayor", x: 0, y: 0 })).toBe(NPC_COLOR);
  364. expect(entityColor({ type: "animal", animalId: "chicken", x: 0, y: 0 })).toBe(ANIMAL_COLOR);
  365. const shed = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
  366. const shedAgain = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
  367. expect(shed).toBe(shedAgain);
  368. expect(shed).toMatch(/^hsl\(\d+ 45% 45%\)$/);
  369. });
  370. });
  371. describe("dirty lifecycle", () => {
  372. it("tracks edits and clears on markSaved", () => {
  373. const m = model();
  374. expect(m.dirty).toBe(false);
  375. addBuilding(m, "well", 1, 1);
  376. expect(m.dirty).toBe(true);
  377. markSaved(m);
  378. expect(m.dirty).toBe(false);
  379. moveEntity(m, 0, 2, 2);
  380. expect(m.dirty).toBe(true);
  381. markSaved(m);
  382. removeEntity(m, 0);
  383. expect(m.dirty).toBe(true);
  384. });
  385. });
  386. describe("real scene fixture end-to-end", () => {
  387. it("edits a real scene through read → parse → model → place → serialize → stringify → parse", async () => {
  388. const sceneText = await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8");
  389. const shedText = await readFile(join(fixturesDir, "buildings", "shed.yaml"), "utf8");
  390. const wellText = await readFile(join(fixturesDir, "buildings", "well.yaml"), "utf8");
  391. const footprints: Record<string, SceneFootprint> = {};
  392. for (const text of [shedText, wellText]) {
  393. const def = buildingSchema.safeParse(parse(text));
  394. expect(def.success).toBe(true);
  395. if (def.success) footprints[def.data.id] = def.data.footprint;
  396. }
  397. const m = createSceneModel(parse(sceneText), { mapWidth: 16, mapHeight: 16, footprints });
  398. expect(m).not.toBeNull();
  399. if (m === null) throw new Error("fixture must be a valid scene");
  400. expect(sceneSchema.safeParse(m.def).success).toBe(true);
  401. expect(m.def.id).toBe("farm-yard");
  402. expect(entities(m)).toHaveLength(3);
  403. const placed = addBuilding(m, "well", 8, 8);
  404. expect(placed).toEqual({ ok: true });
  405. const npc = addNpc(m, "farmer", 10, 10, { label: "Farmer" });
  406. expect(npc).toEqual({ ok: true });
  407. expect(addBuilding(m, "shed", 0, 0).ok).toBe(false);
  408. const out = serialize(m);
  409. expect(out).not.toBeNull();
  410. if (out === null) throw new Error("edited fixture must serialize");
  411. const yaml = stringify(out);
  412. const reparsed = parse(yaml);
  413. expect(reparsed).toEqual(out);
  414. expect(sceneSchema.safeParse(reparsed).success).toBe(true);
  415. const m2 = createSceneModel(reparsed, { mapWidth: 16, mapHeight: 16, footprints });
  416. expect(m2).not.toBeNull();
  417. expect(m2?.def.entities).toHaveLength(5);
  418. expect(m2?.def.entities[3]).toEqual({ type: "building", buildingId: "well", x: 8, y: 8 });
  419. expect(m2?.def.entities[4]).toEqual({ type: "npc", npcId: "farmer", x: 10, y: 10, label: "Farmer" });
  420. expect(await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8")).toBe(sceneText);
  421. });
  422. });