weather.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. createEngine,
  7. weatherSchema,
  8. WeatherSystem,
  9. WEATHER_ENTITY_ID,
  10. type SaveSystem,
  11. type System,
  12. type TimeSystem,
  13. type WeatherDef,
  14. type WeatherRng,
  15. } from "../src/index.js";
  16. const TIME_YAML = [
  17. "id: default",
  18. "dayLengthTicks: 480",
  19. "seasons:",
  20. " - { name: spring, dayCount: 28 }",
  21. " - { name: summer, dayCount: 28 }",
  22. " - { name: autumn, dayCount: 28 }",
  23. " - { name: winter, dayCount: 28 }",
  24. "start: { day: 1, season: spring }",
  25. "",
  26. ].join("\n");
  27. const FARM_YAML = [
  28. "id: farm",
  29. "width: 2",
  30. "height: 2",
  31. 'tilesetRef: "tiles"',
  32. "spawn: { x: 0, y: 0 }",
  33. "layers:",
  34. " - name: ground",
  35. " data:",
  36. " - [a, b]",
  37. " - [b, a]",
  38. "",
  39. ].join("\n");
  40. const WEATHER_YAML = [
  41. "id: default",
  42. "defaults:",
  43. " sunny: { name: Sunny }",
  44. " rain: { name: Rain }",
  45. " windy: { name: Windy }",
  46. "seasons:",
  47. " - season: spring",
  48. " weathers:",
  49. " - { id: sunny, weight: 70 }",
  50. " - { id: rain, weight: 20 }",
  51. " - { id: windy, weight: 10 }",
  52. " - season: summer",
  53. " weathers:",
  54. " - { id: sunny, weight: 10 }",
  55. " - { id: rain, weight: 90 }",
  56. "",
  57. ].join("\n");
  58. const WEATHER_DEF: WeatherDef = {
  59. id: "default",
  60. defaults: { sunny: { name: "Sunny" }, rain: { name: "Rain" }, windy: { name: "Windy" } },
  61. seasons: [
  62. {
  63. season: "spring",
  64. weathers: [
  65. { id: "sunny", weight: 70 },
  66. { id: "rain", weight: 20 },
  67. { id: "windy", weight: 10 },
  68. ],
  69. },
  70. {
  71. season: "summer",
  72. weathers: [
  73. { id: "sunny", weight: 10 },
  74. { id: "rain", weight: 90 },
  75. ],
  76. },
  77. ],
  78. };
  79. interface Setup {
  80. engine: ReturnType<typeof createEngine>;
  81. close: () => Promise<void>;
  82. }
  83. function fixedRng(values: number[]): WeatherRng {
  84. let index = 0;
  85. return {
  86. next() {
  87. const value = values[index] ?? 0;
  88. index += 1;
  89. return value;
  90. },
  91. };
  92. }
  93. async function setup(rng?: WeatherRng, spy?: System): Promise<Setup> {
  94. const tmp = await mkdtemp(join(tmpdir(), "farm-weather-"));
  95. await mkdir(join(tmp, "maps"), { recursive: true });
  96. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  97. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  98. await writeFile(join(tmp, "weather.yaml"), WEATHER_YAML);
  99. const systems: System[] = [];
  100. if (rng) systems.push(new WeatherSystem(rng));
  101. if (spy) systems.push(spy);
  102. const engine = createEngine({ contentDir: tmp, systems });
  103. await engine.start();
  104. return {
  105. engine,
  106. close: async () => {
  107. await engine.stop();
  108. await rm(tmp, { recursive: true, force: true });
  109. },
  110. };
  111. }
  112. async function seededSetup(seed: number): Promise<Setup> {
  113. const tmp = await mkdtemp(join(tmpdir(), "farm-weather-seed-"));
  114. await mkdir(join(tmp, "maps"), { recursive: true });
  115. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  116. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  117. await writeFile(join(tmp, "weather.yaml"), WEATHER_YAML);
  118. const engine = createEngine({ contentDir: tmp, seed });
  119. await engine.start();
  120. return {
  121. engine,
  122. close: async () => {
  123. await engine.stop();
  124. await rm(tmp, { recursive: true, force: true });
  125. },
  126. };
  127. }
  128. function weatherOf(engine: ReturnType<typeof createEngine>): WeatherSystem {
  129. const weather = engine.systems.get<WeatherSystem>("weather");
  130. if (!weather) throw new Error("weather system not booted");
  131. return weather;
  132. }
  133. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  134. const time = engine.systems.get<TimeSystem>("time");
  135. if (!time) throw new Error("time system not booted");
  136. return time;
  137. }
  138. function saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
  139. const save = engine.systems.get<SaveSystem>("save");
  140. if (!save) throw new Error("save system not booted");
  141. return save;
  142. }
  143. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  144. const seen: { event: string; payload: unknown }[] = [];
  145. const system: System = {
  146. id: "spy",
  147. init() {},
  148. on(event, payload) {
  149. seen.push({ event, payload });
  150. },
  151. };
  152. return { system, seen };
  153. }
  154. describe("weatherSchema", () => {
  155. it("accepts a valid def and rejects duplicate seasons, duplicate weather ids, bad defaults, empty weathers, and non-positive weights", () => {
  156. expect(weatherSchema.safeParse(WEATHER_DEF).success).toBe(true);
  157. const duplicateSeason = weatherSchema.safeParse({
  158. id: "default",
  159. seasons: [
  160. { season: "spring", weathers: [{ id: "sunny", weight: 1 }] },
  161. { season: "spring", weathers: [{ id: "rain", weight: 1 }] },
  162. ],
  163. });
  164. expect(duplicateSeason.success).toBe(false);
  165. const duplicateWeather = weatherSchema.safeParse({
  166. id: "default",
  167. seasons: [
  168. {
  169. season: "spring",
  170. weathers: [
  171. { id: "sunny", weight: 1 },
  172. { id: "sunny", weight: 2 },
  173. ],
  174. },
  175. ],
  176. });
  177. expect(duplicateWeather.success).toBe(false);
  178. const badDefault = weatherSchema.safeParse({
  179. id: "default",
  180. defaults: { ghost: { name: "Ghost" } },
  181. seasons: [{ season: "spring", weathers: [{ id: "sunny", weight: 1 }] }],
  182. });
  183. expect(badDefault.success).toBe(false);
  184. const emptyWeathers = weatherSchema.safeParse({
  185. id: "default",
  186. seasons: [{ season: "spring", weathers: [] }],
  187. });
  188. expect(emptyWeathers.success).toBe(false);
  189. const zeroWeight = weatherSchema.safeParse({
  190. id: "default",
  191. seasons: [{ season: "spring", weathers: [{ id: "sunny", weight: 0 }] }],
  192. });
  193. expect(zeroWeight.success).toBe(false);
  194. });
  195. });
  196. describe("WeatherSystem state", () => {
  197. it("boots the weather entity with the current season's first table entry and get() reports it", async () => {
  198. const { engine, close } = await setup();
  199. try {
  200. expect(engine.world.get(WEATHER_ENTITY_ID)).toMatchObject({ type: "weather", weatherId: "sunny" });
  201. expect(weatherOf(engine).get()).toEqual({ weatherId: "sunny", name: "Sunny", season: "spring" });
  202. } finally {
  203. await close();
  204. }
  205. });
  206. it("set() forces a declared weather, emits weather-change with from/to, and returns the new state", async () => {
  207. const { system: spy, seen } = eventSpy();
  208. const { engine, close } = await setup(undefined, spy);
  209. try {
  210. const result = weatherOf(engine).set("rain");
  211. expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "spring", from: "sunny" });
  212. expect(engine.world.get(WEATHER_ENTITY_ID)?.weatherId).toBe("rain");
  213. expect(seen.some((s) => s.event === "weather-change")).toBe(true);
  214. expect(seen.find((s) => s.event === "weather-change")?.payload).toEqual({ from: "sunny", to: "rain" });
  215. } finally {
  216. await close();
  217. }
  218. });
  219. it("set() to the current weather is a no-op (no event, no from)", async () => {
  220. const { system: spy, seen } = eventSpy();
  221. const { engine, close } = await setup(undefined, spy);
  222. try {
  223. const result = weatherOf(engine).set("sunny");
  224. expect(result).toEqual({ weatherId: "sunny", name: "Sunny", season: "spring" });
  225. expect(seen.some((s) => s.event === "weather-change")).toBe(false);
  226. } finally {
  227. await close();
  228. }
  229. });
  230. it("set() throws on an undeclared weather id", async () => {
  231. const { engine, close } = await setup();
  232. try {
  233. expect(() => weatherOf(engine).set("blizzard")).toThrow(/unknown weather id: blizzard/);
  234. } finally {
  235. await close();
  236. }
  237. });
  238. it("table() returns the season's weighted entries and undefined for an uncovered season", async () => {
  239. const { engine, close } = await setup();
  240. try {
  241. expect(weatherOf(engine).table("spring")).toEqual([
  242. { id: "sunny", weight: 70 },
  243. { id: "rain", weight: 20 },
  244. { id: "windy", weight: 10 },
  245. ]);
  246. expect(weatherOf(engine).table("winter")).toBeUndefined();
  247. } finally {
  248. await close();
  249. }
  250. });
  251. });
  252. describe("WeatherSystem roll", () => {
  253. it("roll() does a deterministic weighted pick (injected rng)", async () => {
  254. const { engine, close } = await setup(fixedRng([0.71]));
  255. try {
  256. const result = weatherOf(engine).roll();
  257. expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "spring", from: "sunny" });
  258. } finally {
  259. await close();
  260. }
  261. });
  262. it("roll() picks the first entry for rng ~0 and the last for rng ~1", async () => {
  263. const { engine: first, close: closeFirst } = await setup(fixedRng([0]));
  264. try {
  265. first.world.get(WEATHER_ENTITY_ID)!.weatherId = "rain";
  266. expect(weatherOf(first).roll().weatherId).toBe("sunny");
  267. } finally {
  268. await closeFirst();
  269. }
  270. const { engine: last, close: closeLast } = await setup(fixedRng([0.99]));
  271. try {
  272. last.world.get(WEATHER_ENTITY_ID)!.weatherId = "sunny";
  273. expect(weatherOf(last).roll().weatherId).toBe("windy");
  274. } finally {
  275. await closeLast();
  276. }
  277. });
  278. it("roll() uses the current season's table (season read from TimeSystem)", async () => {
  279. const { engine, close } = await setup(fixedRng([0.5]));
  280. try {
  281. timeOf(engine).set(1, "summer");
  282. const result = weatherOf(engine).roll();
  283. expect(result).toEqual({ weatherId: "rain", name: "Rain", season: "summer", from: "sunny" });
  284. } finally {
  285. await close();
  286. }
  287. });
  288. it("roll() fires the onWeatherChange hook on change", async () => {
  289. const { engine, close } = await setup(fixedRng([0.71]));
  290. try {
  291. const hookFired: unknown[] = [];
  292. engine.hooks.on("onWeatherChange", (ctx) => {
  293. hookFired.push(ctx.world.get(WEATHER_ENTITY_ID)?.weatherId);
  294. });
  295. weatherOf(engine).roll();
  296. expect(hookFired).toEqual(["rain"]);
  297. } finally {
  298. await close();
  299. }
  300. });
  301. it("day-start rolls the weather through the engine wiring", async () => {
  302. const { system: spy, seen } = eventSpy();
  303. const { engine, close } = await setup(fixedRng([0.71]), spy);
  304. try {
  305. const weather = weatherOf(engine);
  306. expect(weather.get().weatherId).toBe("sunny");
  307. timeOf(engine).advance(480);
  308. expect(weather.get().weatherId).toBe("rain");
  309. expect(seen.some((s) => s.event === "weather-change")).toBe(true);
  310. expect(seen.find((s) => s.event === "weather-change")?.payload).toEqual({ from: "sunny", to: "rain" });
  311. } finally {
  312. await close();
  313. }
  314. });
  315. });
  316. describe("WeatherSystem seeded determinism (D28)", () => {
  317. it("the engine default RNG is the seeded ctx.rng (draws consume the facade rng)", async () => {
  318. const { engine, close } = await seededSetup(1234);
  319. try {
  320. expect(engine.rng.state().calls).toBe(0);
  321. timeOf(engine).advance(480);
  322. expect(weatherOf(engine).get().weatherId).not.toBe("");
  323. expect(engine.rng.state().calls).toBe(1);
  324. expect(engine.rng.state().seed).toBe(1234);
  325. } finally {
  326. await close();
  327. }
  328. });
  329. it("two engines with the same seed roll identical day-start weather", async () => {
  330. const a = await seededSetup(2026);
  331. const b = await seededSetup(2026);
  332. try {
  333. for (let day = 1; day <= 4; day++) {
  334. timeOf(a.engine).advance(480);
  335. timeOf(b.engine).advance(480);
  336. expect(weatherOf(a.engine).get()).toEqual(weatherOf(b.engine).get());
  337. }
  338. } finally {
  339. await a.close();
  340. await b.close();
  341. }
  342. });
  343. it("rng.setSeed on the facade makes subsequent day-start rolls deterministic", async () => {
  344. const a = await seededSetup(1);
  345. const b = await seededSetup(2);
  346. try {
  347. a.engine.rng.setSeed(777);
  348. b.engine.rng.setSeed(777);
  349. timeOf(a.engine).advance(480);
  350. timeOf(b.engine).advance(480);
  351. expect(weatherOf(a.engine).get()).toEqual(weatherOf(b.engine).get());
  352. } finally {
  353. await a.close();
  354. await b.close();
  355. }
  356. });
  357. });
  358. describe("WeatherSystem persistence", () => {
  359. it("the weather entity survives save snapshot/restore", async () => {
  360. const { engine, close } = await setup();
  361. try {
  362. weatherOf(engine).set("windy");
  363. const snapshot = saveOf(engine).snapshot();
  364. expect(snapshot.world.some((e) => e.id === WEATHER_ENTITY_ID && e.weatherId === "windy")).toBe(true);
  365. saveOf(engine).restore(snapshot);
  366. expect(weatherOf(engine).get().weatherId).toBe("windy");
  367. } finally {
  368. await close();
  369. }
  370. });
  371. });
  372. describe("WeatherSystem degradation", () => {
  373. it("starts cleanly with no weather def and get() reports a clear error", async () => {
  374. const tmp = await mkdtemp(join(tmpdir(), "farm-weather-none-"));
  375. await mkdir(join(tmp, "maps"), { recursive: true });
  376. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  377. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  378. const engine = createEngine({ contentDir: tmp });
  379. await engine.start();
  380. try {
  381. expect(engine.world.get(WEATHER_ENTITY_ID)).toBeUndefined();
  382. expect(() => weatherOf(engine).get()).toThrow(/no weather def registered/);
  383. expect(engine.world.query()).toHaveLength(2);
  384. } finally {
  385. await engine.stop();
  386. await rm(tmp, { recursive: true, force: true });
  387. }
  388. });
  389. });