import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEngine, type GrowthSystem, type InventorySystem, type PlotEntity, type PlantingSystem, type StateMachineDef, type System, type TimeSystem, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function engineSetup(spy?: System) { return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] }); } function timeOf(engine: ReturnType): TimeSystem { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); return time; } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } function plantingOf(engine: ReturnType): PlantingSystem { const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); return planting; } function stateOf(engine: ReturnType, machineId: string): string | undefined { return engine.world.get(`sm:${machineId}`)?.state as string | undefined; } function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } { const seen: { event: string; payload: unknown }[] = []; const system: System = { id: "spy", init() {}, on(event, payload) { seen.push({ event, payload }); }, }; return { system, seen }; } function plantWateredParsnip(engine: ReturnType, x: number, y: number): void { inventoryOf(engine).give("parsnip-seed", 5); const planting = plantingOf(engine); planting.till(x, y); planting.sow(x, y, "parsnip-seed"); planting.water(x, y); } describe("engine.tick (tick loop, D23)", () => { it("advances time AND growth together (a watered plot progresses over repeated ticks)", async () => { const engine = engineSetup(); await engine.start(); try { plantWateredParsnip(engine, 6, 6); engine.tick(480); expect(timeOf(engine).get()).toEqual({ day: 2, season: "spring", tickInDay: 0 }); let plot = engine.world.get("plot:6:6") as PlotEntity; expect(plot.stageIndex).toBe(0); expect(plot.daysInStage).toBe(1); engine.tick(480); expect(timeOf(engine).get()).toEqual({ day: 3, season: "spring", tickInDay: 0 }); plot = engine.world.get("plot:6:6") as PlotEntity; expect(plot.stageIndex).toBe(1); expect(plot.daysInStage).toBe(0); } finally { await engine.stop(); } }); it("emits a time-tick event each step with the post-step state", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.tick(120); engine.tick(120); engine.tick(240); const ticks = seen .filter((entry) => entry.event === "time-tick") .map((entry) => entry.payload as { day: number; season: string; tickInDay: number }); expect(ticks).toEqual([ { day: 1, season: "spring", tickInDay: 120 }, { day: 1, season: "spring", tickInDay: 240 }, { day: 2, season: "spring", tickInDay: 0 }, ]); } finally { await engine.stop(); } }); it("drives onSchedule at:{tickInDay} transitions at the exact tick", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-intra", initial: "sleeping", states: [{ id: "sleeping" }, { id: "awake" }, { id: "done" }], transitions: [], onSchedule: [ { at: { tickInDay: 120 }, to: "awake" }, { at: { tickInDay: 360 }, to: "done" }, ], } satisfies StateMachineDef); engine.tick(120); expect(stateOf(engine, "test-intra")).toBe("awake"); engine.tick(120); expect(stateOf(engine, "test-intra")).toBe("awake"); engine.tick(120); expect(stateOf(engine, "test-intra")).toBe("done"); } finally { await engine.stop(); } }); it("at:{tickInDay:0} fires on the boundary-crossing tick", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-zero", initial: "sleeping", states: [{ id: "sleeping" }, { id: "awake" }], transitions: [], onSchedule: [{ at: { tickInDay: 0 }, to: "awake" }], } satisfies StateMachineDef); engine.tick(100); expect(stateOf(engine, "test-zero")).toBeUndefined(); engine.tick(380); expect(stateOf(engine, "test-zero")).toBe("awake"); } finally { await engine.stop(); } }); it("tickInDay entries fire on time-tick; day-start entries do not", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-tick-entry", initial: "idle", states: [{ id: "idle" }, { id: "awake" }], transitions: [], onSchedule: [{ at: { tickInDay: 200 }, to: "awake" }], } satisfies StateMachineDef); engine.registry.register("statemachine", { id: "test-day-entry", initial: "idle", states: [{ id: "idle" }, { id: "awake" }], transitions: [], onSchedule: [{ at: "day-start", to: "awake" }], } satisfies StateMachineDef); engine.tick(200); expect(stateOf(engine, "test-tick-entry")).toBe("awake"); expect(stateOf(engine, "test-day-entry")).toBeUndefined(); engine.tick(280); expect(stateOf(engine, "test-day-entry")).toBe("awake"); } finally { await engine.stop(); } }); it("supports the combined at:{day, tickInDay} form", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-combo", initial: "idle", states: [{ id: "idle" }, { id: "active" }], transitions: [], onSchedule: [{ at: { day: 1, tickInDay: 240 }, to: "active" }], } satisfies StateMachineDef); engine.tick(240); expect(stateOf(engine, "test-combo")).toBe("active"); } finally { await engine.stop(); } }); it("rejects a non-positive dt", async () => { const engine = engineSetup(); await engine.start(); try { expect(() => engine.tick(0)).toThrow(/positive number of ticks/); expect(() => engine.tick(-5)).toThrow(/positive number of ticks/); expect(() => engine.tick(Number.NaN)).toThrow(/positive number of ticks/); } finally { await engine.stop(); } }); it("time.advance remains clock-only: no time-tick, no growth", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { plantWateredParsnip(engine, 6, 6); timeOf(engine).advance(480); expect(timeOf(engine).get()).toEqual({ day: 2, season: "spring", tickInDay: 0 }); const plot = engine.world.get("plot:6:6") as PlotEntity; expect(plot.stageIndex).toBe(0); expect(plot.daysInStage).toBe(0); expect(seen.some((entry) => entry.event === "time-tick")).toBe(false); } finally { await engine.stop(); } }); it("auto-tick (fake timers) drives the simulation and stops cleanly on engine.stop", async () => { vi.useFakeTimers(); const engine = createEngine({ contentDir, modsDir, tickIntervalMs: 1000 }); try { await engine.start(); vi.advanceTimersByTime(3000); expect(timeOf(engine).get()).toEqual({ day: 1, season: "spring", tickInDay: 3 }); await engine.stop(); vi.advanceTimersByTime(5000); expect(timeOf(engine).get()).toEqual({ day: 1, season: "spring", tickInDay: 3 }); } finally { await engine.stop().catch(() => undefined); vi.useRealTimers(); } }); it("auto-tick honors tickDeltaTicks (fixed dt per interval)", async () => { vi.useFakeTimers(); const engine = createEngine({ contentDir, modsDir, tickIntervalMs: 1000, tickDeltaTicks: 480 }); try { await engine.start(); vi.advanceTimersByTime(2000); expect(timeOf(engine).get()).toEqual({ day: 3, season: "spring", tickInDay: 0 }); } finally { await engine.stop().catch(() => undefined); vi.useRealTimers(); } }); it("drives the chicken-egg produce machine (produce rules via language)", async () => { const engine = engineSetup(); await engine.start(); try { const growth = engine.systems.get("growth"); if (!growth) throw new Error("growth system not booted"); growth.spawnAnimal("chicken", 3, 3); engine.tick(480); engine.tick(480); growth.feed("animal:chicken:1"); engine.tick(480); growth.feed("animal:chicken:1"); engine.tick(480); engine.tick(480); growth.feed("animal:chicken:1"); engine.tick(480); expect(inventoryOf(engine).count("egg")).toBe(1); expect(stateOf(engine, "chicken-egg")).toBe("laid"); growth.feed("animal:chicken:1"); engine.tick(480); expect(inventoryOf(engine).count("egg")).toBe(1); expect(stateOf(engine, "chicken-egg")).toBe("waiting"); growth.feed("animal:chicken:1"); engine.tick(480); expect(inventoryOf(engine).count("egg")).toBe(2); } finally { await engine.stop(); } }); }); afterEach(() => { vi.useRealTimers(); });