| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- 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<typeof createEngine>;
- close: () => Promise<void>;
- }
- 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<Setup> {
- 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<Setup> {
- 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<typeof createEngine>): WeatherSystem {
- const weather = engine.systems.get<WeatherSystem>("weather");
- if (!weather) throw new Error("weather system not booted");
- return weather;
- }
- 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 saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
- const save = engine.systems.get<SaveSystem>("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 });
- }
- });
- });
|