@datamoc/mw_games 0.41.0 → 0.43.0

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 (160) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +62 -1
  3. package/REFERENCE.md +73 -8
  4. package/api-surface.json +696 -0
  5. package/data/microgm-essential.PROVENANCE.md +13 -0
  6. package/data/microgm-essential.sf2 +0 -0
  7. package/data/soundfont-catalog.json +8 -0
  8. package/dist/audio/AudioLoader.d.ts +28 -0
  9. package/dist/audio/AudioLoader.d.ts.map +1 -0
  10. package/dist/audio/AudioLoader.js +53 -0
  11. package/dist/audio/AudioLoader.js.map +1 -0
  12. package/dist/audio/Channels.d.ts +1 -0
  13. package/dist/audio/Channels.d.ts.map +1 -1
  14. package/dist/audio/Channels.js +2 -1
  15. package/dist/audio/Channels.js.map +1 -1
  16. package/dist/audio/MidiRender.d.ts +19 -1
  17. package/dist/audio/MidiRender.d.ts.map +1 -1
  18. package/dist/audio/MidiRender.js +24 -1
  19. package/dist/audio/MidiRender.js.map +1 -1
  20. package/dist/audio/MusicPlayer.d.ts +80 -0
  21. package/dist/audio/MusicPlayer.d.ts.map +1 -0
  22. package/dist/audio/MusicPlayer.js +147 -0
  23. package/dist/audio/MusicPlayer.js.map +1 -0
  24. package/dist/audio/SampleDecoder.d.ts +16 -2
  25. package/dist/audio/SampleDecoder.d.ts.map +1 -1
  26. package/dist/audio/SampleDecoder.js +10 -6
  27. package/dist/audio/SampleDecoder.js.map +1 -1
  28. package/dist/audio/Sound.d.ts +2 -0
  29. package/dist/audio/Sound.d.ts.map +1 -1
  30. package/dist/audio/Sound.js +3 -0
  31. package/dist/audio/Sound.js.map +1 -1
  32. package/dist/audio/SoundFont.d.ts +40 -5
  33. package/dist/audio/SoundFont.d.ts.map +1 -1
  34. package/dist/audio/SoundFont.js +52 -14
  35. package/dist/audio/SoundFont.js.map +1 -1
  36. package/dist/audio/Synth.d.ts +1 -1
  37. package/dist/audio/Synth.d.ts.map +1 -1
  38. package/dist/audio/Synth.js +4 -0
  39. package/dist/audio/Synth.js.map +1 -1
  40. package/dist/audio/index.d.ts +8 -4
  41. package/dist/audio/index.d.ts.map +1 -1
  42. package/dist/audio/index.js +4 -2
  43. package/dist/audio/index.js.map +1 -1
  44. package/dist/core/DialogueText.d.ts +25 -0
  45. package/dist/core/DialogueText.d.ts.map +1 -1
  46. package/dist/core/DialogueText.js +25 -0
  47. package/dist/core/DialogueText.js.map +1 -1
  48. package/dist/core/Motion.d.ts +20 -0
  49. package/dist/core/Motion.d.ts.map +1 -1
  50. package/dist/core/Motion.js +20 -0
  51. package/dist/core/Motion.js.map +1 -1
  52. package/dist/core/Save.d.ts +31 -0
  53. package/dist/core/Save.d.ts.map +1 -1
  54. package/dist/core/Save.js +31 -0
  55. package/dist/core/Save.js.map +1 -1
  56. package/dist/core/Twee.d.ts +34 -0
  57. package/dist/core/Twee.d.ts.map +1 -1
  58. package/dist/core/Twee.js +34 -0
  59. package/dist/core/Twee.js.map +1 -1
  60. package/dist/mw_games.global.js +22 -22
  61. package/dist/mwl/index.d.ts +2 -0
  62. package/dist/mwl/index.d.ts.map +1 -1
  63. package/dist/mwl/index.js +1 -0
  64. package/dist/mwl/index.js.map +1 -1
  65. package/dist/mwl/objects.d.ts +31 -0
  66. package/dist/mwl/objects.d.ts.map +1 -0
  67. package/dist/mwl/objects.js +83 -0
  68. package/dist/mwl/objects.js.map +1 -0
  69. package/dist/mwl/schema.d.ts +1 -1
  70. package/dist/mwl/schema.d.ts.map +1 -1
  71. package/dist/mwl/tables.d.ts +2 -0
  72. package/dist/mwl/tables.d.ts.map +1 -1
  73. package/dist/mwl/tables.js +13 -0
  74. package/dist/mwl/tables.js.map +1 -1
  75. package/dist/physics/XpbdWorld.d.ts +68 -5
  76. package/dist/physics/XpbdWorld.d.ts.map +1 -1
  77. package/dist/physics/XpbdWorld.js +135 -6
  78. package/dist/physics/XpbdWorld.js.map +1 -1
  79. package/dist/testing/index.d.ts +16 -0
  80. package/dist/testing/index.d.ts.map +1 -1
  81. package/dist/testing/index.js +21 -0
  82. package/dist/testing/index.js.map +1 -1
  83. package/dist/three-d/Collision3D.d.ts +46 -2
  84. package/dist/three-d/Collision3D.d.ts.map +1 -1
  85. package/dist/three-d/Collision3D.js +46 -2
  86. package/dist/three-d/Collision3D.js.map +1 -1
  87. package/dist/three-d/Grid.d.ts +12 -1
  88. package/dist/three-d/Grid.d.ts.map +1 -1
  89. package/dist/three-d/Grid.js +12 -1
  90. package/dist/three-d/Grid.js.map +1 -1
  91. package/dist/three-d/Vox.d.ts +13 -1
  92. package/dist/three-d/Vox.d.ts.map +1 -1
  93. package/dist/three-d/Vox.js +13 -1
  94. package/dist/three-d/Vox.js.map +1 -1
  95. package/dist/two-d/Game.d.ts +21 -1
  96. package/dist/two-d/Game.d.ts.map +1 -1
  97. package/dist/two-d/Game.js +73 -6
  98. package/dist/two-d/Game.js.map +1 -1
  99. package/dist/two-d/Scene2D.d.ts +24 -0
  100. package/dist/two-d/Scene2D.d.ts.map +1 -1
  101. package/dist/two-d/Scene2D.js +30 -0
  102. package/dist/two-d/Scene2D.js.map +1 -1
  103. package/dist/two-d/render/SceneTransition.d.ts +272 -0
  104. package/dist/two-d/render/SceneTransition.d.ts.map +1 -0
  105. package/dist/two-d/render/SceneTransition.js +431 -0
  106. package/dist/two-d/render/SceneTransition.js.map +1 -0
  107. package/dist/two-d/render/index.d.ts +2 -0
  108. package/dist/two-d/render/index.d.ts.map +1 -1
  109. package/dist/two-d/render/index.js +1 -0
  110. package/dist/two-d/render/index.js.map +1 -1
  111. package/dist/two-d/ui/Button.d.ts +23 -0
  112. package/dist/two-d/ui/Button.d.ts.map +1 -1
  113. package/dist/two-d/ui/Button.js +37 -0
  114. package/dist/two-d/ui/Button.js.map +1 -1
  115. package/dist/two-d/ui/DropdownView.d.ts +49 -3
  116. package/dist/two-d/ui/DropdownView.d.ts.map +1 -1
  117. package/dist/two-d/ui/DropdownView.js +150 -9
  118. package/dist/two-d/ui/DropdownView.js.map +1 -1
  119. package/dist/two-d/ui/ScrollBox.d.ts +90 -0
  120. package/dist/two-d/ui/ScrollBox.d.ts.map +1 -1
  121. package/dist/two-d/ui/ScrollBox.js +206 -4
  122. package/dist/two-d/ui/ScrollBox.js.map +1 -1
  123. package/dist/two-d/ui/ScrollText.d.ts +122 -0
  124. package/dist/two-d/ui/ScrollText.d.ts.map +1 -0
  125. package/dist/two-d/ui/ScrollText.js +202 -0
  126. package/dist/two-d/ui/ScrollText.js.map +1 -0
  127. package/dist/two-d/ui/Tooltip.d.ts +70 -1
  128. package/dist/two-d/ui/Tooltip.d.ts.map +1 -1
  129. package/dist/two-d/ui/Tooltip.js +115 -3
  130. package/dist/two-d/ui/Tooltip.js.map +1 -1
  131. package/dist/two-d/ui/Window.d.ts +35 -2
  132. package/dist/two-d/ui/Window.d.ts.map +1 -1
  133. package/dist/two-d/ui/Window.js +58 -3
  134. package/dist/two-d/ui/Window.js.map +1 -1
  135. package/dist/two-d/ui/index.d.ts +7 -5
  136. package/dist/two-d/ui/index.d.ts.map +1 -1
  137. package/dist/two-d/ui/index.js +3 -2
  138. package/dist/two-d/ui/index.js.map +1 -1
  139. package/dist/two-d/ui/theme.d.ts +18 -0
  140. package/dist/two-d/ui/theme.d.ts.map +1 -1
  141. package/dist/two-d/ui/theme.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +14 -3
  145. package/sbom.cdx.json +462 -243
  146. package/tools/browser-driver.mjs +3 -3
  147. package/tools/browser-smoke.mjs +19 -2
  148. package/tools/build-tablebases.mjs +7 -3
  149. package/tools/compile-openings.mjs +7 -3
  150. package/tools/compile-resources.d.mts +8 -0
  151. package/tools/compile-resources.mjs +62 -14
  152. package/tools/emit-page.d.mts +9 -0
  153. package/tools/emit-page.mjs +57 -4
  154. package/tools/find-chrome.mjs +4 -2
  155. package/tools/init.mjs +11 -3
  156. package/tools/play-uci.mjs +9 -5
  157. package/tools/svg-optimise.d.mts +8 -0
  158. package/tools/svg-optimise.mjs +45 -0
  159. package/tools/vite.mjs +10 -1
  160. package/tools.md +5 -2
@@ -361,12 +361,12 @@ function findOnPath(names) {
361
361
  }
362
362
 
363
363
  /**
364
- * Locates a browser executable without a driver install: the `CHROME_PATH` /
365
- * `FIREFOX_PATH` override first, then well-known install locations. Returns null
364
+ * Locates a browser executable without a driver install: `CHROME_PATH` (or `CHROME`) /
365
+ * `FIREFOX_PATH` overrides first, then well-known install locations. Returns null
366
366
  * when neither yields anything, so callers skip rather than fail.
367
367
  */
368
368
  export function findBrowser(kind) {
369
- const override = process.env[kind === 'chrome' ? 'CHROME_PATH' : 'FIREFOX_PATH'];
369
+ const override = kind === 'chrome' ? process.env.CHROME_PATH || process.env.CHROME : process.env.FIREFOX_PATH;
370
370
  if (override) return override;
371
371
  const onPath = findOnPath(kind === 'chrome' ? ['chrome', 'chromium', 'google-chrome'] : ['firefox']);
372
372
  if (onPath) return onPath;
@@ -29,6 +29,23 @@ export async function waitForGame(page, url, pageErrors = []) {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Waits for the page to draw a few frames instead of sleeping a fixed time: a software renderer can need
34
+ * far longer than any fixed pause, and a fast one far less. Capped, since a hidden tab throttles frames.
35
+ */
36
+ async function settle(page, frames = 3, capMs = 5000) {
37
+ await page.evaluate(
38
+ ([count, cap]) =>
39
+ new Promise((done) => {
40
+ const timer = setTimeout(done, cap);
41
+ const next = (left) =>
42
+ left === 0 ? (clearTimeout(timer), done()) : requestAnimationFrame(() => next(left - 1));
43
+ next(count);
44
+ }),
45
+ [frames, capMs],
46
+ );
47
+ }
48
+
32
49
  /**
33
50
  * Opens a built page from `file://` in headless Chrome and asserts it actually rendered.
34
51
  *
@@ -76,11 +93,11 @@ export async function smokePage({
76
93
  await waitForGame(page, url, pageErrors);
77
94
 
78
95
  //let the scene finish its first frames before reading pixels back
79
- await page.waitForTimeout(250);
96
+ await settle(page);
80
97
  if (keyToPress) {
81
98
  await page.keyboard.press(keyToPress);
82
99
  //give whatever the key opened time to lay itself out and draw
83
- await page.waitForTimeout(250);
100
+ await settle(page);
84
101
  }
85
102
  if (after) await after(page);
86
103
 
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { mkdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import { join, relative } from 'node:path';
6
- import { huffEncode } from '../src/board/tablebase.ts';
7
- import { spawn } from '../src/threads/spawn.ts';
6
+ //the framework's own modules: the TypeScript sources inside this repository, the built `dist` inside an
7
+ //installed package, which ships no `src`
8
+ const fromSource = existsSync(new URL('../src/board/tablebase.ts', import.meta.url));
9
+ const load = (path) => import(fromSource ? `../src/${path}.ts` : `../dist/${path}.js`);
10
+ const { huffEncode } = await load('board/tablebase');
11
+ const { spawn } = await load('threads/spawn');
8
12
 
9
13
  /**
10
14
  * Generates pawnless endgame tablebases (WDL plus distance-to-mate) and ships
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
5
5
  import { join, relative } from 'node:path';
6
- import { applyMove, legalMoves, positionKey, sq, squareName, startingChess } from '../src/board/chess.ts';
7
- import { parseUciMove } from '../src/board/openings.ts';
6
+ //the framework's own modules: the TypeScript sources inside this repository, the built `dist` inside an
7
+ //installed package, which ships no `src`
8
+ const fromSource = existsSync(new URL('../src/board/chess.ts', import.meta.url));
9
+ const load = (path) => import(fromSource ? `../src/${path}.ts` : `../dist/${path}.js`);
10
+ const { applyMove, legalMoves, positionKey, sq, squareName, startingChess } = await load('board/chess');
11
+ const { parseUciMove } = await load('board/openings');
8
12
 
9
13
  /**
10
14
  * Compiles opening tables (`eco`, `name`, `pgn` TSV rows, one per line after the header)
@@ -12,6 +12,8 @@ export interface CompileResourcesOptions {
12
12
  toWebp?: boolean;
13
13
  webpLossless?: boolean;
14
14
  webpQuality?: number;
15
+ /** minify embedded `.svg` assets through the optional `svgo` package (version 4 or newer); false by default */
16
+ svgo?: boolean;
15
17
  /** Subset an embedded `.sf2` to the programs the embedded `.mid`/`.midi` files use. Default true; `false` embeds every SoundFont whole. */
16
18
  soundFont?: boolean;
17
19
  /** Explicit `{ bank, program }` list to subset a SoundFont to, overriding the MIDI-derived list. */
@@ -26,9 +28,13 @@ export interface CompileResourcesGroup {
26
28
 
27
29
  export interface CompileResourcesResult {
28
30
  groups: CompileResourcesGroup[];
31
+ /** the asset path of every embedded file, as game code passes it to `load()` */
32
+ keys: string[];
29
33
  rawBytes: number;
30
34
  embeddedBytes: number;
31
35
  webpConverted: number;
36
+ /** SVG assets embedded smaller than their source because of `svgo` */
37
+ svgOptimised: number;
32
38
  skipped: number;
33
39
  /** the `CREDITS.txt` text when the asset folder holds a `credits.json`, else null */
34
40
  credits: string | null;
@@ -50,6 +56,8 @@ export interface SoundFontEmbed {
50
56
  programs: number | null;
51
57
  rawBytes: number;
52
58
  embeddedBytes: number;
59
+ /** the programs the game's MIDI files ask for that this font does not carry; empty when none */
60
+ missing: SoundFontUsage[];
53
61
  }
54
62
 
55
63
  /** parses a `--soundfont-usage=bank:program,...` CLI value; undefined when the flag is absent */
@@ -121,6 +121,10 @@ export async function* walk(dir) {
121
121
  * pixel-art tileset's exact colours survive; set false only with `webpQuality` to opt into
122
122
  * lossy re-encoding for photographic source art)
123
123
  * @param options.webpQuality 0-100, used only when `webpLossless` is false (default 90)
124
+ * @param options.svgo minify embedded `.svg` assets through the optional `svgo` package (default
125
+ * false - see `tools/svg-optimise.mjs`). The key never changes, only the embedded bytes, and
126
+ * only when the optimised file is actually smaller. A file svgo cannot parse fails the build by
127
+ * name rather than shipping unoptimised in silence.
124
128
  * @param options.soundFont subset an embedded `.sf2` or `.sf3` to the programs the embedded
125
129
  * `.mid`/`.midi` files use (default true); `false` embeds every SoundFont whole
126
130
  * @param options.soundFontUsage explicit `{ bank, program }` list to subset to, for a
@@ -141,6 +145,7 @@ export async function compileResources({
141
145
  toWebp: convertToWebp = false,
142
146
  webpLossless = true,
143
147
  webpQuality = 90,
148
+ svgo: optimiseSvgFiles = false,
144
149
  soundFont = true,
145
150
  soundFontUsage,
146
151
  } = {}) {
@@ -205,13 +210,16 @@ export async function compileResources({
205
210
  let rawBytes = 0;
206
211
  let embeddedBytes = 0;
207
212
  let webpConverted = 0;
213
+ let svgOptimised = 0;
208
214
 
209
215
  // only paid when toWebp is actually used, so a build that never asks for it never imports
210
216
  // (or requires installing) sharp
211
217
  const toWebp = convertToWebp ? (await import('./webp-convert.mjs')).toWebp : null;
218
+ const optimiseSvg = optimiseSvgFiles ? (await import('./svg-optimise.mjs')).optimiseSvg : null;
212
219
 
213
220
  const creditsFile = join(from, 'credits.json');
214
221
  let credits = null;
222
+ const keys = [];
215
223
 
216
224
  for (const file of files) {
217
225
  if (selected && !selected.has(keyOf(file))) continue;
@@ -240,6 +248,19 @@ export async function compileResources({
240
248
  }
241
249
  }
242
250
 
251
+ if (optimiseSvg && ext === '.svg') {
252
+ let optimised;
253
+ try {
254
+ optimised = await optimiseSvg(data);
255
+ } catch (error) {
256
+ throw new Error(`cannot optimise SVG asset ${key}: ${error.message ?? error}`);
257
+ }
258
+ if (optimised.length < data.length) {
259
+ bytes = optimised;
260
+ svgOptimised++;
261
+ }
262
+ }
263
+
243
264
  if (isSoundFontKey(key)) {
244
265
  if (usage !== undefined && soundFontTools) {
245
266
  try {
@@ -247,7 +268,14 @@ export async function compileResources({
247
268
  } catch (error) {
248
269
  throw new Error(`cannot subset SoundFont asset ${key}: ${error.message ?? error}`);
249
270
  }
250
- soundFonts.push({ key, programs: usage.length, rawBytes: data.length, embeddedBytes: bytes.length });
271
+ soundFonts.push({
272
+ key,
273
+ programs: usage.length,
274
+ rawBytes: data.length,
275
+ embeddedBytes: bytes.length,
276
+ //the programs the MIDI files ask for that this font does not carry
277
+ missing: soundFontTools.missingSoundFontPrograms(data, usage),
278
+ });
251
279
  } else {
252
280
  soundFonts.push({ key, programs: null, rawBytes: data.length, embeddedBytes: data.length });
253
281
  }
@@ -256,6 +284,7 @@ export async function compileResources({
256
284
  const name = group(key);
257
285
  if (!groups.has(name)) groups.set(name, {});
258
286
  groups.get(name)[key] = `data:${mimeType};base64,${bytes.toString('base64')}`;
287
+ keys.push(key);
259
288
  rawBytes += data.length;
260
289
  embeddedBytes += bytes.length;
261
290
  }
@@ -273,7 +302,17 @@ export async function compileResources({
273
302
 
274
303
  if (credits) await writeFile(join(to, 'CREDITS.txt'), credits, 'utf8');
275
304
 
276
- return { groups: written, rawBytes, embeddedBytes, webpConverted, skipped, credits, soundFonts };
305
+ return {
306
+ groups: written,
307
+ keys,
308
+ rawBytes,
309
+ embeddedBytes,
310
+ webpConverted,
311
+ svgOptimised,
312
+ skipped,
313
+ credits,
314
+ soundFonts,
315
+ };
277
316
  }
278
317
 
279
318
  /** the plain-text credits for a manifest, one block per entry; throws on an entry that cannot be attributed */
@@ -302,7 +341,7 @@ async function runCli() {
302
341
  const [from, to] = args.filter((a) => !a.startsWith('--'));
303
342
  if (!from || !to) {
304
343
  console.error(
305
- 'usage: node compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]] [--no-soundfont-subset] [--soundfont-usage=bank:program,...]',
344
+ 'usage: node compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]] [--svgo] [--no-soundfont-subset] [--soundfont-usage=bank:program,...]',
306
345
  );
307
346
  process.exit(1);
308
347
  }
@@ -315,17 +354,19 @@ async function runCli() {
315
354
  args.find((a) => a.startsWith('--soundfont-usage='))?.slice('--soundfont-usage='.length),
316
355
  );
317
356
 
318
- const { groups, rawBytes, embeddedBytes, webpConverted, skipped, soundFonts } = await compileResources({
319
- from,
320
- to,
321
- //a flat folder has no subdirectories to group by, so everything lands in one file
322
- groupBy: (key) => (key.includes('/') ? key.slice(0, key.indexOf('/')) : 'assets'),
323
- toWebp,
324
- webpLossless,
325
- webpQuality,
326
- soundFont: !args.includes('--no-soundfont-subset'),
327
- soundFontUsage,
328
- });
357
+ const { groups, rawBytes, embeddedBytes, webpConverted, svgOptimised, skipped, soundFonts } =
358
+ await compileResources({
359
+ from,
360
+ to,
361
+ //a flat folder has no subdirectories to group by, so everything lands in one file
362
+ groupBy: (key) => (key.includes('/') ? key.slice(0, key.indexOf('/')) : 'assets'),
363
+ toWebp,
364
+ webpLossless,
365
+ webpQuality,
366
+ svgo: args.includes('--svgo'),
367
+ soundFont: !args.includes('--no-soundfont-subset'),
368
+ soundFontUsage,
369
+ });
329
370
 
330
371
  const kb = (n) => (n / 1024).toFixed(1).padStart(8) + ' KB';
331
372
  for (const { name, count, size } of groups) {
@@ -338,7 +379,14 @@ async function runCli() {
338
379
  ? ` ${font.key}: SoundFont embedded whole (${kb(font.rawBytes)}), no shipped MIDI to subset against`
339
380
  : ` ${font.key}: SoundFont subset to ${font.programs} program(s), ${kb(font.rawBytes)} -> ${kb(font.embeddedBytes)}`,
340
381
  );
382
+ if (font.missing?.length > 0) {
383
+ console.log(
384
+ ` warning: ${font.key} does not carry ${font.missing.map(({ bank, program }) => `${bank}:${program}`).join(', ')}: ` +
385
+ "they play the font's piano, or the built-in synthesizer with `missingProgram: 'synth'`",
386
+ );
387
+ }
341
388
  }
389
+ if (args.includes('--svgo')) console.log(` ${svgOptimised} SVG(s) optimised`);
342
390
  if (toWebp) {
343
391
  console.log(
344
392
  ` ${webpConverted} image(s) converted to WebP (${webpLossless ? 'lossless' : `lossy, quality ${webpQuality}`}), ` +
@@ -20,12 +20,16 @@ export interface EmitPageOptions {
20
20
  toWebp?: boolean;
21
21
  webpLossless?: boolean;
22
22
  webpQuality?: number;
23
+ /** minify embedded `.svg` assets through the optional `svgo` package; false by default */
24
+ svgo?: boolean;
23
25
  /** Subset an embedded `.sf2` to the programs the embedded `.mid`/`.midi` files use. Default true; `false` embeds every SoundFont whole. */
24
26
  soundFont?: boolean;
25
27
  /** Explicit `{ bank, program }` list to subset a SoundFont to, overriding the MIDI-derived list. */
26
28
  soundFontUsage?: readonly SoundFontUsage[];
27
29
  /** the page's Content-Security-Policy: on by default, `false` to leave it out */
28
30
  csp?: boolean | CspOptions;
31
+ /** fails the build when the page a player downloads (the standalone file, else the page and its scripts) is larger than this many bytes */
32
+ maxPageBytes?: number;
29
33
  }
30
34
 
31
35
  export interface CspOptions {
@@ -42,10 +46,15 @@ export interface EmitPageResult {
42
46
  rawBytes: number;
43
47
  embeddedBytes: number;
44
48
  webpConverted: number;
49
+ svgOptimised: number;
45
50
  /** one entry per embedded `.sf2`: the programs kept (null when embedded whole) and the byte saving */
46
51
  soundFonts: SoundFontEmbed[];
47
52
  compressed: CompressRow[] | null;
48
53
  single: SingleFileResult | null;
54
+ /** embedded assets whose path is never named in the game's script: a hint, since a path built at run time is not seen */
55
+ unusedAssets: string[];
56
+ /** bytes a player downloads to open the page */
57
+ pageBytes: number;
49
58
  }
50
59
 
51
60
  /** finishes a vite build as a page that opens from `file://` */
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFile, writeFile, rm, stat } from 'node:fs/promises';
2
+ import { readFile, readdir, writeFile, rm, stat } from 'node:fs/promises';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { join, resolve as resolvePath } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -29,7 +29,7 @@ import { withContentSecurityPolicy } from './csp.mjs';
29
29
  * usage: mwg-emit <project folder> [--dist=<dir>] [--assets=<dir>] [--no-csp] [--connect=<origin>]...
30
30
  * [--no-compress] [--xz]
31
31
  * [--single-file] [--single-file-compress[=level]]
32
- * [--single-file-brotli[=quality]] [--to-webp] [--webp-lossy[=quality]]
32
+ * [--single-file-brotli[=quality]] [--to-webp] [--webp-lossy[=quality]] [--svgo]
33
33
  * [--no-soundfont-subset] [--soundfont-usage=bank:program,...]
34
34
  *
35
35
  * `--dist` defaults to `<project folder>/dist` and `--assets` to `<project folder>/assets`; with
@@ -53,6 +53,9 @@ import { withContentSecurityPolicy } from './csp.mjs';
53
53
  * `--to-webp` converts `.png`/`.jpg`/`.jpeg` example assets to WebP before embedding (see
54
54
  * `tools/webp-convert.mjs`), lossless by default; `--webp-lossy[=quality]` opts into lossy
55
55
  * re-encoding instead. Needs the optional `sharp` devDependency installed.
56
+ *
57
+ * `--svgo` minifies embedded `.svg` assets (see `tools/svg-optimise.mjs`), keeping the `viewBox` and
58
+ * every id. Needs the optional `svgo` package installed.
56
59
  */
57
60
 
58
61
  /**
@@ -74,9 +77,11 @@ export async function emitPage({
74
77
  toWebp = false,
75
78
  webpLossless = true,
76
79
  webpQuality = 90,
80
+ svgo = false,
77
81
  soundFont = true,
78
82
  soundFontUsage,
79
83
  csp = true,
84
+ maxPageBytes,
80
85
  }) {
81
86
  if (!dist) throw new Error('emitPage needs the dist folder of a vite build');
82
87
  if (!assets && assetFiles?.length) throw new Error(`unknown asset file ${assetFiles[0]}: no assets folder`);
@@ -89,10 +94,11 @@ export async function emitPage({
89
94
  toWebp,
90
95
  webpLossless,
91
96
  webpQuality,
97
+ svgo,
92
98
  soundFont,
93
99
  soundFontUsage,
94
100
  })
95
- : { groups: [], rawBytes: 0, embeddedBytes: 0, webpConverted: 0, soundFonts: [] };
101
+ : { groups: [], keys: [], rawBytes: 0, embeddedBytes: 0, webpConverted: 0, svgOptimised: 0, soundFonts: [] };
96
102
 
97
103
  const html = await readFile(join(dist, 'index.html'), 'utf8');
98
104
  const classic = toClassicScript(html);
@@ -119,7 +125,38 @@ export async function emitPage({
119
125
  const standalone = await readFile(single.path, 'utf8');
120
126
  await writeFile(single.path, withContentSecurityPolicy(standalone, { ...cspOptions, scripts }), 'utf8');
121
127
  }
122
- return { dist, entry: classic.src, ...compiled, compressed, single };
128
+ const unusedAssets = await unreferencedAssets(dist, classic.src, compiled.keys);
129
+ const pageBytes = await pageWeight(dist, single, compiled.groups);
130
+ if (maxPageBytes !== undefined && pageBytes > maxPageBytes) {
131
+ throw new Error(
132
+ `the page weighs ${kb(pageBytes)}, over the ${kb(maxPageBytes)} budget (maxPageBytes)` +
133
+ (unusedAssets.length
134
+ ? `; ${unusedAssets.length} embedded asset(s) are never named in the game code: ${unusedAssets.slice(0, 5).join(', ')}`
135
+ : ''),
136
+ );
137
+ }
138
+ return { dist, entry: classic.src, ...compiled, compressed, single, unusedAssets, pageBytes };
139
+ }
140
+
141
+ /** the embedded assets whose path appears nowhere in the game's script: a hint (a path built at run time is not seen), never an error */
142
+ async function unreferencedAssets(dist, entry, keys) {
143
+ if (keys.length === 0) return [];
144
+ const code = await readFile(join(dist, entry), 'utf8').catch(() => '');
145
+ return keys.filter((key) => !code.includes(key)).sort();
146
+ }
147
+
148
+ /** the bytes a player downloads to open the page: the single file when there is one, else the page, the game script and the asset scripts */
149
+ async function pageWeight(dist, single, groups) {
150
+ const files = single
151
+ ? [single.path]
152
+ : [
153
+ join(dist, 'index.html'),
154
+ ...(await readdir(dist)).filter((name) => name.endsWith('.js')).map((name) => join(dist, name)),
155
+ ...groups.map((g) => join(dist, 'assets', `${g.name}.js`)),
156
+ ];
157
+ let total = 0;
158
+ for (const file of files) total += (await stat(file)).size;
159
+ return total;
123
160
  }
124
161
 
125
162
  const kb = (n) => (n / 1024).toFixed(1) + ' KB';
@@ -162,8 +199,10 @@ async function main(argv) {
162
199
  toWebp: argv.includes('--to-webp'),
163
200
  webpLossless: !webpLossyFlag,
164
201
  webpQuality: webpLossyFlag?.includes('=') ? Number(webpLossyFlag.split('=')[1]) : 90,
202
+ svgo: argv.includes('--svgo'),
165
203
  soundFont: !argv.includes('--no-soundfont-subset'),
166
204
  soundFontUsage,
205
+ maxPageBytes: value('max-page-bytes') ? Number(value('max-page-bytes')) : undefined,
167
206
  csp: argv.includes('--no-csp')
168
207
  ? false
169
208
  : { connect: argv.filter((a) => a.startsWith('--connect=')).map((a) => a.slice('--connect='.length)) },
@@ -179,12 +218,26 @@ async function main(argv) {
179
218
  `${kb(result.rawBytes)} -> ${kb(result.embeddedBytes)} embedded`,
180
219
  );
181
220
  }
221
+ if (argv.includes('--svgo')) console.log(` ${result.svgOptimised} SVG(s) optimised`);
222
+ console.log(` page weight ${kb(result.pageBytes)}`);
223
+ if (result.unusedAssets.length > 0) {
224
+ console.log(
225
+ ` warning: ${result.unusedAssets.length} embedded asset(s) are never named in the game code, ` +
226
+ `so they only add weight (a path built at run time is not seen): ${result.unusedAssets.join(', ')}`,
227
+ );
228
+ }
182
229
  for (const font of result.soundFonts ?? []) {
183
230
  console.log(
184
231
  font.programs === null
185
232
  ? ` ${font.key}: SoundFont embedded whole (${kb(font.rawBytes)}), no shipped MIDI to subset against`
186
233
  : ` ${font.key}: SoundFont subset to ${font.programs} program(s), ${kb(font.rawBytes)} -> ${kb(font.embeddedBytes)}`,
187
234
  );
235
+ if (font.missing?.length > 0) {
236
+ console.log(
237
+ ` warning: ${font.key} does not carry ${font.missing.map(({ bank, program }) => `${bank}:${program}`).join(', ')}: ` +
238
+ "they play the font's piano, or the built-in synthesizer with `missingProgram: 'synth'`",
239
+ );
240
+ }
188
241
  }
189
242
  console.log(' open index.html directly - no server needed');
190
243
  if (result.compressed) {
@@ -17,9 +17,11 @@ async function playwright() {
17
17
  /**
18
18
  * The browser to drive: the platform's usual Chrome (or, on Windows, Edge), then a Chromium that
19
19
  * Playwright itself installed (`PLAYWRIGHT_BROWSERS_PATH`, common in CI images and containers),
20
- * then whatever is on PATH. `CHROME_PATH` overrides all of them in `launchChrome`.
20
+ * then whatever is on PATH. `CHROME_PATH` or `CHROME` overrides all of them.
21
21
  */
22
22
  export async function findChrome() {
23
+ const override = process.env.CHROME_PATH || process.env.CHROME;
24
+ if (override) return override;
23
25
  const candidates =
24
26
  process.platform === 'win32'
25
27
  ? [
@@ -93,7 +95,7 @@ async function playwrightChromium() {
93
95
  * page setup and assertions; this is the launch they all had in common.
94
96
  */
95
97
  export async function launchChrome({ argsEnv = 'MWG_BENCHMARK_CHROME_ARGS' } = {}) {
96
- const executablePath = process.env.CHROME_PATH ?? (await findChrome());
98
+ const executablePath = await findChrome();
97
99
  const extraArgs = (process.env[argsEnv] ?? '').split(' ').filter(Boolean);
98
100
  const { chromium } = await playwright();
99
101
  return chromium.launch({
package/tools/init.mjs CHANGED
@@ -26,7 +26,13 @@ export function initFiles(name) {
26
26
  private: true,
27
27
  version: '0.0.0',
28
28
  type: 'module',
29
- scripts: { dev: 'vite', build: 'vite build', check: 'tsc --noEmit' },
29
+ scripts: {
30
+ dev: 'vite',
31
+ build: 'vite build',
32
+ check: 'tsc --noEmit',
33
+ test: 'node --test',
34
+ 'hooks:install': 'git config core.hooksPath .githooks',
35
+ },
30
36
  dependencies: { '@datamoc/mw_games': `^${PACKAGE_VERSION}`, 'pixi.js': '^8.20.1' },
31
37
  devDependencies: { typescript: '^7.0.2', vite: '^8.2.2' },
32
38
  },
@@ -101,7 +107,9 @@ export function initFiles(name) {
101
107
  '',
102
108
  ].join('\n'),
103
109
  'assets/.gitkeep': '',
104
- 'README.md': `# ${name}\n\n\`npm install\`, then \`npm run dev\` to play it while you edit, \`npm run build\` to write \`dist/\`.\nOpen \`dist/index.html\` (or \`dist/standalone.html\`) by double-clicking it: no server needed.\n\nPut images and sounds under \`assets/\`; the build compiles them into the page. An \`assets/credits.json\`\n(\`[{ "name": "...", "license": "...", "author": "...", "url": "..." }]\`) becomes \`dist/CREDITS.txt\`.\n`,
110
+ '.gitignore': 'node_modules/\ndist/\n.opencode/\n.coord-session\n',
111
+ '.githooks/pre-commit': '#!/bin/sh\nset -e\nnpm run check\nnpm test\n',
112
+ 'README.md': `# ${name}\n\n\`npm install\`, then \`npm run dev\` to play it while you edit, \`npm run build\` to write \`dist/\`.\nOpen \`dist/index.html\` (or \`dist/standalone.html\`) by double-clicking it: no server needed.\n\nPut images and sounds under \`assets/\`; the build compiles them into the page. An \`assets/credits.json\`\n(\`[{ "name": "...", "license": "...", "author": "...", "url": "..." }]\`) becomes \`dist/CREDITS.txt\`.\n\nAfter \`git init\`, run \`npm run hooks:install\` to enable the included pre-commit hook.\nIt runs \`npm run check\` and \`npm test\`; add your tests under \`tests/\`.\n`,
105
113
  };
106
114
  }
107
115
 
@@ -121,7 +129,7 @@ export function initProject(folder) {
121
129
  )) {
122
130
  const file = join(target, path);
123
131
  mkdirSync(dirname(file), { recursive: true });
124
- writeFileSync(file, content, 'utf8');
132
+ writeFileSync(file, content, { encoding: 'utf8', mode: path === '.githooks/pre-commit' ? 0o755 : 0o644 });
125
133
  written.push(path);
126
134
  }
127
135
  return { folder: target, files: written };
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'node:url';
3
- import { readFileSync, readdirSync, realpathSync } from 'node:fs';
3
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
4
4
  import { join, extname } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
- import { applyMove, cloneChess, legalMoves, parseFen, squareName, startingChess } from '../src/board/chess.ts';
7
- import { parseUciMove, probeBook } from '../src/board/openings.ts';
8
- import { searchTourney } from '../src/board/tourney.ts';
9
- import { loadTablebaseEnding, probeTablebase } from '../src/board/tablebase.ts';
6
+ //the framework's own modules: the TypeScript sources inside this repository, the built `dist` inside an
7
+ //installed package, which ships no `src`
8
+ const fromSource = existsSync(new URL('../src/board/chess.ts', import.meta.url));
9
+ const load = (path) => import(fromSource ? `../src/${path}.ts` : `../dist/${path}.js`);
10
+ const { applyMove, cloneChess, legalMoves, parseFen, squareName, startingChess } = await load('board/chess');
11
+ const { parseUciMove, probeBook } = await load('board/openings');
12
+ const { searchTourney } = await load('board/tourney');
13
+ const { loadTablebaseEnding, probeTablebase } = await load('board/tablebase');
10
14
 
11
15
  /**
12
16
  * A UCI adapter over the tournament engine: `position`/`go` on stdin, `bestmove`
@@ -0,0 +1,8 @@
1
+ /** the svgo configuration `optimiseSvg` runs */
2
+ export declare const SVGO_CONFIG: {
3
+ multipass: boolean;
4
+ plugins: { name: string; params: { overrides: Record<string, boolean> } }[];
5
+ };
6
+
7
+ /** the SVG bytes minified; needs the optional `svgo` package, and throws for a file that is not valid SVG */
8
+ export declare function optimiseSvg(buffer: Buffer): Promise<Buffer>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Minifies an SVG for embedding, the SVG half of what `webp-convert` is for raster images, kept as
3
+ * its own module so `tools/compile-resources.mjs` never has to import an optimiser itself: it
4
+ * dynamically imports `svgo`, which a game that opts in installs in its own project the way it does
5
+ * `sharp` for WebP (`npm install --save-dev svgo`). A game that never passes `svgo: true` carries
6
+ * no new dependency at all.
7
+ *
8
+ * Optimising must not change what the game can see or name. Ids are left alone (`cleanupIds` is off:
9
+ * a `<use href="#id">`, a gradient reference or a style selector inside the file, or an id the game
10
+ * looks up, would break if minified), and the `viewBox` stays because svgo 4's default preset no
11
+ * longer removes it (a game scales a texture by it), which is why this needs svgo 4 or newer.
12
+ * Everything else in the default preset applies: comments, metadata, editor data, default
13
+ * attributes, redundant groups, path precision.
14
+ */
15
+
16
+ /** the svgo configuration `optimiseSvg` runs, exposed so a game can see exactly what it opts into */
17
+ export const SVGO_CONFIG = {
18
+ multipass: true,
19
+ plugins: [{ name: 'preset-default', params: { overrides: { cleanupIds: false } } }],
20
+ };
21
+
22
+ let svgoModule;
23
+ async function loadSvgo() {
24
+ if (svgoModule) return svgoModule;
25
+ try {
26
+ svgoModule = await import('svgo');
27
+ } catch (err) {
28
+ throw new Error(
29
+ 'SVG optimisation needs the optional `svgo` package, which is not installed. ' +
30
+ 'Run `npm install --save-dev svgo` in your project to use `svgo: true`, or leave ' +
31
+ 'it off (the default) to embed SVG files as they are.',
32
+ { cause: err },
33
+ );
34
+ }
35
+ return svgoModule;
36
+ }
37
+
38
+ /**
39
+ * @param {Buffer} buffer the SVG file's bytes (UTF-8)
40
+ * @returns {Promise<Buffer>} the optimised SVG; svgo throws for a file that is not valid SVG
41
+ */
42
+ export async function optimiseSvg(buffer) {
43
+ const { optimize } = await loadSvgo();
44
+ return Buffer.from(optimize(buffer.toString('utf8'), SVGO_CONFIG).data, 'utf8');
45
+ }
package/tools/vite.mjs CHANGED
@@ -16,6 +16,7 @@ import { buildArtifactSbom, serialize, shippedFiles } from './sbom.mjs';
16
16
  * affects the build only, so the dev server still serves the asset folder normally.
17
17
  * An embedded `.sf2` or `.sf3` is subset at build time to the programs the embedded `.mid` files
18
18
  * use (`soundFont: false` embeds it whole, `soundFontUsage` names the programs instead).
19
+ * `svgo: true` minifies embedded `.svg` assets (needs the optional `svgo` package).
19
20
  * At the end of the build it runs `emitPage` over the output folder with the same
20
21
  * options. A setting the game's own config gives explicitly still wins, since vite merges a
21
22
  * plugin's `config` result under the user's.
@@ -79,7 +80,15 @@ export function mwgPage(options = {}) {
79
80
  const { assets: _assets, sbom = true, ...emitOptions } = options;
80
81
  const source = assets ? resolve(root, assets) : undefined;
81
82
  const dist = resolve(root, outDir);
82
- await emitPage({ ...emitOptions, dist, assets: source && existsSync(source) ? source : undefined });
83
+ const page = await emitPage({
84
+ ...emitOptions,
85
+ dist,
86
+ assets: source && existsSync(source) ? source : undefined,
87
+ });
88
+ if (page.unusedAssets.length > 0)
89
+ console.warn(
90
+ `mwg-page: ${page.unusedAssets.length} embedded asset(s) are never named in the game code: ${page.unusedAssets.join(', ')}`,
91
+ );
83
92
  if (sbom) {
84
93
  const manifest = join(root, 'package.json');
85
94
  const packageJson = existsSync(manifest)
package/tools.md CHANGED
@@ -23,7 +23,8 @@ by double-clicking. Run from the repo root.
23
23
 
24
24
  | tool | what it does | run |
25
25
  | --- | --- | --- |
26
- | `compile-resources` | Turns an asset folder into `data:` URI scripts under `window.__MWG_ASSETS__`, grouped by top-level folder. `toWebp: true` converts `.png`/`.jpg`/`.jpeg` to WebP first (lossless by default, only kept when actually smaller, key unchanged) via the optional `sharp` devDependency, dynamically imported only when used (item 350) | called by the example builds; `node tools/compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]]` |
26
+ | `compile-resources` | Turns an asset folder into `data:` URI scripts under `window.__MWG_ASSETS__`, grouped by top-level folder. `toWebp: true` converts `.png`/`.jpg`/`.jpeg` to WebP first (lossless by default, only kept when actually smaller, key unchanged) via the optional `sharp` devDependency, dynamically imported only when used (item 350). `svgo: true` minifies `.svg` assets through the optional `svgo` package (version 4 or newer, ids and `viewBox` kept, only kept when smaller). Each subset SoundFont's result lists the programs the game's MIDI files ask for that the font does not carry, and `mwg-emit` warns about them | called by the example builds; `node tools/compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]] [--svgo]` |
27
+ | `svg-optimise` | Minifies one SVG file's bytes through `svgo` with ids and the `viewBox` left alone; the standalone half of `compile-resources`'s `svgo` option (item 428). On the repository's own 30 SVGs: 13.6% smaller, worst rendered pixel difference 1 in 255 | `optimiseSvg` from `@datamoc/mw_games/tools/svg-optimise`, or `mwg-emit <folder> --svgo` |
27
28
  | `webp-convert` | Converts one raster image buffer (PNG/JPEG) to WebP, lossless by default; the standalone half of `compile-resources`'s `toWebp` option (item 350) | `node tools/webp-convert.mjs <file.png\|file.jpg> [--lossy[=quality]]` |
28
29
  | `emit-page` | Finishes a vite build so it opens from `file://` with no server: compiles the assets, rewrites the entry to a classic deferred script with the asset scripts before it, then optionally precompresses and writes a single-file variant (item 381) | `emitPage(options)` from `@datamoc/mw_games/tools/emit-page`, the `mwg-emit <folder> [--dist=] [--assets=]` command, or called by each `example:*:build` script here |
29
30
  | `init` | `mwg-init <folder>` writes a starter project (vite config using `mwgPage`, one scene, tsconfig, scripts) that builds to a double-clickable page | `npx mwg-init my-game` |
@@ -199,7 +200,9 @@ same build.
199
200
  | tool | what it does | run |
200
201
  | --- | --- | --- |
201
202
  | `roadmap-progress` | Reports CLOSED.md + ROADMAP.md completion: numbered items in batches of 25, plus the 1.0 exit checklist on its own row (checks to run, not capabilities, so they stay out of the numbered totals), optionally in a browser | `npm run roadmap:progress` |
202
- | `build-webpage-examples` / `build-webpage-docs` | Generate the live examples and Documentation page under `webpage/` (`REFERENCE.md` guide + TypeDoc under `documentation/api/`) | `npm run webpage:examples`, `npm run webpage:docs` |
203
+ | `build-webpage-examples` / `build-webpage-docs` | Generate the live examples and Documentation page under `webpage/` (`REFERENCE.md` guide + TypeDoc under `documentation/api/` + the examples index) | `npm run webpage:examples`, `npm run webpage:docs` |
204
+ | `build-bridge-tutorial` / `bins-smoke` | `build-bridge-tutorial` writes `webpage/tutorials/bridge/index.html` from `tools/templates/bridge-tutorial.html`, quoting the code blocks of `examples/bridge` so a test can check them. `bins-smoke` packs the project, installs the tarball in an empty folder and runs every `mwg-*` bin with `--help` on the Node that runs it; CI runs it on Node 20.19.0. Repository tools, not shipped | `npm run build:bridge-tutorial`, `npm run bins:smoke` |
205
+ | `build-example-index` | Writes `documentation/examples.html`, read from source text (no typedoc, no renderer): for each public module the symbols that carry an `@example`, each linked to its typedoc page when one exists, with the runnable `examples/` that import the module. Part of `webpage:docs`; a repository tool, not shipped (item 430) | `npm run webpage:docs`, or `node tools/build-example-index.mjs [outFile]` |
203
206
  | `make-example-diagrams` / `make-architecture-diagrams` | Rebuild the generated diagrams under `webpage/assets` (never hand-drawn) | `npm run webpage:diagrams` |
204
207
  | `api-report` | Regenerates `API_REPORT.md` from the built declarations; `--check` compares instead. A CI gate | `npm run api:report`, `npm run api:check` |
205
208
  | `project-stats` | Writes `PROJECT_STATS.json`/`PROJECT_STATS.md` and the website statistics page; `--check` compares. A CI gate | `npm run stats:write`, `npm run stats:check` |