planting.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. createEngine,
  5. GrowthSystem,
  6. type CropDef,
  7. type CropDrop,
  8. type InventorySystem,
  9. type PlotEntity,
  10. type PlantingSystem,
  11. } from "../src/index.js";
  12. const repoRoot = resolve(process.cwd(), "../..");
  13. const contentDir = resolve(repoRoot, "content");
  14. const modsDir = resolve(repoRoot, "mods");
  15. function engineSetup() {
  16. return createEngine({ contentDir, modsDir });
  17. }
  18. function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
  19. const planting = engine.systems.get<PlantingSystem>("planting");
  20. if (!planting) throw new Error("planting system not booted");
  21. return planting;
  22. }
  23. function growthOf(engine: ReturnType<typeof createEngine>): GrowthSystem {
  24. const growth = engine.systems.get<GrowthSystem>("growth");
  25. if (!growth) throw new Error("growth system not booted");
  26. return growth;
  27. }
  28. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  29. const inv = engine.systems.get<InventorySystem>("inventory");
  30. if (!inv) throw new Error("inventory system not booted");
  31. return inv;
  32. }
  33. describe("PlantingSystem", () => {
  34. it("till creates a tilled plot entity on the tile", async () => {
  35. const engine = engineSetup();
  36. await engine.start();
  37. try {
  38. const plot = plantingOf(engine).till(3, 3);
  39. expect(plot).toEqual({ id: "plot:3:3", type: "plot", x: 3, y: 3, tilled: true, watered: false });
  40. expect(engine.world.get("plot:3:3")).toBeDefined();
  41. expect(engine.world.query({ type: "plot" }).map((e) => e.id)).toEqual(["plot:3:3"]);
  42. } finally {
  43. await engine.stop();
  44. }
  45. });
  46. it("till is idempotent: re-tilling returns the same plot", async () => {
  47. const engine = engineSetup();
  48. await engine.start();
  49. try {
  50. const first = plantingOf(engine).till(1, 1);
  51. const second = plantingOf(engine).till(1, 1);
  52. expect(second).toBe(first);
  53. expect(engine.world.query({ type: "plot" })).toHaveLength(1);
  54. } finally {
  55. await engine.stop();
  56. }
  57. });
  58. it("till rejects non-integer or negative coordinates", async () => {
  59. const engine = engineSetup();
  60. await engine.start();
  61. try {
  62. const planting = plantingOf(engine);
  63. expect(() => planting.till(-1, 0)).toThrow(/non-negative integers/);
  64. expect(() => planting.till(1.5, 0)).toThrow(/non-negative integers/);
  65. } finally {
  66. await engine.stop();
  67. }
  68. });
  69. it("sow on an untilled tile throws", async () => {
  70. const engine = engineSetup();
  71. await engine.start();
  72. try {
  73. engine.world.create({ id: "plot:5:5", type: "plot", x: 5, y: 5, tilled: false, watered: false });
  74. expect(() => plantingOf(engine).sow(5, 5, "parsnip-seed")).toThrow(/not tilled/);
  75. expect(() => plantingOf(engine).sow(6, 6, "parsnip-seed")).toThrow(/no plot at/);
  76. } finally {
  77. await engine.stop();
  78. }
  79. });
  80. it("sow with a non-seed item throws", async () => {
  81. const engine = engineSetup();
  82. await engine.start();
  83. try {
  84. plantingOf(engine).till(3, 3);
  85. expect(() => plantingOf(engine).sow(3, 3, "hoe")).toThrow(/not a seed/);
  86. } finally {
  87. await engine.stop();
  88. }
  89. });
  90. it("sow with an unknown item throws", async () => {
  91. const engine = engineSetup();
  92. await engine.start();
  93. try {
  94. plantingOf(engine).till(3, 3);
  95. expect(() => plantingOf(engine).sow(3, 3, "nope")).toThrow(/unknown item: nope/);
  96. } finally {
  97. await engine.stop();
  98. }
  99. });
  100. it("sow with a seed that has no crop def throws", async () => {
  101. const engine = engineSetup();
  102. await engine.start();
  103. try {
  104. engine.registry.register("item", { id: "orphan-seed", name: "Orphan Seed", kind: "seed", stackable: true });
  105. plantingOf(engine).till(3, 3);
  106. expect(() => plantingOf(engine).sow(3, 3, "orphan-seed")).toThrow(/no crop registered for seed: orphan-seed/);
  107. } finally {
  108. await engine.stop();
  109. }
  110. });
  111. it("sow consumes one seed from inventory and plants the crop at stage 0", async () => {
  112. const engine = engineSetup();
  113. await engine.start();
  114. try {
  115. inventoryOf(engine).give("parsnip-seed", 3);
  116. plantingOf(engine).till(3, 3);
  117. const plot = plantingOf(engine).sow(3, 3, "parsnip-seed");
  118. expect(plot).toMatchObject({
  119. id: "plot:3:3",
  120. cropItemId: "parsnip",
  121. stageIndex: 0,
  122. daysInStage: 0,
  123. watered: false,
  124. });
  125. expect(inventoryOf(engine).count("parsnip-seed")).toBe(2);
  126. } finally {
  127. await engine.stop();
  128. }
  129. });
  130. it("sow without seeds in inventory throws and plants nothing", async () => {
  131. const engine = engineSetup();
  132. await engine.start();
  133. try {
  134. plantingOf(engine).till(3, 3);
  135. expect(() => plantingOf(engine).sow(3, 3, "parsnip-seed")).toThrow(/no parsnip-seed in inventory/);
  136. const plot = engine.world.get("plot:3:3") as PlotEntity;
  137. expect(plot.cropItemId).toBeUndefined();
  138. } finally {
  139. await engine.stop();
  140. }
  141. });
  142. it("sow on an already-planted plot throws", async () => {
  143. const engine = engineSetup();
  144. await engine.start();
  145. try {
  146. inventoryOf(engine).give("parsnip-seed", 3);
  147. const planting = plantingOf(engine);
  148. planting.till(3, 3);
  149. planting.sow(3, 3, "parsnip-seed");
  150. expect(() => planting.sow(3, 3, "parsnip-seed")).toThrow(/already has a crop/);
  151. } finally {
  152. await engine.stop();
  153. }
  154. });
  155. it("water sets watered true; watering a missing tile throws", async () => {
  156. const engine = engineSetup();
  157. await engine.start();
  158. try {
  159. const planting = plantingOf(engine);
  160. planting.till(3, 3);
  161. expect(planting.water(3, 3).watered).toBe(true);
  162. expect(() => planting.water(5, 5)).toThrow(/no plot at/);
  163. } finally {
  164. await engine.stop();
  165. }
  166. });
  167. it("harvest before maturity throws", async () => {
  168. const engine = engineSetup();
  169. await engine.start();
  170. try {
  171. inventoryOf(engine).give("parsnip-seed", 3);
  172. const planting = plantingOf(engine);
  173. planting.till(3, 3);
  174. planting.sow(3, 3, "parsnip-seed");
  175. expect(() => planting.harvest(3, 3)).toThrow(/not mature/);
  176. } finally {
  177. await engine.stop();
  178. }
  179. });
  180. it("harvest at the final stage returns drops, clears the crop, grants inventory, and fires onCropHarvest", async () => {
  181. const engine = engineSetup();
  182. await engine.start();
  183. try {
  184. const harvested: unknown[] = [];
  185. engine.hooks.on("onCropHarvest", (ctx) => {
  186. harvested.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  187. });
  188. const crop = engine.registry.get<CropDef>("crop", "parsnip");
  189. if (!crop) throw new Error("parsnip crop not loaded");
  190. const lastStage = crop.stages.length - 1;
  191. const inv = inventoryOf(engine);
  192. inv.give("parsnip-seed", 3);
  193. const planting = plantingOf(engine);
  194. planting.till(3, 3);
  195. planting.sow(3, 3, "parsnip-seed");
  196. growthOf(engine).forceStage("plot:3:3", lastStage);
  197. const drops = planting.harvest(3, 3);
  198. expect(drops).toEqual([{ itemId: "parsnip", qty: 1 }]);
  199. const plot = engine.world.get("plot:3:3") as PlotEntity;
  200. expect(plot.cropItemId).toBeUndefined();
  201. expect(plot.stageIndex).toBeUndefined();
  202. expect(plot.daysInStage).toBeUndefined();
  203. expect(plot.tilled).toBe(true);
  204. expect(plot.watered).toBe(false);
  205. expect(inv.count("parsnip")).toBe(1);
  206. expect(harvested).toEqual([{ world: "player", time: 1 }]);
  207. } finally {
  208. await engine.stop();
  209. }
  210. });
  211. it("harvest without a crop throws", async () => {
  212. const engine = engineSetup();
  213. await engine.start();
  214. try {
  215. plantingOf(engine).till(3, 3);
  216. expect(() => plantingOf(engine).harvest(3, 3)).toThrow(/has no crop/);
  217. } finally {
  218. await engine.stop();
  219. }
  220. });
  221. });
  222. describe("PlantingSystem drop determinism (D28)", () => {
  223. async function seededEngineSetup(seed: number): Promise<ReturnType<typeof createEngine>> {
  224. const engine = createEngine({ contentDir, modsDir, seed });
  225. await engine.start();
  226. engine.registry.register("item", { id: "lucky-seed", name: "Lucky Seed", kind: "seed", stackable: true });
  227. engine.registry.register("item", { id: "gold", name: "Gold", kind: "material", stackable: true });
  228. engine.registry.register("crop", {
  229. id: "lucky",
  230. seedItem: "lucky-seed",
  231. stages: [
  232. { name: "sprout", days: 1, waterRequired: false },
  233. { name: "mature", days: 1, waterRequired: false },
  234. ],
  235. seasons: ["spring"],
  236. drops: [{ itemId: "gold", qty: 1, chance: 0.5 }],
  237. });
  238. return engine;
  239. }
  240. it("harvest drop rolls are deterministic across same-seed engines (drawn from ctx.rng)", async () => {
  241. const engineA = await seededEngineSetup(555);
  242. const engineB = await seededEngineSetup(555);
  243. try {
  244. const harvest = (engine: ReturnType<typeof createEngine>): CropDrop[] => {
  245. inventoryOf(engine).give("lucky-seed", 3);
  246. const planting = plantingOf(engine);
  247. planting.till(2, 2);
  248. planting.sow(2, 2, "lucky-seed");
  249. growthOf(engine).forceStage("plot:2:2", 1);
  250. return planting.harvest(2, 2);
  251. };
  252. expect(harvest(engineA)).toEqual(harvest(engineB));
  253. expect(engineA.rng.state().calls).toBe(1);
  254. expect(engineB.rng.state().calls).toBe(1);
  255. expect(inventoryOf(engineA).count("gold")).toBe(inventoryOf(engineB).count("gold"));
  256. } finally {
  257. await engineA.stop();
  258. await engineB.stop();
  259. }
  260. });
  261. });
  262. describe("GrowthSystem", () => {
  263. it("step advances a watered crop through stages and fires onCropGrow per day boundary", async () => {
  264. const engine = engineSetup();
  265. await engine.start();
  266. try {
  267. const grew: number[] = [];
  268. engine.hooks.on("onCropGrow", (ctx) => {
  269. const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined;
  270. grew.push(plot?.stageIndex ?? -1);
  271. });
  272. inventoryOf(engine).give("parsnip-seed", 3);
  273. const planting = plantingOf(engine);
  274. planting.till(3, 3);
  275. planting.sow(3, 3, "parsnip-seed");
  276. planting.water(3, 3);
  277. const growth = growthOf(engine);
  278. growth.step(480);
  279. expect(grew).toEqual([]);
  280. let plot = engine.world.get("plot:3:3") as PlotEntity;
  281. expect(plot.stageIndex).toBe(0);
  282. expect(plot.daysInStage).toBe(1);
  283. planting.water(3, 3);
  284. growth.step(480);
  285. plot = engine.world.get("plot:3:3") as PlotEntity;
  286. expect(plot.stageIndex).toBe(1);
  287. expect(plot.daysInStage).toBe(0);
  288. expect(plot.watered).toBe(false);
  289. expect(grew).toEqual([1]);
  290. } finally {
  291. await engine.stop();
  292. }
  293. });
  294. it("step does not advance an unwatered crop when the stage requires water", async () => {
  295. const engine = engineSetup();
  296. await engine.start();
  297. try {
  298. inventoryOf(engine).give("parsnip-seed", 3);
  299. const planting = plantingOf(engine);
  300. planting.till(3, 3);
  301. planting.sow(3, 3, "parsnip-seed");
  302. const growth = growthOf(engine);
  303. growth.step(480);
  304. growth.step(480);
  305. growth.step(480);
  306. const plot = engine.world.get("plot:3:3") as PlotEntity;
  307. expect(plot.stageIndex).toBe(0);
  308. expect(plot.daysInStage).toBe(0);
  309. } finally {
  310. await engine.stop();
  311. }
  312. });
  313. it("step to maturity (final stage) then a watered crop stops progressing", async () => {
  314. const engine = engineSetup();
  315. await engine.start();
  316. try {
  317. const grew: number[] = [];
  318. engine.hooks.on("onCropGrow", (ctx) => {
  319. const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined;
  320. grew.push(plot?.stageIndex ?? -1);
  321. });
  322. inventoryOf(engine).give("parsnip-seed", 3);
  323. const planting = plantingOf(engine);
  324. planting.till(3, 3);
  325. planting.sow(3, 3, "parsnip-seed");
  326. const growth = growthOf(engine);
  327. planting.water(3, 3);
  328. growth.step(480);
  329. planting.water(3, 3);
  330. growth.step(480);
  331. planting.water(3, 3);
  332. growth.step(480);
  333. planting.water(3, 3);
  334. growth.step(480);
  335. planting.water(3, 3);
  336. growth.step(480);
  337. planting.water(3, 3);
  338. growth.step(480);
  339. const plot = engine.world.get("plot:3:3") as PlotEntity;
  340. expect(plot.stageIndex).toBe(2);
  341. expect(grew).toEqual([1, 2]);
  342. } finally {
  343. await engine.stop();
  344. }
  345. });
  346. it("forceStage jumps a plot to a stage, fires onCropGrow, and rejects bad indices", async () => {
  347. const engine = engineSetup();
  348. await engine.start();
  349. try {
  350. const grew: number[] = [];
  351. engine.hooks.on("onCropGrow", (ctx) => {
  352. const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined;
  353. grew.push(plot?.stageIndex ?? -1);
  354. });
  355. inventoryOf(engine).give("parsnip-seed", 3);
  356. const planting = plantingOf(engine);
  357. planting.till(3, 3);
  358. planting.sow(3, 3, "parsnip-seed");
  359. const growth = growthOf(engine);
  360. const plot = growth.forceStage("plot:3:3", 2);
  361. expect(plot.stageIndex).toBe(2);
  362. expect(plot.daysInStage).toBe(0);
  363. expect(grew).toEqual([2]);
  364. expect(() => growth.forceStage("plot:3:3", 5)).toThrow(/out of range/);
  365. expect(() => growth.forceStage("plot:9:9", 0)).toThrow(/no plot found/);
  366. expect(() => growth.forceStage("plot:3:3", -1)).toThrow(/out of range/);
  367. } finally {
  368. await engine.stop();
  369. }
  370. });
  371. });