content.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join, resolve } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. ContentLoader,
  7. ContentRegistry,
  8. type AnimalDef,
  9. type BuildingDef,
  10. type CropDef,
  11. type DialogueDef,
  12. type ItemDef,
  13. type MapDef,
  14. type ShopDef,
  15. type StateMachineDef,
  16. type TimeDef,
  17. type TriggerDef,
  18. type WeatherDef,
  19. } from "../src/index.js";
  20. const repoRoot = resolve(process.cwd(), "../..");
  21. const contentDir = resolve(repoRoot, "content");
  22. describe("ContentLoader", () => {
  23. it("loads the real content dir and registers map+time with correct ids", async () => {
  24. const registry = new ContentRegistry();
  25. const loader = new ContentLoader(registry);
  26. const report = await loader.load(contentDir);
  27. expect(report.ok).toBe(true);
  28. expect(report.items.length).toBe(18);
  29. expect(report.items.every((i) => i.status === "ok")).toBe(true);
  30. expect(registry.ids("map")).toEqual(["farm"]);
  31. expect(registry.ids("time")).toEqual(["default"]);
  32. expect(registry.ids("item")).toEqual(["egg", "hoe", "parsnip-seed", "parsnip"]);
  33. expect(registry.ids("crop")).toEqual(["parsnip"]);
  34. expect(registry.ids("animal")).toEqual(["chicken"]);
  35. expect(registry.ids("shop")).toEqual(["general-store"]);
  36. expect(registry.ids("behavior")).toEqual(["season-welcome", "spring-gift"]);
  37. expect(registry.ids("statemachine")).toEqual(["chicken-egg", "daily-routine", "spring-quest"]);
  38. expect(registry.ids("dialogue")).toEqual(["welcome"]);
  39. expect(registry.ids("weather")).toEqual(["default"]);
  40. expect(registry.ids("building")).toEqual(["fence", "shed"]);
  41. const map = registry.get<MapDef>("map", "farm");
  42. expect(map?.id).toBe("farm");
  43. expect(map?.width).toBe(12);
  44. expect(map?.height).toBe(12);
  45. expect(map?.spawn).toEqual({ x: 2, y: 2 });
  46. expect(map?.layers).toHaveLength(1);
  47. expect(map?.layers[0]?.data).toHaveLength(12);
  48. expect(map?.layers[0]?.data[0]).toHaveLength(12);
  49. const time = registry.get<TimeDef>("time", "default");
  50. expect(time?.dayLengthTicks).toBe(480);
  51. expect(time?.seasons).toHaveLength(4);
  52. expect(time?.seasons[0]?.name).toBe("spring");
  53. expect(time?.start).toEqual({ day: 1, season: "spring" });
  54. const hoe = registry.get<ItemDef>("item", "hoe");
  55. expect(hoe).toEqual({ id: "hoe", name: "Hoe", kind: "tool", stackable: false, price: 500, rarity: "common", use: "tills a soil tile for planting" });
  56. const seed = registry.get<ItemDef>("item", "parsnip-seed");
  57. expect(seed?.kind).toBe("seed");
  58. expect(seed?.stackable).toBe(true);
  59. const parsnip = registry.get<ItemDef>("item", "parsnip");
  60. expect(parsnip?.kind).toBe("material");
  61. expect(parsnip?.price).toBe(35);
  62. const crop = registry.get<CropDef>("crop", "parsnip");
  63. expect(crop).toEqual({
  64. id: "parsnip",
  65. seedItem: "parsnip-seed",
  66. stages: [
  67. { name: "sprout", days: 2, waterRequired: true },
  68. { name: "grown", days: 2, waterRequired: true },
  69. { name: "mature", days: 1, waterRequired: false },
  70. ],
  71. seasons: ["spring"],
  72. drops: [{ itemId: "parsnip", qty: 1 }],
  73. });
  74. const egg = registry.get<ItemDef>("item", "egg");
  75. expect(egg).toEqual({ id: "egg", name: "Egg", kind: "material", stackable: true, price: 15, rarity: "common", use: "a chicken's daily produce" });
  76. const chicken = registry.get<AnimalDef>("animal", "chicken");
  77. expect(chicken).toEqual({
  78. id: "chicken",
  79. stages: [
  80. { name: "chick", days: 2, feedRequired: false },
  81. { name: "grown", days: 2, feedRequired: true },
  82. { name: "laying", days: 1, feedRequired: true },
  83. ],
  84. produces: [{ itemId: "egg", qty: 1 }],
  85. });
  86. const chickenEgg = registry.get<StateMachineDef>("statemachine", "chicken-egg");
  87. expect(chickenEgg).toEqual({
  88. id: "chicken-egg",
  89. initial: "waiting",
  90. states: [
  91. { id: "waiting" },
  92. { id: "laid", onEnter: [{ action: "give-item", itemId: "egg", qty: 1 }] },
  93. ],
  94. transitions: [
  95. { from: "waiting", to: "laid", on: "animal-produce", when: { op: "eq", path: "payload.animalDefId", value: "chicken" } },
  96. { from: "laid", to: "waiting", on: "animal-produce", when: { op: "eq", path: "payload.animalDefId", value: "chicken" } },
  97. ],
  98. });
  99. const shop = registry.get<ShopDef>("shop", "general-store");
  100. expect(shop).toEqual({
  101. id: "general-store",
  102. buys: [{ itemId: "parsnip", price: 30 }],
  103. sells: [
  104. { itemId: "hoe", price: 500 },
  105. { itemId: "parsnip-seed", price: 20, stock: 99 },
  106. ],
  107. });
  108. const springGift = registry.get<TriggerDef>("behavior", "spring-gift");
  109. expect(springGift).toEqual({
  110. id: "spring-gift",
  111. when: { op: "gte", path: "time.day", value: 5 },
  112. fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
  113. });
  114. const seasonWelcome = registry.get<TriggerDef>("behavior", "season-welcome");
  115. expect(seasonWelcome?.when).toEqual({
  116. and: [
  117. { op: "eq", path: "time.day", value: 1 },
  118. {
  119. or: [
  120. { op: "eq", path: "time.season", value: "summer" },
  121. { op: "eq", path: "time.season", value: "autumn" },
  122. ],
  123. },
  124. { not: { op: "exists", path: "player.flag" } },
  125. ],
  126. });
  127. expect(seasonWelcome?.fire).toEqual([
  128. { action: "emit-event", event: "behavior:season-welcome", payload: { season: "welcome" } },
  129. { action: "set-state", path: "player.flag", value: "welcomed" },
  130. ]);
  131. const dailyRoutine = registry.get<StateMachineDef>("statemachine", "daily-routine");
  132. expect(dailyRoutine).toEqual({
  133. id: "daily-routine",
  134. initial: "sleeping",
  135. states: [
  136. { id: "sleeping" },
  137. {
  138. id: "working",
  139. onEnter: [{ action: "emit-event", event: "behavior:daily-routine-working", payload: { state: "working" } }],
  140. },
  141. { id: "resting" },
  142. ],
  143. transitions: [{ from: "working", to: "resting", on: "item-acquire" }],
  144. onSchedule: [
  145. { at: "day-start", to: "resting", when: { op: "gte", path: "payload.day", value: 28 } },
  146. { at: "day-start", to: "working" },
  147. ],
  148. });
  149. const springQuest = registry.get<StateMachineDef>("statemachine", "spring-quest");
  150. expect(springQuest?.initial).toBe("inactive");
  151. expect(springQuest?.states).toHaveLength(3);
  152. expect(springQuest?.states[1]?.onEnter).toEqual([
  153. { action: "give-item", itemId: "parsnip-seed", qty: 1 },
  154. ]);
  155. expect(springQuest?.transitions).toHaveLength(2);
  156. expect(springQuest?.transitions[1]?.when).toEqual({ op: "gte", path: "player.inventory.parsnip", value: 5 });
  157. const welcome = registry.get<DialogueDef>("dialogue", "welcome");
  158. expect(welcome).toEqual({
  159. id: "welcome",
  160. startNode: "hello",
  161. nodes: [
  162. {
  163. id: "hello",
  164. text: "Welcome to your farm! A new season is about to begin.",
  165. choices: [
  166. {
  167. text: "Thanks! What should I plant first?",
  168. to: "seed-tip",
  169. actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
  170. },
  171. { text: "You remember me from last season?", to: "returning", when: { op: "exists", path: "player.flag" } },
  172. { text: "I am just exploring today.", to: "farewell" },
  173. ],
  174. },
  175. { id: "seed-tip", text: "Parsnip seeds grow well in spring — here is one to start with.", to: "farewell" },
  176. { id: "returning", text: "Of course! Welcome back to the farm.", to: "farewell" },
  177. { id: "farewell", text: "See you around!" },
  178. ],
  179. });
  180. const weather = registry.get<WeatherDef>("weather", "default");
  181. expect(weather).toEqual({
  182. id: "default",
  183. defaults: {
  184. sunny: { name: "Sunny" },
  185. rain: { name: "Rain" },
  186. wind: { name: "Wind" },
  187. storm: { name: "Storm" },
  188. },
  189. seasons: [
  190. { season: "spring", weathers: [
  191. { id: "sunny", weight: 50 },
  192. { id: "rain", weight: 30 },
  193. { id: "wind", weight: 15 },
  194. { id: "storm", weight: 5 },
  195. ] },
  196. { season: "summer", weathers: [
  197. { id: "sunny", weight: 70 },
  198. { id: "rain", weight: 15 },
  199. { id: "wind", weight: 10 },
  200. { id: "storm", weight: 5 },
  201. ] },
  202. { season: "autumn", weathers: [
  203. { id: "sunny", weight: 40 },
  204. { id: "rain", weight: 35 },
  205. { id: "wind", weight: 20 },
  206. { id: "storm", weight: 5 },
  207. ] },
  208. { season: "winter", weathers: [
  209. { id: "sunny", weight: 20 },
  210. { id: "rain", weight: 25 },
  211. { id: "wind", weight: 40 },
  212. { id: "storm", weight: 15 },
  213. ] },
  214. ],
  215. });
  216. const shed = registry.get<BuildingDef>("building", "shed");
  217. expect(shed).toEqual({
  218. id: "shed",
  219. name: "Shed",
  220. footprint: { w: 2, h: 2 },
  221. buildable: "map",
  222. blocksMovement: true,
  223. description: "Stores tools and harvests.",
  224. });
  225. expect(registry.get<BuildingDef>("building", "fence")).toEqual({
  226. id: "fence",
  227. name: "Fence",
  228. footprint: { w: 1, h: 1 },
  229. buildable: { tiles: ["grass"] },
  230. blocksMovement: true,
  231. description: "A wooden fence post.",
  232. });
  233. });
  234. it("reports an invalid item as error without aborting siblings", async () => {
  235. const tmp = await mkdtemp(join(tmpdir(), "farm-content-"));
  236. try {
  237. await mkdir(join(tmp, "maps"), { recursive: true });
  238. await writeFile(
  239. join(tmp, "maps", "good.yaml"),
  240. [
  241. "id: good",
  242. "width: 2",
  243. "height: 2",
  244. 'tilesetRef: "tiles"',
  245. "spawn: { x: 0, y: 0 }",
  246. "layers:",
  247. " - name: ground",
  248. " data:",
  249. " - [a, b]",
  250. " - [b, a]",
  251. "",
  252. ].join("\n"),
  253. );
  254. await writeFile(
  255. join(tmp, "maps", "bad.yaml"),
  256. ["id: bad", "width: not-a-number", "height: 2", ""].join("\n"),
  257. );
  258. const registry = new ContentRegistry();
  259. const loader = new ContentLoader(registry);
  260. const report = await loader.load(tmp);
  261. expect(report.ok).toBe(false);
  262. const ok = report.items.find((i) => i.id === "good");
  263. const bad = report.items.find((i) => i.id === "bad");
  264. expect(ok?.status).toBe("ok");
  265. expect(bad?.status).toBe("error");
  266. expect(bad?.errors?.length).toBeGreaterThan(0);
  267. expect(registry.get("map", "good")).toBeDefined();
  268. expect(registry.get("map", "bad")).toBeUndefined();
  269. } finally {
  270. await rm(tmp, { recursive: true, force: true });
  271. }
  272. });
  273. });