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, type BuildingDef, type CropDef, type DialogueDef, type GrowthSystem, type InteractAction, type IntentSystem, type InventorySystem, type ItemDef, type PlacementSystem, type PlayerIntentEvent, type ShopDef, type System, } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 5", "height: 5", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass, water, water, water]", " - [grass, grass, water, water, water]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", "", ].join("\n"); const PARSNIP_SEED: ItemDef = { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }; const CARROT_SEED: ItemDef = { id: "carrot-seed", name: "Carrot Seed", kind: "seed", stackable: true }; const HOE: ItemDef = { id: "hoe", name: "Hoe", kind: "tool", stackable: false }; const PARSNIP: ItemDef = { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }; const PARSNIP_CROP: CropDef = { id: "parsnip", seedItem: "parsnip-seed", stages: [ { name: "sprout", days: 1, waterRequired: true }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "parsnip", qty: 1 }], }; const CARROT_CROP: CropDef = { id: "carrot", seedItem: "carrot-seed", stages: [ { name: "sprout", days: 1, waterRequired: true }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "carrot", qty: 1 }], }; const SHED: BuildingDef = { id: "shed", footprint: { w: 2, h: 2 }, buildable: "map", blocksMovement: true, }; const GENERAL_STORE: ShopDef = { id: "general-store", buys: [{ itemId: "parsnip", price: 30 }], sells: [ { itemId: "hoe", price: 500 }, { itemId: "parsnip-seed", price: 20, stock: 99 }, ], }; const MAYOR_DIALOGUE: DialogueDef = { id: "mayor", startNode: "greeting", nodes: [ { id: "greeting", text: "Hello!", choices: [{ text: "Bye", to: "farewell" }] }, { id: "farewell", text: "See ya" }, ], }; interface Setup { engine: ReturnType; close: () => Promise; } async function setup(spy?: System): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-intent-")); 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 systems: System[] = []; if (spy) systems.push(spy); const engine = createEngine({ contentDir: tmp, systems }); await engine.start(); for (const item of [PARSNIP_SEED, CARROT_SEED, HOE, PARSNIP]) { engine.registry.register("item", item); } engine.registry.register("crop", PARSNIP_CROP); engine.registry.register("crop", CARROT_CROP); engine.registry.register("building", SHED); engine.registry.register("shop", GENERAL_STORE); engine.registry.register("dialogue", MAYOR_DIALOGUE); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function intentOf(engine: ReturnType): IntentSystem { const intent = engine.systems.get("intent"); if (!intent) throw new Error("intent system not booted"); return intent; } function spawnNpc(engine: ReturnType, id: string, x: number, y: number, dialogue?: string): void { engine.world.create({ id, type: "npc", x, y, ...(dialogue !== undefined ? { dialogue } : {}) }); } 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 }; } function intentEvents(seen: { event: string; payload: unknown }[]): PlayerIntentEvent[] { return seen.filter((entry) => entry.event === "player-intent").map((entry) => entry.payload as PlayerIntentEvent); } describe("IntentSystem.handle move", () => { it("moves the player entity to a walkable tile and emits player-intent", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const result = intentOf(engine).handle("move", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ action: "move", tile: { x: 2, y: 2 }, ok: true, detail: "moved to (2,2)" }); expect(engine.world.get("player")).toMatchObject({ x: 2, y: 2 }); expect(intentEvents(seen)).toEqual([ { action: "move", tile: { x: 2, y: 2 }, ok: true, detail: "moved to (2,2)" }, ]); } finally { await close(); } }); it("rejects water tiles, out-of-bounds targets, and building-blocked cells", async () => { const { engine, close } = await setup(); try { const water = intentOf(engine).handle("move", { tile: { x: 2, y: 0 } }); expect(water).toMatchObject({ ok: false, error: "intent.move: tile (2,0) is not walkable" }); const bounds = intentOf(engine).handle("move", { tile: { x: 5, y: 0 } }); expect(bounds).toMatchObject({ ok: false, error: "intent.move: target out of bounds: (5,0)" }); const placement = engine.systems.get("placement"); if (!placement) throw new Error("placement system not booted"); placement.place("shed", 0, 0); const blocked = intentOf(engine).handle("move", { tile: { x: 0, y: 0 } }); expect(blocked).toMatchObject({ ok: false, error: "intent.move: blocked by building building:shed:0:0 at (0,0)" }); expect(engine.world.get("player")).toMatchObject({ x: 0, y: 0 }); } finally { await close(); } }); it("emits player-intent for failed moves too", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { intentOf(engine).handle("move", { tile: { x: 2, y: 0 } }); const events = intentEvents(seen); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ action: "move", tile: { x: 2, y: 0 }, ok: false }); expect(events[0].error).toMatch(/not walkable/); } finally { await close(); } }); }); describe("IntentSystem.handle interact", () => { it("tills an un-tilled tile (creating the plot entity)", async () => { const { engine, close } = await setup(); try { const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result).toMatchObject({ action: "interact", tile: { x: 3, y: 3 }, ok: true, detail: "tilled plot" }); expect(engine.world.get("plot:3:3")).toMatchObject({ type: "plot", x: 3, y: 3, tilled: true }); } finally { await close(); } }); it("tills a pre-tilled tile that has no plot entity yet", async () => { const { engine, close } = await setup(); try { const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result.ok).toBe(true); expect(result.detail).toBe("tilled plot"); } finally { await close(); } }); it("sows the first seed-kind item in the player inventory on a tilled empty plot", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 3); inv.give("carrot-seed", 2); inv.give("hoe", 1); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(3, 3); const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result).toMatchObject({ action: "interact", ok: true, detail: "sowed Parsnip Seed" }); expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip", stageIndex: 0 }); expect(inv.count("parsnip-seed")).toBe(2); expect(inv.count("carrot-seed")).toBe(2); } finally { await close(); } }); it("fails with 'no seed in inventory' when the player has no seeds", async () => { const { engine, close } = await setup(); try { const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result.ok).toBe(true); expect(result.detail).toBe("tilled plot"); const again = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(again).toMatchObject({ ok: false, error: "no seed in inventory" }); } finally { await close(); } }); it("waters a planted plot that is not watered", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ action: "interact", ok: true, detail: "watered plot" }); expect(engine.world.get("plot:2:2")).toMatchObject({ watered: true }); } finally { await close(); } }); it("errors when a planted watered crop is not mature", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); planting.water(2, 2); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ ok: false, error: "crop parsnip is not ready to harvest" }); } finally { await close(); } }); it("harvests a mature crop and grants the drop", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); const growth = engine.systems.get("growth"); if (!planting || !growth) throw new Error("systems not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); growth.forceStage("plot:2:2", 1); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ action: "interact", ok: true, detail: "harvested parsnip" }); expect(inv.count("parsnip")).toBe(1); expect(engine.world.get("plot:2:2")).not.toMatchObject({ cropItemId: "parsnip" }); } finally { await close(); } }); }); describe("IntentSystem.handle interact order (D33 content-ized)", () => { it("custom order [water, harvest, ...] waters a mature unwatered crop (default would harvest)", async () => { const { engine, close } = await setup(); try { engine.registry.register("interact", { id: "default", order: ["water", "harvest", "sow", "till"] }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); const growth = engine.systems.get("growth"); if (!planting || !growth) throw new Error("systems not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); growth.forceStage("plot:2:2", 1); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ action: "interact", ok: true, detail: "watered plot" }); expect(engine.world.get("plot:2:2")).toMatchObject({ watered: true, cropItemId: "parsnip" }); } finally { await close(); } }); it("an interact def with a non-default id is picked as the first def of the kind", async () => { const { engine, close } = await setup(); try { engine.registry.register("interact", { id: "custom-order", order: ["water", "harvest", "sow", "till"] }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); const growth = engine.systems.get("growth"); if (!planting || !growth) throw new Error("systems not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); growth.forceStage("plot:2:2", 1); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ ok: true, detail: "watered plot" }); } finally { await close(); } }); it("order [sow, till, ...] on an untilled tile without seed falls through to till", async () => { const { engine, close } = await setup(); try { engine.registry.register("interact", { id: "default", order: ["sow", "till", "harvest", "water"] }); const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result).toMatchObject({ action: "interact", ok: true, detail: "tilled plot" }); expect(engine.world.get("plot:3:3")).toMatchObject({ type: "plot", x: 3, y: 3, tilled: true }); } finally { await close(); } }); it("order [harvest, sow, till, ...] picks sow over till on a tilled-empty plot with seed", async () => { const { engine, close } = await setup(); try { engine.registry.register("interact", { id: "default", order: ["harvest", "sow", "till", "water"] }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(3, 3); const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" }); expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip" }); } finally { await close(); } }); it("the default-id def wins over other interact defs", async () => { const { engine, close } = await setup(); try { engine.registry.register("interact", { id: "custom-order", order: ["harvest", "water", "sow", "till"] }); engine.registry.register("interact", { id: "default", order: ["water", "harvest", "sow", "till"] }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); const growth = engine.systems.get("growth"); if (!planting || !growth) throw new Error("systems not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); growth.forceStage("plot:2:2", 1); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ ok: true, detail: "watered plot" }); } finally { await close(); } }); it("without any interact def the built-in default order applies (harvest beats water)", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 1); const planting = engine.systems.get("planting"); const growth = engine.systems.get("growth"); if (!planting || !growth) throw new Error("systems not booted"); planting.till(2, 2); planting.sow(2, 2, "parsnip-seed"); growth.forceStage("plot:2:2", 1); const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } }); expect(result).toMatchObject({ ok: true, detail: "harvested parsnip" }); } finally { await close(); } }); }); describe("IntentSystem.handle validation", () => { it("rejects a malformed tile", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const result = intentOf(engine).handle("move", { tile: "nope" }); expect(result).toMatchObject({ ok: false, error: "intent: tile must be {x, y} non-negative integers" }); expect(result.tile).toEqual({ x: 0, y: 0 }); expect(intentEvents(seen)).toHaveLength(1); } finally { await close(); } }); it("rejects an unknown action and still emits player-intent", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const result = intentOf(engine).handle("dance", { tile: { x: 1, y: 1 } }); expect(result).toMatchObject({ ok: false, error: "intent: unknown action: dance" }); expect(intentEvents(seen)).toHaveLength(1); } finally { await close(); } }); }); describe("IntentSystem.handle buy/sell", () => { it("buy: purchases an item with gold, emits player-intent, and reports the transaction detail", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; const result = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 2 }); expect(result).toMatchObject({ action: "buy", ok: true }); expect(result.detail).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); expect(inv.count("parsnip-seed")).toBe(2); const events = intentEvents(seen); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ action: "buy", ok: true }); } finally { await close(); } }); it("buy: defaults qty to 1 and rejects missing itemId, missing shopId, and non-positive qty", async () => { const { engine, close } = await setup(); try { const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; const single = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed" }); expect(single.ok).toBe(true); expect(single.detail).toMatchObject({ qty: 1, total: 20, gold: 80 }); const missingItem = intentOf(engine).handle("buy", { shopId: "general-store" }); expect(missingItem).toMatchObject({ ok: false, error: "player.intent: buy requires itemId" }); const missingShop = intentOf(engine).handle("buy", { itemId: "parsnip-seed" }); expect(missingShop).toMatchObject({ ok: false, error: "player.intent: buy requires shopId" }); const badQty = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 0 }); expect(badQty).toMatchObject({ ok: false, error: "player.intent: buy qty must be a positive integer" }); const floatQty = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 1.5 }); expect(floatQty.ok).toBe(false); } finally { await close(); } }); it("buy: passthrough economy errors — unknown shop, not sold, insufficient gold", async () => { const { engine, close } = await setup(); try { const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 10; const unknownShop = intentOf(engine).handle("buy", { shopId: "nope", itemId: "parsnip-seed" }); expect(unknownShop).toMatchObject({ ok: false, error: "economy: unknown shop: nope" }); const notSold = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip" }); expect(notSold).toMatchObject({ ok: false, error: "economy.buy: general-store does not sell parsnip" }); const broke = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "hoe", qty: 1 }); expect(broke).toMatchObject({ ok: false, error: "economy.buy: insufficient gold (need 500, have 10)" }); } finally { await close(); } }); it("sell: sells an owned item, reports the transaction, and emits player-intent", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip", 3); const result = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "parsnip", qty: 2 }); expect(result.ok).toBe(true); expect(result.detail).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 60 }); expect(inv.count("parsnip")).toBe(1); expect(intentEvents(seen)).toHaveLength(1); } finally { await close(); } }); it("sell: passthrough economy errors — not bought by the shop, insufficient inventory, missing itemId", async () => { const { engine, close } = await setup(); try { const missingItem = intentOf(engine).handle("sell", { shopId: "general-store" }); expect(missingItem).toMatchObject({ ok: false, error: "player.intent: sell requires itemId" }); const notBought = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "hoe" }); expect(notBought).toMatchObject({ ok: false, error: "economy.sell: general-store does not buy hoe" }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip", 1); const short = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "parsnip", qty: 9 }); expect(short).toMatchObject({ ok: false, error: "economy.sell: insufficient parsnip in inventory (have 1, need 9)", }); } finally { await close(); } }); }); describe("IntentSystem.handle talk/choose", () => { it("talk: starts an npc's dialogue by npcId and reports the DialogueState", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { spawnNpc(engine, "npc:mayor", 6, 3, "mayor"); const result = intentOf(engine).handle("talk", { npcId: "npc:mayor" }); expect(result.ok).toBe(true); expect(result.detail).toEqual({ dialogueId: "mayor", nodeId: "greeting", text: "Hello!", choices: [{ text: "Bye", to: "farewell" }], }); expect(engine.world.get("dialogue:active")).toMatchObject({ dialogueId: "mayor", nodeId: "greeting" }); const events = intentEvents(seen); expect(events[events.length - 1]).toMatchObject({ action: "talk", ok: true }); } finally { await close(); } }); it("talk: resolves the npc from a tile when npcId is absent", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "npc:mayor", 6, 3, "mayor"); const result = intentOf(engine).handle("talk", { tile: { x: 6, y: 3 } }); expect(result.ok).toBe(true); expect(result.detail).toMatchObject({ dialogueId: "mayor" }); } finally { await close(); } }); it("talk: errors — no npcId and no tile, unknown npc, no npc at tile, npc without dialogue", async () => { const { engine, close } = await setup(); try { const noTarget = intentOf(engine).handle("talk", {}); expect(noTarget).toMatchObject({ ok: false, error: "player.intent: talk requires npcId or tile" }); const unknown = intentOf(engine).handle("talk", { npcId: "ghost" }); expect(unknown).toMatchObject({ ok: false, error: "player.intent: unknown npc: ghost" }); const noNpcAtTile = intentOf(engine).handle("talk", { tile: { x: 4, y: 4 } }); expect(noNpcAtTile).toMatchObject({ ok: false, error: "player.intent: no npc at tile" }); spawnNpc(engine, "npc:mute", 6, 3); const noDialogue = intentOf(engine).handle("talk", { npcId: "npc:mute" }); expect(noDialogue).toMatchObject({ ok: false, error: "player.intent: npc npc:mute has no dialogue" }); } finally { await close(); } }); it("choose: advances the dialogue and errors on no-active / non-integer / out-of-range", async () => { const { engine, close } = await setup(); try { const noActive = intentOf(engine).handle("choose", { index: 0 }); expect(noActive).toMatchObject({ ok: false, error: "player.intent: no active dialogue" }); const badIndex = intentOf(engine).handle("choose", { index: "x" }); expect(badIndex).toMatchObject({ ok: false, error: "player.intent: choose requires a non-negative integer index" }); spawnNpc(engine, "npc:mayor", 6, 3, "mayor"); intentOf(engine).handle("talk", { npcId: "npc:mayor" }); const outOfRange = intentOf(engine).handle("choose", { index: 5 }); expect(outOfRange).toMatchObject({ ok: false, error: "dialogue: choice index out of range: 5" }); const chosen = intentOf(engine).handle("choose", { index: 0 }); expect(chosen.ok).toBe(true); expect(chosen.detail).toEqual({ dialogueId: "mayor", nodeId: "farewell", text: "See ya", choices: [] }); expect(engine.world.get("dialogue:active")).toBeUndefined(); } finally { await close(); } }); }); describe("IntentSystem.handle select-seed and the selectedSeed sow fix", () => { it("select-seed: sets player.selectedSeed for a seed item and clears when absent", async () => { const { engine, close } = await setup(); try { const set = intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" }); expect(set).toMatchObject({ action: "select-seed", ok: true, detail: { selectedSeed: "parsnip-seed" } }); expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" }); const cleared = intentOf(engine).handle("select-seed", {}); expect(cleared).toMatchObject({ ok: true, detail: { selectedSeed: null } }); expect((engine.world.get("player") as { selectedSeed?: string }).selectedSeed).toBeUndefined(); } finally { await close(); } }); it("select-seed: rejects non-seed and unknown items", async () => { const { engine, close } = await setup(); try { const notASeed = intentOf(engine).handle("select-seed", { seedItemId: "hoe" }); expect(notASeed).toMatchObject({ ok: false, error: "player.intent: hoe is not a seed item" }); const unknown = intentOf(engine).handle("select-seed", { seedItemId: "ghost" }); expect(unknown).toMatchObject({ ok: false, error: "player.intent: ghost is not a seed item" }); } finally { await close(); } }); it("sow uses the selected seed over the first inventory seed and keeps the selection", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("carrot-seed", 2); inv.give("parsnip-seed", 3); intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" }); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(3, 3); const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(result).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" }); expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip" }); expect(inv.count("parsnip-seed")).toBe(2); expect(inv.count("carrot-seed")).toBe(2); expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" }); } finally { await close(); } }); it("sow falls back to the first inventory seed when the selected seed is not owned or not a seed", async () => { const { engine, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); inv.give("carrot-seed", 2); intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" }); planting.till(3, 3); const notOwned = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } }); expect(notOwned).toMatchObject({ ok: true, detail: "sowed Carrot Seed" }); expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "carrot" }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.selectedSeed = "hoe"; inv.take("carrot-seed", 1); inv.give("hoe", 1); inv.give("parsnip-seed", 1); planting.till(4, 4); const invalid = intentOf(engine).handle("interact", { tile: { x: 4, y: 4 } }); expect(invalid).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" }); } finally { await close(); } }); });