import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { ContentRegistry, createEngine, EconomySystem, STARTING_GOLD, World, WorldEventBus, type InventorySystem, type ShopDef, type System, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function engineSetup(spy?: System) { return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] }); } function economyOf(engine: ReturnType): EconomySystem { const economy = engine.systems.get("economy"); if (!economy) throw new Error("economy system not booted"); return economy; } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } function player(engine: ReturnType) { const entity = engine.world.get("player"); if (!entity) throw new Error("player entity not seeded"); return entity; } function setGold(engine: ReturnType, gold: number): void { player(engine).gold = gold; } function shopOf(engine: ReturnType): ShopDef { const shop = engine.registry.get("shop", "general-store"); if (!shop) throw new Error("general-store not loaded"); return shop; } 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 }; } describe("EconomySystem", () => { it("init initializes the player gold to STARTING_GOLD", async () => { const engine = engineSetup(); await engine.start(); try { expect(player(engine).gold).toBe(STARTING_GOLD); expect(STARTING_GOLD).toBe(0); } finally { await engine.stop(); } }); it("init leaves an existing valid gold balance untouched", () => { const world = new World(); world.create({ id: "player", type: "player", x: 0, y: 0, gold: 1234 }); const economy = new EconomySystem(); economy.init({ world, registry: new ContentRegistry(), events: new WorldEventBus() }); expect(world.get("player")?.gold).toBe(1234); }); it("init replaces an invalid gold balance with STARTING_GOLD", () => { const world = new World(); world.create({ id: "player", type: "player", x: 0, y: 0, gold: -5 }); const economy = new EconomySystem(); economy.init({ world, registry: new ContentRegistry(), events: new WorldEventBus() }); expect(world.get("player")?.gold).toBe(STARTING_GOLD); }); it("price reads both the buy line and the sell line with stock", async () => { const engine = engineSetup(); await engine.start(); try { expect(shopOf(engine)).toEqual({ id: "general-store", buys: [{ itemId: "parsnip", price: 30 }], sells: [ { itemId: "hoe", price: 500 }, { itemId: "parsnip-seed", price: 20, stock: 99 }, ], }); expect(economyOf(engine).price("general-store", "parsnip")).toEqual({ shopId: "general-store", itemId: "parsnip", buy: 30, sell: null, stock: null, }); expect(economyOf(engine).price("general-store", "parsnip-seed")).toEqual({ shopId: "general-store", itemId: "parsnip-seed", buy: null, sell: 20, stock: 99, }); } finally { await engine.stop(); } }); it("buy happy path: deducts gold, grants items, emits item-acquire + transaction, and fires onTransaction", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { const hookFired: unknown[] = []; engine.hooks.on("onTransaction", (ctx) => { hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); setGold(engine, 100); const result = economyOf(engine).buy("general-store", "parsnip-seed", 2); expect(result).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 }); expect(player(engine).gold).toBe(60); expect(inventoryOf(engine).count("parsnip-seed")).toBe(2); expect(hookFired).toEqual([{ world: "player", time: 1 }]); expect(seen).toEqual([ { event: "item-acquire", payload: { itemId: "parsnip-seed", qty: 2, total: 2 } }, { event: "transaction", payload: { shopId: "general-store", type: "buy", itemId: "parsnip-seed", qty: 2, total: 40 }, }, ]); } finally { await engine.stop(); } }); it("buy with insufficient gold throws and changes nothing", async () => { const engine = engineSetup(); await engine.start(); try { setGold(engine, 100); expect(() => economyOf(engine).buy("general-store", "hoe", 1)).toThrow(/insufficient gold/); expect(player(engine).gold).toBe(100); expect(inventoryOf(engine).count("hoe")).toBe(0); } finally { await engine.stop(); } }); it("buy with an unknown shop throws", async () => { const engine = engineSetup(); await engine.start(); try { expect(() => economyOf(engine).buy("nope", "hoe", 1)).toThrow(/unknown shop: nope/); } finally { await engine.stop(); } }); it("buy an item the shop does not sell throws", async () => { const engine = engineSetup(); await engine.start(); try { setGold(engine, 1000); expect(() => economyOf(engine).buy("general-store", "parsnip", 1)).toThrow(/does not sell parsnip/); } finally { await engine.stop(); } }); it("sell happy path: removes items, adds gold, emits item-use + transaction, and fires onTransaction", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { const hookFired: unknown[] = []; engine.hooks.on("onTransaction", (ctx) => { hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); inventoryOf(engine).give("parsnip", 3); const result = economyOf(engine).sell("general-store", "parsnip", 2); expect(result).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 60 }); expect(player(engine).gold).toBe(60); expect(inventoryOf(engine).count("parsnip")).toBe(1); expect(hookFired).toEqual([{ world: "player", time: 1 }]); expect(seen).toEqual([ { event: "item-acquire", payload: { itemId: "parsnip", qty: 3, total: 3 } }, { event: "item-use", payload: { itemId: "parsnip", qty: 2, total: 1 } }, { event: "transaction", payload: { shopId: "general-store", type: "sell", itemId: "parsnip", qty: 2, total: 60 }, }, ]); } finally { await engine.stop(); } }); it("sell without enough items throws and changes nothing", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip", 1); expect(() => economyOf(engine).sell("general-store", "parsnip", 2)).toThrow(/insufficient parsnip/); expect(player(engine).gold).toBe(0); expect(inventoryOf(engine).count("parsnip")).toBe(1); } finally { await engine.stop(); } }); it("sell an item the shop does not buy throws", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("hoe", 1); expect(() => economyOf(engine).sell("general-store", "hoe", 1)).toThrow(/does not buy hoe/); } finally { await engine.stop(); } }); it("buy and sell reject non-positive quantities", async () => { const engine = engineSetup(); await engine.start(); try { const economy = economyOf(engine); expect(() => economy.buy("general-store", "parsnip-seed", 0)).toThrow(/positive integer/); expect(() => economy.buy("general-store", "parsnip-seed", -1)).toThrow(/positive integer/); expect(() => economy.buy("general-store", "parsnip-seed", 1.5)).toThrow(/positive integer/); expect(() => economy.sell("general-store", "parsnip", 0)).toThrow(/positive integer/); } finally { await engine.stop(); } }); it("price on an unknown shop throws", async () => { const engine = engineSetup(); await engine.start(); try { expect(() => economyOf(engine).price("nope", "hoe")).toThrow(/unknown shop: nope/); } finally { await engine.stop(); } }); });