inventory.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import { createEngine, ContentRegistry, InventorySystem, World, WorldEventBus } from "../src/index.js";
  4. const repoRoot = resolve(process.cwd(), "../..");
  5. const contentDir = resolve(repoRoot, "content");
  6. const modsDir = resolve(repoRoot, "mods");
  7. function setup() {
  8. const world = new World();
  9. const registry = new ContentRegistry();
  10. const events = new WorldEventBus();
  11. world.create({ id: "player", type: "player", x: 0, y: 0 });
  12. const inv = new InventorySystem();
  13. inv.init({ world, registry, events });
  14. return { inv, world, events };
  15. }
  16. describe("InventorySystem", () => {
  17. it("init creates an inventory field on the player entity", () => {
  18. const { world } = setup();
  19. expect(world.get("player")?.inventory).toEqual({});
  20. });
  21. it("give/count/items roundtrip", () => {
  22. const { inv } = setup();
  23. expect(inv.give("hoe", 2)).toBe(2);
  24. expect(inv.give("hoe", 1)).toBe(3);
  25. expect(inv.count("hoe")).toBe(3);
  26. expect(inv.count("parsnip")).toBe(0);
  27. expect(inv.items()).toEqual({ hoe: 3 });
  28. });
  29. it("items() returns a snapshot copy, not a live reference", () => {
  30. const { inv } = setup();
  31. inv.give("hoe", 1);
  32. const snapshot = inv.items();
  33. inv.give("hoe", 1);
  34. expect(snapshot).toEqual({ hoe: 1 });
  35. expect(inv.items()).toEqual({ hoe: 2 });
  36. });
  37. it("give emits item-acquire with itemId, qty, and total", () => {
  38. const { inv, events } = setup();
  39. const seen: unknown[] = [];
  40. events.on("item-acquire", (p) => seen.push(p));
  41. inv.give("hoe", 2);
  42. expect(seen).toEqual([{ itemId: "hoe", qty: 2, total: 2 }]);
  43. });
  44. it("take decrements and emits item-use", () => {
  45. const { inv, events } = setup();
  46. const seen: unknown[] = [];
  47. events.on("item-use", (p) => seen.push(p));
  48. inv.give("hoe", 3);
  49. expect(inv.take("hoe", 1)).toBe(1);
  50. expect(seen).toEqual([{ itemId: "hoe", qty: 1, total: 2 }]);
  51. expect(inv.count("hoe")).toBe(2);
  52. });
  53. it("take clamps to the available quantity when insufficient", () => {
  54. const { inv, events } = setup();
  55. const seen: unknown[] = [];
  56. events.on("item-use", (p) => seen.push(p));
  57. inv.give("hoe", 3);
  58. expect(inv.take("hoe", 10)).toBe(3);
  59. expect(inv.count("hoe")).toBe(0);
  60. expect(inv.items()).toEqual({});
  61. expect(seen).toEqual([{ itemId: "hoe", qty: 3, total: 0 }]);
  62. });
  63. it("take of an absent item returns 0 and emits nothing", () => {
  64. const { inv, events } = setup();
  65. const seen: unknown[] = [];
  66. events.on("item-use", (p) => seen.push(p));
  67. expect(inv.take("hoe", 5)).toBe(0);
  68. expect(seen).toHaveLength(0);
  69. });
  70. it("give/take reject non-positive quantities", () => {
  71. const { inv } = setup();
  72. expect(() => inv.give("hoe", 0)).toThrow(/positive integer/);
  73. expect(() => inv.give("hoe", -1)).toThrow(/positive integer/);
  74. expect(() => inv.give("hoe", 1.5)).toThrow(/positive integer/);
  75. expect(() => inv.take("hoe", 0)).toThrow(/positive integer/);
  76. });
  77. it("wireEvents: inventory events fire onItemAcquire/onItemUse hooks with a HookContext", async () => {
  78. const engine = createEngine({ contentDir, modsDir });
  79. await engine.start();
  80. try {
  81. const acquired: unknown[] = [];
  82. const used: unknown[] = [];
  83. engine.hooks.on("onItemAcquire", (ctx) => {
  84. acquired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  85. });
  86. engine.hooks.on("onItemUse", (ctx) => {
  87. used.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  88. });
  89. const inv = engine.systems.get<InventorySystem>("inventory");
  90. expect(inv).toBeDefined();
  91. inv!.give("hoe", 2);
  92. inv!.take("hoe", 1);
  93. expect(acquired).toEqual([{ world: "player", time: 1 }]);
  94. expect(used).toEqual([{ world: "player", time: 1 }]);
  95. expect(inv!.items()).toEqual({ hoe: 1 });
  96. } finally {
  97. await engine.stop();
  98. }
  99. });
  100. it("TR-2.1: damage durability item 4 times x30 breaks once and count becomes 0", () => {
  101. const world = new World();
  102. const registry = new ContentRegistry();
  103. const events = new WorldEventBus();
  104. registry.register("item", { id: "hoe", durability: 100 });
  105. world.create({ id: "player", type: "player", x: 0, y: 0 });
  106. const inv = new InventorySystem();
  107. inv.init({ world, registry, events });
  108. inv.give("hoe", 1);
  109. expect(inv.count("hoe")).toBe(1);
  110. expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 });
  111. const breaks: unknown[] = [];
  112. events.on("item-break", (p) => breaks.push(p));
  113. inv.damage("hoe", 30);
  114. expect(inv.durability("hoe")).toEqual({ max: 100, current: 70 });
  115. expect(inv.count("hoe")).toBe(1);
  116. inv.damage("hoe", 30);
  117. expect(inv.durability("hoe")).toEqual({ max: 100, current: 40 });
  118. expect(inv.count("hoe")).toBe(1);
  119. inv.damage("hoe", 30);
  120. expect(inv.durability("hoe")).toEqual({ max: 100, current: 10 });
  121. expect(inv.count("hoe")).toBe(1);
  122. inv.damage("hoe", 30);
  123. expect(inv.count("hoe")).toBe(0);
  124. expect(inv.durability("hoe")).toBeNull();
  125. expect(breaks).toEqual([{ itemId: "hoe" }]);
  126. });
  127. it("TR-2.2: use consumable pumpkin restores stamina capped at 100 and emits item-consume", () => {
  128. const world = new World();
  129. const registry = new ContentRegistry();
  130. const events = new WorldEventBus();
  131. registry.register("item", {
  132. id: "pumpkin",
  133. effects: { consumable: { stamina: 15 } },
  134. });
  135. world.create({ id: "player", type: "player", x: 0, y: 0 });
  136. const inv = new InventorySystem();
  137. inv.init({ world, registry, events });
  138. inv.give("pumpkin", 2);
  139. expect(inv.count("pumpkin")).toBe(2);
  140. const player = world.get("player")!;
  141. player.stamina = 85;
  142. const consumed: unknown[] = [];
  143. events.on("item-consume", (p) => consumed.push(p));
  144. inv.use("pumpkin");
  145. expect(player.stamina).toBe(100);
  146. expect(inv.count("pumpkin")).toBe(1);
  147. expect(consumed).toEqual([{ itemId: "pumpkin", effects: { stamina: 15 } }]);
  148. });
  149. it("TR-2.3 use on non-consumable throws and consumable gold bonus works", () => {
  150. const world = new World();
  151. const registry = new ContentRegistry();
  152. const events = new WorldEventBus();
  153. registry.register("item", { id: "rock" });
  154. registry.register("item", {
  155. id: "coinbag",
  156. effects: { consumable: { gold: 50 } },
  157. });
  158. world.create({ id: "player", type: "player", x: 0, y: 0 });
  159. const inv = new InventorySystem();
  160. inv.init({ world, registry, events });
  161. expect(() => inv.use("rock")).toThrow(/inventory\.use: rock is not consumable/);
  162. inv.give("coinbag", 1);
  163. const player = world.get("player")!;
  164. player.gold = 10;
  165. inv.use("coinbag");
  166. expect(player.gold).toBe(60);
  167. });
  168. });