import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, ContentRegistry, InventorySystem, World, WorldEventBus } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function setup() { const world = new World(); const registry = new ContentRegistry(); const events = new WorldEventBus(); world.create({ id: "player", type: "player", x: 0, y: 0 }); const inv = new InventorySystem(); inv.init({ world, registry, events }); return { inv, world, events }; } describe("InventorySystem", () => { it("init creates an inventory field on the player entity", () => { const { world } = setup(); expect(world.get("player")?.inventory).toEqual({}); }); it("give/count/items roundtrip", () => { const { inv } = setup(); expect(inv.give("hoe", 2)).toBe(2); expect(inv.give("hoe", 1)).toBe(3); expect(inv.count("hoe")).toBe(3); expect(inv.count("parsnip")).toBe(0); expect(inv.items()).toEqual({ hoe: 3 }); }); it("items() returns a snapshot copy, not a live reference", () => { const { inv } = setup(); inv.give("hoe", 1); const snapshot = inv.items(); inv.give("hoe", 1); expect(snapshot).toEqual({ hoe: 1 }); expect(inv.items()).toEqual({ hoe: 2 }); }); it("give emits item-acquire with itemId, qty, and total", () => { const { inv, events } = setup(); const seen: unknown[] = []; events.on("item-acquire", (p) => seen.push(p)); inv.give("hoe", 2); expect(seen).toEqual([{ itemId: "hoe", qty: 2, total: 2 }]); }); it("take decrements and emits item-use", () => { const { inv, events } = setup(); const seen: unknown[] = []; events.on("item-use", (p) => seen.push(p)); inv.give("hoe", 3); expect(inv.take("hoe", 1)).toBe(1); expect(seen).toEqual([{ itemId: "hoe", qty: 1, total: 2 }]); expect(inv.count("hoe")).toBe(2); }); it("take clamps to the available quantity when insufficient", () => { const { inv, events } = setup(); const seen: unknown[] = []; events.on("item-use", (p) => seen.push(p)); inv.give("hoe", 3); expect(inv.take("hoe", 10)).toBe(3); expect(inv.count("hoe")).toBe(0); expect(inv.items()).toEqual({}); expect(seen).toEqual([{ itemId: "hoe", qty: 3, total: 0 }]); }); it("take of an absent item returns 0 and emits nothing", () => { const { inv, events } = setup(); const seen: unknown[] = []; events.on("item-use", (p) => seen.push(p)); expect(inv.take("hoe", 5)).toBe(0); expect(seen).toHaveLength(0); }); it("give/take reject non-positive quantities", () => { const { inv } = setup(); expect(() => inv.give("hoe", 0)).toThrow(/positive integer/); expect(() => inv.give("hoe", -1)).toThrow(/positive integer/); expect(() => inv.give("hoe", 1.5)).toThrow(/positive integer/); expect(() => inv.take("hoe", 0)).toThrow(/positive integer/); }); it("wireEvents: inventory events fire onItemAcquire/onItemUse hooks with a HookContext", async () => { const engine = createEngine({ contentDir, modsDir }); await engine.start(); try { const acquired: unknown[] = []; const used: unknown[] = []; engine.hooks.on("onItemAcquire", (ctx) => { acquired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); engine.hooks.on("onItemUse", (ctx) => { used.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); const inv = engine.systems.get("inventory"); expect(inv).toBeDefined(); inv!.give("hoe", 2); inv!.take("hoe", 1); expect(acquired).toEqual([{ world: "player", time: 1 }]); expect(used).toEqual([{ world: "player", time: 1 }]); expect(inv!.items()).toEqual({ hoe: 1 }); } finally { await engine.stop(); } }); it("TR-2.1: damage durability item 4 times x30 breaks once and count becomes 0", () => { const world = new World(); const registry = new ContentRegistry(); const events = new WorldEventBus(); registry.register("item", { id: "hoe", durability: 100 }); world.create({ id: "player", type: "player", x: 0, y: 0 }); const inv = new InventorySystem(); inv.init({ world, registry, events }); inv.give("hoe", 1); expect(inv.count("hoe")).toBe(1); expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 }); const breaks: unknown[] = []; events.on("item-break", (p) => breaks.push(p)); inv.damage("hoe", 30); expect(inv.durability("hoe")).toEqual({ max: 100, current: 70 }); expect(inv.count("hoe")).toBe(1); inv.damage("hoe", 30); expect(inv.durability("hoe")).toEqual({ max: 100, current: 40 }); expect(inv.count("hoe")).toBe(1); inv.damage("hoe", 30); expect(inv.durability("hoe")).toEqual({ max: 100, current: 10 }); expect(inv.count("hoe")).toBe(1); inv.damage("hoe", 30); expect(inv.count("hoe")).toBe(0); expect(inv.durability("hoe")).toBeNull(); expect(breaks).toEqual([{ itemId: "hoe" }]); }); it("TR-2.2: use consumable pumpkin restores stamina capped at 100 and emits item-consume", () => { const world = new World(); const registry = new ContentRegistry(); const events = new WorldEventBus(); registry.register("item", { id: "pumpkin", effects: { consumable: { stamina: 15 } }, }); world.create({ id: "player", type: "player", x: 0, y: 0 }); const inv = new InventorySystem(); inv.init({ world, registry, events }); inv.give("pumpkin", 2); expect(inv.count("pumpkin")).toBe(2); const player = world.get("player")!; player.stamina = 85; const consumed: unknown[] = []; events.on("item-consume", (p) => consumed.push(p)); inv.use("pumpkin"); expect(player.stamina).toBe(100); expect(inv.count("pumpkin")).toBe(1); expect(consumed).toEqual([{ itemId: "pumpkin", effects: { stamina: 15 } }]); }); it("TR-2.3 use on non-consumable throws and consumable gold bonus works", () => { const world = new World(); const registry = new ContentRegistry(); const events = new WorldEventBus(); registry.register("item", { id: "rock" }); registry.register("item", { id: "coinbag", effects: { consumable: { gold: 50 } }, }); world.create({ id: "player", type: "player", x: 0, y: 0 }); const inv = new InventorySystem(); inv.init({ world, registry, events }); expect(() => inv.use("rock")).toThrow(/inventory\.use: rock is not consumable/); inv.give("coinbag", 1); const player = world.get("player")!; player.gold = 10; inv.use("coinbag"); expect(player.gold).toBe(60); }); });