| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- 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<typeof createEngine>): EconomySystem {
- const economy = engine.systems.get<EconomySystem>("economy");
- if (!economy) throw new Error("economy system not booted");
- return economy;
- }
- function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- return inv;
- }
- function player(engine: ReturnType<typeof createEngine>) {
- const entity = engine.world.get("player");
- if (!entity) throw new Error("player entity not seeded");
- return entity;
- }
- function setGold(engine: ReturnType<typeof createEngine>, gold: number): void {
- player(engine).gold = gold;
- }
- function shopOf(engine: ReturnType<typeof createEngine>): ShopDef {
- const shop = engine.registry.get<ShopDef>("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();
- }
- });
- });
|