assets.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join, resolve } from "node:path";
  4. import { afterEach, beforeEach, describe, expect, it } from "vitest";
  5. import { ContentLoader, ContentRegistry, mapSchema, spriteSchema, type ContentBundle } from "../src/index.js";
  6. const repoRoot = resolve(process.cwd(), "../..");
  7. const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
  8. /** Minimal valid 1x1 PNG (8x8 transparent) and OGG headers — shape only; the
  9. * loader reads bytes, it does not decode them. */
  10. const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
  11. const OGG_BASE64 = "T2dnUwAAAAAAAAAAAAAAAAAAAAAAAE9nZ1M=";
  12. async function makeTmp(): Promise<string> {
  13. const tmp = await mkdtemp(join(tmpdir(), "nengine-assets-"));
  14. await mkdir(join(tmp, "assets"), { recursive: true });
  15. await mkdir(join(tmp, "maps"), { recursive: true });
  16. await writeFile(join(tmp, "maps", "farm.yaml"), [
  17. "id: farm",
  18. "width: 2",
  19. "height: 2",
  20. 'tilesetRef: "tiles"',
  21. "spawn: { x: 0, y: 0 }",
  22. "layers:",
  23. " - name: ground",
  24. " data:",
  25. " - [grass, grass]",
  26. " - [grass, grass]",
  27. "",
  28. ].join("\n"));
  29. return tmp;
  30. }
  31. describe("ContentLoader asset table (D31)", () => {
  32. it("collects png + ogg files from content/assets/ into the registry asset table", async () => {
  33. const tmp = await makeTmp();
  34. try {
  35. await writeFile(join(tmp, "assets", "grass-1.png"), Buffer.from(PNG_BASE64, "base64"));
  36. await writeFile(join(tmp, "assets", "till-1.ogg"), Buffer.from(OGG_BASE64, "base64"));
  37. const registry = new ContentRegistry();
  38. const loader = new ContentLoader(registry);
  39. const report = await loader.load(tmp);
  40. expect(report.ok).toBe(true);
  41. const grass = registry.assets.get("grass-1");
  42. expect(grass).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 });
  43. const till = registry.assets.get("till-1");
  44. expect(till).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 });
  45. expect(registry.asset("grass-1")).toBe(grass);
  46. } finally {
  47. await rm(tmp, { recursive: true, force: true });
  48. }
  49. });
  50. it("no assets dir (or empty) yields an empty asset table", async () => {
  51. const tmp = await makeTmp();
  52. try {
  53. const registry = new ContentRegistry();
  54. const loader = new ContentLoader(registry);
  55. await loader.load(tmp);
  56. expect(registry.assets.size).toBe(0);
  57. } finally {
  58. await rm(tmp, { recursive: true, force: true });
  59. }
  60. });
  61. it("asset id is the filename without extension (case-insensitive extension)", async () => {
  62. const tmp = await makeTmp();
  63. try {
  64. await writeFile(join(tmp, "assets", "grass-1.PNG"), Buffer.from(PNG_BASE64, "base64"));
  65. await writeFile(join(tmp, "assets", "notes.txt"), "not an asset");
  66. const registry = new ContentRegistry();
  67. const loader = new ContentLoader(registry);
  68. await loader.load(tmp);
  69. expect(registry.assets.get("grass-1")?.kind).toBe("image");
  70. expect(registry.assets.size).toBe(1);
  71. } finally {
  72. await rm(tmp, { recursive: true, force: true });
  73. }
  74. });
  75. it("watch() re-scans the asset table when a file under assets/ changes", async () => {
  76. const tmp = await makeTmp();
  77. try {
  78. const registry = new ContentRegistry();
  79. const loader = new ContentLoader(registry);
  80. const stop = loader.watch(tmp);
  81. await loader.load(tmp);
  82. await writeFile(join(tmp, "assets", "new-1.png"), Buffer.from(PNG_BASE64, "base64"));
  83. await waitFor(() => registry.assets.has("new-1"), 3000);
  84. expect(registry.assets.get("new-1")?.kind).toBe("image");
  85. stop();
  86. } finally {
  87. await rm(tmp, { recursive: true, force: true });
  88. }
  89. });
  90. it("loadBundle collects bundle.assets into the same asset table", async () => {
  91. const bundle: ContentBundle = {
  92. format: "nengine-bundle",
  93. version: 1,
  94. content: {
  95. map: [
  96. {
  97. id: "farm",
  98. width: 2,
  99. height: 2,
  100. tilesetRef: "tiles",
  101. spawn: { x: 0, y: 0 },
  102. layers: [{ name: "ground", data: [["grass", "grass"], ["grass", "grass"]] }],
  103. },
  104. ],
  105. },
  106. assets: {
  107. "grass-1": { kind: "image", mime: "image/png", data: PNG_BASE64 },
  108. "till-1": { kind: "audio", mime: "audio/ogg", data: OGG_BASE64 },
  109. },
  110. };
  111. const registry = new ContentRegistry();
  112. const loader = new ContentLoader(registry);
  113. const report = await loader.loadBundle(bundle);
  114. expect(report.ok).toBe(true);
  115. expect(registry.assets.get("grass-1")).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 });
  116. expect(registry.assets.get("till-1")).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 });
  117. });
  118. it("a bundle without assets loads unchanged (empty asset table)", async () => {
  119. const bundle: ContentBundle = {
  120. format: "nengine-bundle",
  121. version: 1,
  122. content: {},
  123. };
  124. const registry = new ContentRegistry();
  125. const loader = new ContentLoader(registry);
  126. const report = await loader.loadBundle(bundle);
  127. expect(report.ok).toBe(false); // no content items → ok false (unchanged D30 semantics)
  128. expect(registry.assets.size).toBe(0);
  129. });
  130. it("rejects a structurally invalid asset entry in a bundle", async () => {
  131. const registry = new ContentRegistry();
  132. const loader = new ContentLoader(registry);
  133. await expect(
  134. loader.loadBundle({
  135. format: "nengine-bundle",
  136. version: 1,
  137. content: {},
  138. assets: { "grass-1": { kind: "movie", mime: "image/png", data: "x" } },
  139. } as unknown as ContentBundle),
  140. ).rejects.toThrow("not a valid nengine-bundle");
  141. });
  142. });
  143. describe("sprite schema image layer (D31)", () => {
  144. it("accepts an image layer with src plus optional x/y/w/h", () => {
  145. const valid = spriteSchema.safeParse({
  146. id: "s",
  147. frames: [
  148. {
  149. layers: [
  150. { shape: "image", src: "plant-1" },
  151. { shape: "image", src: "plant-1", x: 4, y: 6, w: 20, h: 20 },
  152. ],
  153. },
  154. ],
  155. });
  156. expect(valid.success).toBe(true);
  157. });
  158. it("rejects an image layer with a missing/blank src, or rect layers with unknown keys", () => {
  159. expect(
  160. spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image" }] }] }).success,
  161. ).toBe(false);
  162. expect(
  163. spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image", src: "" }] }] }).success,
  164. ).toBe(false);
  165. expect(
  166. spriteSchema.safeParse({
  167. id: "s",
  168. frames: [{ layers: [{ shape: "image", src: "a", fill: "#ffffff" }] }],
  169. }).success,
  170. ).toBe(false);
  171. expect(
  172. spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image", src: "a", w: -1 }] }] }).success,
  173. ).toBe(false);
  174. });
  175. it("keeps the existing rect/circle layer shapes working unchanged", () => {
  176. expect(
  177. spriteSchema.safeParse({
  178. id: "s",
  179. frames: [{ layers: [{ shape: "rect", x: 0, y: 0, w: 4, h: 4, fill: "#33691e" }] }],
  180. }).success,
  181. ).toBe(true);
  182. expect(
  183. spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "triangle", x: 0, y: 0 }] }] }).success,
  184. ).toBe(false);
  185. });
  186. });
  187. describe("map schema tiles mapping (D31)", () => {
  188. const base = {
  189. id: "farm",
  190. width: 2,
  191. height: 2,
  192. tilesetRef: "tiles",
  193. spawn: { x: 0, y: 0 },
  194. layers: [{ name: "ground", data: [["grass", "water"], ["grass", "tilled"]] }],
  195. };
  196. it("accepts an optional tile-id → asset-id mapping", () => {
  197. expect(mapSchema.safeParse({ ...base, tiles: { grass: "grass-1", water: "water-1", tilled: "tilled-1" } }).success).toBe(true);
  198. });
  199. it("accepts a map without tiles (palette fallback unchanged)", () => {
  200. expect(mapSchema.safeParse(base).success).toBe(true);
  201. });
  202. it("rejects malformed tiles mappings", () => {
  203. expect(mapSchema.safeParse({ ...base, tiles: { grass: "" } }).success).toBe(false);
  204. expect(mapSchema.safeParse({ ...base, tiles: { grass: "a", water: 3 } }).success).toBe(false);
  205. });
  206. });
  207. describe("sample-game content asset adoption", () => {
  208. it("loads the sample game's assets alongside its content", async () => {
  209. const registry = new ContentRegistry();
  210. const loader = new ContentLoader(registry);
  211. const report = await loader.load(sampleContentDir);
  212. expect(report.ok).toBe(true);
  213. expect(registry.assets.size).toBe(21);
  214. for (const id of ["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]) {
  215. expect(registry.assets.get(id)?.kind).toBe("image");
  216. }
  217. for (const id of ["click-1", "confirm-1", "drop-1", "pluck-1"]) {
  218. expect(registry.assets.get(id)?.kind).toBe("audio");
  219. }
  220. });
  221. });
  222. async function waitFor(condition: () => boolean, timeoutMs: number): Promise<void> {
  223. const start = Date.now();
  224. while (!condition()) {
  225. if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for condition");
  226. await new Promise((resolveWait) => setTimeout(resolveWait, 25));
  227. }
  228. }