economy.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. ContentRegistry,
  5. createEngine,
  6. EconomySystem,
  7. STARTING_GOLD,
  8. World,
  9. WorldEventBus,
  10. type InventorySystem,
  11. type ShopDef,
  12. type System,
  13. } from "../src/index.js";
  14. const repoRoot = resolve(process.cwd(), "../..");
  15. const contentDir = resolve(repoRoot, "content");
  16. const modsDir = resolve(repoRoot, "mods");
  17. function engineSetup(spy?: System) {
  18. return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] });
  19. }
  20. function economyOf(engine: ReturnType<typeof createEngine>): EconomySystem {
  21. const economy = engine.systems.get<EconomySystem>("economy");
  22. if (!economy) throw new Error("economy system not booted");
  23. return economy;
  24. }
  25. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  26. const inv = engine.systems.get<InventorySystem>("inventory");
  27. if (!inv) throw new Error("inventory system not booted");
  28. return inv;
  29. }
  30. function player(engine: ReturnType<typeof createEngine>) {
  31. const entity = engine.world.get("player");
  32. if (!entity) throw new Error("player entity not seeded");
  33. return entity;
  34. }
  35. function setGold(engine: ReturnType<typeof createEngine>, gold: number): void {
  36. player(engine).gold = gold;
  37. }
  38. function shopOf(engine: ReturnType<typeof createEngine>): ShopDef {
  39. const shop = engine.registry.get<ShopDef>("shop", "general-store");
  40. if (!shop) throw new Error("general-store not loaded");
  41. return shop;
  42. }
  43. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  44. const seen: { event: string; payload: unknown }[] = [];
  45. const system: System = {
  46. id: "spy",
  47. init() {},
  48. on(event, payload) {
  49. seen.push({ event, payload });
  50. },
  51. };
  52. return { system, seen };
  53. }
  54. describe("EconomySystem", () => {
  55. it("init initializes the player gold to STARTING_GOLD", async () => {
  56. const engine = engineSetup();
  57. await engine.start();
  58. try {
  59. expect(player(engine).gold).toBe(STARTING_GOLD);
  60. expect(STARTING_GOLD).toBe(0);
  61. } finally {
  62. await engine.stop();
  63. }
  64. });
  65. it("init leaves an existing valid gold balance untouched", () => {
  66. const world = new World();
  67. world.create({ id: "player", type: "player", x: 0, y: 0, gold: 1234 });
  68. const economy = new EconomySystem();
  69. economy.init({ world, registry: new ContentRegistry(), events: new WorldEventBus() });
  70. expect(world.get("player")?.gold).toBe(1234);
  71. });
  72. it("init replaces an invalid gold balance with STARTING_GOLD", () => {
  73. const world = new World();
  74. world.create({ id: "player", type: "player", x: 0, y: 0, gold: -5 });
  75. const economy = new EconomySystem();
  76. economy.init({ world, registry: new ContentRegistry(), events: new WorldEventBus() });
  77. expect(world.get("player")?.gold).toBe(STARTING_GOLD);
  78. });
  79. it("price reads both the buy line and the sell line with stock", async () => {
  80. const engine = engineSetup();
  81. await engine.start();
  82. try {
  83. expect(shopOf(engine)).toEqual({
  84. id: "general-store",
  85. buys: [{ itemId: "parsnip", price: 30 }],
  86. sells: [
  87. { itemId: "hoe", price: 500 },
  88. { itemId: "parsnip-seed", price: 20, stock: 99 },
  89. ],
  90. });
  91. expect(economyOf(engine).price("general-store", "parsnip")).toEqual({
  92. shopId: "general-store",
  93. itemId: "parsnip",
  94. buy: 30,
  95. sell: null,
  96. stock: null,
  97. });
  98. expect(economyOf(engine).price("general-store", "parsnip-seed")).toEqual({
  99. shopId: "general-store",
  100. itemId: "parsnip-seed",
  101. buy: null,
  102. sell: 20,
  103. stock: 99,
  104. });
  105. } finally {
  106. await engine.stop();
  107. }
  108. });
  109. it("buy happy path: deducts gold, grants items, emits item-acquire + transaction, and fires onTransaction", async () => {
  110. const { system: spy, seen } = eventSpy();
  111. const engine = engineSetup(spy);
  112. await engine.start();
  113. try {
  114. const hookFired: unknown[] = [];
  115. engine.hooks.on("onTransaction", (ctx) => {
  116. hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  117. });
  118. setGold(engine, 100);
  119. const result = economyOf(engine).buy("general-store", "parsnip-seed", 2);
  120. expect(result).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
  121. expect(player(engine).gold).toBe(60);
  122. expect(inventoryOf(engine).count("parsnip-seed")).toBe(2);
  123. expect(hookFired).toEqual([{ world: "player", time: 1 }]);
  124. expect(seen).toEqual([
  125. { event: "item-acquire", payload: { itemId: "parsnip-seed", qty: 2, total: 2 } },
  126. {
  127. event: "transaction",
  128. payload: { shopId: "general-store", type: "buy", itemId: "parsnip-seed", qty: 2, total: 40 },
  129. },
  130. ]);
  131. } finally {
  132. await engine.stop();
  133. }
  134. });
  135. it("buy with insufficient gold throws and changes nothing", async () => {
  136. const engine = engineSetup();
  137. await engine.start();
  138. try {
  139. setGold(engine, 100);
  140. expect(() => economyOf(engine).buy("general-store", "hoe", 1)).toThrow(/insufficient gold/);
  141. expect(player(engine).gold).toBe(100);
  142. expect(inventoryOf(engine).count("hoe")).toBe(0);
  143. } finally {
  144. await engine.stop();
  145. }
  146. });
  147. it("buy with an unknown shop throws", async () => {
  148. const engine = engineSetup();
  149. await engine.start();
  150. try {
  151. expect(() => economyOf(engine).buy("nope", "hoe", 1)).toThrow(/unknown shop: nope/);
  152. } finally {
  153. await engine.stop();
  154. }
  155. });
  156. it("buy an item the shop does not sell throws", async () => {
  157. const engine = engineSetup();
  158. await engine.start();
  159. try {
  160. setGold(engine, 1000);
  161. expect(() => economyOf(engine).buy("general-store", "parsnip", 1)).toThrow(/does not sell parsnip/);
  162. } finally {
  163. await engine.stop();
  164. }
  165. });
  166. it("sell happy path: removes items, adds gold, emits item-use + transaction, and fires onTransaction", async () => {
  167. const { system: spy, seen } = eventSpy();
  168. const engine = engineSetup(spy);
  169. await engine.start();
  170. try {
  171. const hookFired: unknown[] = [];
  172. engine.hooks.on("onTransaction", (ctx) => {
  173. hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  174. });
  175. inventoryOf(engine).give("parsnip", 3);
  176. const result = economyOf(engine).sell("general-store", "parsnip", 2);
  177. expect(result).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 60 });
  178. expect(player(engine).gold).toBe(60);
  179. expect(inventoryOf(engine).count("parsnip")).toBe(1);
  180. expect(hookFired).toEqual([{ world: "player", time: 1 }]);
  181. expect(seen).toEqual([
  182. { event: "item-acquire", payload: { itemId: "parsnip", qty: 3, total: 3 } },
  183. { event: "item-use", payload: { itemId: "parsnip", qty: 2, total: 1 } },
  184. {
  185. event: "transaction",
  186. payload: { shopId: "general-store", type: "sell", itemId: "parsnip", qty: 2, total: 60 },
  187. },
  188. ]);
  189. } finally {
  190. await engine.stop();
  191. }
  192. });
  193. it("sell without enough items throws and changes nothing", async () => {
  194. const engine = engineSetup();
  195. await engine.start();
  196. try {
  197. inventoryOf(engine).give("parsnip", 1);
  198. expect(() => economyOf(engine).sell("general-store", "parsnip", 2)).toThrow(/insufficient parsnip/);
  199. expect(player(engine).gold).toBe(0);
  200. expect(inventoryOf(engine).count("parsnip")).toBe(1);
  201. } finally {
  202. await engine.stop();
  203. }
  204. });
  205. it("sell an item the shop does not buy throws", async () => {
  206. const engine = engineSetup();
  207. await engine.start();
  208. try {
  209. inventoryOf(engine).give("hoe", 1);
  210. expect(() => economyOf(engine).sell("general-store", "hoe", 1)).toThrow(/does not buy hoe/);
  211. } finally {
  212. await engine.stop();
  213. }
  214. });
  215. it("buy and sell reject non-positive quantities", async () => {
  216. const engine = engineSetup();
  217. await engine.start();
  218. try {
  219. const economy = economyOf(engine);
  220. expect(() => economy.buy("general-store", "parsnip-seed", 0)).toThrow(/positive integer/);
  221. expect(() => economy.buy("general-store", "parsnip-seed", -1)).toThrow(/positive integer/);
  222. expect(() => economy.buy("general-store", "parsnip-seed", 1.5)).toThrow(/positive integer/);
  223. expect(() => economy.sell("general-store", "parsnip", 0)).toThrow(/positive integer/);
  224. } finally {
  225. await engine.stop();
  226. }
  227. });
  228. it("price on an unknown shop throws", async () => {
  229. const engine = engineSetup();
  230. await engine.start();
  231. try {
  232. expect(() => economyOf(engine).price("nope", "hoe")).toThrow(/unknown shop: nope/);
  233. } finally {
  234. await engine.stop();
  235. }
  236. });
  237. });