import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, GrowthSystem, type CropDef, type CropDrop, type InventorySystem, type PlotEntity, type PlantingSystem, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function engineSetup() { return createEngine({ contentDir, modsDir }); } function plantingOf(engine: ReturnType): PlantingSystem { const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); return planting; } function growthOf(engine: ReturnType): GrowthSystem { const growth = engine.systems.get("growth"); if (!growth) throw new Error("growth system not booted"); return growth; } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } describe("PlantingSystem", () => { it("till creates a tilled plot entity on the tile", async () => { const engine = engineSetup(); await engine.start(); try { const plot = plantingOf(engine).till(3, 3); expect(plot).toEqual({ id: "plot:3:3", type: "plot", x: 3, y: 3, tilled: true, watered: false }); expect(engine.world.get("plot:3:3")).toBeDefined(); expect(engine.world.query({ type: "plot" }).map((e) => e.id)).toEqual(["plot:3:3"]); } finally { await engine.stop(); } }); it("till is idempotent: re-tilling returns the same plot", async () => { const engine = engineSetup(); await engine.start(); try { const first = plantingOf(engine).till(1, 1); const second = plantingOf(engine).till(1, 1); expect(second).toBe(first); expect(engine.world.query({ type: "plot" })).toHaveLength(1); } finally { await engine.stop(); } }); it("till rejects non-integer or negative coordinates", async () => { const engine = engineSetup(); await engine.start(); try { const planting = plantingOf(engine); expect(() => planting.till(-1, 0)).toThrow(/non-negative integers/); expect(() => planting.till(1.5, 0)).toThrow(/non-negative integers/); } finally { await engine.stop(); } }); it("sow on an untilled tile throws", async () => { const engine = engineSetup(); await engine.start(); try { engine.world.create({ id: "plot:5:5", type: "plot", x: 5, y: 5, tilled: false, watered: false }); expect(() => plantingOf(engine).sow(5, 5, "parsnip-seed")).toThrow(/not tilled/); expect(() => plantingOf(engine).sow(6, 6, "parsnip-seed")).toThrow(/no plot at/); } finally { await engine.stop(); } }); it("sow with a non-seed item throws", async () => { const engine = engineSetup(); await engine.start(); try { plantingOf(engine).till(3, 3); expect(() => plantingOf(engine).sow(3, 3, "hoe")).toThrow(/not a seed/); } finally { await engine.stop(); } }); it("sow with an unknown item throws", async () => { const engine = engineSetup(); await engine.start(); try { plantingOf(engine).till(3, 3); expect(() => plantingOf(engine).sow(3, 3, "nope")).toThrow(/unknown item: nope/); } finally { await engine.stop(); } }); it("sow with a seed that has no crop def throws", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("item", { id: "orphan-seed", name: "Orphan Seed", kind: "seed", stackable: true }); plantingOf(engine).till(3, 3); expect(() => plantingOf(engine).sow(3, 3, "orphan-seed")).toThrow(/no crop registered for seed: orphan-seed/); } finally { await engine.stop(); } }); it("sow consumes one seed from inventory and plants the crop at stage 0", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip-seed", 3); plantingOf(engine).till(3, 3); const plot = plantingOf(engine).sow(3, 3, "parsnip-seed"); expect(plot).toMatchObject({ id: "plot:3:3", cropItemId: "parsnip", stageIndex: 0, daysInStage: 0, watered: false, }); expect(inventoryOf(engine).count("parsnip-seed")).toBe(2); } finally { await engine.stop(); } }); it("sow without seeds in inventory throws and plants nothing", async () => { const engine = engineSetup(); await engine.start(); try { plantingOf(engine).till(3, 3); expect(() => plantingOf(engine).sow(3, 3, "parsnip-seed")).toThrow(/no parsnip-seed in inventory/); const plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.cropItemId).toBeUndefined(); } finally { await engine.stop(); } }); it("sow on an already-planted plot throws", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); expect(() => planting.sow(3, 3, "parsnip-seed")).toThrow(/already has a crop/); } finally { await engine.stop(); } }); it("water sets watered true; watering a missing tile throws", async () => { const engine = engineSetup(); await engine.start(); try { const planting = plantingOf(engine); planting.till(3, 3); expect(planting.water(3, 3).watered).toBe(true); expect(() => planting.water(5, 5)).toThrow(/no plot at/); } finally { await engine.stop(); } }); it("harvest before maturity throws", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); expect(() => planting.harvest(3, 3)).toThrow(/not mature/); } finally { await engine.stop(); } }); it("harvest at the final stage returns drops, clears the crop, grants inventory, and fires onCropHarvest", async () => { const engine = engineSetup(); await engine.start(); try { const harvested: unknown[] = []; engine.hooks.on("onCropHarvest", (ctx) => { harvested.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); const crop = engine.registry.get("crop", "parsnip"); if (!crop) throw new Error("parsnip crop not loaded"); const lastStage = crop.stages.length - 1; const inv = inventoryOf(engine); inv.give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); growthOf(engine).forceStage("plot:3:3", lastStage); const drops = planting.harvest(3, 3); expect(drops).toEqual([{ itemId: "parsnip", qty: 1 }]); const plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.cropItemId).toBeUndefined(); expect(plot.stageIndex).toBeUndefined(); expect(plot.daysInStage).toBeUndefined(); expect(plot.tilled).toBe(true); expect(plot.watered).toBe(false); expect(inv.count("parsnip")).toBe(1); expect(harvested).toEqual([{ world: "player", time: 1 }]); } finally { await engine.stop(); } }); it("harvest without a crop throws", async () => { const engine = engineSetup(); await engine.start(); try { plantingOf(engine).till(3, 3); expect(() => plantingOf(engine).harvest(3, 3)).toThrow(/has no crop/); } finally { await engine.stop(); } }); }); describe("PlantingSystem drop determinism (D28)", () => { async function seededEngineSetup(seed: number): Promise> { const engine = createEngine({ contentDir, modsDir, seed }); await engine.start(); engine.registry.register("item", { id: "lucky-seed", name: "Lucky Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "gold", name: "Gold", kind: "material", stackable: true }); engine.registry.register("crop", { id: "lucky", seedItem: "lucky-seed", stages: [ { name: "sprout", days: 1, waterRequired: false }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "gold", qty: 1, chance: 0.5 }], }); return engine; } it("harvest drop rolls are deterministic across same-seed engines (drawn from ctx.rng)", async () => { const engineA = await seededEngineSetup(555); const engineB = await seededEngineSetup(555); try { const harvest = (engine: ReturnType): CropDrop[] => { inventoryOf(engine).give("lucky-seed", 3); const planting = plantingOf(engine); planting.till(2, 2); planting.sow(2, 2, "lucky-seed"); growthOf(engine).forceStage("plot:2:2", 1); return planting.harvest(2, 2); }; expect(harvest(engineA)).toEqual(harvest(engineB)); expect(engineA.rng.state().calls).toBe(1); expect(engineB.rng.state().calls).toBe(1); expect(inventoryOf(engineA).count("gold")).toBe(inventoryOf(engineB).count("gold")); } finally { await engineA.stop(); await engineB.stop(); } }); }); describe("GrowthSystem", () => { it("step advances a watered crop through stages and fires onCropGrow per day boundary", async () => { const engine = engineSetup(); await engine.start(); try { const grew: number[] = []; engine.hooks.on("onCropGrow", (ctx) => { const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined; grew.push(plot?.stageIndex ?? -1); }); inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); planting.water(3, 3); const growth = growthOf(engine); growth.step(480); expect(grew).toEqual([]); let plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.stageIndex).toBe(0); expect(plot.daysInStage).toBe(1); planting.water(3, 3); growth.step(480); plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.stageIndex).toBe(1); expect(plot.daysInStage).toBe(0); expect(plot.watered).toBe(false); expect(grew).toEqual([1]); } finally { await engine.stop(); } }); it("step does not advance an unwatered crop when the stage requires water", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); const growth = growthOf(engine); growth.step(480); growth.step(480); growth.step(480); const plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.stageIndex).toBe(0); expect(plot.daysInStage).toBe(0); } finally { await engine.stop(); } }); it("step to maturity (final stage) then a watered crop stops progressing", async () => { const engine = engineSetup(); await engine.start(); try { const grew: number[] = []; engine.hooks.on("onCropGrow", (ctx) => { const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined; grew.push(plot?.stageIndex ?? -1); }); inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); const growth = growthOf(engine); planting.water(3, 3); growth.step(480); planting.water(3, 3); growth.step(480); planting.water(3, 3); growth.step(480); planting.water(3, 3); growth.step(480); planting.water(3, 3); growth.step(480); planting.water(3, 3); growth.step(480); const plot = engine.world.get("plot:3:3") as PlotEntity; expect(plot.stageIndex).toBe(2); expect(grew).toEqual([1, 2]); } finally { await engine.stop(); } }); it("forceStage jumps a plot to a stage, fires onCropGrow, and rejects bad indices", async () => { const engine = engineSetup(); await engine.start(); try { const grew: number[] = []; engine.hooks.on("onCropGrow", (ctx) => { const plot = ctx.world.query({ type: "plot" })[0] as PlotEntity | undefined; grew.push(plot?.stageIndex ?? -1); }); inventoryOf(engine).give("parsnip-seed", 3); const planting = plantingOf(engine); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); const growth = growthOf(engine); const plot = growth.forceStage("plot:3:3", 2); expect(plot.stageIndex).toBe(2); expect(plot.daysInStage).toBe(0); expect(grew).toEqual([2]); expect(() => growth.forceStage("plot:3:3", 5)).toThrow(/out of range/); expect(() => growth.forceStage("plot:9:9", 0)).toThrow(/no plot found/); expect(() => growth.forceStage("plot:3:3", -1)).toThrow(/out of range/); } finally { await engine.stop(); } }); });