save.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. createEngine,
  5. CURRENT_SAVE_VERSION,
  6. SAVE_FORMAT,
  7. type InventorySystem,
  8. type PlantingSystem,
  9. type SaveSystem,
  10. type TimeSystem,
  11. type WorldSnapshot,
  12. } from "../src/index.js";
  13. const repoRoot = resolve(process.cwd(), "../..");
  14. const contentDir = resolve(repoRoot, "content");
  15. const modsDir = resolve(repoRoot, "mods");
  16. function engineSetup() {
  17. return createEngine({ contentDir, modsDir });
  18. }
  19. function saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
  20. const save = engine.systems.get<SaveSystem>("save");
  21. if (!save) throw new Error("save system not booted");
  22. return save;
  23. }
  24. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  25. const inv = engine.systems.get<InventorySystem>("inventory");
  26. if (!inv) throw new Error("inventory system not booted");
  27. return inv;
  28. }
  29. function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
  30. const planting = engine.systems.get<PlantingSystem>("planting");
  31. if (!planting) throw new Error("planting system not booted");
  32. return planting;
  33. }
  34. describe("SaveSystem", () => {
  35. it("snapshot after start serializes the seeded world with format and version", async () => {
  36. const engine = engineSetup();
  37. await engine.start();
  38. try {
  39. const snap = saveOf(engine).snapshot();
  40. expect(snap.format).toBe(SAVE_FORMAT);
  41. expect(snap.version).toBe(CURRENT_SAVE_VERSION);
  42. expect(typeof snap.savedAt).toBe("string");
  43. expect(snap.savedAt.length).toBeGreaterThan(0);
  44. expect(snap.world.map((e) => e.type).sort()).toEqual(["map", "player", "weather"]);
  45. expect(snap.world.find((e) => e.type === "map")).toMatchObject({ id: "farm", width: 12, height: 12 });
  46. expect(snap.world.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 2, y: 2 });
  47. } finally {
  48. await engine.stop();
  49. }
  50. });
  51. it("snapshot returns entity copies, not aliases into the live world", async () => {
  52. const engine = engineSetup();
  53. await engine.start();
  54. try {
  55. const snap = saveOf(engine).snapshot();
  56. const playerInSnap = snap.world.find((e) => e.id === "player");
  57. if (!playerInSnap) throw new Error("player entity not in snapshot");
  58. playerInSnap.x = 999;
  59. expect(engine.world.get("player")?.x).toBe(2);
  60. } finally {
  61. await engine.stop();
  62. }
  63. });
  64. it("snapshot reflects world mutations: inventory, gold, and plot entities", async () => {
  65. const engine = engineSetup();
  66. await engine.start();
  67. try {
  68. inventoryOf(engine).give("parsnip-seed", 5);
  69. plantingOf(engine).till(6, 6);
  70. plantingOf(engine).sow(6, 6, "parsnip-seed");
  71. const player = engine.world.get("player");
  72. if (!player) throw new Error("player entity not seeded");
  73. player.gold = 1234;
  74. const snap = saveOf(engine).snapshot();
  75. expect(snap.world.find((e) => e.id === "player")).toMatchObject({
  76. gold: 1234,
  77. inventory: { "parsnip-seed": 4 },
  78. });
  79. expect(snap.world.find((e) => e.id === "plot:6:6")).toMatchObject({
  80. type: "plot",
  81. x: 6,
  82. y: 6,
  83. tilled: true,
  84. watered: false,
  85. cropItemId: "parsnip",
  86. stageIndex: 0,
  87. daysInStage: 0,
  88. });
  89. } finally {
  90. await engine.stop();
  91. }
  92. });
  93. it("restore replaces all world entities, preserving inventory, gold, and plot fields", async () => {
  94. const engineA = engineSetup();
  95. await engineA.start();
  96. try {
  97. inventoryOf(engineA).give("parsnip", 2);
  98. inventoryOf(engineA).give("parsnip-seed", 4);
  99. const playerA = engineA.world.get("player");
  100. if (!playerA) throw new Error("player entity not seeded");
  101. playerA.gold = 77;
  102. plantingOf(engineA).till(3, 3);
  103. plantingOf(engineA).sow(3, 3, "parsnip-seed");
  104. plantingOf(engineA).water(3, 3);
  105. const snap = saveOf(engineA).snapshot();
  106. const engineB = engineSetup();
  107. await engineB.start();
  108. try {
  109. const result = saveOf(engineB).restore(snap);
  110. expect(result).toEqual({ restored: snap.world.length });
  111. expect(engineB.world.query()).toHaveLength(snap.world.length);
  112. expect(engineB.world.get("player")).toMatchObject({
  113. gold: 77,
  114. inventory: { parsnip: 2, "parsnip-seed": 3 },
  115. x: 2,
  116. y: 2,
  117. });
  118. expect(engineB.world.get("plot:3:3")).toMatchObject({
  119. type: "plot",
  120. x: 3,
  121. y: 3,
  122. tilled: true,
  123. watered: true,
  124. cropItemId: "parsnip",
  125. stageIndex: 0,
  126. daysInStage: 0,
  127. });
  128. expect(engineB.world.get("farm")).toMatchObject({ type: "map", width: 12, height: 12 });
  129. } finally {
  130. await engineB.stop();
  131. }
  132. } finally {
  133. await engineA.stop();
  134. }
  135. });
  136. it("restore with a malformed snapshot throws", async () => {
  137. const engine = engineSetup();
  138. await engine.start();
  139. try {
  140. const save = saveOf(engine);
  141. expect(() => save.restore({ format: "nengine-save" })).toThrow(/invalid snapshot/);
  142. expect(() => save.restore(null)).toThrow(/invalid snapshot/);
  143. expect(() => save.restore("nope")).toThrow(/invalid snapshot/);
  144. expect(() =>
  145. save.restore({ format: "nengine-save", version: 1, savedAt: "x", world: [{ id: "", type: "player" }] }),
  146. ).toThrow(/invalid snapshot/);
  147. expect(() =>
  148. save.restore({ format: "nengine-save", version: 1, savedAt: "x", world: [{ id: "player" }] }),
  149. ).toThrow(/invalid snapshot/);
  150. } finally {
  151. await engine.stop();
  152. }
  153. });
  154. it("restore rejects a snapshot from a future save version and changes nothing", async () => {
  155. const engine = engineSetup();
  156. await engine.start();
  157. try {
  158. const save = saveOf(engine);
  159. const snap = save.snapshot();
  160. const future = { ...snap, version: CURRENT_SAVE_VERSION + 1 };
  161. expect(() => save.restore(future)).toThrow(/future save version/);
  162. expect(engine.world.query()).toHaveLength(3);
  163. } finally {
  164. await engine.stop();
  165. }
  166. });
  167. it("migrate no-ops when versions are equal and returns the same snapshot", async () => {
  168. const engine = engineSetup();
  169. await engine.start();
  170. try {
  171. const save = saveOf(engine);
  172. const snap = save.snapshot();
  173. expect(save.migrate(snap, CURRENT_SAVE_VERSION, CURRENT_SAVE_VERSION)).toBe(snap);
  174. expect(save.migrate(snap)).toBe(snap);
  175. } finally {
  176. await engine.stop();
  177. }
  178. });
  179. it("migrate with an empty registry leaves the snapshot unchanged but validates it", async () => {
  180. const engine = engineSetup();
  181. await engine.start();
  182. try {
  183. const save = saveOf(engine);
  184. const snap = save.snapshot();
  185. const forward = save.migrate(snap, 1, CURRENT_SAVE_VERSION + 2);
  186. expect(forward).toEqual(snap);
  187. expect(forward.version).toBe(CURRENT_SAVE_VERSION);
  188. expect(save.validate(forward)).toEqual({ ok: true });
  189. expect(() => save.migrate(snap, 2, 1)).toThrow(/backwards/);
  190. expect(() => save.migrate(snap, 0, 1)).toThrow(/positive integer/);
  191. expect(() => save.migrate({ format: "nengine-save" }, 1, 2)).toThrow(/invalid snapshot/);
  192. } finally {
  193. await engine.stop();
  194. }
  195. });
  196. it("validate reports valid snapshots and collects structural errors for invalid ones", async () => {
  197. const engine = engineSetup();
  198. await engine.start();
  199. try {
  200. const save = saveOf(engine);
  201. expect(save.validate(save.snapshot())).toEqual({ ok: true });
  202. const missingWorld = save.validate({ format: "nengine-save", version: 1, savedAt: "x" });
  203. expect(missingWorld.ok).toBe(false);
  204. expect(missingWorld.errors?.length).toBeGreaterThan(0);
  205. const emptySavedAt = save.validate({ format: "nengine-save", version: 1, savedAt: "", world: [] });
  206. expect(emptySavedAt.ok).toBe(false);
  207. const zeroVersion = save.validate({ format: "nengine-save", version: 0, savedAt: "x", world: [] });
  208. expect(zeroVersion.ok).toBe(false);
  209. expect(save.validate(null).ok).toBe(false);
  210. expect(save.validate("nope").ok).toBe(false);
  211. } finally {
  212. await engine.stop();
  213. }
  214. });
  215. });
  216. describe("SaveSystem time state (D33)", () => {
  217. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  218. const time = engine.systems.get<TimeSystem>("time");
  219. if (!time) throw new Error("time system not booted");
  220. return time;
  221. }
  222. it("snapshot carries time {day, season, tickInDay} matching TimeSystem", async () => {
  223. const engine = engineSetup();
  224. await engine.start();
  225. try {
  226. timeOf(engine).advance(120);
  227. const snap = saveOf(engine).snapshot();
  228. expect(snap.time).toEqual(timeOf(engine).get());
  229. expect(snap.time).toEqual({ day: 1, season: "spring", tickInDay: 120 });
  230. } finally {
  231. await engine.stop();
  232. }
  233. });
  234. it("restore sets the clock to the snapshot's time", async () => {
  235. const engineA = engineSetup();
  236. await engineA.start();
  237. let snap: WorldSnapshot | undefined;
  238. try {
  239. timeOf(engineA).set(2, "summer", 60);
  240. snap = saveOf(engineA).snapshot();
  241. expect(snap.time).toEqual({ day: 2, season: "summer", tickInDay: 60 });
  242. } finally {
  243. await engineA.stop();
  244. }
  245. const engineB = engineSetup();
  246. await engineB.start();
  247. try {
  248. expect(timeOf(engineB).get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  249. saveOf(engineB).restore(snap!);
  250. expect(timeOf(engineB).get()).toEqual({ day: 2, season: "summer", tickInDay: 60 });
  251. } finally {
  252. await engineB.stop();
  253. }
  254. });
  255. it("a legacy snapshot without time leaves the current clock unchanged", async () => {
  256. const engine = engineSetup();
  257. await engine.start();
  258. try {
  259. const legacy = {
  260. format: SAVE_FORMAT,
  261. version: CURRENT_SAVE_VERSION,
  262. savedAt: "x",
  263. world: [{ id: "player", type: "player" }],
  264. };
  265. expect(saveOf(engine).validate(legacy)).toEqual({ ok: true });
  266. timeOf(engine).set(3, "summer", 10);
  267. saveOf(engine).restore(legacy);
  268. expect(timeOf(engine).get()).toEqual({ day: 3, season: "summer", tickInDay: 10 });
  269. } finally {
  270. await engine.stop();
  271. }
  272. });
  273. it("migrate passthrough preserves the time field", async () => {
  274. const engine = engineSetup();
  275. await engine.start();
  276. try {
  277. timeOf(engine).set(7, "spring", 30);
  278. const snap = saveOf(engine).snapshot();
  279. const forward = saveOf(engine).migrate(snap, CURRENT_SAVE_VERSION, CURRENT_SAVE_VERSION + 2);
  280. expect(forward.time).toEqual(snap.time);
  281. expect(saveOf(engine).validate(forward)).toEqual({ ok: true });
  282. } finally {
  283. await engine.stop();
  284. }
  285. });
  286. it("validate rejects a malformed time field", async () => {
  287. const engine = engineSetup();
  288. await engine.start();
  289. try {
  290. const bad = saveOf(engine).validate({
  291. format: SAVE_FORMAT,
  292. version: CURRENT_SAVE_VERSION,
  293. savedAt: "x",
  294. world: [],
  295. time: { day: 1, season: "", tickInDay: 0 },
  296. });
  297. expect(bad.ok).toBe(false);
  298. const badTick = saveOf(engine).validate({
  299. format: SAVE_FORMAT,
  300. version: CURRENT_SAVE_VERSION,
  301. savedAt: "x",
  302. world: [],
  303. time: { day: 1, season: "spring", tickInDay: -1 },
  304. });
  305. expect(badTick.ok).toBe(false);
  306. } finally {
  307. await engine.stop();
  308. }
  309. });
  310. });
  311. describe("SaveSystem rngState (D28)", () => {
  312. function saveEngineSetup(seed?: number): ReturnType<typeof createEngine> {
  313. return createEngine({ contentDir, modsDir, seed });
  314. }
  315. it("snapshot carries rngState {seed, calls} from the engine rng", async () => {
  316. const engine = saveEngineSetup(99);
  317. await engine.start();
  318. try {
  319. engine.rng.next();
  320. const snap = saveOf(engine).snapshot();
  321. expect(snap.rngState).toEqual({ seed: 99, calls: 1 });
  322. } finally {
  323. await engine.stop();
  324. }
  325. });
  326. it("restore sets the rng seed from rngState (calls reset to 0) and a later snapshot replays", async () => {
  327. const engineA = saveEngineSetup(123);
  328. await engineA.start();
  329. let snap: WorldSnapshot | undefined;
  330. try {
  331. engineA.rng.next();
  332. engineA.rng.next();
  333. snap = saveOf(engineA).snapshot();
  334. expect(snap.rngState).toEqual({ seed: 123, calls: 2 });
  335. } finally {
  336. await engineA.stop();
  337. }
  338. const engineB = saveEngineSetup(777);
  339. await engineB.start();
  340. try {
  341. engineB.rng.next();
  342. expect(engineB.rng.state()).toEqual({ seed: 777, calls: 1 });
  343. saveOf(engineB).restore(snap!);
  344. expect(engineB.rng.state()).toEqual({ seed: 123, calls: 0 });
  345. const replay = saveOf(engineB).snapshot();
  346. expect(replay.rngState).toEqual({ seed: 123, calls: 0 });
  347. } finally {
  348. await engineB.stop();
  349. }
  350. });
  351. it("old snapshots without rngState remain valid and restore leaves the rng untouched", async () => {
  352. const engine = saveEngineSetup(5);
  353. await engine.start();
  354. try {
  355. const legacy = {
  356. format: SAVE_FORMAT,
  357. version: CURRENT_SAVE_VERSION,
  358. savedAt: "x",
  359. world: [{ id: "player", type: "player" }],
  360. };
  361. expect(saveOf(engine).validate(legacy)).toEqual({ ok: true });
  362. engine.rng.next();
  363. saveOf(engine).restore(legacy);
  364. expect(engine.rng.state()).toEqual({ seed: 5, calls: 1 });
  365. } finally {
  366. await engine.stop();
  367. }
  368. });
  369. it("validate rejects a malformed rngState", async () => {
  370. const engine = saveEngineSetup();
  371. await engine.start();
  372. try {
  373. const badCalls = saveOf(engine).validate({
  374. format: SAVE_FORMAT,
  375. version: CURRENT_SAVE_VERSION,
  376. savedAt: "x",
  377. world: [],
  378. rngState: { seed: 1, calls: -1 },
  379. });
  380. expect(badCalls.ok).toBe(false);
  381. const badSeed = saveOf(engine).validate({
  382. format: SAVE_FORMAT,
  383. version: CURRENT_SAVE_VERSION,
  384. savedAt: "x",
  385. world: [],
  386. rngState: { seed: "one" },
  387. });
  388. expect(badSeed.ok).toBe(false);
  389. } finally {
  390. await engine.stop();
  391. }
  392. });
  393. });