@moku-labs/game 0.14.11 → 0.14.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +13 -12
  2. package/dist/app/page.d.mts +8 -3
  3. package/dist/app/page.mjs +57 -12
  4. package/dist/app/system.d.mts +5 -3
  5. package/dist/app/system.mjs +2 -0
  6. package/dist/app.d.mts +15 -7
  7. package/dist/app.mjs +131 -27
  8. package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
  9. package/dist/assets.d.mts +30 -13
  10. package/dist/assets.mjs +1 -1
  11. package/dist/cli.d.mts +105 -8
  12. package/dist/cli.mjs +1036 -128
  13. package/dist/{commands-d5Zlh9hf.d.mts → commands-DAETmK86.d.mts} +43 -7
  14. package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
  15. package/dist/control.d.mts +130 -17
  16. package/dist/control.mjs +4 -4
  17. package/dist/{define-BN15lAWb.d.mts → define-BASd9ZUh.d.mts} +32 -4
  18. package/dist/dev-DbHgx-R-.mjs +89 -0
  19. package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
  20. package/dist/features-DowEilZm.mjs +266 -0
  21. package/dist/{headless-CrhefcLu.d.mts → headless-cFveciuY.d.mts} +1 -1
  22. package/dist/hot.d.mts +4 -2
  23. package/dist/hot.mjs +7 -4
  24. package/dist/{index-0GIP3tgm.d.mts → index-DedbED3T.d.mts} +317 -69
  25. package/dist/index.d.mts +5 -6
  26. package/dist/index.mjs +3 -3
  27. package/dist/inspect.d.mts +12 -7
  28. package/dist/inspect.mjs +2 -2
  29. package/dist/jsx-dev-runtime.d.mts +1 -1
  30. package/dist/jsx-runtime.d.mts +1 -1
  31. package/dist/lint.d.mts +8 -3
  32. package/dist/lint.mjs +432 -20
  33. package/dist/project.d.mts +15 -1
  34. package/dist/project.mjs +1 -1
  35. package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
  36. package/dist/{runtime-aLkoeoGj.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
  37. package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
  38. package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
  39. package/dist/{src-Do697seR.mjs → src-BEbJ_sSx.mjs} +29154 -11121
  40. package/dist/testing.d.mts +119 -14
  41. package/dist/testing.mjs +97 -7
  42. package/dist/{types-BZa187kz.d.mts → types-7GXcXxxv.d.mts} +196 -242
  43. package/dist/{types-BT9S_Lo2.d.mts → types-BGcda5Le.d.mts} +4464 -735
  44. package/dist/{types-yiHtzg1Y.d.mts → types-C73hbmxi.d.mts} +129 -17
  45. package/dist/{types-Bc9AvZVO.d.mts → types-DngFQJro.d.mts} +43 -5
  46. package/dist/{types-kNiV7iFj.d.mts → types-GHE4J5Uf.d.mts} +1027 -192
  47. package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
  48. package/dist/visual-_XhoGeIC.d.mts +345 -0
  49. package/dist/visual.d.mts +1 -332
  50. package/dist/visual.mjs +130 -20
  51. package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
  52. package/llms.txt +103 -51
  53. package/package.json +8 -6
  54. package/dist/dev-Cc50Dk7H.mjs +0 -47
  55. package/dist/memory-DR1ibF_s.d.mts +0 -50
  56. package/dist/types-DMoi447V.d.mts +0 -656
@@ -1,9 +1,10 @@
1
+ import { t as findFeatureOwners } from "./features-DowEilZm.mjs";
1
2
  import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { createBrandConsole } from "@moku-labs/common/cli";
4
5
  import { pathToFileURL } from "node:url";
5
- import { mkdir, open, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
6
6
  import { createHash } from "node:crypto";
7
+ import { mkdir, open, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
7
8
  import { MaxRectsBin, MaxRectsPacker } from "maxrects-packer";
8
9
  /** Bytes per pixel of the decoded images: RGBA, 8 bits each. */
9
10
  const CHANNELS = 4;
@@ -334,7 +335,7 @@ function byName$2(left, right) {
334
335
  }
335
336
  /**
336
337
  * Writes one file entry in the field order of the manifest: `key`, `path`, `kind`, `width`,
337
- * `height`, `mb`, `pages`, `nine`, `atlas`. A field the file does not carry is left out: a
338
+ * `height`, `mb`, `mips`, `pages`, `nine`, `atlas`. A field the file does not carry is left out: a
338
339
  * texture says nothing about its kind, which keeps a manifest written before fonts and audio
339
340
  * readable, and a texture packed in an atlas has no `path`. A font and a sound carry no size.
340
341
  *
@@ -357,6 +358,7 @@ function plainFile(file) {
357
358
  width: file.width,
358
359
  height: file.height,
359
360
  mb: file.mb,
361
+ ...file.mips === true ? { mips: true } : {},
360
362
  ...file.nine === void 0 ? {} : { nine: file.nine },
361
363
  ...file.atlas === void 0 ? {} : { atlas: { ...file.atlas } }
362
364
  };
@@ -377,8 +379,9 @@ function plainPage(page) {
377
379
  };
378
380
  }
379
381
  /**
380
- * Writes one bundle with a fixed field order, its pages sorted by id when it has any and its
381
- * files sorted by key.
382
+ * Writes one bundle with a fixed field order (`feature`, `parent`, `dir`, `tier`, `release`, `mb`,
383
+ * `pages`, `files`), its pages sorted by id when it has any and its files sorted by key. A field
384
+ * the bundle does not carry is left out, so the bundle of a flat feature keeps its old bytes.
382
385
  *
383
386
  * @param bundle - The bundle.
384
387
  * @returns The bundle as JSON data.
@@ -387,7 +390,10 @@ function plainBundle(bundle) {
387
390
  const pages = bundle.pages?.toSorted((left, right) => byName$2(left.id, right.id)).map((page) => plainPage(page));
388
391
  return {
389
392
  feature: bundle.feature,
393
+ ...bundle.parent === void 0 ? {} : { parent: bundle.parent },
394
+ ...bundle.dir === void 0 ? {} : { dir: bundle.dir },
390
395
  tier: bundle.tier,
396
+ ...bundle.release === void 0 ? {} : { release: bundle.release },
391
397
  mb: bundle.mb,
392
398
  ...pages === void 0 ? {} : { pages },
393
399
  files: bundle.files.toSorted((left, right) => byName$2(left.key, right.key)).map((file) => plainFile(file))
@@ -646,19 +652,50 @@ function readImageSize(head, file) {
646
652
  throw problem$5(`"${file}" is not a PNG or a WebP file.`);
647
653
  }
648
654
  /**
649
- * Estimates what one image costs on the GPU: four bytes per pixel, no mipmaps, rounded to three
650
- * decimals so two scans of the same tree write the same bytes.
655
+ * Counts the pixels of an image on the GPU: one level, or with `mips` every level of the full
656
+ * chain Pixi builds once `autoGenerateMipmaps` is set (`floor(log2(max(width, height))) + 1`
657
+ * levels, each side halved and floored, never below 1 px).
651
658
  *
652
659
  * @param width - Width in pixels.
653
660
  * @param height - Height in pixels.
661
+ * @param mips - True for the full mip chain.
662
+ * @returns The pixels of every level.
663
+ * @example
664
+ * ```ts
665
+ * chainPixels(4, 4, true); // 21: 16 + 4 + 1
666
+ * ```
667
+ */
668
+ function chainPixels(width, height, mips) {
669
+ if (!mips) return width * height;
670
+ const levels = Math.floor(Math.log2(Math.max(width, height))) + 1;
671
+ let pixels = 0;
672
+ let levelWidth = width;
673
+ let levelHeight = height;
674
+ for (let level = 0; level < levels; level += 1) {
675
+ pixels += levelWidth * levelHeight;
676
+ levelWidth = Math.max(1, Math.floor(levelWidth / 2));
677
+ levelHeight = Math.max(1, Math.floor(levelHeight / 2));
678
+ }
679
+ return pixels;
680
+ }
681
+ /**
682
+ * Estimates what one image costs on the GPU: four bytes per pixel, every level of the mip chain
683
+ * with `mips`, rounded to three decimals so two scans of the same tree write the same bytes. Its
684
+ * twin is `textureMb` of `manifest.ts`, which the plugin reads: a change edits both.
685
+ *
686
+ * @param width - Width in pixels.
687
+ * @param height - Height in pixels.
688
+ * @param mips - True for a texture uploaded with its full mip chain.
654
689
  * @returns The estimated texture memory in MB.
655
690
  * @example
656
691
  * ```ts
657
692
  * textureMb(256, 128); // 0.125
693
+ * textureMb(256, 128, true); // 0.167
658
694
  * ```
659
695
  */
660
- function textureMb(width, height) {
661
- return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * 1e3) / 1e3;
696
+ function textureMb(width, height, mips = false) {
697
+ const bytes = chainPixels(width, height, mips) * BYTES_PER_PIXEL;
698
+ return Math.round(bytes / BYTES_PER_MB * 1e3) / 1e3;
662
699
  }
663
700
  /**
664
701
  * What a file costs in memory when it is kept as it is: an audio file is held undecoded, so its
@@ -1064,21 +1101,24 @@ const FX_FOLDER = "fx";
1064
1101
  /** The groups in the order a bundle packs them. */
1065
1102
  const GROUP_ORDER = ["fx", "main"];
1066
1103
  /**
1067
- * Picks the atlas group of one texture. A key whose last segment starts with `fx-`, or with a
1068
- * folder segment `fx` between the bundle name and the stem, goes to `fx` whatever its size: the
1069
- * frames of an animation in `fx/` too. Any other texture goes to `main` when no side is above
1070
- * 512 px.
1104
+ * Picks the atlas group of one texture. A mipmapped texture stays loose whatever its key: Pixi
1105
+ * builds its full chain, and from the second level on the neighbours on a page would bleed into
1106
+ * it. A key whose last segment starts with `fx-`, or with a folder segment `fx` between the bundle
1107
+ * name and the stem, goes to `fx` whatever its size: the frames of an animation in `fx/` too. Any
1108
+ * other texture goes to `main` when no side is above 512 px.
1071
1109
  *
1072
- * @param texture - The key and the size of the texture.
1073
- * @returns The group, or `undefined` when the texture stays loose by its size.
1110
+ * @param texture - The key, the size and the mipmaps of the texture.
1111
+ * @returns The group, or `undefined` when the texture stays loose.
1074
1112
  * @example
1075
1113
  * ```ts
1076
1114
  * groupOf({ key: "ui.fx-rays", width: 504, height: 512 }); // "fx"
1077
1115
  * groupOf({ key: "ui.fx.leaf", width: 64, height: 64 }); // "fx", from assets/fx/leaf.webp of ui
1078
1116
  * groupOf({ key: "ui.fx", width: 64, height: 64 }); // "main": a stem, no folder
1117
+ * groupOf({ key: "ep01.items.cup", width: 336, height: 288, mips: true }); // undefined: loose
1079
1118
  * ```
1080
1119
  */
1081
1120
  function groupOf(texture) {
1121
+ if (texture.mips === true) return void 0;
1082
1122
  const segments = texture.key.split(".");
1083
1123
  const name = segments.at(-1) ?? "";
1084
1124
  const folders = segments.slice(1, -1);
@@ -1222,6 +1262,7 @@ async function readTextures(run, bundle) {
1222
1262
  key: file.key,
1223
1263
  width: file.width,
1224
1264
  height: file.height,
1265
+ ...file.mips === true ? { mips: true } : {},
1225
1266
  file,
1226
1267
  bytes,
1227
1268
  sha: sha256(bytes)
@@ -1474,7 +1515,10 @@ async function packBundle(run, name, bundle) {
1474
1515
  const mb = sumMb$1([...packed.pages, ...files.filter((file) => file.atlas === void 0)]);
1475
1516
  return {
1476
1517
  feature: bundle.feature,
1518
+ ...bundle.parent === void 0 ? {} : { parent: bundle.parent },
1519
+ ...bundle.dir === void 0 ? {} : { dir: bundle.dir },
1477
1520
  tier: bundle.tier,
1521
+ ...bundle.release === void 0 ? {} : { release: bundle.release },
1478
1522
  mb,
1479
1523
  ...packed.pages.length === 0 ? {} : { pages: packed.pages.toSorted((left, right) => left.id.localeCompare(right.id)) },
1480
1524
  files: files.toSorted((left, right) => left.key.localeCompare(right.key))
@@ -1493,6 +1537,7 @@ function checkTexture(bundle, entry, file, source) {
1493
1537
  const atlas = file.atlas;
1494
1538
  if (atlas === void 0 === (file.path === void 0)) return [`the texture "${file.key}" must have either a path or an atlas frame.`];
1495
1539
  if (atlas === void 0) return [];
1540
+ if (file.mips === true) return [`the texture "${file.key}" has mipmaps, so it must stay loose, but lies on page "${atlas.page}".`];
1496
1541
  const page = entry.pages?.find((candidate) => candidate.id === atlas.page);
1497
1542
  if (page === void 0) return [`the texture "${file.key}" names page "${atlas.page}", which bundle "${bundle}" does not list.`];
1498
1543
  const problems = [];
@@ -1503,7 +1548,7 @@ function checkTexture(bundle, entry, file, source) {
1503
1548
  /**
1504
1549
  * Checks a packed manifest against the scan it came from: every key landed exactly once, as a
1505
1550
  * page frame or a file; every frame names a page of its own bundle, lies inside it and has the
1506
- * size of its source, so `nine` stays valid as it is.
1551
+ * size of its source, so `nine` stays valid as it is; a mipmapped texture is never a frame.
1507
1552
  *
1508
1553
  * @param source - The manifest of the scan.
1509
1554
  * @param packed - The packed manifest.
@@ -1903,10 +1948,11 @@ function keyOf(feature, relative, file) {
1903
1948
  //#endregion
1904
1949
  //#region src/plugins/assets/scan/scan.ts
1905
1950
  /**
1906
- * @file assets plugin, build time — the walk. `features/*` with an `assets/` folder, and every
1907
- * layer the options map (a folder under the root read like one more feature), becomes the manifest:
1908
- * one bundle per feature or layer, split by the optional `assets.ts` it exports. Node and Bun only.
1909
- * Nothing under `src/` outside `scan/` imports it, so no game bundles it.
1951
+ * @file assets plugin, build time — the walk. Every feature owner (a folder of `features/`, and a
1952
+ * sub-feature with a door and resources of its own at any depth, found by `findFeatureOwners`), and
1953
+ * every layer the options map (a folder under the root read like one more feature), becomes the
1954
+ * manifest: one bundle per owner or layer, split by the optional `assets.ts` it exports. Node and
1955
+ * Bun only. Nothing under `src/` outside `scan/` imports it, so no game bundles it.
1910
1956
  */
1911
1957
  const TIERS = [
1912
1958
  "boot",
@@ -1915,6 +1961,8 @@ const TIERS = [
1915
1961
  "feature",
1916
1962
  "lazy"
1917
1963
  ];
1964
+ /** The name of the folder of an owner the scanner reads. */
1965
+ const ASSETS = "assets";
1918
1966
  const PREFIX$1 = "[game] assets: ";
1919
1967
  const ESCAPE = /[.+^${}()|[\]\\]/g;
1920
1968
  /**
@@ -1950,13 +1998,11 @@ function detailOf$2(failure) {
1950
1998
  * built inside an exported declaration.
1951
1999
  *
1952
2000
  * @param root - Absolute or relative path of the game source root.
1953
- * @param features - Name of the features folder.
1954
2001
  * @returns The empty state.
1955
2002
  */
1956
- function createState(root, features) {
2003
+ function createState(root) {
1957
2004
  return {
1958
2005
  root,
1959
- featuresFolder: path.join(root, features),
1960
2006
  drafts: /* @__PURE__ */ new Map(),
1961
2007
  keyOwner: /* @__PURE__ */ new Map(),
1962
2008
  problems: [],
@@ -1983,13 +2029,37 @@ function byName$1(left, right) {
1983
2029
  return left.localeCompare(right);
1984
2030
  }
1985
2031
  /**
1986
- * Lists the feature folders, sorted. A missing features folder is an empty game, not a problem.
2032
+ * Reads the feature owners of the game, at any depth, in the order `findFeatureOwners` returns
2033
+ * them. A missing features folder is an empty game, not a problem. One name taken by two folders
2034
+ * is one problem of the scan, and the walk goes on with no feature owner, so the layer refusals
2035
+ * are still collected and the scan fails with every problem at once.
2036
+ *
2037
+ * @param scan - State of the scan.
2038
+ * @param features - Name of the features folder.
2039
+ * @returns The owners, each with its folder on disk.
2040
+ */
2041
+ function readOwners(scan, features) {
2042
+ try {
2043
+ return findFeatureOwners(scan.root, features).map((owner) => ({
2044
+ name: owner.name,
2045
+ dir: path.join(scan.root, ...owner.dir.split("/")),
2046
+ folder: owner.dir,
2047
+ parent: owner.parent,
2048
+ resources: { ...owner.resources }
2049
+ }));
2050
+ } catch (error) {
2051
+ scan.problems.push(detailOf$2(error));
2052
+ return [];
2053
+ }
2054
+ }
2055
+ /**
2056
+ * Tells whether a path is a file.
1987
2057
  *
1988
- * @param dir - The features folder.
1989
- * @returns The folder names.
2058
+ * @param file - The path to look at.
2059
+ * @returns True for a file; false for a folder or nothing.
1990
2060
  */
1991
- async function listFeatures(dir) {
1992
- return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted(byName$1);
2061
+ async function isFile(file) {
2062
+ return await stat(file).then((entry) => entry.isFile(), () => false);
1993
2063
  }
1994
2064
  /**
1995
2065
  * Tells whether a path is a folder.
@@ -2042,7 +2112,7 @@ function nameTakenRefusal(first, folder, name) {
2042
2112
  *
2043
2113
  * @param layers - Folder to name.
2044
2114
  * @param features - Name of the features folder.
2045
- * @param featureNames - The feature folders found under it.
2115
+ * @param featureNames - Every feature owner name, at any depth.
2046
2116
  * @returns Every layer with its refusal, in the order of the sorted folders.
2047
2117
  */
2048
2118
  function checkLayers(layers, features, featureNames) {
@@ -2118,10 +2188,66 @@ async function importDescription(file) {
2118
2188
  );
2119
2189
  }
2120
2190
  /**
2121
- * Reads one declared bundle into the specs of its feature.
2191
+ * Says why the release of a declared bundle is refused: a value that is neither `"budget"` nor
2192
+ * `"leave"`, or `"leave"` on a tier that stays for the whole session.
2193
+ *
2194
+ * @param name - Name of the bundle.
2195
+ * @param tier - Its tier, already checked.
2196
+ * @param release - What the description carried as `release`.
2197
+ * @returns The problem, or `undefined` when the release is fine.
2198
+ * @example
2199
+ * ```ts
2200
+ * releaseProblem("ep01", "lazy", "later");
2201
+ * // 'the bundle "ep01" has the unknown release "later". Use budget or leave.'
2202
+ * ```
2203
+ */
2204
+ function releaseProblem(name, tier, release) {
2205
+ if (release !== void 0 && release !== "budget" && release !== "leave") return `the bundle "${name}" has the unknown release "${String(release)}". Use budget or leave.`;
2206
+ if (release !== "leave" || tier !== "boot" && tier !== "core") return void 0;
2207
+ return `the bundle "${name}" is tier "${tier}", which stays for the whole session: release "leave" needs scene, feature or lazy.`;
2208
+ }
2209
+ /**
2210
+ * Tells whether a `mipmaps` value is one the scanner reads: absent, `false`, `true` or a list of
2211
+ * globs. `false` and `[]` are read as absent: a JavaScript or a cast description may carry them.
2212
+ *
2213
+ * @param mipmaps - What the description carried as `mipmaps`.
2214
+ * @returns True when the value can be read.
2215
+ * @example
2216
+ * ```ts
2217
+ * isMipmapsValue("items/**"); // false: a glob needs a list
2218
+ * ```
2219
+ */
2220
+ function isMipmapsValue(mipmaps) {
2221
+ if (mipmaps === void 0 || typeof mipmaps === "boolean") return true;
2222
+ return Array.isArray(mipmaps) && mipmaps.every((glob) => typeof glob === "string");
2223
+ }
2224
+ /**
2225
+ * Builds the stored spec of a declared bundle: `release` only when it is `"leave"`, `mipmaps`
2226
+ * only when it is `true` or a non-empty list (copied).
2227
+ *
2228
+ * @param tier - The checked tier.
2229
+ * @param raw - The checked description of the bundle.
2230
+ * @returns The spec.
2231
+ */
2232
+ function specOf(tier, raw) {
2233
+ const files = Array.isArray(raw.files) ? raw.files.map(String) : void 0;
2234
+ const mipmaps = raw.mipmaps;
2235
+ const globs = Array.isArray(mipmaps) && mipmaps.length > 0 ? mipmaps.map(String) : void 0;
2236
+ return {
2237
+ tier,
2238
+ ...files === void 0 ? {} : { files },
2239
+ ...raw.release === "leave" ? { release: "leave" } : {},
2240
+ ...mipmaps === true ? { mipmaps: true } : {},
2241
+ ...globs === void 0 ? {} : { mipmaps: globs }
2242
+ };
2243
+ }
2244
+ /**
2245
+ * Reads one declared bundle into the specs of its feature: the bundle-name rule against the owner,
2246
+ * the tier, the release and the mipmaps. Each failure is one problem, and the bundle is then not
2247
+ * recorded.
2122
2248
  *
2123
2249
  * @param scan - State of the scan.
2124
- * @param feature - Name of the feature.
2250
+ * @param feature - Name of the feature: the inner folder name, or the mapped name of a layer.
2125
2251
  * @param name - Name of the bundle.
2126
2252
  * @param raw - What the description carried under that name.
2127
2253
  * @param specs - Where a valid bundle goes.
@@ -2132,15 +2258,20 @@ function readSpec(scan, feature, name, raw, specs) {
2132
2258
  return;
2133
2259
  }
2134
2260
  const tier = isRecord(raw) ? raw.tier : void 0;
2135
- if (typeof tier !== "string" || !TIERS.includes(tier)) {
2261
+ if (!isRecord(raw) || typeof tier !== "string" || !TIERS.includes(tier)) {
2136
2262
  scan.problems.push(`the bundle "${name}" has the unknown tier "${String(tier)}". Use one of ${TIERS.join(", ")}.`);
2137
2263
  return;
2138
2264
  }
2139
- const files = isRecord(raw) && Array.isArray(raw.files) ? raw.files.map(String) : void 0;
2140
- specs.set(name, files === void 0 ? { tier } : {
2141
- tier,
2142
- files
2143
- });
2265
+ const refusal = releaseProblem(name, tier, raw.release);
2266
+ if (refusal !== void 0) {
2267
+ scan.problems.push(refusal);
2268
+ return;
2269
+ }
2270
+ if (!isMipmapsValue(raw.mipmaps)) {
2271
+ scan.problems.push(`the bundle "${name}" has mipmaps ${JSON.stringify(raw.mipmaps)}: use true or a list of globs relative to assets/.`);
2272
+ return;
2273
+ }
2274
+ specs.set(name, specOf(tier, raw));
2144
2275
  }
2145
2276
  /**
2146
2277
  * Reads the bundles a feature or a layer declares. Every export that is a `defineBundles` result
@@ -2213,6 +2344,61 @@ function splitsOf(specs) {
2213
2344
  return splits;
2214
2345
  }
2215
2346
  /**
2347
+ * Builds the mipmaps rules of the declared bundles, sorted by bundle name.
2348
+ *
2349
+ * @param specs - The declared bundles of one owner.
2350
+ * @returns Bundle name to its rule, for every bundle that declares mipmaps.
2351
+ */
2352
+ function mipRulesOf(specs) {
2353
+ const rules = /* @__PURE__ */ new Map();
2354
+ for (const [name, spec] of [...specs].toSorted((left, right) => byName$1(left[0], right[0]))) {
2355
+ const mipmaps = spec.mipmaps;
2356
+ if (mipmaps === true) rules.set(name, { all: true });
2357
+ else if (mipmaps !== void 0) rules.set(name, { globs: mipmaps.map((glob) => ({
2358
+ glob,
2359
+ pattern: globToRegExp(glob)
2360
+ })) });
2361
+ }
2362
+ return rules;
2363
+ }
2364
+ /**
2365
+ * Tells whether a texture is mipmapped by the bundle that claims it, and notes every glob of that
2366
+ * bundle it matched. A glob of one bundle never marks a file of another.
2367
+ *
2368
+ * @param pass - What this owner is read with.
2369
+ * @param bundle - The bundle that claims the texture.
2370
+ * @param relative - POSIX path of the texture inside `assets/`.
2371
+ * @returns True when the texture carries mipmaps.
2372
+ */
2373
+ function wantsMips(pass, bundle, relative) {
2374
+ const rule = pass.mips.get(bundle);
2375
+ if (rule === void 0) return false;
2376
+ if ("all" in rule) return true;
2377
+ let wanted = false;
2378
+ for (const { glob, pattern } of rule.globs) {
2379
+ if (!pattern.test(relative)) continue;
2380
+ pass.matched.add(`${bundle}\n${glob}`);
2381
+ wanted = true;
2382
+ }
2383
+ return wanted;
2384
+ }
2385
+ /**
2386
+ * Notes every mipmaps glob of an owner that matched no texture of its bundle: a typo would
2387
+ * otherwise leave the art without its chain in silence.
2388
+ *
2389
+ * @param scan - State of the scan.
2390
+ * @param pass - What the owner was read with, after its files.
2391
+ */
2392
+ function noteUnmatchedGlobs(scan, pass) {
2393
+ for (const [bundle, rule] of pass.mips) {
2394
+ if ("all" in rule) continue;
2395
+ for (const { glob } of rule.globs) {
2396
+ if (pass.matched.has(`${bundle}\n${glob}`)) continue;
2397
+ scan.notes.push(`the mipmaps glob "${glob}" of the bundle "${bundle}" matches no texture of it.`);
2398
+ }
2399
+ }
2400
+ }
2401
+ /**
2216
2402
  * Works out which declared bundle claims a file.
2217
2403
  *
2218
2404
  * @param splits - The splits of the feature.
@@ -2245,20 +2431,24 @@ function checkNine(nine, size, file) {
2245
2431
  throw problem$2(`the nine-slice ${left === top && top === right && right === bottom ? `${left} of "${file}" must be smaller than half of` : `${left},${top},${right},${bottom} of "${file}" must leave a centre inside`} ${size.width}×${size.height}.`);
2246
2432
  }
2247
2433
  /**
2248
- * Finds or starts the draft of one bundle.
2434
+ * Finds or starts the draft of one bundle: its owner, and the tier and release its spec declares
2435
+ * (the tier `feature` and no release without one).
2249
2436
  *
2250
2437
  * @param scan - State of the scan.
2251
2438
  * @param name - Name of the bundle.
2252
- * @param feature - Feature that owns it.
2253
- * @param tier - When it loads.
2439
+ * @param owner - The feature or layer that owns it.
2440
+ * @param spec - What the owner's `assets.ts` declared for it, if anything.
2254
2441
  * @returns The draft.
2255
2442
  */
2256
- function draftOf(scan, name, feature, tier) {
2443
+ function draftOf(scan, name, owner, spec) {
2257
2444
  const found = scan.drafts.get(name);
2258
2445
  if (found !== void 0) return found;
2259
2446
  const draft = {
2260
- feature,
2261
- tier,
2447
+ feature: owner.name,
2448
+ parent: owner.parent,
2449
+ folder: owner.folder,
2450
+ tier: spec?.tier ?? "feature",
2451
+ release: spec?.release === "leave" ? "leave" : void 0,
2262
2452
  files: []
2263
2453
  };
2264
2454
  scan.drafts.set(name, draft);
@@ -2352,20 +2542,21 @@ async function describePages(scan, pass, relative) {
2352
2542
  return pages;
2353
2543
  }
2354
2544
  /**
2355
- * Reads one asset file into a manifest entry: a texture by its pixels, a font with its pages, an
2356
- * audio file by its bytes.
2545
+ * Reads one asset file into a manifest entry: a texture by its pixels (every level of its chain
2546
+ * when it carries mipmaps), a font with its pages, an audio file by its bytes.
2357
2547
  *
2358
2548
  * @param scan - State of the scan.
2359
2549
  * @param pass - What this feature is read with.
2360
2550
  * @param relative - POSIX path of the file inside `assets/`.
2551
+ * @param bundle - The bundle that claims the file, whose mipmaps rule a texture follows.
2361
2552
  * @returns The entry.
2362
2553
  * @throws {Error} When the key, the tags, the header bytes or the nine-slice are wrong.
2363
2554
  */
2364
- async function describeFile(scan, pass, relative) {
2555
+ async function describeFile(scan, pass, relative, bundle) {
2365
2556
  const absolute = path.join(pass.assetsFolder, ...relative.split("/"));
2366
2557
  const file = toPosix(path.relative(scan.root, absolute));
2367
2558
  const fileName = relative.slice(relative.lastIndexOf("/") + 1);
2368
- const key = keyOf(pass.feature, relative, file);
2559
+ const key = keyOf(pass.owner.name, relative, file);
2369
2560
  const owner = scan.keyOwner.get(key);
2370
2561
  if (owner !== void 0) throw problem$2(`key "${key}" comes from two files: ${owner} and ${file}.`);
2371
2562
  const kind = assetKindOf(fileName);
@@ -2399,12 +2590,14 @@ async function describeFile(scan, pass, relative) {
2399
2590
  const size = readImageSize(await readHead(absolute), file);
2400
2591
  checkNine(nine, size, file);
2401
2592
  scan.keyOwner.set(key, file);
2593
+ const mips = wantsMips(pass, bundle, relative);
2402
2594
  return {
2403
2595
  key,
2404
2596
  path: file,
2405
2597
  width: size.width,
2406
2598
  height: size.height,
2407
- mb: textureMb(size.width, size.height),
2599
+ mb: textureMb(size.width, size.height, mips),
2600
+ ...mips ? { mips: true } : {},
2408
2601
  ...nine === void 0 ? {} : { nine }
2409
2602
  };
2410
2603
  }
@@ -2423,39 +2616,71 @@ async function addFile(scan, pass, relative) {
2423
2616
  return;
2424
2617
  }
2425
2618
  try {
2426
- const name = ownerOf(pass.splits, relative, file) ?? pass.feature;
2427
- const entry = await describeFile(scan, pass, relative);
2428
- draftOf(scan, name, pass.feature, pass.defaultTier).files.push(entry);
2619
+ const name = ownerOf(pass.splits, relative, file) ?? pass.owner.name;
2620
+ const entry = await describeFile(scan, pass, relative, name);
2621
+ draftOf(scan, name, pass.owner, pass.specs.get(name)).files.push(entry);
2429
2622
  } catch (error) {
2430
2623
  scan.problems.push(detailOf$2(error));
2431
2624
  }
2432
2625
  }
2433
2626
  /**
2434
2627
  * Reads one feature or layer: its description first, then its fonts, then every file of its
2435
- * `assets/`.
2628
+ * `assets/` (never a child owner's), then the notes of its unmatched mipmaps globs. A sub-feature
2629
+ * with resources has its default bundle even with no file, so its lazy strings travel with it.
2436
2630
  *
2437
2631
  * @param scan - State of the scan.
2438
- * @param owner - The feature or layer: its folder and the name its keys and bundles take.
2632
+ * @param owner - The feature or layer: its folders and the name its keys and bundles take.
2439
2633
  */
2440
2634
  async function scanOwner(scan, owner) {
2441
- const feature = owner.name;
2442
2635
  const specs = await readBundleSpecs(scan, owner);
2443
- const assetsFolder = path.join(owner.dir, "assets");
2444
- for (const [name, spec] of specs) draftOf(scan, name, feature, spec.tier);
2636
+ const assetsFolder = path.join(owner.dir, ASSETS);
2637
+ for (const [name, spec] of specs) draftOf(scan, name, owner, spec);
2638
+ if (owner.parent !== void 0 && (owner.resources.assets || owner.resources.strings)) draftOf(scan, owner.name, owner, specs.get(owner.name));
2445
2639
  const files = await walkAssets(assetsFolder, "");
2446
2640
  const fonts = await readFonts(scan, assetsFolder, files);
2447
2641
  const pages = new Set([...fonts.values()].flat());
2448
2642
  const pass = {
2449
- feature,
2643
+ owner,
2450
2644
  assetsFolder,
2451
2645
  splits: splitsOf(specs),
2452
- defaultTier: specs.get(feature)?.tier ?? "feature",
2453
- fonts
2646
+ specs,
2647
+ fonts,
2648
+ mips: mipRulesOf(specs),
2649
+ matched: /* @__PURE__ */ new Set()
2454
2650
  };
2455
2651
  for (const relative of files) {
2456
2652
  if (pages.has(relative)) continue;
2457
2653
  await addFile(scan, pass, relative);
2458
2654
  }
2655
+ noteUnmatchedGlobs(scan, pass);
2656
+ }
2657
+ /**
2658
+ * Tells whether a folder brings assets of its own: an `assets/` folder or an `assets.ts` file.
2659
+ *
2660
+ * @param dir - The folder on disk.
2661
+ * @returns True when it has either.
2662
+ */
2663
+ async function hasAssets(dir) {
2664
+ return await isFolder(path.join(dir, ASSETS)) || await isFile(path.join(dir, "assets.ts"));
2665
+ }
2666
+ /**
2667
+ * Notes every near miss: a direct child folder of a feature owner that has assets but is no owner
2668
+ * itself (it has no door), so none of its files are in the manifest. Notes never stop a build.
2669
+ *
2670
+ * @param scan - State of the scan.
2671
+ * @param owners - The feature owners, in walk order.
2672
+ */
2673
+ async function noteStrayFolders(scan, owners) {
2674
+ const folders = new Set(owners.map((owner) => owner.folder));
2675
+ for (const owner of owners) {
2676
+ const entries = await readdir(owner.dir, { withFileTypes: true }).catch(() => []);
2677
+ for (const entry of entries.toSorted((left, right) => byName$1(left.name, right.name))) {
2678
+ const folder = `${owner.folder}/${entry.name}`;
2679
+ if (!entry.isDirectory() || entry.name === ASSETS || folders.has(folder)) continue;
2680
+ if (!await hasAssets(path.join(owner.dir, entry.name))) continue;
2681
+ scan.notes.push(`the folder "${folder}" has assets but is no feature: its files are not in the manifest. A sub-feature needs a door index.ts.`);
2682
+ }
2683
+ }
2459
2684
  }
2460
2685
  /**
2461
2686
  * Adds up what a list of files costs: the files of a bundle, or the pages of a font.
@@ -2480,7 +2705,12 @@ function toManifest(scan) {
2480
2705
  const files = draft.files.toSorted((left, right) => byName$1(left.key, right.key));
2481
2706
  bundles[name] = {
2482
2707
  feature: draft.feature,
2708
+ ...draft.parent === void 0 ? {} : {
2709
+ parent: draft.parent,
2710
+ dir: draft.folder
2711
+ },
2483
2712
  tier: draft.tier,
2713
+ ...draft.release === void 0 ? {} : { release: draft.release },
2484
2714
  mb: sumMb(files),
2485
2715
  files
2486
2716
  };
@@ -2555,19 +2785,29 @@ async function scanLayers(scan, checks) {
2555
2785
  }
2556
2786
  await scanOwner(scan, {
2557
2787
  name,
2558
- dir
2788
+ dir,
2789
+ folder,
2790
+ parent: void 0,
2791
+ resources: {
2792
+ assets: true,
2793
+ strings: false
2794
+ }
2559
2795
  });
2560
2796
  }
2561
2797
  }
2562
2798
  /**
2563
2799
  * Walks the features and the layers of a game and writes its two generated files: one bundle per
2564
- * feature or layer, split by its optional `assets.ts`, every file keyed by the key rule. A layer is
2565
- * read like one more feature under its mapped name, after the features. A check run
2566
- * (`write: false`) writes nothing and only reports whether something would change.
2800
+ * feature owner or layer, split by its optional `assets.ts`, every file keyed by the key rule. A
2801
+ * sub-feature (a folder with a door and resources inside a feature, at any depth) is an owner of
2802
+ * its own: its keys and its bundle take the inner folder name, and only its bundle carries
2803
+ * `parent` and `dir`. A layer is read like one more feature under its mapped name, after the
2804
+ * features. A check run (`write: false`) writes nothing and only reports whether something would
2805
+ * change.
2567
2806
  *
2568
2807
  * @param options - The game source root, the layers, the two output paths and whether to write.
2569
2808
  * @returns The manifest, the text of the key module and whether an output differed.
2570
- * @throws {Error} One error that lists every problem the scan found, the layer problems first.
2809
+ * @throws {Error} One error that lists every problem the scan found, the owner and layer problems
2810
+ * first.
2571
2811
  * @example
2572
2812
  * ```ts
2573
2813
  * const { manifest, changed } = await scanAssets({
@@ -2577,18 +2817,17 @@ async function scanLayers(scan, checks) {
2577
2817
  * layers: { shared: "ui" } // shared/assets/button/primary.png keeps the key "ui.button.primary"
2578
2818
  * });
2579
2819
  * // manifest.bundles.ui.files[0].key: "ui.button.primary", changed: true on the first run
2820
+ * // features/episodes/ep01/assets/ward/room.png keeps the key "ep01.ward.room" in the bundle "ep01"
2580
2821
  * ```
2581
2822
  */
2582
2823
  async function scanAssets(options) {
2583
2824
  const features = options.features ?? "features";
2584
- const scan = createState(options.root, features);
2585
- const featureNames = await listFeatures(scan.featuresFolder);
2586
- const checks = checkLayers(options.layers ?? {}, features, featureNames);
2825
+ const scan = createState(options.root);
2826
+ const owners = readOwners(scan, features);
2827
+ const checks = checkLayers(options.layers ?? {}, features, owners.map((owner) => owner.name));
2587
2828
  for (const check of checks) if (check.refusal !== void 0) scan.problems.push(check.refusal);
2588
- for (const feature of featureNames) await scanOwner(scan, {
2589
- name: feature,
2590
- dir: path.join(scan.featuresFolder, feature)
2591
- });
2829
+ for (const owner of owners) await scanOwner(scan, owner);
2830
+ await noteStrayFolders(scan, owners);
2592
2831
  await scanLayers(scan, checks);
2593
2832
  if (scan.problems.length > 0) throw collected(scan.problems);
2594
2833
  const manifest = toManifest(scan);
@@ -6457,16 +6696,18 @@ function pseudoMessage(elements) {
6457
6696
  //#endregion
6458
6697
  //#region src/plugins/i18n/compile/walk.ts
6459
6698
  /**
6460
- * @file i18n plugin, build time — the walk over `features/*​/strings/<locale>.json` and the
6461
- * `strings/` of every layer: every file read, every key claimed by one feature or layer, every
6462
- * message compiled once and every problem collected, with the text and the translator note of each
6463
- * key kept aside. `compileStrings`, `exportStrings` and `importStrings` all start from it. Node and
6464
- * Bun only.
6699
+ * @file i18n plugin, build time — the walk over every feature owner of `findFeatureOwners` (a
6700
+ * top-level folder of `features/`, or a sub-feature at any depth) and the `strings/` of every layer:
6701
+ * every file read, every key claimed by one feature or layer, every message compiled once and every
6702
+ * problem collected, with the text and the translator note of each key kept aside.
6703
+ * `compileStrings`, `exportStrings` and `importStrings` all start from it. Node and Bun only.
6465
6704
  */
6466
6705
  /** How every problem of the string compiler is prefixed. */
6467
6706
  const PREFIX = "[game] i18n: ";
6468
6707
  /** What a layer folder may not hold: a separator or a ".", which would lead out of the root. */
6469
6708
  const NOT_ONE_FOLDER = /[/\\.]/;
6709
+ /** What a sub-feature name may hold: it sits between two dots of `strings.<name>.<locale>.ts`. */
6710
+ const MODULE_NAME = /^[\w-]+$/;
6470
6711
  /**
6471
6712
  * Creates the table of compiled messages per locale. Its own function because lint rule L5
6472
6713
  * refuses a collection built inside an exported declaration.
@@ -6501,6 +6742,14 @@ function emptyTexts() {
6501
6742
  return /* @__PURE__ */ new Map();
6502
6743
  }
6503
6744
  /**
6745
+ * Creates the table of the sub-features that brought string files.
6746
+ *
6747
+ * @returns An empty table.
6748
+ */
6749
+ function emptySubFeatures() {
6750
+ return /* @__PURE__ */ new Map();
6751
+ }
6752
+ /**
6504
6753
  * Creates the parameter table of one key.
6505
6754
  *
6506
6755
  * @returns An empty table.
@@ -6584,17 +6833,16 @@ function isMessageEntry(value) {
6584
6833
  return isNoted(value);
6585
6834
  }
6586
6835
  /**
6587
- * Lists the sub-folders of a folder that may not exist.
6836
+ * Compares two texts by code unit, the order `.toSorted()` gives strings.
6588
6837
  *
6589
- * @param folder - Path of the folder.
6590
- * @returns The names, sorted.
6838
+ * @param left - One text.
6839
+ * @param right - The other text.
6840
+ * @returns A negative number, zero or a positive number.
6841
+ * @example compareText("features/Board", "features/apple"); // -1
6591
6842
  */
6592
- async function listFolders(folder) {
6593
- try {
6594
- return (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted();
6595
- } catch {
6596
- return [];
6597
- }
6843
+ function compareText(left, right) {
6844
+ if (left === right) return 0;
6845
+ return left < right ? -1 : 1;
6598
6846
  }
6599
6847
  /**
6600
6848
  * Lists the locales one feature brought, by the names of its string files.
@@ -6614,21 +6862,39 @@ async function listLocales(folder) {
6614
6862
  * yields nothing.
6615
6863
  *
6616
6864
  * @param root - Game source root.
6617
- * @param feature - The label: the feature folder name, or the mapped name of a layer.
6618
- * @param folder - Root-relative POSIX path of the owner folder.
6865
+ * @param owner - The label (the feature folder name, or the mapped name of a layer), the
6866
+ * root-relative POSIX folder, and the parent of a sub-feature.
6619
6867
  * @returns The files of that folder.
6620
6868
  */
6621
- async function listOwnerFiles(root, feature, folder) {
6869
+ async function listOwnerFiles(root, owner) {
6870
+ const folder = owner.dir;
6622
6871
  const stringsFolder = path.join(root, ...folder.split("/"), "strings");
6623
- return (await listLocales(stringsFolder)).map((locale) => ({
6624
- feature,
6872
+ const locales = await listLocales(stringsFolder);
6873
+ const parent = owner.parent === void 0 ? {} : { parent: owner.parent };
6874
+ return locales.map((locale) => ({
6875
+ feature: owner.name,
6625
6876
  folder,
6626
6877
  locale,
6627
6878
  file: path.join(stringsFolder, `${locale}.json`),
6628
- relative: `${folder}/strings/${locale}.json`
6879
+ relative: `${folder}/strings/${locale}.json`,
6880
+ ...parent
6629
6881
  }));
6630
6882
  }
6631
6883
  /**
6884
+ * The owner record of a layer: no parent, so its keys take no prefix.
6885
+ *
6886
+ * @param folder - The layer folder under the root.
6887
+ * @param name - The name its keys take.
6888
+ * @returns The record `listOwnerFiles` reads.
6889
+ * @example layerOwner("shared", "ui"); // { name: "ui", dir: "shared" }
6890
+ */
6891
+ function layerOwner(folder, name) {
6892
+ return {
6893
+ name,
6894
+ dir: folder
6895
+ };
6896
+ }
6897
+ /**
6632
6898
  * The error for a layer folder that is not one folder name under the root: empty, or with a
6633
6899
  * separator or a "." that would read and write outside it.
6634
6900
  *
@@ -6639,24 +6905,25 @@ function notAFolderName(folder) {
6639
6905
  return /* @__PURE__ */ new Error(`${PREFIX}the layer "${folder}" is not a folder name.\n` + String.raw` Use a name without "/", "\" or ".".`);
6640
6906
  }
6641
6907
  /**
6642
- * Lists every string file of a game: features sorted, then the layers sorted by folder; inside
6643
- * each, locales sorted. Every layer folder is checked before anything is read.
6908
+ * Lists every string file of a game: the feature owners of `findFeatureOwners` (top-level features
6909
+ * and sub-features at any depth) sorted by folder in code-unit order, then the layers sorted by
6910
+ * folder; inside each, locales sorted. Every layer folder is checked before anything is read.
6644
6911
  *
6645
6912
  * @param root - Game source root, the folder that holds the features.
6646
6913
  * @param features - Name of the folder that holds the features.
6647
6914
  * @param layers - Folder under the root to the name its keys take. Default `{}`: features only.
6648
6915
  * @returns The files, in walk order.
6649
- * @throws {Error} When a layer folder is empty or holds "/", "\" or ".".
6916
+ * @throws {Error} When a layer folder is empty or holds "/", "\" or ".", or when one feature name
6917
+ * belongs to two folders (the error of `findFeatureOwners`): nothing is read then.
6650
6918
  */
6651
6919
  async function listStringFiles(root, features, layers = {}) {
6652
6920
  const sortedLayers = Object.entries(layers).toSorted((left, right) => left[0] < right[0] ? -1 : 1);
6653
6921
  const outside = sortedLayers.find(([folder]) => folder === "" || NOT_ONE_FOLDER.test(folder));
6654
6922
  if (outside !== void 0) throw notAFolderName(outside[0]);
6655
6923
  const files = [];
6656
- const featuresFolder = path.join(root, features);
6657
- const featuresPath = features.split(path.sep).join("/");
6658
- for (const feature of await listFolders(featuresFolder)) files.push(...await listOwnerFiles(root, feature, `${featuresPath}/${feature}`));
6659
- for (const [folder, name] of sortedLayers) files.push(...await listOwnerFiles(root, name, folder));
6924
+ const owners = [...findFeatureOwners(root, features)].toSorted((left, right) => compareText(left.dir, right.dir));
6925
+ for (const owner of owners) files.push(...await listOwnerFiles(root, owner));
6926
+ for (const [folder, name] of sortedLayers) files.push(...await listOwnerFiles(root, layerOwner(folder, name)));
6660
6927
  return files;
6661
6928
  }
6662
6929
  /**
@@ -6759,6 +7026,31 @@ function remember(table, key, locale, text) {
6759
7026
  table.set(key, byLocale);
6760
7027
  }
6761
7028
  /**
7029
+ * Tells whether a key may live in the file it came from: any key of a top-level feature or a
7030
+ * layer, and for a sub-feature a key that starts with `<name>.` and goes on after it.
7031
+ *
7032
+ * @param key - The message key.
7033
+ * @param source - Where this message came from.
7034
+ * @returns True when the key belongs there.
7035
+ */
7036
+ function isOwnKey(key, source) {
7037
+ if (source.parent === void 0) return true;
7038
+ const prefix = `${source.feature}.`;
7039
+ return key.startsWith(prefix) && key.length > prefix.length;
7040
+ }
7041
+ /**
7042
+ * Records a sub-feature that brought a string file, checking its name once: the name sits between
7043
+ * two dots of its module's file name.
7044
+ *
7045
+ * @param walk - What the walk collects.
7046
+ * @param file - One string file of the walk.
7047
+ */
7048
+ function noteSubFeature(walk, file) {
7049
+ if (file.parent === void 0 || walk.subFeatures.has(file.feature)) return;
7050
+ walk.subFeatures.set(file.feature, file.folder);
7051
+ if (!MODULE_NAME.test(file.feature)) walk.problems.push(`${file.folder}: a sub-feature name is letters, digits, "_" and "-", so generated/strings.<name>.<locale>.ts can be read back.`);
7052
+ }
7053
+ /**
6762
7054
  * Reads one value of a string file and compiles its message into the locale it belongs to.
6763
7055
  *
6764
7056
  * @param walk - What the walk collects.
@@ -6771,6 +7063,10 @@ function compileOne(walk, key, value, source) {
6771
7063
  walk.problems.push(`"${key}" in ${source.relative}: a message is a string or { "text": string, "note": string }.`);
6772
7064
  return;
6773
7065
  }
7066
+ if (!isOwnKey(key, source)) {
7067
+ walk.problems.push(`"${key}" in ${source.relative}: a key of the sub-feature "${source.feature}" starts with "${source.feature}.".`);
7068
+ return;
7069
+ }
6774
7070
  if (!claim(walk, key, source)) return;
6775
7071
  const text = typeof value === "string" ? value : value.text;
6776
7072
  remember(walk.texts, key, source.locale, text);
@@ -6789,15 +7085,17 @@ function compileOne(walk, key, value, source) {
6789
7085
  }
6790
7086
  }
6791
7087
  /**
6792
- * Walks every string file of a game, features first and then the layers, and compiles every
6793
- * message once. Problems are collected, not thrown, so the caller reports them all in one error.
7088
+ * Walks every string file of a game, the feature owners first (sub-features at any depth) and then
7089
+ * the layers, and compiles every message once. Problems are collected, not thrown, so the caller
7090
+ * reports them all in one error.
6794
7091
  *
6795
7092
  * @param root - Game source root, the folder that holds the features.
6796
7093
  * @param features - Name of the folder that holds the features.
6797
7094
  * @param layers - Folder under the root to the name its keys take. Default `{}`: features only.
6798
7095
  * @returns The compiled messages per locale, the owner and parameters of each key, the texts and
6799
7096
  * notes of each key per locale, and every problem.
6800
- * @throws {Error} When a layer folder is empty or holds "/", "\" or ".": nothing is read.
7097
+ * @throws {Error} When a layer folder is empty or holds "/", "\" or ".", or when one feature name
7098
+ * belongs to two folders: nothing is read.
6801
7099
  */
6802
7100
  async function walkStrings(root, features, layers = {}) {
6803
7101
  const walk = {
@@ -6806,16 +7104,19 @@ async function walkStrings(root, features, layers = {}) {
6806
7104
  params: emptyParameters(),
6807
7105
  problems: [],
6808
7106
  texts: emptyTexts(),
6809
- notes: emptyTexts()
7107
+ notes: emptyTexts(),
7108
+ subFeatures: emptySubFeatures()
6810
7109
  };
6811
7110
  for (const file of await listStringFiles(root, features, layers)) {
7111
+ noteSubFeature(walk, file);
6812
7112
  const table = await readTable(file.file, file.relative, walk);
6813
7113
  if (table === void 0) continue;
6814
7114
  const source = {
6815
7115
  feature: file.feature,
6816
7116
  folder: file.folder,
6817
7117
  locale: file.locale,
6818
- relative: file.relative
7118
+ relative: file.relative,
7119
+ ...file.parent === void 0 ? {} : { parent: file.parent }
6819
7120
  };
6820
7121
  for (const [key, value] of Object.entries(table)) compileOne(walk, key, value, source);
6821
7122
  }
@@ -6824,8 +7125,9 @@ async function walkStrings(root, features, layers = {}) {
6824
7125
  //#endregion
6825
7126
  //#region src/plugins/i18n/compile/compile.ts
6826
7127
  /**
6827
- * @file i18n plugin, build time — `compileStrings`: the walk over `features/*​/strings/<locale>.json`
6828
- * and the `strings/` of every layer turned into the generated modules, and on request the
7128
+ * @file i18n plugin, build time — `compileStrings`: the walk over the `strings/` of every feature
7129
+ * owner (top-level features and sub-features at any depth) and of every layer turned into the
7130
+ * generated modules (a sub-feature's keys into a module of its own), and on request the
6829
7131
  * pseudo-locale `en-XA` derived from English. Every problem of a run is collected and reported in
6830
7132
  * one error, so a game fixes its messages in one round. Node and Bun only: nothing under `src/`
6831
7133
  * outside this folder and `src/assets.ts` imports it, so no game bundles the ICU parser.
@@ -6837,19 +7139,20 @@ const PSEUDO_LOCALE = "en-XA";
6837
7139
  /**
6838
7140
  * Wraps a pseudo run without English in the message shape of the framework.
6839
7141
  *
7142
+ * @param features - Name of the folder that holds the features.
6840
7143
  * @returns The error to reject with.
6841
7144
  */
6842
- function pseudoNeedsEnglish() {
6843
- return /* @__PURE__ */ new Error(`${PREFIX}"--pseudo" needs an "en" string file in at least one feature.\n Add features/<feature>/strings/en.json or drop "--pseudo".`);
7145
+ function pseudoNeedsEnglish(features) {
7146
+ return /* @__PURE__ */ new Error(`${PREFIX}"--pseudo" needs an "en" string file in at least one feature.\n Add ${features}/<feature>/strings/en.json (a sub-feature's own strings/en.json counts too) or drop "--pseudo".`);
6844
7147
  }
6845
7148
  /**
6846
- * Tells whether at least one feature or layer brings a string file for a locale.
7149
+ * Tells whether at least one feature, sub-feature or layer brings a string file for a locale.
6847
7150
  *
6848
7151
  * @param root - Game source root, the folder that holds the features.
6849
7152
  * @param features - Name of the folder that holds the features.
6850
7153
  * @param layers - Folder under the root to the name its keys take.
6851
7154
  * @param locale - The locale to look for.
6852
- * @returns True when a feature or a layer has `strings/<locale>.json`.
7155
+ * @returns True when a feature, a sub-feature or a layer has `strings/<locale>.json`.
6853
7156
  */
6854
7157
  async function bringsLocale(root, features, layers, locale) {
6855
7158
  return (await listStringFiles(root, features, layers)).some((file) => file.locale === locale);
@@ -6887,6 +7190,53 @@ function localeModules(walk, pseudo) {
6887
7190
  return modules;
6888
7191
  }
6889
7192
  /**
7193
+ * Splits the entries of one locale by the owner of each key: a sub-feature's keys go to its own
7194
+ * module, every other key to the whole-game module.
7195
+ *
7196
+ * @param walk - What the walk collected.
7197
+ * @param entries - The entries of one locale, the pseudo-locale included.
7198
+ * @returns The whole-game entries and the entries of each sub-feature that has some.
7199
+ */
7200
+ function splitLocale(walk, entries) {
7201
+ const split = {
7202
+ game: [],
7203
+ byFeature: /* @__PURE__ */ new Map()
7204
+ };
7205
+ for (const entry of entries) {
7206
+ const owner = walk.owner.get(entry.key);
7207
+ if (owner?.parent === void 0) {
7208
+ split.game.push(entry);
7209
+ continue;
7210
+ }
7211
+ const own = split.byFeature.get(owner.feature) ?? [];
7212
+ own.push(entry);
7213
+ split.byFeature.set(owner.feature, own);
7214
+ }
7215
+ return split;
7216
+ }
7217
+ /**
7218
+ * The locale outputs of one compile: per locale the whole-game module `strings.<locale>.ts`, then
7219
+ * for every sub-feature with string files (sorted by name) and every locale its
7220
+ * `strings.<feature>.<locale>.ts`, an empty table where it brought nothing for that locale.
7221
+ *
7222
+ * @param walk - What the walk collected.
7223
+ * @param modules - The compiled messages per locale, the pseudo-locale included.
7224
+ * @param out - The generated directory.
7225
+ * @returns The file and the text of each locale output.
7226
+ */
7227
+ function localeOutputs(walk, modules, out) {
7228
+ const locales = [...modules.keys()].toSorted();
7229
+ const split = new Map(locales.map((locale) => [locale, splitLocale(walk, modules.get(locale) ?? [])]));
7230
+ const subFeatures = [...walk.subFeatures.keys()].toSorted();
7231
+ return [...locales.map((locale) => ({
7232
+ file: path.join(out, `strings.${locale}.ts`),
7233
+ text: emitLocale(split.get(locale)?.game ?? [])
7234
+ })), ...subFeatures.flatMap((feature) => locales.map((locale) => ({
7235
+ file: path.join(out, `strings.${feature}.${locale}.ts`),
7236
+ text: emitLocale(split.get(locale)?.byFeature.get(feature) ?? [])
7237
+ })))];
7238
+ }
7239
+ /**
6890
7240
  * Lists the keys a locale is missing, so a translator sees the gap without the build stopping.
6891
7241
  *
6892
7242
  * @param walk - What the compile collects.
@@ -6920,11 +7270,14 @@ function typeEntries(walk) {
6920
7270
  return entries;
6921
7271
  }
6922
7272
  /**
6923
- * Walks `features/*​/strings/<locale>.json` of a game and the `strings/` of every layer, compiles
6924
- * every message and writes the generated modules: the types module `strings.ts` and one
6925
- * `strings.<locale>.ts` per locale, plus `strings.en-XA.ts` with `pseudo`. A check run writes
6926
- * nothing and only reports whether something would change; it checks `strings.en-XA.ts` only with
6927
- * `pseudo`.
7273
+ * Walks the `strings/<locale>.json` of every feature of a game, sub-features at any depth
7274
+ * included, and the `strings/` of every layer, compiles every message and writes the generated
7275
+ * modules: the types module `strings.ts` with every key; one `strings.<locale>.ts` per locale with
7276
+ * the keys of the top-level features and the layers; and for every sub-feature that brings string
7277
+ * files one `strings.<feature>.<locale>.ts` per locale with its own keys (an empty table where it
7278
+ * lacks the locale). `strings.en-XA.ts` and its per-feature twins come with `pseudo`. A game
7279
+ * without sub-features writes exactly the modules 0.14.12 wrote. A check run writes nothing and
7280
+ * only reports whether something would change; it checks the `en-XA` modules only with `pseudo`.
6928
7281
  *
6929
7282
  * @param root - Game source root, the folder that holds the features.
6930
7283
  * @param out - The generated directory the modules are written into.
@@ -6943,13 +7296,14 @@ function typeEntries(walk) {
6943
7296
  * // shared/strings/en.json { "ui.ok": "OK" } compile together.
6944
7297
  * const layered = await compileStrings("src", "src/generated", { layers: { shared: "ui" } });
6945
7298
  * // layered.keys: ["board.full", "ui.ok"]
7299
+ * // features/episodes/ep01/strings/en.json { "ep01.title": "Ward" } also writes generated/strings.ep01.en.ts
6946
7300
  * ```
6947
7301
  */
6948
7302
  async function compileStrings(root, out, options = {}) {
6949
7303
  const features = options.features ?? "features";
6950
7304
  const layers = options.layers ?? {};
6951
7305
  const pseudo = options.pseudo === true;
6952
- if (pseudo && !await bringsLocale(root, features, layers, PSEUDO_SOURCE)) throw pseudoNeedsEnglish();
7306
+ if (pseudo && !await bringsLocale(root, features, layers, PSEUDO_SOURCE)) throw pseudoNeedsEnglish(features);
6953
7307
  const walk = await walkStrings(root, features, layers);
6954
7308
  if (walk.problems.length > 0) throw compileFailure(walk.problems);
6955
7309
  const brought = [...walk.byLocale.keys()].toSorted();
@@ -6966,10 +7320,7 @@ async function compileStrings(root, out, options = {}) {
6966
7320
  const outputs = [{
6967
7321
  file: path.join(out, "strings.ts"),
6968
7322
  text: emitTypes(typeEntries(walk))
6969
- }, ...locales.map((locale) => ({
6970
- file: path.join(out, `strings.${locale}.ts`),
6971
- text: emitLocale(modules.get(locale) ?? [])
6972
- }))];
7323
+ }, ...localeOutputs(walk, modules, out)];
6973
7324
  let changed = false;
6974
7325
  for (const output of outputs) changed = await writeIfChanged(output.file, output.text, write) || changed;
6975
7326
  return {
@@ -7001,8 +7352,8 @@ async function checkStrings(root, out) {
7001
7352
  /**
7002
7353
  * @file i18n plugin, build time — the exchange with translators. `exportStrings` writes one JSON
7003
7354
  * file per locale with every key, the source text and the note beside it; `importStrings` checks
7004
- * the translated files against the game, writes them into the string files of the features and
7005
- * layers that own the keys, and compiles. Node and Bun only, reached through `src/assets.ts`.
7355
+ * the translated files against the game, writes them into the string files of the features,
7356
+ * sub-features and layers that own the keys, and compiles. Node and Bun only, reached through `src/assets.ts`.
7006
7357
  */
7007
7358
  /** The locale translators read from when none is named. */
7008
7359
  const DEFAULT_SOURCE = "en";