import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, weatherSchema, WeatherSystem, WEATHER_ENTITY_ID, type SaveSystem, type System, type TimeSystem, type WeatherDef, type WeatherRng, } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", " - { name: summer, dayCount: 28 }", " - { name: autumn, dayCount: 28 }", " - { name: winter, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"); const WEATHER_YAML = [ "id: default", "defaults:", " sunny: { name: Sunny }", " rain: { name: Rain }", " windy: { name: Windy }", "seasons:", " - season: spring", " weathers:", " - { id: sunny, weight: 70 }", " - { id: rain, weight: 20 }", " - { id: windy, weight: 10 }", " - season: summer", " weathers:", " - { id: sunny, weight: 10 }", " - { id: rain, weight: 90 }", "", ].join("\n"); const WEATHER_DEF: WeatherDef = { id: "default", defaults: { sunny: { name: "Sunny" }, rain: { name: "Rain" }, windy: { name: "Windy" } }, seasons: [ { season: "spring", weathers: [ { id: "sunny", weight: 70 }, { id: "rain", weight: 20 }, { id: "windy", weight: 10 }, ], }, { season: "summer", weathers: [ { id: "sunny", weight: 10 }, { id: "rain", weight: 90 }, ], }, ], }; interface Setup { engine: ReturnType; close: () => Promise; } function fixedRng(values: number[]): WeatherRng { let index = 0; return { next() { const value = values[index] ?? 0; index += 1; return value; }, }; } async function setup(rng?: WeatherRng, spy?: System): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-weather-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); await writeFile(join(tmp, "weather.yaml"), WEATHER_YAML); const systems: System[] = []; if (rng) systems.push(new WeatherSystem(rng)); if (spy) systems.push(spy); const engine = createEngine({ contentDir: tmp, systems }); await engine.start(); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } async function seededSetup(seed: number): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-weather-seed-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); await writeFile(join(tmp, "weather.yaml"), WEATHER_YAML); const engine = createEngine({ contentDir: tmp, seed }); await engine.start(); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function weatherOf(engine: ReturnType): WeatherSystem { const weather = engine.systems.get("weather"); if (!weather) throw new Error("weather system not booted"); return weather; } function timeOf(engine: ReturnType): TimeSystem { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); return time; } function saveOf(engine: ReturnType): SaveSystem { const save = engine.systems.get("save"); if (!save) throw new Error("save system not booted"); return save; } 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 }; } describe("weatherSchema", () => { it("accepts a valid def and rejects duplicate seasons, duplicate weather ids, bad defaults, empty weathers, and non-positive weights", () => { expect(weatherSchema.safeParse(WEATHER_DEF).success).toBe(true); const duplicateSeason = weatherSchema.safeParse({ id: "default", seasons: [ { season: "spring", weathers: [{ id: "sunny", weight: 1 }] }, { season: "spring", weathers: [{ id: "rain", weight: 1 }] }, ], }); expect(duplicateSeason.success).toBe(false); const duplicateWeather = weatherSchema.safeParse({ id: "default", seasons: [ { season: "spring", weathers: [ { id: "sunny", weight: 1 }, { id: "sunny", weight: 2 }, ], }, ], }); expect(duplicateWeather.success).toBe(false); const badDefault = weatherSchema.safeParse({ id: "default", defaults: { ghost: { name: "Ghost" } }, seasons: [{ season: "spring", weathers: [{ id: "sunny", weight: 1 }] }], }); expect(badDefault.success).toBe(false); const emptyWeathers = weatherSchema.safeParse({ id: "default", seasons: [{ season: "spring", weathers: [] }], }); expect(emptyWeathers.success).toBe(false); const zeroWeight = weatherSchema.safeParse({ id: "default", seasons: [{ season: "spring", weathers: [{ id: "sunny", weight: 0 }] }], }); expect(zeroWeight.success).toBe(false); }); }); describe("WeatherSystem state", () => { it("boots the weather entity with the current season's first table entry and get() reports it", async () => { const { engine, close } = await setup(); try { expect(engine.world.get(WEATHER_ENTITY_ID)).toMatchObject({ type: "weather", weatherId: "sunny" }); expect(weatherOf(engine).get()).toEqual({ weatherId: "sunny", name: "Sunny", season: "spring" }); } finally { await close(); } }); it("set() forces a declared weather, emits weather-change with from/to, and returns the new state", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(undefined, spy); try { const result = weatherOf(engine).set("rain"); expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "spring", from: "sunny" }); expect(engine.world.get(WEATHER_ENTITY_ID)?.weatherId).toBe("rain"); expect(seen.some((s) => s.event === "weather-change")).toBe(true); expect(seen.find((s) => s.event === "weather-change")?.payload).toEqual({ from: "sunny", to: "rain" }); } finally { await close(); } }); it("set() to the current weather is a no-op (no event, no from)", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(undefined, spy); try { const result = weatherOf(engine).set("sunny"); expect(result).toEqual({ weatherId: "sunny", name: "Sunny", season: "spring" }); expect(seen.some((s) => s.event === "weather-change")).toBe(false); } finally { await close(); } }); it("set() throws on an undeclared weather id", async () => { const { engine, close } = await setup(); try { expect(() => weatherOf(engine).set("blizzard")).toThrow(/unknown weather id: blizzard/); } finally { await close(); } }); it("table() returns the season's weighted entries and undefined for an uncovered season", async () => { const { engine, close } = await setup(); try { expect(weatherOf(engine).table("spring")).toEqual([ { id: "sunny", weight: 70 }, { id: "rain", weight: 20 }, { id: "windy", weight: 10 }, ]); expect(weatherOf(engine).table("winter")).toBeUndefined(); } finally { await close(); } }); }); describe("WeatherSystem roll", () => { it("roll() does a deterministic weighted pick (injected rng)", async () => { const { engine, close } = await setup(fixedRng([0.71])); try { const result = weatherOf(engine).roll(); expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "spring", from: "sunny" }); } finally { await close(); } }); it("roll() picks the first entry for rng ~0 and the last for rng ~1", async () => { const { engine: first, close: closeFirst } = await setup(fixedRng([0])); try { first.world.get(WEATHER_ENTITY_ID)!.weatherId = "rain"; expect(weatherOf(first).roll().weatherId).toBe("sunny"); } finally { await closeFirst(); } const { engine: last, close: closeLast } = await setup(fixedRng([0.99])); try { last.world.get(WEATHER_ENTITY_ID)!.weatherId = "sunny"; expect(weatherOf(last).roll().weatherId).toBe("windy"); } finally { await closeLast(); } }); it("roll() uses the current season's table (season read from TimeSystem)", async () => { const { engine, close } = await setup(fixedRng([0.5])); try { timeOf(engine).set(1, "summer"); const result = weatherOf(engine).roll(); expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "summer", from: "sunny" }); } finally { await close(); } }); it("roll() fires the onWeatherChange hook on change", async () => { const { engine, close } = await setup(fixedRng([0.71])); try { const hookFired: unknown[] = []; engine.hooks.on("onWeatherChange", (ctx) => { hookFired.push(ctx.world.get(WEATHER_ENTITY_ID)?.weatherId); }); weatherOf(engine).roll(); expect(hookFired).toEqual(["rain"]); } finally { await close(); } }); it("day-start rolls the weather through the engine wiring", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(fixedRng([0.71]), spy); try { const weather = weatherOf(engine); expect(weather.get().weatherId).toBe("sunny"); timeOf(engine).advance(480); expect(weather.get().weatherId).toBe("rain"); expect(seen.some((s) => s.event === "weather-change")).toBe(true); expect(seen.find((s) => s.event === "weather-change")?.payload).toEqual({ from: "sunny", to: "rain" }); } finally { await close(); } }); }); describe("WeatherSystem seeded determinism (D28)", () => { it("the engine default RNG is the seeded ctx.rng (draws consume the facade rng)", async () => { const { engine, close } = await seededSetup(1234); try { expect(engine.rng.state().calls).toBe(0); timeOf(engine).advance(480); expect(weatherOf(engine).get().weatherId).not.toBe(""); expect(engine.rng.state().calls).toBe(1); expect(engine.rng.state().seed).toBe(1234); } finally { await close(); } }); it("two engines with the same seed roll identical day-start weather", async () => { const a = await seededSetup(2026); const b = await seededSetup(2026); try { for (let day = 1; day <= 4; day++) { timeOf(a.engine).advance(480); timeOf(b.engine).advance(480); expect(weatherOf(a.engine).get()).toEqual(weatherOf(b.engine).get()); } } finally { await a.close(); await b.close(); } }); it("rng.setSeed on the facade makes subsequent day-start rolls deterministic", async () => { const a = await seededSetup(1); const b = await seededSetup(2); try { a.engine.rng.setSeed(777); b.engine.rng.setSeed(777); timeOf(a.engine).advance(480); timeOf(b.engine).advance(480); expect(weatherOf(a.engine).get()).toEqual(weatherOf(b.engine).get()); } finally { await a.close(); await b.close(); } }); }); describe("WeatherSystem persistence", () => { it("the weather entity survives save snapshot/restore", async () => { const { engine, close } = await setup(); try { weatherOf(engine).set("windy"); const snapshot = saveOf(engine).snapshot(); expect(snapshot.world.some((e) => e.id === WEATHER_ENTITY_ID && e.weatherId === "windy")).toBe(true); saveOf(engine).restore(snapshot); expect(weatherOf(engine).get().weatherId).toBe("windy"); } finally { await close(); } }); }); describe("WeatherSystem degradation", () => { it("starts cleanly with no weather def and get() reports a clear error", async () => { const tmp = await mkdtemp(join(tmpdir(), "farm-weather-none-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); const engine = createEngine({ contentDir: tmp }); await engine.start(); try { expect(engine.world.get(WEATHER_ENTITY_ID)).toBeUndefined(); expect(() => weatherOf(engine).get()).toThrow(/no weather def registered/); expect(engine.world.query()).toHaveLength(2); } finally { await engine.stop(); await rm(tmp, { recursive: true, force: true }); } }); });