tick.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import { resolve } from "node:path";
  2. import { afterEach, describe, expect, it, vi } from "vitest";
  3. import {
  4. createEngine,
  5. type GrowthSystem,
  6. type InventorySystem,
  7. type PlotEntity,
  8. type PlantingSystem,
  9. type StateMachineDef,
  10. type System,
  11. type TimeSystem,
  12. } from "../src/index.js";
  13. const repoRoot = resolve(process.cwd(), "../..");
  14. const contentDir = resolve(repoRoot, "content");
  15. const modsDir = resolve(repoRoot, "mods");
  16. function engineSetup(spy?: System) {
  17. return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] });
  18. }
  19. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  20. const time = engine.systems.get<TimeSystem>("time");
  21. if (!time) throw new Error("time system not booted");
  22. return time;
  23. }
  24. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  25. const inv = engine.systems.get<InventorySystem>("inventory");
  26. if (!inv) throw new Error("inventory system not booted");
  27. return inv;
  28. }
  29. function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
  30. const planting = engine.systems.get<PlantingSystem>("planting");
  31. if (!planting) throw new Error("planting system not booted");
  32. return planting;
  33. }
  34. function stateOf(engine: ReturnType<typeof createEngine>, machineId: string): string | undefined {
  35. return engine.world.get(`sm:${machineId}`)?.state as string | undefined;
  36. }
  37. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  38. const seen: { event: string; payload: unknown }[] = [];
  39. const system: System = {
  40. id: "spy",
  41. init() {},
  42. on(event, payload) {
  43. seen.push({ event, payload });
  44. },
  45. };
  46. return { system, seen };
  47. }
  48. function plantWateredParsnip(engine: ReturnType<typeof createEngine>, x: number, y: number): void {
  49. inventoryOf(engine).give("parsnip-seed", 5);
  50. const planting = plantingOf(engine);
  51. planting.till(x, y);
  52. planting.sow(x, y, "parsnip-seed");
  53. planting.water(x, y);
  54. }
  55. describe("engine.tick (tick loop, D23)", () => {
  56. it("advances time AND growth together (a watered plot progresses over repeated ticks)", async () => {
  57. const engine = engineSetup();
  58. await engine.start();
  59. try {
  60. plantWateredParsnip(engine, 6, 6);
  61. engine.tick(480);
  62. expect(timeOf(engine).get()).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  63. let plot = engine.world.get("plot:6:6") as PlotEntity;
  64. expect(plot.stageIndex).toBe(0);
  65. expect(plot.daysInStage).toBe(1);
  66. engine.tick(480);
  67. expect(timeOf(engine).get()).toEqual({ day: 3, season: "spring", tickInDay: 0 });
  68. plot = engine.world.get("plot:6:6") as PlotEntity;
  69. expect(plot.stageIndex).toBe(1);
  70. expect(plot.daysInStage).toBe(0);
  71. } finally {
  72. await engine.stop();
  73. }
  74. });
  75. it("emits a time-tick event each step with the post-step state", async () => {
  76. const { system: spy, seen } = eventSpy();
  77. const engine = engineSetup(spy);
  78. await engine.start();
  79. try {
  80. engine.tick(120);
  81. engine.tick(120);
  82. engine.tick(240);
  83. const ticks = seen
  84. .filter((entry) => entry.event === "time-tick")
  85. .map((entry) => entry.payload as { day: number; season: string; tickInDay: number });
  86. expect(ticks).toEqual([
  87. { day: 1, season: "spring", tickInDay: 120 },
  88. { day: 1, season: "spring", tickInDay: 240 },
  89. { day: 2, season: "spring", tickInDay: 0 },
  90. ]);
  91. } finally {
  92. await engine.stop();
  93. }
  94. });
  95. it("drives onSchedule at:{tickInDay} transitions at the exact tick", async () => {
  96. const engine = engineSetup();
  97. await engine.start();
  98. try {
  99. engine.registry.register("statemachine", {
  100. id: "test-intra",
  101. initial: "sleeping",
  102. states: [{ id: "sleeping" }, { id: "awake" }, { id: "done" }],
  103. transitions: [],
  104. onSchedule: [
  105. { at: { tickInDay: 120 }, to: "awake" },
  106. { at: { tickInDay: 360 }, to: "done" },
  107. ],
  108. } satisfies StateMachineDef);
  109. engine.tick(120);
  110. expect(stateOf(engine, "test-intra")).toBe("awake");
  111. engine.tick(120);
  112. expect(stateOf(engine, "test-intra")).toBe("awake");
  113. engine.tick(120);
  114. expect(stateOf(engine, "test-intra")).toBe("done");
  115. } finally {
  116. await engine.stop();
  117. }
  118. });
  119. it("at:{tickInDay:0} fires on the boundary-crossing tick", async () => {
  120. const engine = engineSetup();
  121. await engine.start();
  122. try {
  123. engine.registry.register("statemachine", {
  124. id: "test-zero",
  125. initial: "sleeping",
  126. states: [{ id: "sleeping" }, { id: "awake" }],
  127. transitions: [],
  128. onSchedule: [{ at: { tickInDay: 0 }, to: "awake" }],
  129. } satisfies StateMachineDef);
  130. engine.tick(100);
  131. expect(stateOf(engine, "test-zero")).toBeUndefined();
  132. engine.tick(380);
  133. expect(stateOf(engine, "test-zero")).toBe("awake");
  134. } finally {
  135. await engine.stop();
  136. }
  137. });
  138. it("tickInDay entries fire on time-tick; day-start entries do not", async () => {
  139. const engine = engineSetup();
  140. await engine.start();
  141. try {
  142. engine.registry.register("statemachine", {
  143. id: "test-tick-entry",
  144. initial: "idle",
  145. states: [{ id: "idle" }, { id: "awake" }],
  146. transitions: [],
  147. onSchedule: [{ at: { tickInDay: 200 }, to: "awake" }],
  148. } satisfies StateMachineDef);
  149. engine.registry.register("statemachine", {
  150. id: "test-day-entry",
  151. initial: "idle",
  152. states: [{ id: "idle" }, { id: "awake" }],
  153. transitions: [],
  154. onSchedule: [{ at: "day-start", to: "awake" }],
  155. } satisfies StateMachineDef);
  156. engine.tick(200);
  157. expect(stateOf(engine, "test-tick-entry")).toBe("awake");
  158. expect(stateOf(engine, "test-day-entry")).toBeUndefined();
  159. engine.tick(280);
  160. expect(stateOf(engine, "test-day-entry")).toBe("awake");
  161. } finally {
  162. await engine.stop();
  163. }
  164. });
  165. it("supports the combined at:{day, tickInDay} form", async () => {
  166. const engine = engineSetup();
  167. await engine.start();
  168. try {
  169. engine.registry.register("statemachine", {
  170. id: "test-combo",
  171. initial: "idle",
  172. states: [{ id: "idle" }, { id: "active" }],
  173. transitions: [],
  174. onSchedule: [{ at: { day: 1, tickInDay: 240 }, to: "active" }],
  175. } satisfies StateMachineDef);
  176. engine.tick(240);
  177. expect(stateOf(engine, "test-combo")).toBe("active");
  178. } finally {
  179. await engine.stop();
  180. }
  181. });
  182. it("rejects a non-positive dt", async () => {
  183. const engine = engineSetup();
  184. await engine.start();
  185. try {
  186. expect(() => engine.tick(0)).toThrow(/positive number of ticks/);
  187. expect(() => engine.tick(-5)).toThrow(/positive number of ticks/);
  188. expect(() => engine.tick(Number.NaN)).toThrow(/positive number of ticks/);
  189. } finally {
  190. await engine.stop();
  191. }
  192. });
  193. it("time.advance remains clock-only: no time-tick, no growth", async () => {
  194. const { system: spy, seen } = eventSpy();
  195. const engine = engineSetup(spy);
  196. await engine.start();
  197. try {
  198. plantWateredParsnip(engine, 6, 6);
  199. timeOf(engine).advance(480);
  200. expect(timeOf(engine).get()).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  201. const plot = engine.world.get("plot:6:6") as PlotEntity;
  202. expect(plot.stageIndex).toBe(0);
  203. expect(plot.daysInStage).toBe(0);
  204. expect(seen.some((entry) => entry.event === "time-tick")).toBe(false);
  205. } finally {
  206. await engine.stop();
  207. }
  208. });
  209. it("auto-tick (fake timers) drives the simulation and stops cleanly on engine.stop", async () => {
  210. vi.useFakeTimers();
  211. const engine = createEngine({ contentDir, modsDir, tickIntervalMs: 1000 });
  212. try {
  213. await engine.start();
  214. vi.advanceTimersByTime(3000);
  215. expect(timeOf(engine).get()).toEqual({ day: 1, season: "spring", tickInDay: 3 });
  216. await engine.stop();
  217. vi.advanceTimersByTime(5000);
  218. expect(timeOf(engine).get()).toEqual({ day: 1, season: "spring", tickInDay: 3 });
  219. } finally {
  220. await engine.stop().catch(() => undefined);
  221. vi.useRealTimers();
  222. }
  223. });
  224. it("auto-tick honors tickDeltaTicks (fixed dt per interval)", async () => {
  225. vi.useFakeTimers();
  226. const engine = createEngine({ contentDir, modsDir, tickIntervalMs: 1000, tickDeltaTicks: 480 });
  227. try {
  228. await engine.start();
  229. vi.advanceTimersByTime(2000);
  230. expect(timeOf(engine).get()).toEqual({ day: 3, season: "spring", tickInDay: 0 });
  231. } finally {
  232. await engine.stop().catch(() => undefined);
  233. vi.useRealTimers();
  234. }
  235. });
  236. it("drives the chicken-egg produce machine (produce rules via language)", async () => {
  237. const engine = engineSetup();
  238. await engine.start();
  239. try {
  240. const growth = engine.systems.get<GrowthSystem>("growth");
  241. if (!growth) throw new Error("growth system not booted");
  242. growth.spawnAnimal("chicken", 3, 3);
  243. engine.tick(480);
  244. engine.tick(480);
  245. growth.feed("animal:chicken:1");
  246. engine.tick(480);
  247. growth.feed("animal:chicken:1");
  248. engine.tick(480);
  249. engine.tick(480);
  250. growth.feed("animal:chicken:1");
  251. engine.tick(480);
  252. expect(inventoryOf(engine).count("egg")).toBe(1);
  253. expect(stateOf(engine, "chicken-egg")).toBe("laid");
  254. growth.feed("animal:chicken:1");
  255. engine.tick(480);
  256. expect(inventoryOf(engine).count("egg")).toBe(1);
  257. expect(stateOf(engine, "chicken-egg")).toBe("waiting");
  258. growth.feed("animal:chicken:1");
  259. engine.tick(480);
  260. expect(inventoryOf(engine).count("egg")).toBe(2);
  261. } finally {
  262. await engine.stop();
  263. }
  264. });
  265. });
  266. afterEach(() => {
  267. vi.useRealTimers();
  268. });