animal.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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 {
  6. createEngine,
  7. type AnimalDef,
  8. type AnimalEntity,
  9. type AnimalProduceEvent,
  10. type GrowthSystem,
  11. type SaveSystem,
  12. type System,
  13. } from "../src/index.js";
  14. const TIME_YAML = [
  15. "id: default",
  16. "dayLengthTicks: 480",
  17. "seasons:",
  18. " - { name: spring, dayCount: 28 }",
  19. "start: { day: 1, season: spring }",
  20. "",
  21. ].join("\n");
  22. const FARM_YAML = [
  23. "id: farm",
  24. "width: 2",
  25. "height: 2",
  26. 'tilesetRef: "tiles"',
  27. "spawn: { x: 0, y: 0 }",
  28. "layers:",
  29. " - name: ground",
  30. " data:",
  31. " - [a, b]",
  32. " - [b, a]",
  33. "",
  34. ].join("\n");
  35. const CHICKEN_DEF: AnimalDef = {
  36. id: "chicken",
  37. stages: [
  38. { name: "chick", days: 2, feedRequired: false },
  39. { name: "grown", days: 2, feedRequired: true },
  40. { name: "laying", days: 1, feedRequired: true },
  41. ],
  42. produces: [{ itemId: "egg", qty: 1 }],
  43. };
  44. interface Setup {
  45. engine: ReturnType<typeof createEngine>;
  46. close: () => Promise<void>;
  47. }
  48. async function setup(spy?: System): Promise<Setup> {
  49. const tmp = await mkdtemp(join(tmpdir(), "farm-animal-"));
  50. await mkdir(join(tmp, "maps"), { recursive: true });
  51. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  52. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  53. const systems: System[] = [];
  54. if (spy) systems.push(spy);
  55. const engine = createEngine({ contentDir: tmp, systems });
  56. await engine.start();
  57. engine.registry.register("animal", CHICKEN_DEF);
  58. return {
  59. engine,
  60. close: async () => {
  61. await engine.stop();
  62. await rm(tmp, { recursive: true, force: true });
  63. },
  64. };
  65. }
  66. function growthOf(engine: ReturnType<typeof createEngine>): GrowthSystem {
  67. const growth = engine.systems.get<GrowthSystem>("growth");
  68. if (!growth) throw new Error("growth system not booted");
  69. return growth;
  70. }
  71. function saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
  72. const save = engine.systems.get<SaveSystem>("save");
  73. if (!save) throw new Error("save system not booted");
  74. return save;
  75. }
  76. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  77. const seen: { event: string; payload: unknown }[] = [];
  78. const system: System = {
  79. id: "spy",
  80. init() {},
  81. on(event, payload) {
  82. seen.push({ event, payload });
  83. },
  84. };
  85. return { system, seen };
  86. }
  87. function eventsOf(seen: { event: string; payload: unknown }[], event: string): AnimalProduceEvent[] {
  88. return seen.filter((entry) => entry.event === event).map((entry) => entry.payload as AnimalProduceEvent);
  89. }
  90. describe("GrowthSystem animals", () => {
  91. it("spawnAnimal creates an entity at stage 0 with the next available number", async () => {
  92. const { engine, close } = await setup();
  93. try {
  94. const first = growthOf(engine).spawnAnimal("chicken", 3, 3);
  95. expect(first).toEqual({
  96. id: "animal:chicken:1",
  97. type: "animal",
  98. animalId: "chicken",
  99. x: 3,
  100. y: 3,
  101. stageIndex: 0,
  102. daysInStage: 0,
  103. fed: false,
  104. });
  105. expect(engine.world.get("animal:chicken:1")).toBeDefined();
  106. const second = growthOf(engine).spawnAnimal("chicken", 4, 4);
  107. expect(second.id).toBe("animal:chicken:2");
  108. expect(engine.world.query({ type: "animal" })).toHaveLength(2);
  109. } finally {
  110. await close();
  111. }
  112. });
  113. it("spawnAnimal rejects unknown animal defs and bad coords", async () => {
  114. const { engine, close } = await setup();
  115. try {
  116. const growth = growthOf(engine);
  117. expect(() => growth.spawnAnimal("cow", 1, 1)).toThrow(/unknown animal def: cow/);
  118. expect(() => growth.spawnAnimal("chicken", -1, 1)).toThrow(/non-negative integers/);
  119. expect(() => growth.spawnAnimal("chicken", 1.5, 1)).toThrow(/non-negative integers/);
  120. expect(engine.world.query({ type: "animal" })).toHaveLength(0);
  121. } finally {
  122. await close();
  123. }
  124. });
  125. it("a fed animal advances through stages on day boundaries and emits animal-grow", async () => {
  126. const { system: spy, seen } = eventSpy();
  127. const { engine, close } = await setup(spy);
  128. try {
  129. const growth = growthOf(engine);
  130. growth.spawnAnimal("chicken", 3, 3);
  131. growth.step(480);
  132. let animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  133. expect(animal.stageIndex).toBe(0);
  134. expect(animal.daysInStage).toBe(1);
  135. expect(eventsOf(seen, "animal-grow")).toHaveLength(0);
  136. growth.step(480);
  137. animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  138. expect(animal.stageIndex).toBe(1);
  139. expect(animal.daysInStage).toBe(0);
  140. expect(animal.fed).toBe(false);
  141. expect(eventsOf(seen, "animal-grow")).toEqual([
  142. { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 1, stage: "grown" },
  143. ]);
  144. growth.feed("animal:chicken:1");
  145. growth.step(480);
  146. growth.feed("animal:chicken:1");
  147. growth.step(480);
  148. animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  149. expect(animal.stageIndex).toBe(2);
  150. expect(animal.daysInStage).toBe(0);
  151. expect(eventsOf(seen, "animal-grow").map((e) => e.stageIndex)).toEqual([1, 2]);
  152. } finally {
  153. await close();
  154. }
  155. });
  156. it("an un-fed animal on a feedRequired stage does not advance", async () => {
  157. const { engine, close } = await setup();
  158. try {
  159. const growth = growthOf(engine);
  160. growth.spawnAnimal("chicken", 3, 3);
  161. growth.step(480);
  162. growth.step(480);
  163. expect((engine.world.get("animal:chicken:1") as AnimalEntity).stageIndex).toBe(1);
  164. for (let day = 0; day < 3; day++) {
  165. growth.step(480);
  166. }
  167. const animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  168. expect(animal.stageIndex).toBe(1);
  169. expect(animal.daysInStage).toBe(0);
  170. } finally {
  171. await close();
  172. }
  173. });
  174. it("a mature animal produces per eligible day, resets fed, and skips without feeding", async () => {
  175. const { system: spy, seen } = eventSpy();
  176. const { engine, close } = await setup(spy);
  177. try {
  178. const growth = growthOf(engine);
  179. growth.spawnAnimal("chicken", 3, 3);
  180. growth.step(480);
  181. growth.step(480);
  182. growth.feed("animal:chicken:1");
  183. growth.step(480);
  184. growth.feed("animal:chicken:1");
  185. growth.step(480);
  186. expect((engine.world.get("animal:chicken:1") as AnimalEntity).stageIndex).toBe(2);
  187. growth.feed("animal:chicken:1");
  188. growth.step(480);
  189. const produces = eventsOf(seen, "animal-produce");
  190. expect(produces).toEqual([
  191. { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 2, stage: "laying" },
  192. ]);
  193. let animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  194. expect(animal.fed).toBe(false);
  195. growth.step(480);
  196. expect(eventsOf(seen, "animal-produce")).toHaveLength(1);
  197. animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  198. expect(animal.fed).toBe(false);
  199. growth.feed("animal:chicken:1");
  200. growth.step(480);
  201. expect(eventsOf(seen, "animal-produce")).toHaveLength(2);
  202. } finally {
  203. await close();
  204. }
  205. });
  206. it("feed sets fed true and emits animal-fed; unknown entity throws", async () => {
  207. const { system: spy, seen } = eventSpy();
  208. const { engine, close } = await setup(spy);
  209. try {
  210. const growth = growthOf(engine);
  211. growth.spawnAnimal("chicken", 3, 3);
  212. const fed = growth.feed("animal:chicken:1");
  213. expect(fed.fed).toBe(true);
  214. expect(eventsOf(seen, "animal-fed")).toEqual([{ animalId: "animal:chicken:1", animalDefId: "chicken" }]);
  215. expect(() => growth.feed("ghost")).toThrow(/unknown animal: ghost/);
  216. engine.world.create({ id: "plot:1:1", type: "plot", x: 1, y: 1, tilled: false, watered: false });
  217. expect(() => growth.feed("plot:1:1")).toThrow(/unknown animal: plot:1:1/);
  218. } finally {
  219. await close();
  220. }
  221. });
  222. it("forceAnimalStage jumps an animal and emits animal-grow; rejects bad indices and entities", async () => {
  223. const { system: spy, seen } = eventSpy();
  224. const { engine, close } = await setup(spy);
  225. try {
  226. const growth = growthOf(engine);
  227. growth.spawnAnimal("chicken", 3, 3);
  228. const animal = growth.forceAnimalStage("animal:chicken:1", 2);
  229. expect(animal.stageIndex).toBe(2);
  230. expect(animal.daysInStage).toBe(0);
  231. expect(eventsOf(seen, "animal-grow")).toEqual([
  232. { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 2, stage: "laying" },
  233. ]);
  234. expect(() => growth.forceAnimalStage("animal:chicken:1", 5)).toThrow(/out of range/);
  235. expect(() => growth.forceAnimalStage("ghost", 0)).toThrow(/unknown animal: ghost/);
  236. } finally {
  237. await close();
  238. }
  239. });
  240. it("onAnimalGrow and onAnimalProduce hooks fire", async () => {
  241. const { engine, close } = await setup();
  242. try {
  243. const grew: string[] = [];
  244. const produced: string[] = [];
  245. engine.hooks.on("onAnimalGrow", (ctx) => {
  246. const animal = ctx.world.query({ type: "animal" })[0] as AnimalEntity | undefined;
  247. grew.push(`${animal?.animalId}:${animal?.stageIndex ?? -1}`);
  248. });
  249. engine.hooks.on("onAnimalProduce", (ctx) => {
  250. const animal = ctx.world.query({ type: "animal" })[0] as AnimalEntity | undefined;
  251. produced.push(`${animal?.animalId}:${animal?.stageIndex ?? -1}`);
  252. });
  253. const growth = growthOf(engine);
  254. growth.spawnAnimal("chicken", 3, 3);
  255. growth.step(480);
  256. growth.step(480);
  257. growth.feed("animal:chicken:1");
  258. growth.step(480);
  259. growth.feed("animal:chicken:1");
  260. growth.step(480);
  261. growth.feed("animal:chicken:1");
  262. growth.step(480);
  263. expect(grew).toEqual(["chicken:1", "chicken:2"]);
  264. expect(produced).toEqual(["chicken:2"]);
  265. } finally {
  266. await close();
  267. }
  268. });
  269. it("animal entities survive save snapshot/restore", async () => {
  270. const { engine, close } = await setup();
  271. try {
  272. const growth = growthOf(engine);
  273. growth.spawnAnimal("chicken", 3, 3);
  274. growth.feed("animal:chicken:1");
  275. growth.forceAnimalStage("animal:chicken:1", 1);
  276. const snapshot = saveOf(engine).snapshot();
  277. expect(
  278. snapshot.world.some(
  279. (e) => e.id === "animal:chicken:1" && e.type === "animal" && e.animalId === "chicken" && e.stageIndex === 1 && e.fed === true,
  280. ),
  281. ).toBe(true);
  282. saveOf(engine).restore(snapshot);
  283. const animal = engine.world.get("animal:chicken:1") as AnimalEntity;
  284. expect(animal).toMatchObject({ type: "animal", animalId: "chicken", stageIndex: 1, fed: true });
  285. } finally {
  286. await close();
  287. }
  288. });
  289. });