bughunt-regressions.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import { tmpdir } from "node:os";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. ContentLoader,
  7. ContentRegistry,
  8. createEngine,
  9. EconomySystem,
  10. GrowthSystem,
  11. InventorySystem,
  12. PlantingSystem,
  13. SystemsHost,
  14. TriggerSystem,
  15. World,
  16. WorldEventBus,
  17. type Entity,
  18. type System,
  19. type TimeSystem,
  20. } from "../src/index.js";
  21. /**
  22. * Regression tests for the 2026-08-14 full-repo bug-fix round:
  23. * - inventory.use with an empty inventory must not apply effects / emit
  24. * - use() on a durable item decrements (removes at 0), never the whole stack
  25. * - grant paths (economy/behavior/planting) route durable items into instances
  26. * - a throwing system event handler must not break the tick chain
  27. * - crop-harvest / animal-produce fire AFTER the world state is updated
  28. * - watch() reloads assets whose same-name file CONTENT changed
  29. */
  30. function bareSetup() {
  31. const world = new World();
  32. const registry = new ContentRegistry();
  33. const events = new WorldEventBus();
  34. const host = new SystemsHost();
  35. world.create({ id: "player", type: "player", x: 0, y: 0, gold: 100, stamina: 50 });
  36. registry.register("time", {
  37. id: "default",
  38. dayLengthTicks: 480,
  39. seasons: [{ name: "spring", dayCount: 14 }],
  40. start: { day: 1, season: "spring" },
  41. });
  42. const inv = new InventorySystem();
  43. const plant = new PlantingSystem();
  44. const growth = new GrowthSystem();
  45. const eco = new EconomySystem();
  46. const trigger = new TriggerSystem();
  47. for (const system of [inv, plant, growth, eco, trigger]) host.register(system);
  48. const ctx = { world, registry, events, systems: host };
  49. inv.init(ctx);
  50. plant.init(ctx);
  51. growth.init(ctx);
  52. eco.init(ctx);
  53. trigger.init(ctx);
  54. return { world, registry, events, host, inv, plant, growth, eco, trigger };
  55. }
  56. describe("inventory.use (bug-fix round)", () => {
  57. it("throws on an empty inventory and applies NO effects / emits nothing", () => {
  58. const { registry, events, inv, world } = bareSetup();
  59. registry.register("item", {
  60. id: "pumpkin",
  61. name: "Pumpkin",
  62. kind: "crop",
  63. effects: { consumable: { stamina: 15 } },
  64. });
  65. const consumed: unknown[] = [];
  66. events.on("item-consume", (p) => consumed.push(p));
  67. const player = world.get("player") as Entity & Record<string, unknown>;
  68. expect(() => inv.use("pumpkin")).toThrow(/no pumpkin in inventory/);
  69. expect(player["stamina"]).toBe(50);
  70. expect(consumed).toEqual([]);
  71. });
  72. it("decrements a durable item's durability instead of removing the whole instance", () => {
  73. const { registry, inv, world } = bareSetup();
  74. registry.register("item", {
  75. id: "hoe",
  76. name: "Hoe",
  77. kind: "tool",
  78. durability: { max: 100 },
  79. effects: { consumable: { stamina: 10 } },
  80. });
  81. inv.give("hoe", 1);
  82. inv.use("hoe");
  83. expect(inv.count("hoe")).toBe(1); // the instance is still there
  84. // "use() decrements, removes at 0" — one use = one durability point.
  85. expect(inv.durability("hoe")).toEqual({ max: 100, current: 99 });
  86. const player = world.get("player") as Entity & Record<string, unknown>;
  87. expect(player["stamina"]).toBe(60);
  88. });
  89. it("removes the instance when durability reaches 0 and emits item-break", () => {
  90. const { registry, events, inv } = bareSetup();
  91. registry.register("item", {
  92. id: "hoe",
  93. name: "Hoe",
  94. kind: "tool",
  95. durability: { max: 100 },
  96. effects: { consumable: { stamina: 10 } },
  97. });
  98. const broken: unknown[] = [];
  99. events.on("item-break", (p) => broken.push(p));
  100. inv.give("hoe", 1);
  101. inv.damage("hoe", 99);
  102. inv.use("hoe"); // 1 remaining durability consumed → instance breaks
  103. expect(inv.count("hoe")).toBe(0);
  104. expect(inv.durability("hoe")).toBeNull();
  105. expect(broken).toEqual([{ itemId: "hoe" }]);
  106. });
  107. it("damage returns whether an instance was actually damaged", () => {
  108. const { inv } = bareSetup();
  109. expect(inv.damage("hoe", 1)).toBe(false);
  110. });
  111. });
  112. describe("durable grants route through inventory instances (bug-fix round)", () => {
  113. it("economy.buy of a durable item lands in inventoryInstances", () => {
  114. const { registry, inv, eco } = bareSetup();
  115. registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } });
  116. registry.register("shop", {
  117. id: "tools",
  118. name: "Tools",
  119. sells: [{ itemId: "hoe", price: 50 }],
  120. buys: [],
  121. });
  122. eco.buy("tools", "hoe", 1);
  123. expect(inv.count("hoe")).toBe(1);
  124. expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 });
  125. });
  126. it("trigger give-item of a durable item lands in inventoryInstances", () => {
  127. const { registry, inv, trigger } = bareSetup();
  128. registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } });
  129. registry.register("behavior", {
  130. id: "grant-hoe",
  131. when: { op: "exists", path: "player" },
  132. fire: [{ action: "give-item", itemId: "hoe", qty: 1 }],
  133. });
  134. trigger.fire("grant-hoe");
  135. expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 });
  136. });
  137. });
  138. describe("event chain resilience (bug-fix round)", () => {
  139. it("a throwing system event handler does not break the rest of the chain", async () => {
  140. const seen: string[] = [];
  141. const thrower: System = {
  142. id: "thrower",
  143. init() {},
  144. on() {
  145. throw new Error("boom");
  146. },
  147. };
  148. const listener: System = {
  149. id: "listener",
  150. init() {},
  151. on(event) {
  152. if (event === "day-start") seen.push("day-start");
  153. },
  154. };
  155. const engine = createEngine({ contentDir: repoContent, modsDir: repoMods, systems: [thrower, listener] });
  156. await engine.start();
  157. try {
  158. expect(() => engine.tick(480)).not.toThrow();
  159. expect(seen).toContain("day-start");
  160. } finally {
  161. await engine.stop();
  162. }
  163. });
  164. it("a bad trigger action during day-start does not freeze the tick chain", async () => {
  165. const dir = await mkdtemp(join(tmpdir(), "nengine-badtrigger-"));
  166. try {
  167. await writeFile(
  168. join(dir, "time.yaml"),
  169. ["id: default", "dayLengthTicks: 480", "seasons:", " - name: spring", " dayCount: 14", "start:", " day: 1", " season: spring"].join("\n"),
  170. );
  171. await writeFile(
  172. join(dir, "weather.yaml"),
  173. ["id: default", "seasons:", " - season: spring", " weathers:", " - id: sunny", " weight: 100"].join("\n"),
  174. );
  175. await mkdir(join(dir, "maps"), { recursive: true });
  176. await writeFile(
  177. join(dir, "maps", "farm.yaml"),
  178. ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", " x: 0", " y: 0", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]"].join("\n"),
  179. );
  180. await mkdir(join(dir, "behaviors"), { recursive: true });
  181. await writeFile(
  182. join(dir, "behaviors", "bad-trigger.yaml"),
  183. [
  184. "id: bad-trigger",
  185. "when:",
  186. " op: gte",
  187. " path: time.day",
  188. " value: 1",
  189. "fire:",
  190. " - action: add",
  191. " path: player.stamina",
  192. " value: 1",
  193. ].join("\n"),
  194. );
  195. const seen: string[] = [];
  196. const listener: System = {
  197. id: "listener",
  198. init() {},
  199. on(event) {
  200. if (event === "day-start") seen.push("day-start");
  201. },
  202. };
  203. const engine = createEngine({ contentDir: dir, systems: [listener] });
  204. await engine.start();
  205. try {
  206. // Make the add target a string so the action throws.
  207. const player = engine.world.get("player") as Entity & Record<string, unknown>;
  208. player["stamina"] = "high";
  209. // Previously the throw aborted time.step mid-emit, skipping every
  210. // later system step AND re-throwing out of engine.tick.
  211. expect(() => engine.tick(480)).not.toThrow();
  212. expect(seen).toContain("day-start");
  213. // The engine keeps ticking afterwards.
  214. expect(() => engine.tick(480)).not.toThrow();
  215. } finally {
  216. await engine.stop();
  217. }
  218. } finally {
  219. await rm(dir, { recursive: true, force: true });
  220. }
  221. });
  222. });
  223. describe("state-updated-then-emit (bug-fix round)", () => {
  224. it("crop-harvest fires AFTER the plot is cleared", () => {
  225. const { registry, world, events, plant, inv } = bareSetup();
  226. registry.register("crop", {
  227. id: "carrot",
  228. name: "Carrot",
  229. stages: [
  230. { name: "sprout", days: 1, waterRequired: false },
  231. { name: "grown", days: 1, waterRequired: false },
  232. ],
  233. seasons: ["spring"],
  234. drops: [{ itemId: "carrot", qty: 1 }],
  235. });
  236. registry.register("item", { id: "carrot", name: "Carrot", kind: "crop" });
  237. world.create({ id: "plot:0:0", type: "plot", x: 0, y: 0, tilled: true, cropItemId: "carrot", stageIndex: 1, daysInStage: 1 });
  238. let cropItemIdAtEvent: unknown = "unset";
  239. events.on("crop-harvest", () => {
  240. const plot = world.get("plot:0:0");
  241. cropItemIdAtEvent = plot?.["cropItemId"];
  242. });
  243. plant.harvest(0, 0);
  244. expect(cropItemIdAtEvent).toBeUndefined();
  245. expect(inv.count("carrot")).toBe(1);
  246. });
  247. it("animal-produce fires AFTER the fed state is reset", () => {
  248. const { registry, world, events, growth } = bareSetup();
  249. registry.register("animal", {
  250. id: "cow",
  251. name: "Cow",
  252. stages: [
  253. { name: "calf", days: 1, feedRequired: true },
  254. { name: "grown", days: 1, feedRequired: true },
  255. ],
  256. });
  257. const animal = world.create({
  258. id: "animal:cow:1",
  259. type: "animal",
  260. animalId: "cow",
  261. x: 1,
  262. y: 1,
  263. stageIndex: 1,
  264. fed: true,
  265. });
  266. let fedAtEvent: unknown = "unset";
  267. events.on("animal-produce", () => {
  268. fedAtEvent = world.get("animal:cow:1")?.["fed"];
  269. });
  270. growth.step(480); // a full day — produce only advances on day boundaries
  271. expect(fedAtEvent).toBe(false);
  272. expect(world.get("animal:cow:1")?.["fed"]).toBe(false);
  273. void animal;
  274. });
  275. });
  276. describe("watch() same-name content changes (bug-fix round)", () => {
  277. it("reloads an asset whose file content changed under the same name", async () => {
  278. const dir = await mkdtemp(join(tmpdir(), "nengine-watchsize-"));
  279. try {
  280. const registry = new ContentRegistry();
  281. const loader = new ContentLoader(registry);
  282. const stop = loader.watch(dir);
  283. await mkdir(join(dir, "assets"), { recursive: true });
  284. await mkdir(join(dir, "maps"), { recursive: true });
  285. await writeFile(
  286. join(dir, "maps", "farm.yaml"),
  287. ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", " x: 0", " y: 0", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]"].join("\n"),
  288. );
  289. const bytes = Buffer.from("AAAA", "utf8");
  290. await writeFile(join(dir, "assets", "icon.png"), bytes);
  291. await loader.load(dir);
  292. const before = registry.assets.get("icon");
  293. expect(before?.data).toBe(Buffer.from("AAAA", "utf8").toString("base64"));
  294. // Overwrite the same file with DIFFERENT bytes (same name, new size).
  295. await writeFile(join(dir, "assets", "icon.png"), Buffer.from("BBBBBBBB", "utf8"));
  296. await waitFor(() => registry.assets.get("icon")?.data === Buffer.from("BBBBBBBB", "utf8").toString("base64"), 3000);
  297. stop();
  298. } finally {
  299. await rm(dir, { recursive: true, force: true });
  300. }
  301. });
  302. });
  303. const repoContent = join(process.cwd(), "../..", "content");
  304. const repoMods = join(process.cwd(), "../..", "mods");
  305. async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
  306. const start = Date.now();
  307. while (!predicate()) {
  308. if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout");
  309. await new Promise((resolve) => setTimeout(resolve, 20));
  310. }
  311. }