scene.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { createEngine, sceneSchema } from "../src/index.js";
  6. const TIME_YAML = [
  7. "id: default",
  8. "dayLengthTicks: 480",
  9. "seasons:",
  10. " - { name: spring, dayCount: 28 }",
  11. "start: { day: 1, season: spring }",
  12. "",
  13. ].join("\n");
  14. const FARM_YAML = [
  15. "id: farm",
  16. "width: 5",
  17. "height: 5",
  18. 'tilesetRef: "tiles"',
  19. "spawn: { x: 0, y: 0 }",
  20. "layers:",
  21. " - name: ground",
  22. " data:",
  23. " - [grass, grass, grass, grass, grass]",
  24. " - [grass, grass, grass, grass, grass]",
  25. " - [grass, grass, grass, grass, water]",
  26. " - [grass, grass, grass, grass, water]",
  27. " - [grass, grass, grass, grass, grass]",
  28. "",
  29. ].join("\n");
  30. const SHED_YAML = [
  31. "id: shed",
  32. "name: Shed",
  33. "footprint: { w: 2, h: 2 }",
  34. "buildable: any",
  35. "blocksMovement: true",
  36. "",
  37. ].join("\n");
  38. const FENCE_YAML = [
  39. "id: fence",
  40. "name: Fence",
  41. "footprint: { w: 1, h: 1 }",
  42. "buildable: { tiles: [grass] }",
  43. "blocksMovement: true",
  44. "",
  45. ].join("\n");
  46. const CHICKEN_YAML = [
  47. "id: chicken",
  48. "stages:",
  49. " - { name: chick, days: 2, feedRequired: false }",
  50. " - { name: grown, days: 2, feedRequired: true }",
  51. "produces:",
  52. " - { itemId: egg, qty: 1 }",
  53. "",
  54. ].join("\n");
  55. interface Setup {
  56. engine: ReturnType<typeof createEngine>;
  57. close: () => Promise<void>;
  58. }
  59. async function boot(sceneYamls: Record<string, string> = {}): Promise<Setup> {
  60. const tmp = await mkdtemp(join(tmpdir(), "scene-"));
  61. await mkdir(join(tmp, "maps"), { recursive: true });
  62. await mkdir(join(tmp, "buildings"), { recursive: true });
  63. await mkdir(join(tmp, "animals"), { recursive: true });
  64. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  65. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  66. await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML);
  67. await writeFile(join(tmp, "buildings", "fence.yaml"), FENCE_YAML);
  68. await writeFile(join(tmp, "animals", "chicken.yaml"), CHICKEN_YAML);
  69. if (Object.keys(sceneYamls).length > 0) {
  70. await mkdir(join(tmp, "scenes"), { recursive: true });
  71. for (const [id, yaml] of Object.entries(sceneYamls)) {
  72. await writeFile(join(tmp, "scenes", `${id}.yaml`), yaml);
  73. }
  74. }
  75. const engine = createEngine({ contentDir: tmp });
  76. try {
  77. await engine.start();
  78. } catch (err) {
  79. await rm(tmp, { recursive: true, force: true });
  80. throw err;
  81. }
  82. return {
  83. engine,
  84. close: async () => {
  85. await engine.stop();
  86. await rm(tmp, { recursive: true, force: true });
  87. },
  88. };
  89. }
  90. function sceneYaml(id: string, entities: string[]): string {
  91. return [`id: ${id}`, "entities:", ...entities.map((entity) => ` - ${entity}`), ""].join("\n");
  92. }
  93. describe("sceneSchema", () => {
  94. it("accepts a valid building/npc/animal mix and an empty entity list", () => {
  95. expect(
  96. sceneSchema.safeParse({
  97. id: "farm-start",
  98. entities: [
  99. { type: "building", buildingId: "shed", x: 0, y: 0 },
  100. { type: "npc", npcId: "mayor", x: 1, y: 1, dialogue: "welcome", label: "Mayor" },
  101. { type: "animal", animalId: "chicken", x: 2, y: 2 },
  102. ],
  103. }).success,
  104. ).toBe(true);
  105. expect(sceneSchema.safeParse({ id: "empty", entities: [] }).success).toBe(true);
  106. });
  107. it("rejects discriminated-union misuse and unknown entity types", () => {
  108. const npcWithBuildingId = sceneSchema.safeParse({
  109. id: "s",
  110. entities: [{ type: "npc", buildingId: "shed", x: 0, y: 0 }],
  111. });
  112. expect(npcWithBuildingId.success).toBe(false);
  113. const unknownType = sceneSchema.safeParse({
  114. id: "s",
  115. entities: [{ type: "plot", x: 0, y: 0 }],
  116. });
  117. expect(unknownType.success).toBe(false);
  118. const missingType = sceneSchema.safeParse({
  119. id: "s",
  120. entities: [{ buildingId: "shed", x: 0, y: 0 }],
  121. });
  122. expect(missingType.success).toBe(false);
  123. });
  124. it("rejects negative coords, non-integer coords, and unknown keys", () => {
  125. expect(
  126. sceneSchema.safeParse({ id: "s", entities: [{ type: "building", buildingId: "shed", x: -1, y: 0 }] }).success,
  127. ).toBe(false);
  128. expect(
  129. sceneSchema.safeParse({ id: "s", entities: [{ type: "npc", npcId: "m", x: 0, y: 1.5 }] }).success,
  130. ).toBe(false);
  131. expect(
  132. sceneSchema.safeParse({ id: "s", entities: [{ type: "animal", animalId: "c", x: 0, y: 0, extra: 1 }] }).success,
  133. ).toBe(false);
  134. });
  135. it("rejects a missing id and non-array entities", () => {
  136. expect(sceneSchema.safeParse({ entities: [] }).success).toBe(false);
  137. expect(sceneSchema.safeParse({ id: "s", entities: "nope" }).success).toBe(false);
  138. });
  139. });
  140. describe("scene seed instantiation", () => {
  141. it("instantiates a building via PlacementSystem semantics with the runtime entity id", async () => {
  142. const { engine, close } = await boot({
  143. "farm-start": sceneYaml("farm-start", [
  144. "{ type: building, buildingId: shed, x: 0, y: 0 }",
  145. "{ type: building, buildingId: fence, x: 3, y: 3 }",
  146. ]),
  147. });
  148. try {
  149. const shed = engine.world.get("building:shed:0:0");
  150. expect(shed).toMatchObject({
  151. id: "building:shed:0:0",
  152. type: "building",
  153. buildingId: "shed",
  154. x: 0,
  155. y: 0,
  156. w: 2,
  157. h: 2,
  158. blocksMovement: true,
  159. });
  160. expect(engine.world.get("building:fence:3:3")).toMatchObject({ type: "building", buildingId: "fence" });
  161. } finally {
  162. await close();
  163. }
  164. });
  165. it("fails boot loudly when two scene buildings overlap", async () => {
  166. await expect(
  167. boot({
  168. "farm-start": sceneYaml("farm-start", [
  169. "{ type: building, buildingId: shed, x: 0, y: 0 }",
  170. "{ type: building, buildingId: fence, x: 1, y: 1 }",
  171. ]),
  172. }),
  173. ).rejects.toThrow(/overlaps existing building/);
  174. });
  175. it("fails boot loudly when a scene building is not buildable on its tile", async () => {
  176. await expect(
  177. boot({
  178. "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: fence, x: 4, y: 2 }"]),
  179. }),
  180. ).rejects.toThrow(/not buildable for fence/);
  181. });
  182. it("fails boot loudly when a scene references an unknown building def", async () => {
  183. await expect(
  184. boot({
  185. "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: barn, x: 0, y: 0 }"]),
  186. }),
  187. ).rejects.toThrow(/unknown building: barn/);
  188. });
  189. it("creates an npc entity with optional dialogue and label fields", async () => {
  190. const { engine, close } = await boot({
  191. "farm-start": sceneYaml("farm-start", [
  192. "{ type: npc, npcId: mayor, x: 1, y: 1, dialogue: welcome, label: Mayor }",
  193. "{ type: npc, npcId: villager, x: 3, y: 1 }",
  194. ]),
  195. });
  196. try {
  197. expect(engine.world.get("mayor")).toMatchObject({
  198. id: "mayor",
  199. type: "npc",
  200. x: 1,
  201. y: 1,
  202. dialogue: "welcome",
  203. label: "Mayor",
  204. });
  205. expect(engine.world.get("villager")).toEqual({ id: "villager", type: "npc", x: 3, y: 1 });
  206. } finally {
  207. await close();
  208. }
  209. });
  210. it("fails boot loudly on a duplicate npc entity id", async () => {
  211. await expect(
  212. boot({
  213. "farm-start": sceneYaml("farm-start", [
  214. "{ type: npc, npcId: mayor, x: 1, y: 1 }",
  215. "{ type: npc, npcId: mayor, x: 2, y: 2 }",
  216. ]),
  217. }),
  218. ).rejects.toThrow(/duplicate npc entity id: mayor/);
  219. });
  220. it("spawns animals via GrowthSystem.spawnAnimal with the runtime entity id", async () => {
  221. const { engine, close } = await boot({
  222. "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: chicken, x: 2, y: 2 }"]),
  223. });
  224. try {
  225. expect(engine.world.get("animal:chicken:1")).toMatchObject({
  226. id: "animal:chicken:1",
  227. type: "animal",
  228. animalId: "chicken",
  229. x: 2,
  230. y: 2,
  231. stageIndex: 0,
  232. daysInStage: 0,
  233. fed: false,
  234. });
  235. } finally {
  236. await close();
  237. }
  238. });
  239. it("fails boot loudly when a scene references an unknown animal def", async () => {
  240. await expect(
  241. boot({
  242. "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: cow, x: 2, y: 2 }"]),
  243. }),
  244. ).rejects.toThrow(/unknown animal def: cow/);
  245. });
  246. it("boots unchanged when zero scenes are registered", async () => {
  247. const { engine, close } = await boot();
  248. try {
  249. expect(engine.registry.list("scene")).toEqual([]);
  250. expect(engine.world.query({ type: "building" })).toHaveLength(0);
  251. expect(engine.world.query({ type: "npc" })).toHaveLength(0);
  252. expect(engine.world.query({ type: "animal" })).toHaveLength(0);
  253. expect(engine.world.get("player")).toMatchObject({ id: "player", type: "player", x: 0, y: 0 });
  254. } finally {
  255. await close();
  256. }
  257. });
  258. });