scene-model.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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. canRedo,
  16. canUndo,
  17. clearHistory,
  18. createSceneModel,
  19. entityAt,
  20. entityColor,
  21. entityFootprint,
  22. entityIndexAt,
  23. markSaved,
  24. moveEntity,
  25. redo,
  26. removeEntity,
  27. sceneFitsAfterResize,
  28. sceneFitsIn,
  29. serialize,
  30. syncMapSize,
  31. undo,
  32. } from "../src/renderer/scene-model.js";
  33. import type { SceneFootprint, SceneModel } from "../src/renderer/scene-model.js";
  34. const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
  35. const FOOTPRINTS: Record<string, SceneFootprint> = {
  36. house: { w: 2, h: 2 },
  37. shed: { w: 2, h: 2 },
  38. well: { w: 1, h: 1 },
  39. };
  40. function model(mapWidth = 10, mapHeight = 10, footprints: Record<string, SceneFootprint> = FOOTPRINTS): SceneModel {
  41. const m = createSceneModel(
  42. { id: "farm", entities: [] },
  43. { mapWidth, mapHeight, footprints },
  44. );
  45. if (m === null) throw new Error("expected a valid model");
  46. return m;
  47. }
  48. function entities(m: SceneModel): SceneEntity[] {
  49. return m.def.entities;
  50. }
  51. describe("createSceneModel", () => {
  52. it("builds a model from a valid def without mutating the input", () => {
  53. const def: SceneDef = {
  54. id: "farm",
  55. entities: [{ type: "building", buildingId: "well", x: 1, y: 1 }],
  56. };
  57. const snapshot = JSON.stringify(def);
  58. const m = createSceneModel(def, { mapWidth: 10, mapHeight: 10, footprints: FOOTPRINTS });
  59. expect(m).not.toBeNull();
  60. if (m === null) throw new Error("expected a valid model");
  61. expect(m.dirty).toBe(false);
  62. addNpc(m, "mayor", 3, 3);
  63. expect(JSON.stringify(def)).toBe(snapshot);
  64. expect(m.dirty).toBe(true);
  65. });
  66. it("rejects corrupt defs", () => {
  67. expect(createSceneModel({ id: 42 }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
  68. expect(createSceneModel({ id: "farm", entities: [{ type: "npc", x: 1 }] }, { mapWidth: 10, mapHeight: 10 })).toBeNull();
  69. expect(
  70. createSceneModel({ id: "farm", entities: [{ type: "building", buildingId: "well", x: -1, y: 0 }] }, { mapWidth: 10, mapHeight: 10 }),
  71. ).toBeNull();
  72. expect(
  73. createSceneModel({ id: "farm", entities: [{ type: "silo", x: 1, y: 1 }] }, { mapWidth: 10, mapHeight: 10 }),
  74. ).toBeNull();
  75. });
  76. it("accepts an empty entities array", () => {
  77. const m = createSceneModel({ id: "farm", entities: [] }, { mapWidth: 10, mapHeight: 10 });
  78. expect(m?.def.entities).toEqual([]);
  79. });
  80. it("creates a new empty scene from undefined input with the default id", () => {
  81. const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10 });
  82. expect(m).not.toBeNull();
  83. expect(m?.def).toEqual({ id: DEFAULT_SCENE_ID, entities: [] });
  84. expect(m?.dirty).toBe(false);
  85. });
  86. it("honours a custom default id for new scenes", () => {
  87. const m = createSceneModel(undefined, { mapWidth: 10, mapHeight: 10, defaultId: "farm" });
  88. expect(m?.def.id).toBe("farm");
  89. });
  90. it("deep-clones the footprints table", () => {
  91. const footprints: Record<string, SceneFootprint> = { house: { w: 2, h: 2 } };
  92. const m = model(10, 10, footprints);
  93. m.footprints.house = { w: 5, h: 5 };
  94. expect(footprints.house).toEqual({ w: 2, h: 2 });
  95. });
  96. });
  97. describe("addBuilding", () => {
  98. it("places a building and marks dirty", () => {
  99. const m = model();
  100. expect(m.dirty).toBe(false);
  101. const result = addBuilding(m, "well", 3, 4);
  102. expect(result).toEqual({ ok: true });
  103. expect(entities(m)).toEqual([{ type: "building", buildingId: "well", x: 3, y: 4 }]);
  104. expect(m.dirty).toBe(true);
  105. });
  106. it("clamps out-of-bounds coordinates footprint-aware", () => {
  107. const m = model(10, 10);
  108. addBuilding(m, "house", 9, 9);
  109. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 8, y: 8 });
  110. addBuilding(m, "well", -3, -3);
  111. expect(entities(m)[1]).toEqual({ type: "building", buildingId: "well", x: 0, y: 0 });
  112. });
  113. it("rejects non-integer coordinates", () => {
  114. const m = model();
  115. expect(addBuilding(m, "well", 1.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  116. expect(addBuilding(m, "well", Number.NaN, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  117. expect(entities(m)).toHaveLength(0);
  118. expect(m.dirty).toBe(false);
  119. });
  120. it("rejects an unknown building id", () => {
  121. const m = model();
  122. const result = addBuilding(m, "silo", 1, 1);
  123. expect(result).toEqual({ ok: false, reason: expect.stringContaining('unknown building "silo"') });
  124. expect(entities(m)).toHaveLength(0);
  125. });
  126. it("rejects an empty building id", () => {
  127. const m = model();
  128. expect(addBuilding(m, "", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("must not be empty") });
  129. });
  130. it("rejects overlap with another building's footprint", () => {
  131. const m = model();
  132. addBuilding(m, "house", 0, 0);
  133. const result = addBuilding(m, "well", 1, 1);
  134. expect(result.ok).toBe(false);
  135. if (!result.ok) expect(result.reason).toContain("overlaps");
  136. expect(entities(m)).toHaveLength(1);
  137. expect(m.dirty).toBe(true);
  138. });
  139. it("rejects a duplicate building id at the same coordinates", () => {
  140. const m = model();
  141. addBuilding(m, "well", 2, 2);
  142. const result = addBuilding(m, "well", 2, 2);
  143. expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate building "well" at (2,2)') });
  144. expect(entities(m)).toHaveLength(1);
  145. });
  146. it("rejects overlap with a placed npc or animal", () => {
  147. const m = model();
  148. addNpc(m, "mayor", 2, 2);
  149. expect(addBuilding(m, "house", 1, 1).ok).toBe(false);
  150. addAnimal(m, "chicken", 5, 5);
  151. expect(addBuilding(m, "house", 4, 4).ok).toBe(false);
  152. expect(entities(m).filter((e) => e.type === "building")).toHaveLength(0);
  153. });
  154. it("allows adjacent placement that only touches edges", () => {
  155. const m = model();
  156. addBuilding(m, "house", 0, 0);
  157. const result = addBuilding(m, "well", 2, 0);
  158. expect(result).toEqual({ ok: true });
  159. expect(entities(m)).toHaveLength(2);
  160. });
  161. it("rejects a footprint larger than the map", () => {
  162. const m = model(1, 1);
  163. const result = addBuilding(m, "house", 0, 0);
  164. expect(result.ok).toBe(false);
  165. if (!result.ok) expect(result.reason).toContain("does not fit");
  166. expect(entities(m)).toHaveLength(0);
  167. });
  168. });
  169. describe("addNpc", () => {
  170. it("places an npc with optional dialogue and label", () => {
  171. const m = model();
  172. const result = addNpc(m, "mayor", 4, 4, { dialogue: "intro", label: "Mayor" });
  173. expect(result).toEqual({ ok: true });
  174. expect(entities(m)).toEqual([
  175. { type: "npc", npcId: "mayor", x: 4, y: 4, dialogue: "intro", label: "Mayor" },
  176. ]);
  177. expect(m.dirty).toBe(true);
  178. });
  179. it("omits empty dialogue and label", () => {
  180. const m = model();
  181. addNpc(m, "mayor", 4, 4, { dialogue: "", label: "" });
  182. expect(entities(m)).toEqual([{ type: "npc", npcId: "mayor", x: 4, y: 4 }]);
  183. });
  184. it("rejects a duplicate npc id", () => {
  185. const m = model();
  186. addNpc(m, "mayor", 1, 1);
  187. const result = addNpc(m, "mayor", 5, 5);
  188. expect(result).toEqual({ ok: false, reason: expect.stringContaining('duplicate npc id "mayor"') });
  189. expect(entities(m)).toHaveLength(1);
  190. });
  191. it("clamps out-of-bounds coordinates", () => {
  192. const m = model(10, 10);
  193. addNpc(m, "mayor", -5, 99);
  194. expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 9 });
  195. });
  196. it("rejects placement inside a building footprint", () => {
  197. const m = model();
  198. addBuilding(m, "house", 0, 0);
  199. const result = addNpc(m, "mayor", 1, 1);
  200. expect(result.ok).toBe(false);
  201. if (!result.ok) expect(result.reason).toContain("overlaps");
  202. expect(entities(m)).toHaveLength(1);
  203. });
  204. });
  205. describe("addAnimal", () => {
  206. it("places an animal and marks dirty", () => {
  207. const m = model();
  208. const result = addAnimal(m, "chicken", 3, 3);
  209. expect(result).toEqual({ ok: true });
  210. expect(entities(m)).toEqual([{ type: "animal", animalId: "chicken", x: 3, y: 3 }]);
  211. expect(m.dirty).toBe(true);
  212. });
  213. it("clamps out-of-bounds coordinates", () => {
  214. const m = model(10, 10);
  215. addAnimal(m, "chicken", 10, -2);
  216. expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 9, y: 0 });
  217. });
  218. it("allows multiple animals with the same animal id", () => {
  219. const m = model();
  220. addAnimal(m, "chicken", 1, 1);
  221. addAnimal(m, "chicken", 3, 3);
  222. expect(entities(m)).toHaveLength(2);
  223. });
  224. it("rejects overlap with a building", () => {
  225. const m = model();
  226. addBuilding(m, "house", 0, 0);
  227. expect(addAnimal(m, "chicken", 0, 0).ok).toBe(false);
  228. expect(entities(m)).toHaveLength(1);
  229. });
  230. });
  231. describe("removeEntity", () => {
  232. it("removes the entity at the index and marks dirty", () => {
  233. const m = model();
  234. addBuilding(m, "well", 1, 1);
  235. addNpc(m, "mayor", 3, 3);
  236. const result = removeEntity(m, 0);
  237. expect(result).toEqual({ ok: true });
  238. expect(entities(m)).toHaveLength(1);
  239. expect(entities(m)[0]?.type).toBe("npc");
  240. expect(m.dirty).toBe(true);
  241. });
  242. it("rejects an out-of-range index", () => {
  243. const m = model();
  244. expect(removeEntity(m, 0)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  245. addAnimal(m, "chicken", 1, 1);
  246. expect(removeEntity(m, 5)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  247. expect(entities(m)).toHaveLength(1);
  248. });
  249. });
  250. describe("moveEntity", () => {
  251. it("moves an entity and marks dirty", () => {
  252. const m = model();
  253. addNpc(m, "mayor", 1, 1);
  254. const result = moveEntity(m, 0, 4, 5);
  255. expect(result).toEqual({ ok: true });
  256. expect(entities(m)[0]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 5 });
  257. expect(m.dirty).toBe(true);
  258. });
  259. it("re-clamps out-of-bounds targets footprint-aware", () => {
  260. const m = model(10, 10);
  261. addBuilding(m, "house", 1, 1);
  262. moveEntity(m, 0, -5, 99);
  263. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 0, y: 8 });
  264. });
  265. it("rejects a move into another entity's footprint without mutating", () => {
  266. const m = model();
  267. addBuilding(m, "house", 0, 0);
  268. addNpc(m, "mayor", 6, 6);
  269. const before = JSON.stringify(m.def);
  270. const result = moveEntity(m, 0, 6, 6);
  271. expect(result.ok).toBe(false);
  272. if (!result.ok) expect(result.reason).toContain("overlaps");
  273. expect(JSON.stringify(m.def)).toBe(before);
  274. expect(m.dirty).toBe(true);
  275. });
  276. it("rejects a move into its own footprint (same entity excluded from overlap)", () => {
  277. const m = model();
  278. addBuilding(m, "house", 0, 0);
  279. addNpc(m, "mayor", 6, 6);
  280. const result = moveEntity(m, 0, 1, 1);
  281. expect(result).toEqual({ ok: true });
  282. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 1, y: 1 });
  283. });
  284. it("rejects an invalid index and non-integer coordinates", () => {
  285. const m = model();
  286. expect(moveEntity(m, 0, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("no entity at index") });
  287. addAnimal(m, "chicken", 1, 1);
  288. expect(moveEntity(m, 0, 2.5, 1)).toEqual({ ok: false, reason: expect.stringContaining("integer") });
  289. expect(entities(m)[0]).toEqual({ type: "animal", animalId: "chicken", x: 1, y: 1 });
  290. });
  291. });
  292. describe("serialize", () => {
  293. it("roundtrips a valid scene as a fresh object", () => {
  294. const m = model();
  295. addBuilding(m, "house", 0, 0);
  296. addNpc(m, "mayor", 3, 3, { dialogue: "intro" });
  297. const out = serialize(m);
  298. expect(out).not.toBeNull();
  299. expect(out).toEqual({
  300. id: "farm",
  301. entities: [
  302. { type: "building", buildingId: "house", x: 0, y: 0 },
  303. { type: "npc", npcId: "mayor", x: 3, y: 3, dialogue: "intro" },
  304. ],
  305. });
  306. expect(out).not.toBe(m.def);
  307. expect(out?.entities[0]).not.toBe(m.def.entities[0]);
  308. expect(sceneSchema.safeParse(out as SceneDef).success).toBe(true);
  309. });
  310. it("rejects an invalid working copy", () => {
  311. const m = model();
  312. addAnimal(m, "chicken", 1, 1);
  313. const invalid = entities(m)[0] as { animalId: string };
  314. delete invalid.animalId;
  315. expect(serialize(m)).toBeNull();
  316. });
  317. it("still serializes after a rejected op (invalid ops never corrupt)", () => {
  318. const m = model();
  319. addBuilding(m, "house", 0, 0);
  320. addBuilding(m, "well", 1, 1);
  321. addNpc(m, "mayor", 1, 1);
  322. const out = serialize(m);
  323. expect(out).not.toBeNull();
  324. expect(out?.entities).toHaveLength(1);
  325. });
  326. it("serializes an empty scene", () => {
  327. const m = model();
  328. const out = serialize(m);
  329. expect(out).toEqual({ id: "farm", entities: [] });
  330. });
  331. });
  332. describe("entityAt / entityIndexAt", () => {
  333. it("finds entities by any footprint tile", () => {
  334. const m = model();
  335. addBuilding(m, "house", 0, 0);
  336. addNpc(m, "mayor", 5, 5);
  337. expect(entityIndexAt(m, 0, 0)).toBe(0);
  338. expect(entityIndexAt(m, 1, 1)).toBe(0);
  339. expect(entityIndexAt(m, 0, 1)).toBe(0);
  340. expect(entityIndexAt(m, 5, 5)).toBe(1);
  341. expect(entityIndexAt(m, 3, 3)).toBe(-1);
  342. expect(entityAt(m, 1, 1)?.type).toBe("building");
  343. expect(entityAt(m, 3, 3)).toBeUndefined();
  344. });
  345. it("returns the topmost entity for overlapping loaded defs", () => {
  346. const m = model();
  347. addBuilding(m, "house", 0, 0);
  348. const raw = m.def as SceneDef;
  349. raw.entities.push({ type: "npc", npcId: "mayor", x: 1, y: 1 });
  350. expect(entityIndexAt(m, 1, 1)).toBe(1);
  351. expect(entityAt(m, 1, 1)?.type).toBe("npc");
  352. });
  353. });
  354. describe("entityFootprint / entityColor", () => {
  355. it("uses the footprint table for buildings and 1x1 for others", () => {
  356. const m = model();
  357. addBuilding(m, "house", 0, 0);
  358. addNpc(m, "mayor", 5, 5);
  359. addAnimal(m, "chicken", 8, 8);
  360. expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 2, h: 2 });
  361. expect(entityFootprint(m, entities(m)[1] as SceneEntity)).toEqual({ w: 1, h: 1 });
  362. expect(entityFootprint(m, entities(m)[2] as SceneEntity)).toEqual({ w: 1, h: 1 });
  363. });
  364. it("falls back to 1x1 for a building with no footprint entry", () => {
  365. const m = model(10, 10, {});
  366. const raw = m.def as SceneDef;
  367. raw.entities.push({ type: "building", buildingId: "silo", x: 0, y: 0 });
  368. expect(entityFootprint(m, entities(m)[0] as SceneEntity)).toEqual({ w: 1, h: 1 });
  369. });
  370. it("colors npc amber, animal red, buildings a stable hash hue", () => {
  371. expect(entityColor({ type: "npc", npcId: "mayor", x: 0, y: 0 })).toBe(NPC_COLOR);
  372. expect(entityColor({ type: "animal", animalId: "chicken", x: 0, y: 0 })).toBe(ANIMAL_COLOR);
  373. const shed = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
  374. const shedAgain = entityColor({ type: "building", buildingId: "shed", x: 0, y: 0 });
  375. expect(shed).toBe(shedAgain);
  376. expect(shed).toMatch(/^hsl\(\d+ 45% 45%\)$/);
  377. });
  378. });
  379. describe("dirty lifecycle", () => {
  380. it("tracks edits and clears on markSaved", () => {
  381. const m = model();
  382. expect(m.dirty).toBe(false);
  383. addBuilding(m, "well", 1, 1);
  384. expect(m.dirty).toBe(true);
  385. markSaved(m);
  386. expect(m.dirty).toBe(false);
  387. moveEntity(m, 0, 2, 2);
  388. expect(m.dirty).toBe(true);
  389. markSaved(m);
  390. removeEntity(m, 0);
  391. expect(m.dirty).toBe(true);
  392. });
  393. });
  394. describe("real scene fixture end-to-end", () => {
  395. it("edits a real scene through read → parse → model → place → serialize → stringify → parse", async () => {
  396. const sceneText = await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8");
  397. const shedText = await readFile(join(fixturesDir, "buildings", "shed.yaml"), "utf8");
  398. const wellText = await readFile(join(fixturesDir, "buildings", "well.yaml"), "utf8");
  399. const footprints: Record<string, SceneFootprint> = {};
  400. for (const text of [shedText, wellText]) {
  401. const def = buildingSchema.safeParse(parse(text));
  402. expect(def.success).toBe(true);
  403. if (def.success) footprints[def.data.id] = def.data.footprint;
  404. }
  405. const m = createSceneModel(parse(sceneText), { mapWidth: 16, mapHeight: 16, footprints });
  406. expect(m).not.toBeNull();
  407. if (m === null) throw new Error("fixture must be a valid scene");
  408. expect(sceneSchema.safeParse(m.def).success).toBe(true);
  409. expect(m.def.id).toBe("farm-yard");
  410. expect(entities(m)).toHaveLength(3);
  411. const placed = addBuilding(m, "well", 8, 8);
  412. expect(placed).toEqual({ ok: true });
  413. const npc = addNpc(m, "farmer", 10, 10, { label: "Farmer" });
  414. expect(npc).toEqual({ ok: true });
  415. expect(addBuilding(m, "shed", 0, 0).ok).toBe(false);
  416. const out = serialize(m);
  417. expect(out).not.toBeNull();
  418. if (out === null) throw new Error("edited fixture must serialize");
  419. const yaml = stringify(out);
  420. const reparsed = parse(yaml);
  421. expect(reparsed).toEqual(out);
  422. expect(sceneSchema.safeParse(reparsed).success).toBe(true);
  423. const m2 = createSceneModel(reparsed, { mapWidth: 16, mapHeight: 16, footprints });
  424. expect(m2).not.toBeNull();
  425. expect(m2?.def.entities).toHaveLength(5);
  426. expect(m2?.def.entities[3]).toEqual({ type: "building", buildingId: "well", x: 8, y: 8 });
  427. expect(m2?.def.entities[4]).toEqual({ type: "npc", npcId: "farmer", x: 10, y: 10, label: "Farmer" });
  428. expect(await readFile(join(fixturesDir, "scene-farm.yaml"), "utf8")).toBe(sceneText);
  429. });
  430. });
  431. describe("undo/redo", () => {
  432. it("addBuilding/addNpc/addAnimal undo removes the entity and redo restores it", () => {
  433. const m = model();
  434. addBuilding(m, "house", 4, 4);
  435. addNpc(m, "mayor", 0, 0, { dialogue: "intro" });
  436. addAnimal(m, "chicken", 7, 7);
  437. expect(entities(m).length).toBe(3);
  438. expect(undo(m)).toBe(true);
  439. expect(entities(m).length).toBe(2);
  440. expect(undo(m)).toBe(true);
  441. expect(entities(m).length).toBe(1);
  442. expect(undo(m)).toBe(true);
  443. expect(entities(m).length).toBe(0);
  444. expect(m.dirty).toBe(false);
  445. expect(redo(m)).toBe(true);
  446. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
  447. expect(redo(m)).toBe(true);
  448. expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 0, y: 0, dialogue: "intro" });
  449. expect(m.dirty).toBe(true);
  450. });
  451. it("removeEntity undo re-inserts the snapshot at the original index", () => {
  452. const m = model();
  453. addBuilding(m, "house", 4, 4);
  454. addNpc(m, "mayor", 0, 0);
  455. expect(removeEntity(m, 0)).toEqual({ ok: true });
  456. expect(entities(m).map((e) => e.type)).toEqual(["npc"]);
  457. expect(undo(m)).toBe(true);
  458. expect(entities(m).map((e) => e.type)).toEqual(["building", "npc"]);
  459. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
  460. });
  461. it("history entries snapshot entities: a later move never corrupts redo", () => {
  462. const m = model();
  463. addBuilding(m, "house", 4, 4); // add entry snapshots house at (4,4)
  464. expect(moveEntity(m, 0, 7, 7)).toEqual({ ok: true }); // live house → (7,7)
  465. expect(undo(m)).toBe(true); // undo the move
  466. expect(undo(m)).toBe(true); // undo the add
  467. expect(entities(m)).toHaveLength(0);
  468. expect(redo(m)).toBe(true); // redo the add — restores the SNAPSHOT coords, not (7,7)
  469. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
  470. });
  471. it("moveEntity undo restores the previous coordinates", () => {
  472. const m = model();
  473. addNpc(m, "mayor", 2, 2);
  474. expect(moveEntity(m, 0, 8, 8)).toEqual({ ok: true });
  475. expect(entities(m)[0].x).toBe(8);
  476. expect(undo(m)).toBe(true);
  477. expect(entities(m)[0]).toEqual({ x: 2, y: 2, type: "npc", npcId: "mayor" });
  478. });
  479. it("rejected ops never enter the stack", () => {
  480. const m = model();
  481. addBuilding(m, "house", 4, 4);
  482. expect(addBuilding(m, "house", 4, 4)).toEqual({ ok: false, reason: expect.stringContaining("duplicate") });
  483. expect(addNpc(m, "mayor", 0, 0));
  484. expect(addNpc(m, "mayor", 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("duplicate npc") });
  485. expect(moveEntity(m, 99, 0, 0)).toEqual({ ok: false, reason: expect.stringContaining("index") });
  486. expect(m.history.length).toBe(2); // only the two successful adds
  487. });
  488. it("markSaved is a checkpoint and undo-all returns clean", () => {
  489. const m = model();
  490. addNpc(m, "mayor", 0, 0);
  491. undo(m);
  492. markSaved(m);
  493. expect(m.history.length).toBe(0);
  494. expect(m.future.length).toBe(0);
  495. expect(m.dirty).toBe(false);
  496. addNpc(m, "mia", 1, 1);
  497. addNpc(m, "cow", 2, 2);
  498. while (undo(m)) {
  499. // drain
  500. }
  501. expect(m.dirty).toBe(false);
  502. expect(entities(m).length).toBe(0);
  503. });
  504. });
  505. describe("syncMapSize", () => {
  506. it("updates bounds and clamps entities into the new size", () => {
  507. const m = model(10, 10);
  508. addBuilding(m, "house", 8, 8); // 2×2 at 8,8 → fits in 10×10
  509. addNpc(m, "mayor", 9, 0);
  510. syncMapSize(m, 5, 5);
  511. expect(m.mapWidth).toBe(5);
  512. expect(m.mapHeight).toBe(5);
  513. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 3, y: 3 });
  514. expect(entities(m)[1]).toEqual({ type: "npc", npcId: "mayor", x: 4, y: 0 });
  515. });
  516. it("leaves a building whose footprint cannot fit in place (defensive — sceneFitsIn guards first)", () => {
  517. const m = model(10, 10);
  518. addBuilding(m, "house", 4, 4);
  519. syncMapSize(m, 1, 1);
  520. expect(entities(m)[0]).toEqual({ type: "building", buildingId: "house", x: 4, y: 4 });
  521. });
  522. });
  523. describe("sceneFitsIn", () => {
  524. it("rejects a size that cannot contain a placed building footprint", () => {
  525. const m = model(10, 10);
  526. addBuilding(m, "house", 4, 4); // 2×2
  527. expect(sceneFitsIn(m, 10, 10)).toEqual({ ok: true });
  528. expect(sceneFitsIn(m, 2, 2)).toEqual({ ok: true });
  529. const result = sceneFitsIn(m, 1, 1);
  530. expect(result).toEqual({ ok: false, reason: expect.stringContaining('building "house"') });
  531. expect(result).toEqual({ ok: false, reason: expect.stringContaining("1×1") });
  532. });
  533. it("always fits npc/animal (1×1)", () => {
  534. const m = model(10, 10);
  535. addNpc(m, "mayor", 9, 9);
  536. expect(addAnimal(m, "chicken", 8, 8)).toEqual({ ok: true });
  537. expect(sceneFitsIn(m, 1, 1)).toEqual({ ok: true });
  538. });
  539. });
  540. describe("clearHistory + sceneFitsAfterResize (bug-fix round)", () => {
  541. it("clearHistory keeps the dirty flag — the resize is an unsaved edit", () => {
  542. const m = model();
  543. addNpc(m, "mayor", 0, 0);
  544. clearHistory(m);
  545. expect(m.history.length).toBe(0);
  546. expect(m.dirty).toBe(true);
  547. expect(canUndo(m)).toBe(false);
  548. });
  549. it("sceneFitsAfterResize rejects a shrink whose clamping would overlap buildings", () => {
  550. const m = model(10, 10);
  551. addBuilding(m, "house", 0, 0); // 2×2
  552. addBuilding(m, "shed", 3, 0); // 2×2 — clamp target (1,0) would overlap the house
  553. const result = sceneFitsAfterResize(m, 3, 3);
  554. expect(result).toEqual({ ok: false, reason: expect.stringContaining("would overlap") });
  555. // per-building fit still passes at 3×3 (each 2×2 fits) — the overlap
  556. // simulation is what rejects it
  557. expect(sceneFitsIn(m, 3, 3)).toEqual({ ok: true });
  558. });
  559. it("sceneFitsAfterResize passes when clamped entities stay apart", () => {
  560. const m = model(10, 10);
  561. addBuilding(m, "house", 0, 0);
  562. addBuilding(m, "shed", 5, 0);
  563. addNpc(m, "mayor", 9, 9);
  564. expect(sceneFitsAfterResize(m, 4, 4)).toEqual({ ok: true });
  565. });
  566. it("sceneFitsAfterResize still rejects uncontainable buildings", () => {
  567. const m = model(10, 10);
  568. addBuilding(m, "house", 0, 0); // 2×2
  569. expect(sceneFitsAfterResize(m, 1, 1)).toEqual({ ok: false, reason: expect.stringContaining("does not fit") });
  570. });
  571. });