playtest-boot.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { existsSync } from "node:fs";
  3. import { tmpdir } from "node:os";
  4. import { dirname, join } from "node:path";
  5. import { describe, expect, it } from "vitest";
  6. import { createEngine } from "@nengine/core";
  7. import type { ContentBundle } from "@nengine/core";
  8. import { compileBundle } from "@nengine/pack";
  9. import { createPlaytestSession, saveAllFlushes } from "../src/renderer/playtest-session.js";
  10. const MOD_ID = "playtest-mod";
  11. const MOD_CODE = [
  12. "export default {",
  13. " manifest: { id: \"playtest-mod\", version: \"0.1.0\" },",
  14. " hooks: {",
  15. " onDayStart(ctx) {",
  16. " const { world } = ctx;",
  17. " let stats = world.get(\"mod:playtest-stats\");",
  18. " if (!stats) {",
  19. " stats = world.create({ id: \"mod:playtest-stats\", type: \"mod-state\", days: 0 });",
  20. " }",
  21. " stats.days += 1;",
  22. " },",
  23. " },",
  24. "};",
  25. "",
  26. ].join("\n");
  27. const FIXTURE_FILES: Record<string, string> = {
  28. "content/time.yaml": [
  29. "id: default",
  30. "dayLengthTicks: 480",
  31. "seasons:",
  32. " - { name: spring, dayCount: 14 }",
  33. "start: { day: 1, season: spring }",
  34. "",
  35. ].join("\n"),
  36. "content/weather.yaml": [
  37. "id: default",
  38. "defaults:",
  39. " sunny: { name: Sunny }",
  40. "seasons:",
  41. " - season: spring",
  42. " weathers:",
  43. " - { id: sunny, weight: 100 }",
  44. "",
  45. ].join("\n"),
  46. "content/interact.yaml": [
  47. "id: default",
  48. "order:",
  49. " - harvest",
  50. " - water",
  51. " - sow",
  52. " - till",
  53. "",
  54. ].join("\n"),
  55. "content/maps/farm.yaml": [
  56. "# Playtest fixture map: grass with a water pond; spawn at (2,2).",
  57. "id: farm",
  58. "width: 12",
  59. "height: 12",
  60. "tilesetRef: farm-tiles",
  61. "spawn: { x: 2, y: 2 }",
  62. "walkable:",
  63. " water: false",
  64. "layers:",
  65. " - name: ground",
  66. " data:",
  67. mapRows(),
  68. "",
  69. ].join("\n"),
  70. "content/items/hoe.yaml": [
  71. "id: hoe",
  72. "name: Hoe",
  73. "kind: tool",
  74. "stackable: false",
  75. "price: 100",
  76. "use: tills a soil tile",
  77. "",
  78. ].join("\n"),
  79. "content/items/carrot-seed.yaml": [
  80. "id: carrot-seed",
  81. "name: Carrot Seed",
  82. "kind: seed",
  83. "stackable: true",
  84. "price: 20",
  85. "use: plant in tilled soil",
  86. "",
  87. ].join("\n"),
  88. "content/items/carrot.yaml": [
  89. "id: carrot",
  90. "name: Carrot",
  91. "kind: material",
  92. "stackable: true",
  93. "price: 35",
  94. "use: harvested from a carrot plant",
  95. "",
  96. ].join("\n"),
  97. "content/items/egg.yaml": [
  98. "id: egg",
  99. "name: Egg",
  100. "kind: material",
  101. "stackable: true",
  102. "price: 25",
  103. "use: laid by a chicken",
  104. "",
  105. ].join("\n"),
  106. "content/crops/carrot.yaml": [
  107. "id: carrot",
  108. "seedItem: carrot-seed",
  109. "stages:",
  110. " - { name: sprout, days: 1, waterRequired: true }",
  111. " - { name: mature, days: 1, waterRequired: false }",
  112. "seasons:",
  113. " - spring",
  114. "drops:",
  115. " - { itemId: carrot, qty: 1 }",
  116. "",
  117. ].join("\n"),
  118. "content/buildings/shed.yaml": [
  119. "id: shed",
  120. "name: Shed",
  121. "footprint: { w: 2, h: 2 }",
  122. "buildable: any",
  123. "blocksMovement: true",
  124. "",
  125. ].join("\n"),
  126. "content/animals/chicken.yaml": [
  127. "id: chicken",
  128. "stages:",
  129. " - { name: chick, days: 1, feedRequired: true }",
  130. "produces:",
  131. " - { itemId: egg, qty: 1 }",
  132. "",
  133. ].join("\n"),
  134. "content/scenes/farm.yaml": [
  135. "id: farm",
  136. "entities:",
  137. " - { type: building, buildingId: shed, x: 4, y: 4 }",
  138. " - { type: npc, npcId: mayor, x: 6, y: 2, label: Mayor }",
  139. " - { type: animal, animalId: chicken, x: 8, y: 8 }",
  140. "",
  141. ].join("\n"),
  142. "mods/playtest-mod/index.mjs": MOD_CODE,
  143. };
  144. /** A 12×12 map: a 2×2 water pond at the top-left, grass elsewhere. */
  145. function mapRows(): string {
  146. const rows: string[] = [];
  147. for (let y = 0; y < 12; y++) {
  148. const row: string[] = [];
  149. for (let x = 0; x < 12; x++) {
  150. row.push(x < 2 && y < 2 ? "water" : "grass");
  151. }
  152. rows.push(` - [${row.join(", ")}]`);
  153. }
  154. return rows.join("\n");
  155. }
  156. async function fixtureDir(): Promise<string> {
  157. const dir = await mkdtemp(join(tmpdir(), "editor-playtest-"));
  158. for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
  159. const target = join(dir, rel);
  160. await mkdir(dirname(target), { recursive: true });
  161. await writeFile(target, content, "utf8");
  162. }
  163. return dir;
  164. }
  165. async function rmRecursive(dir: string): Promise<void> {
  166. await rm(dir, { recursive: true, force: true });
  167. }
  168. describe("playtest bundle boot (E5 fixture)", () => {
  169. it("compiles a project with a scene + mod and boots in bundle mode with the scene seeded", async () => {
  170. const dir = await fixtureDir();
  171. try {
  172. const bundle = await compileBundle(join(dir, "content"), join(dir, "mods"));
  173. expect(bundle.mods?.[MOD_ID]).toBeDefined();
  174. expect(typeof bundle.mods?.[MOD_ID]?.code).toBe("string");
  175. expect(bundle.content["building"]).toBeDefined();
  176. expect(bundle.content["animal"]).toBeDefined();
  177. expect(bundle.content["scene"]).toBeDefined();
  178. const engine = createEngine({ bundle });
  179. await engine.start();
  180. try {
  181. const mapEntity = engine.world.query({ type: "map" })[0];
  182. expect(mapEntity?.id).toBe("farm");
  183. expect(engine.world.get("player")).toMatchObject({ id: "player", x: 2, y: 2 });
  184. const shed = engine.world.query({ type: "building" })[0];
  185. expect(shed?.id).toBe("building:shed:4:4");
  186. expect(shed?.x).toBe(4);
  187. expect(shed?.y).toBe(4);
  188. expect(shed?.w).toBe(2);
  189. expect(shed?.h).toBe(2);
  190. const mayor = engine.world.get("mayor");
  191. expect(mayor?.type).toBe("npc");
  192. expect(mayor?.label).toBe("Mayor");
  193. const chicken = engine.world.query({ type: "animal" })[0];
  194. expect(chicken?.id).toBe("animal:chicken:1");
  195. expect(chicken?.x).toBe(8);
  196. expect(chicken?.y).toBe(8);
  197. expect(engine.mods).toHaveLength(1);
  198. expect(engine.mods[0]?.id).toBe(MOD_ID);
  199. expect(engine.mods[0]?.loaded).toBe(true);
  200. expect(engine.world.get("mod:playtest-stats")).toBeUndefined();
  201. engine.tick(480);
  202. expect(engine.world.get("mod:playtest-stats")?.days).toBe(1);
  203. } finally {
  204. await engine.stop();
  205. }
  206. } finally {
  207. await rmRecursive(dir);
  208. }
  209. });
  210. it("save-all flush content lands in the compiled bundle (see what ships)", async () => {
  211. const dir = await fixtureDir();
  212. try {
  213. const modified = FIXTURE_FILES["content/maps/farm.yaml"] as string;
  214. const result = await saveAllFlushes(
  215. [{ path: "content/maps/farm.yaml", content: modified.replace("spawn: { x: 2, y: 2 }", "spawn: { x: 5, y: 5 }") }],
  216. (path, content) => writeFile(join(dir, path), content, "utf8"),
  217. );
  218. expect(result.failures).toEqual([]);
  219. expect(result.flushed).toEqual(["content/maps/farm.yaml"]);
  220. const bundle = await compileBundle(join(dir, "content"));
  221. const engine = createEngine({ bundle });
  222. await engine.start();
  223. try {
  224. expect(engine.world.get("player")?.x).toBe(5);
  225. expect(engine.world.get("player")?.y).toBe(5);
  226. } finally {
  227. await engine.stop();
  228. }
  229. } finally {
  230. await rmRecursive(dir);
  231. }
  232. });
  233. it("leaves bundle.mods undefined when the project has no mods dir (main handler parity)", async () => {
  234. const dir = await fixtureDir();
  235. try {
  236. await rm(join(dir, "mods"), { recursive: true, force: true });
  237. const modsDir = join(dir, "mods");
  238. const bundle = await compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
  239. expect(bundle.mods).toBeUndefined();
  240. } finally {
  241. await rmRecursive(dir);
  242. }
  243. });
  244. it("the full playtest session orchestrates save-all → compile → boot and tears down", async () => {
  245. const dir = await fixtureDir();
  246. try {
  247. let bootedBundle: ContentBundle | undefined;
  248. let tornDown = false;
  249. const session = createPlaytestSession({
  250. collectFlushes: () => [],
  251. write: async (path, content) => writeFile(join(dir, path), content, "utf8"),
  252. compile: async () => {
  253. const modsDir = join(dir, "mods");
  254. return compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
  255. },
  256. boot: async (bundle) => {
  257. bootedBundle = bundle;
  258. },
  259. teardown: async () => {
  260. tornDown = true;
  261. },
  262. });
  263. const result = await session.start();
  264. expect(result.ok).toBe(true);
  265. expect(bootedBundle?.content["scene"]).toBeDefined();
  266. expect(bootedBundle?.mods?.[MOD_ID]).toBeDefined();
  267. await session.stop();
  268. expect(tornDown).toBe(true);
  269. expect(session.phase).toBe("idle");
  270. } finally {
  271. await rmRecursive(dir);
  272. }
  273. });
  274. });