| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- 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<typeof createEngine>): TimeSystem {
- const time = engine.systems.get<TimeSystem>("time");
- if (!time) throw new Error("time system not booted");
- return time;
- }
- function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- return inv;
- }
- function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
- const planting = engine.systems.get<PlantingSystem>("planting");
- if (!planting) throw new Error("planting system not booted");
- return planting;
- }
- function stateOf(engine: ReturnType<typeof createEngine>, 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<typeof createEngine>, 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<GrowthSystem>("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();
- });
|