@datamoc/mw_games 0.16.0 → 0.17.1

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 (127) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +21 -1
  3. package/dist/ai/lua.d.ts.map +1 -1
  4. package/dist/ai/lua.js +1 -0
  5. package/dist/ai/lua.js.map +1 -1
  6. package/dist/core/ActionJournal.d.ts +5 -0
  7. package/dist/core/ActionJournal.d.ts.map +1 -1
  8. package/dist/core/ActionJournal.js +12 -1
  9. package/dist/core/ActionJournal.js.map +1 -1
  10. package/dist/core/Clone.d.ts +31 -0
  11. package/dist/core/Clone.d.ts.map +1 -0
  12. package/dist/core/Clone.js +84 -0
  13. package/dist/core/Clone.js.map +1 -0
  14. package/dist/core/Collection.d.ts +1 -0
  15. package/dist/core/Collection.d.ts.map +1 -1
  16. package/dist/core/Collection.js +6 -2
  17. package/dist/core/Collection.js.map +1 -1
  18. package/dist/core/HttpTransport.d.ts +17 -0
  19. package/dist/core/HttpTransport.d.ts.map +1 -1
  20. package/dist/core/HttpTransport.js +61 -0
  21. package/dist/core/HttpTransport.js.map +1 -1
  22. package/dist/core/Multiplayer.d.ts +9 -0
  23. package/dist/core/Multiplayer.d.ts.map +1 -1
  24. package/dist/core/Multiplayer.js +48 -4
  25. package/dist/core/Multiplayer.js.map +1 -1
  26. package/dist/core/News.d.ts.map +1 -1
  27. package/dist/core/News.js +1 -2
  28. package/dist/core/News.js.map +1 -1
  29. package/dist/core/Sanitize.d.ts +19 -0
  30. package/dist/core/Sanitize.d.ts.map +1 -1
  31. package/dist/core/Sanitize.js +37 -0
  32. package/dist/core/Sanitize.js.map +1 -1
  33. package/dist/core/Save.d.ts +18 -9
  34. package/dist/core/Save.d.ts.map +1 -1
  35. package/dist/core/Save.js +37 -21
  36. package/dist/core/Save.js.map +1 -1
  37. package/dist/core/SaveSync.js +4 -4
  38. package/dist/core/SaveSync.js.map +1 -1
  39. package/dist/core/Settings.d.ts.map +1 -1
  40. package/dist/core/Settings.js +2 -1
  41. package/dist/core/Settings.js.map +1 -1
  42. package/dist/core/State.d.ts.map +1 -1
  43. package/dist/core/State.js +8 -7
  44. package/dist/core/State.js.map +1 -1
  45. package/dist/core/StoredValue.d.ts +1 -1
  46. package/dist/core/StoredValue.d.ts.map +1 -1
  47. package/dist/core/StoredValue.js +3 -2
  48. package/dist/core/StoredValue.js.map +1 -1
  49. package/dist/core/Telemetry.d.ts +19 -1
  50. package/dist/core/Telemetry.d.ts.map +1 -1
  51. package/dist/core/Telemetry.js +26 -1
  52. package/dist/core/Telemetry.js.map +1 -1
  53. package/dist/core/index.d.ts +2 -1
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +2 -1
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/mw_games.global.js +107 -107
  58. package/dist/mwl/MapFile.js +11 -1
  59. package/dist/mwl/MapFile.js.map +1 -1
  60. package/dist/mwl/expression.js +38 -5
  61. package/dist/mwl/expression.js.map +1 -1
  62. package/dist/mwl/fengari.d.ts +8 -0
  63. package/dist/mwl/fengari.d.ts.map +1 -1
  64. package/dist/mwl/fengari.js +72 -2
  65. package/dist/mwl/fengari.js.map +1 -1
  66. package/dist/mwl/persistence.d.ts.map +1 -1
  67. package/dist/mwl/persistence.js +2 -1
  68. package/dist/mwl/persistence.js.map +1 -1
  69. package/dist/mwl/runtime.d.ts.map +1 -1
  70. package/dist/mwl/runtime.js +6 -1
  71. package/dist/mwl/runtime.js.map +1 -1
  72. package/dist/rpg/Marshal.d.ts.map +1 -1
  73. package/dist/rpg/Marshal.js +13 -0
  74. package/dist/rpg/Marshal.js.map +1 -1
  75. package/dist/simulation/Campaign.d.ts.map +1 -1
  76. package/dist/simulation/Campaign.js +9 -5
  77. package/dist/simulation/Campaign.js.map +1 -1
  78. package/dist/simulation/Harness.d.ts.map +1 -1
  79. package/dist/simulation/Harness.js +2 -1
  80. package/dist/simulation/Harness.js.map +1 -1
  81. package/dist/simulation/Runtime.d.ts +10 -0
  82. package/dist/simulation/Runtime.d.ts.map +1 -1
  83. package/dist/simulation/Runtime.js +25 -4
  84. package/dist/simulation/Runtime.js.map +1 -1
  85. package/dist/testing/index.d.ts +105 -0
  86. package/dist/testing/index.d.ts.map +1 -0
  87. package/dist/testing/index.js +134 -0
  88. package/dist/testing/index.js.map +1 -0
  89. package/dist/version.d.ts +1 -1
  90. package/dist/version.js +1 -1
  91. package/package.json +90 -25
  92. package/sbom.cdx.json +5589 -0
  93. package/tools/benchmark-browser.d.mts +33 -0
  94. package/tools/benchmark-browser.mjs +163 -0
  95. package/tools/benchmark-history.d.mts +8 -0
  96. package/tools/benchmark-history.mjs +31 -0
  97. package/tools/browser-smoke.d.mts +30 -0
  98. package/tools/browser-smoke.mjs +167 -0
  99. package/tools/bundle-size.d.mts +11 -0
  100. package/tools/bundle-size.mjs +138 -0
  101. package/tools/csp.d.mts +7 -0
  102. package/tools/csp.mjs +65 -0
  103. package/tools/emit-page.d.mts +44 -0
  104. package/tools/emit-page.mjs +188 -0
  105. package/tools/extract-rgssad.d.mts +5 -0
  106. package/tools/extract-rgssad.mjs +64 -0
  107. package/tools/find-chrome.d.mts +7 -0
  108. package/tools/find-chrome.mjs +104 -0
  109. package/tools/i18n-edit.d.mts +29 -0
  110. package/tools/i18n-edit.mjs +748 -0
  111. package/tools/make-example-assets.d.mts +2 -0
  112. package/tools/make-example-assets.mjs +727 -0
  113. package/tools/multiplayer-server.d.mts +27 -0
  114. package/tools/multiplayer-server.mjs +195 -0
  115. package/tools/mwl.mjs +1 -0
  116. package/tools/sbom.d.mts +83 -0
  117. package/tools/sbom.mjs +332 -0
  118. package/tools/vendored/rgssad-wasm/LICENSE-MIT +21 -0
  119. package/tools/vendored/rgssad-wasm/README.md +15 -0
  120. package/tools/vendored/rgssad-wasm/package.json +17 -0
  121. package/tools/vendored/rgssad-wasm/rgssad_wasm.d.ts +41 -0
  122. package/tools/vendored/rgssad-wasm/rgssad_wasm.js +6 -0
  123. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.js +399 -0
  124. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.wasm +0 -0
  125. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.wasm.d.ts +17 -0
  126. package/tools/vite.d.mts +17 -0
  127. package/tools/vite.mjs +83 -0
@@ -0,0 +1,33 @@
1
+ export interface MeasurePageOptions {
2
+ /** a `file://` url of the built page */
3
+ url: string;
4
+ /** frame intervals timed; at least 30, default 180 */
5
+ frames?: number;
6
+ /** fails below this mean fps; default 45 */
7
+ minFps?: number;
8
+ /** fails above this 95th-percentile frame time; default 40 ms */
9
+ maxP95FrameMs?: number;
10
+ /** a JSON history the run is appended to and compared with; none by default */
11
+ historyPath?: string | null;
12
+ /** how far below the history's best fps a run may fall; default 0.15 */
13
+ maxFpsRegression?: number;
14
+ screenshot?: string;
15
+ }
16
+
17
+ export interface MeasurePageResult {
18
+ page: string;
19
+ gameReady: boolean;
20
+ canvas: { width: number; height: number };
21
+ renderer: string;
22
+ fps: number;
23
+ averageMs: number;
24
+ p95FrameMs: number;
25
+ frames: number;
26
+ memoryMB: number | null;
27
+ screenshot: string;
28
+ pageErrors: string[];
29
+ thresholds: { minFps: number; maxP95FrameMs: number };
30
+ }
31
+
32
+ /** measures a built page's frame rate in headless Chrome and throws past its thresholds */
33
+ export function measurePage(options: MeasurePageOptions): Promise<MeasurePageResult>;
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ import { statSync, realpathSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { launchChrome } from './find-chrome.mjs';
7
+ import { waitForGame } from './browser-smoke.mjs';
8
+ import { readHistory, appendHistory, bestSeen } from './benchmark-history.mjs';
9
+
10
+ /**
11
+ * Measures a built page's frame rate in headless Chrome: `frames` frame intervals timed with
12
+ * `requestAnimationFrame`, their mean as fps and their 95th percentile, and Chrome's JS heap
13
+ * where it reports one. Throws when the page is not a ready game on Pixi's (or Babylon's)
14
+ * WebGL/WebGPU path, when it logs an error, when it misses `minFps` or `maxP95FrameMs`, or, with
15
+ * a `historyPath`, when it falls more than `maxFpsRegression` below the best fps that history
16
+ * has seen (a fixed gate alone cannot see a slow decline that never quite crosses it). The run is
17
+ * appended to that history either way.
18
+ *
19
+ * Shipped for a game (item 387) as `@datamoc/mw_games/tools/benchmark-browser` and
20
+ * `mwg-bench <page | dist>`; this repository runs it through `npm run benchmark:*`. A CI runner
21
+ * has no GPU, so Chrome there is told which software WebGL path to take through
22
+ * `MWG_BENCHMARK_CHROME_ARGS` (`--use-gl=angle --use-angle=swiftshader`), a property of the
23
+ * machine rather than of the page.
24
+ */
25
+ export async function measurePage({
26
+ url,
27
+ frames = 180,
28
+ minFps = 45,
29
+ maxP95FrameMs = 40,
30
+ historyPath = null,
31
+ maxFpsRegression = 0.15,
32
+ screenshot = join(tmpdir(), `mwg-browser-benchmark-${process.pid}.png`),
33
+ }) {
34
+ if (!Number.isInteger(frames) || frames < 30) throw new Error('frames must be an integer of at least 30');
35
+ const browser = await launchChrome();
36
+ try {
37
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
38
+ const pageErrors = [];
39
+ page.on('pageerror', (error) => pageErrors.push(error.message));
40
+ page.on('console', (message) => {
41
+ if (message.type() === 'error') pageErrors.push(message.text());
42
+ });
43
+ await page.goto(url, { waitUntil: 'load' });
44
+ await waitForGame(page, url, pageErrors);
45
+
46
+ const state = await page.evaluate(() => {
47
+ const canvas = document.querySelector('canvas');
48
+ const app = window.__PIXI_APP__;
49
+ const babylon = window.__MWG_3D__;
50
+ return {
51
+ gameReady: Boolean(window.__MWG__ || babylon),
52
+ canvas: { width: canvas?.width ?? 0, height: canvas?.height ?? 0 },
53
+ renderer: babylon?.engine?.isWebGPU
54
+ ? 'webgpu'
55
+ : babylon?.engine?.webGLVersion > 0
56
+ ? 'webgl'
57
+ : String(app?.renderer?.name ?? 'unknown').toLowerCase(),
58
+ webGLVersion: babylon?.engine?.webGLVersion ?? null,
59
+ meshes: babylon?.scene?.meshes?.length ?? null,
60
+ };
61
+ });
62
+ const metrics = await page.evaluate(async (count) => {
63
+ const samples = [];
64
+ let previous;
65
+ await new Promise((resolve) => {
66
+ const frame = (now) => {
67
+ if (previous !== undefined) samples.push(now - previous);
68
+ previous = now;
69
+ if (samples.length >= count) resolve();
70
+ else requestAnimationFrame(frame);
71
+ };
72
+ requestAnimationFrame(frame);
73
+ });
74
+ samples.sort((a, b) => a - b);
75
+ const averageMs = samples.reduce((sum, value) => sum + value, 0) / samples.length;
76
+ const p95FrameMs = samples[Math.min(samples.length - 1, Math.floor(samples.length * 0.95))];
77
+ //`performance.memory` is a non-standard Chrome extension: null everywhere else,
78
+ //reported as such rather than guessed at
79
+ const memory = performance.memory;
80
+ const memoryMB = memory ? memory.usedJSHeapSize / (1024 * 1024) : null;
81
+ return { averageMs, p95FrameMs, fps: 1000 / averageMs, frames: samples.length, memoryMB };
82
+ }, frames);
83
+
84
+ await page.screenshot({ path: screenshot, type: 'png' });
85
+ const result = {
86
+ page: url,
87
+ ...state,
88
+ ...metrics,
89
+ screenshot,
90
+ pageErrors,
91
+ thresholds: { minFps, maxP95FrameMs },
92
+ };
93
+
94
+ if (!['webgl', 'webgpu'].includes(state.renderer)) {
95
+ throw new Error(`browser benchmark requires WebGL or WebGPU, got ${state.renderer}`);
96
+ }
97
+ if (pageErrors.length) throw new Error(`browser benchmark page errors: ${pageErrors.join('; ')}`);
98
+ if (metrics.fps < minFps || metrics.p95FrameMs > maxP95FrameMs) {
99
+ throw Object.assign(
100
+ new Error(
101
+ `browser performance threshold failed: ${metrics.fps.toFixed(1)} FPS, p95 ${metrics.p95FrameMs.toFixed(2)} ms`,
102
+ ),
103
+ { result },
104
+ );
105
+ }
106
+
107
+ if (historyPath) {
108
+ const history = await readHistory(historyPath);
109
+ const bestPriorFps = bestSeen(history, (entry) => entry.fps);
110
+ await appendHistory(historyPath, history, {
111
+ timestamp: new Date().toISOString(),
112
+ fps: metrics.fps,
113
+ p95FrameMs: metrics.p95FrameMs,
114
+ memoryMB: metrics.memoryMB,
115
+ });
116
+ if (bestPriorFps !== null && metrics.fps < bestPriorFps * (1 - maxFpsRegression)) {
117
+ throw Object.assign(
118
+ new Error(
119
+ `browser performance regressed against history: ${metrics.fps.toFixed(1)} FPS now vs ` +
120
+ `${bestPriorFps.toFixed(1)} FPS best-seen (${historyPath})`,
121
+ ),
122
+ { result },
123
+ );
124
+ }
125
+ }
126
+ return result;
127
+ } finally {
128
+ await browser.close();
129
+ }
130
+ }
131
+
132
+ /**
133
+ * `mwg-bench <page | dist> [--frames=180] [--min-fps=45] [--max-p95=40] [--history=<file>]`,
134
+ * each option also read from `MWG_BENCHMARK_FRAMES`, `MWG_BENCHMARK_MIN_FPS`,
135
+ * `MWG_BENCHMARK_MAX_P95_MS` and `MWG_BENCHMARK_MAX_FPS_REGRESSION`. The history defaults to
136
+ * `benchmark-results/<page>.json`, named by the page so two pages keep separate histories.
137
+ */
138
+ async function main(argv) {
139
+ const value = (name, env, fallback) =>
140
+ argv.find((a) => a.startsWith(`--${name}=`))?.split('=')[1] ?? process.env[env] ?? fallback;
141
+ const target = argv.find((arg) => !arg.startsWith('--')) ?? 'examples/dungeon/dist/index.html';
142
+ const relativePage = statSync(target).isDirectory() ? join(target, 'index.html') : target;
143
+ const slug = relativePage.replace(/[\\/]/g, '-').replace(/\.html$/, '');
144
+ try {
145
+ const result = await measurePage({
146
+ url: pathToFileURL(resolve(relativePage)).href,
147
+ frames: Number(value('frames', 'MWG_BENCHMARK_FRAMES', 180)),
148
+ minFps: Number(value('min-fps', 'MWG_BENCHMARK_MIN_FPS', 45)),
149
+ maxP95FrameMs: Number(value('max-p95', 'MWG_BENCHMARK_MAX_P95_MS', 40)),
150
+ maxFpsRegression: Number(process.env.MWG_BENCHMARK_MAX_FPS_REGRESSION ?? 0.15),
151
+ historyPath: resolve(value('history', 'MWG_BENCHMARK_HISTORY', join('benchmark-results', `${slug}.json`))),
152
+ });
153
+ console.log(JSON.stringify(result, null, 2));
154
+ } catch (error) {
155
+ if (error?.result) console.log(JSON.stringify(error.result, null, 2));
156
+ console.error(error instanceof Error ? error.message : error);
157
+ process.exitCode = 1;
158
+ }
159
+ }
160
+
161
+ //realpath, since an npm `bin` shim reaches this file through a symlink
162
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
163
+ await main(process.argv.slice(2));
@@ -0,0 +1,8 @@
1
+ /** a benchmark's recorded runs, or `[]` when none has been recorded yet */
2
+ export function readHistory<T = Record<string, unknown>>(historyPath: string): Promise<T[]>;
3
+
4
+ /** appends `entry` to `history` and writes the last 50 runs back */
5
+ export function appendHistory<T>(historyPath: string, history: T[], entry: T): Promise<void>;
6
+
7
+ /** the best value of `metric` across `history`, or null when it is empty */
8
+ export function bestSeen<T>(history: readonly T[], metric: (entry: T) => number): number | null;
@@ -0,0 +1,31 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ const maxHistoryEntries = 50;
5
+
6
+ /**
7
+ * Per-benchmark history: read what a prior run of the same benchmark recorded, append what
8
+ * this run measured, and detect a regression against the best one seen so far. Shared by
9
+ * `benchmark-browser.mjs` (fps/p95/memory) and `benchmark-simulation.mjs` (commands/steps per
10
+ * second) - the two started as separate copies of the same read/append/compare logic before
11
+ * this module existed to hold it once.
12
+ */
13
+ export async function readHistory(historyPath) {
14
+ try {
15
+ return JSON.parse(await readFile(historyPath, 'utf8'));
16
+ } catch {
17
+ return []; //no prior run recorded yet - not an error, just nothing to compare against
18
+ }
19
+ }
20
+
21
+ /** appends `entry` to `history` (already loaded by the caller) and persists the result */
22
+ export async function appendHistory(historyPath, history, entry) {
23
+ history.push(entry);
24
+ await mkdir(dirname(historyPath), { recursive: true });
25
+ await writeFile(historyPath, JSON.stringify(history.slice(-maxHistoryEntries), null, 2));
26
+ }
27
+
28
+ /** the best value of `metric` seen across `history`, or null if it is empty */
29
+ export function bestSeen(history, metric) {
30
+ return history.length > 0 ? Math.max(...history.map((entry) => metric(entry))) : null;
31
+ }
@@ -0,0 +1,30 @@
1
+ import type { Page } from 'playwright-core';
2
+
3
+ export interface SmokePageOptions {
4
+ /** a `file://` url of the built page */
5
+ url: string;
6
+ /** where the screenshot is written */
7
+ screenshot: string;
8
+ keyToPress?: string | null;
9
+ reducedMotion?: 'reduce' | 'no-preference' | null;
10
+ /** a JavaScript expression evaluated in the page, returned as `probe` */
11
+ probe?: string | null;
12
+ after?: ((page: Page) => Promise<void>) | null;
13
+ }
14
+
15
+ export interface SmokePageResult {
16
+ url: string;
17
+ screenshot: string;
18
+ pageErrors: string[];
19
+ gameReady: boolean;
20
+ renderer: string;
21
+ canvas: { width: number; height: number };
22
+ sample: { width: number; height: number; sampled: number; opaque: number; distinctColours: number } | null;
23
+ probe: unknown;
24
+ }
25
+
26
+ /** opens a built page from `file://` in headless Chrome and throws unless it rendered a live, non-blank game canvas */
27
+ export function smokePage(options: SmokePageOptions): Promise<SmokePageResult>;
28
+
29
+ /** waits up to 5 s for an mwg game to be ready on `page`, then throws naming the page and its logged errors */
30
+ export function waitForGame(page: Page, url: string, pageErrors?: string[]): Promise<void>;
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync, statSync } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { launchChrome } from './find-chrome.mjs';
7
+
8
+ /**
9
+ * Waits up to five seconds for an mwg game to be ready on `page` (`window.__MWG__` or
10
+ * `window.__MWG_3D__` set, and a canvas with a size), and otherwise throws a sentence naming the
11
+ * page and whatever errors it logged. Shared with `benchmark-browser.mjs`.
12
+ */
13
+ export async function waitForGame(page, url, pageErrors = []) {
14
+ try {
15
+ await page.waitForFunction(
16
+ () => {
17
+ const canvas = document.querySelector('canvas');
18
+ return (
19
+ Boolean(window.__MWG__ || window.__MWG_3D__) &&
20
+ Boolean(canvas && canvas.width > 0 && canvas.height > 0)
21
+ );
22
+ },
23
+ undefined,
24
+ { timeout: 5000 },
25
+ );
26
+ } catch {
27
+ const errors = pageErrors.length ? `; page errors: ${pageErrors.join('; ')}` : '';
28
+ throw new Error(`no mwg game became ready within 5 s on ${url} (window.__MWG__ and a sized canvas)${errors}`);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Opens a built page from `file://` in headless Chrome and asserts it actually rendered.
34
+ *
35
+ * Shared by the per-pull-request visual smoke and the published-package smoke so both judge
36
+ * a page the same way: no uncaught error, a canvas on Pixi's WebGL/WebGPU path, and pixels
37
+ * that are neither empty nor one flat colour. Each caller chooses the page and where its
38
+ * screenshot goes.
39
+ *
40
+ * It deliberately does not pixel-diff against a committed baseline. Font hinting and
41
+ * anti-aliasing differ between a developer's machine and the Linux runner, and a gate that
42
+ * fails for the renderer rather than the change is worse than no gate - the same argument the
43
+ * scheduled browser benchmark records for keeping its fps history off the per-push path.
44
+ *
45
+ * `MWG_VISUAL_CHROME_ARGS` carries machine-specific flags, as on a runner with no GPU.
46
+ *
47
+ * Three optional extras let a caller check more than "it rendered":
48
+ *
49
+ * - `reducedMotion`: `'reduce'` or `'no-preference'`, emulated before the page loads;
50
+ * - `probe`: a JavaScript expression evaluated in the page after it settles, returned as
51
+ * `state.probe` (the reduced-motion smoke reads `window.__MWG_MOTION__` this way);
52
+ * - `after`: an async hook run once the page has settled and `keyToPress` has fired, for a
53
+ * scenario that changes something while the page is open (flipping the emulated media).
54
+ */
55
+ export async function smokePage({
56
+ url,
57
+ screenshot,
58
+ keyToPress = null,
59
+ reducedMotion = null,
60
+ probe = null,
61
+ after = null,
62
+ }) {
63
+ await mkdir(dirname(screenshot), { recursive: true });
64
+ const browser = await launchChrome({ argsEnv: 'MWG_VISUAL_CHROME_ARGS' });
65
+
66
+ try {
67
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
68
+ if (reducedMotion) await page.emulateMedia({ reducedMotion });
69
+ const pageErrors = [];
70
+ page.on('pageerror', (error) => pageErrors.push(error.message));
71
+ page.on('console', (message) => {
72
+ if (message.type() === 'error') pageErrors.push(message.text());
73
+ });
74
+
75
+ await page.goto(url, { waitUntil: 'load' });
76
+ await waitForGame(page, url, pageErrors);
77
+
78
+ //let the scene finish its first frames before reading pixels back
79
+ await page.waitForTimeout(250);
80
+ if (keyToPress) {
81
+ await page.keyboard.press(keyToPress);
82
+ //give whatever the key opened time to lay itself out and draw
83
+ await page.waitForTimeout(250);
84
+ }
85
+ if (after) await after(page);
86
+
87
+ const state = await page.evaluate(() => {
88
+ const canvas = document.querySelector('canvas');
89
+ const app = window.__PIXI_APP__;
90
+ const renderer = String(app?.renderer?.name ?? 'unknown').toLowerCase();
91
+ const base = {
92
+ gameReady: Boolean(window.__MWG__),
93
+ renderer,
94
+ canvas: { width: canvas?.width ?? 0, height: canvas?.height ?? 0 },
95
+ sample: null,
96
+ };
97
+ if (!app?.renderer?.extract) return base;
98
+
99
+ //Pixi's own extract reads the rendered pixels back, so this works without the
100
+ //page having to preserve its WebGL drawing buffer
101
+ const { pixels, width, height } = app.renderer.extract.pixels(app.stage);
102
+ //sample every 16th pixel: enough to tell a painted scene from an empty or solid
103
+ //one, without copying millions of values back to Node
104
+ const colours = new Set();
105
+ let opaque = 0;
106
+ let sampled = 0;
107
+ for (let i = 0; i < pixels.length; i += 4 * 16) {
108
+ sampled++;
109
+ if (pixels[i + 3] === 0) continue;
110
+ opaque++;
111
+ if (colours.size < 64) colours.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
112
+ }
113
+ return { ...base, sample: { width, height, sampled, opaque, distinctColours: colours.size } };
114
+ });
115
+
116
+ await page.screenshot({ path: screenshot, type: 'png' });
117
+ const probed =
118
+ probe === null ? null : await page.evaluate((source) => new Function(`return (${source})`)(), probe);
119
+
120
+ if (!state.gameReady || state.canvas.width === 0 || state.canvas.height === 0) {
121
+ throw new Error(`page did not render a ready game canvas: ${url}`);
122
+ }
123
+ if (!['webgl', 'webgpu'].includes(state.renderer)) {
124
+ throw new Error(`page is not on Pixi's WebGL/WebGPU path, got ${state.renderer}: ${url}`);
125
+ }
126
+ if (pageErrors.length) throw new Error(`page errors on ${url}: ${pageErrors.join('; ')}`);
127
+ const sample = state.sample;
128
+ if (!sample || sample.opaque < sample.sampled * 0.5 || sample.distinctColours < 8) {
129
+ throw new Error(`canvas looks blank on ${url}: ${JSON.stringify(sample)}`);
130
+ }
131
+
132
+ return { url, keyToPress, reducedMotion, screenshot, pageErrors, ...state, probe: probed };
133
+ } finally {
134
+ await browser.close();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * `mwg-smoke <page.html | dist folder> [--screenshot=<file.png>] [--key=<key>]`: the same check
140
+ * from a game's own CI. Prints the result as JSON and exits non-zero when the page did not
141
+ * render, so `vite build && mwg-smoke dist` proves a build still opens by double-clicking.
142
+ * Needs `playwright-core` and a Chrome or Chromium (`CHROME_PATH` to name one).
143
+ */
144
+ async function main(argv) {
145
+ const target = argv.find((arg) => !arg.startsWith('--'));
146
+ if (!target) {
147
+ console.error('usage: mwg-smoke <page.html | dist folder> [--screenshot=<file.png>] [--key=<key>]');
148
+ process.exit(1);
149
+ }
150
+ const value = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
151
+ const page = statSync(target).isDirectory() ? join(target, 'index.html') : target;
152
+ try {
153
+ const result = await smokePage({
154
+ url: pathToFileURL(resolve(page)).href,
155
+ screenshot: resolve(value('screenshot') ?? join(dirname(page), 'smoke.png')),
156
+ keyToPress: value('key') ?? null,
157
+ });
158
+ console.log(JSON.stringify(result, null, 2));
159
+ } catch (error) {
160
+ console.error(error instanceof Error ? error.message : error);
161
+ process.exitCode = 1;
162
+ }
163
+ }
164
+
165
+ //realpath, since an npm `bin` shim reaches this file through a symlink
166
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
167
+ await main(process.argv.slice(2));
@@ -0,0 +1,11 @@
1
+ export type SizeMetrics = Record<string, Partial<Record<'raw' | 'gzip', number>>>;
2
+
3
+ /** raw and gzipped bytes of every file a player receives from `dist`, plus `total` */
4
+ export function measureBuild(dist: string): SizeMetrics;
5
+
6
+ /** each metric against its baseline; `failed` when one grew more than `tolerance` (0.02 by default) */
7
+ export function compareBudget(
8
+ current: SizeMetrics,
9
+ baseline: SizeMetrics,
10
+ tolerance?: number,
11
+ ): { lines: string[]; failed: boolean };
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import { gzipSync } from 'node:zlib';
3
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ /**
8
+ * The bundle-size budget: the rendering-backend policy weighs bundle cost, and this is the
9
+ * number it weighs. Here it records the classic global build (the one file a script-tag player
10
+ * downloads, raw and gzipped) and the whole published `dist` tree, source maps excluded so
11
+ * the separate map-files decision cannot perturb it.
12
+ *
13
+ * `npm run size:check` fails when either grows past `MWG_SIZE_TOLERANCE` (default 2%) over
14
+ * the committed baseline; `npm run size:update` rewrites the baseline after a deliberate
15
+ * growth, the same "commit the new number on purpose" shape `npm run api:report` uses.
16
+ * Run `npm run build` first.
17
+ *
18
+ * A game uses the same gate on its own build (item 386): `mwg-size dist` measures every file a
19
+ * player receives, raw and gzipped, plus their total, against `size-budget.json` beside the
20
+ * project (`--baseline=` to move it, `--update` to write it on purpose). A page that grows past
21
+ * the tolerance fails the game's CI, which matters most for a player on a phone.
22
+ */
23
+
24
+ /** every file under `dir`, recursively */
25
+ function walk(dir) {
26
+ const out = [];
27
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
28
+ const full = join(dir, entry.name);
29
+ if (entry.isDirectory()) out.push(...walk(full));
30
+ else out.push(full);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Raw and gzipped bytes of every file a player receives from a finished build, keyed by path,
37
+ * plus `total`. Source maps, precompressed siblings and SBOMs are left out: none of them is
38
+ * what the page loads.
39
+ */
40
+ export function measureBuild(dist) {
41
+ const current = {};
42
+ let raw = 0;
43
+ let gzip = 0;
44
+ for (const file of walk(dist).sort()) {
45
+ const path = relative(dist, file).split(sep).join('/');
46
+ if (/\.(map|gz|br|xz)$/.test(path) || path.endsWith('.cdx.json')) continue;
47
+ const bytes = readFileSync(file);
48
+ const entry = { raw: bytes.length, gzip: gzipSync(bytes).length };
49
+ current[path] = entry;
50
+ raw += entry.raw;
51
+ gzip += entry.gzip;
52
+ }
53
+ current.total = { raw, gzip };
54
+ return current;
55
+ }
56
+
57
+ /**
58
+ * Compares `current` with `baseline` metric by metric: a metric more than `tolerance` over its
59
+ * baseline fails, one with no baseline (a new file) is reported and passes, since the total
60
+ * still counts it. Returns the report lines and whether anything is over.
61
+ */
62
+ export function compareBudget(current, baseline, tolerance = 0.02) {
63
+ const lines = [];
64
+ let failed = false;
65
+ for (const [name, metrics] of Object.entries(current)) {
66
+ for (const [metric, bytes] of Object.entries(metrics)) {
67
+ const before = baseline[name]?.[metric];
68
+ if (before === undefined) {
69
+ lines.push(`${name} ${metric}: ${bytes} B (no baseline)`);
70
+ continue;
71
+ }
72
+ const growth = bytes / before - 1;
73
+ const over = growth > tolerance;
74
+ if (over) failed = true;
75
+ lines.push(
76
+ `${name} ${metric}: ${bytes} B vs ${before} B baseline (${growth >= 0 ? '+' : ''}${(growth * 100).toFixed(1)}%) ${over ? 'OVER' : 'ok'}`,
77
+ );
78
+ }
79
+ }
80
+ return { lines, failed };
81
+ }
82
+
83
+ /** this repository's own budget: the global bundle and the published `dist` tree */
84
+ function measureFramework(root) {
85
+ const dist = join(root, 'dist');
86
+ const globalBundle = join(dist, 'mw_games.global.js');
87
+ if (!existsSync(globalBundle)) {
88
+ throw new Error(`no build found at ${relative(root, globalBundle)} - run "npm run build" first`);
89
+ }
90
+ const globalBytes = readFileSync(globalBundle);
91
+ return {
92
+ 'mw_games.global.js': { raw: globalBytes.length, gzip: gzipSync(globalBytes).length },
93
+ dist: {
94
+ raw: walk(dist)
95
+ .filter((file) => !file.endsWith('.map'))
96
+ .reduce((sum, file) => sum + statSync(file).size, 0),
97
+ },
98
+ };
99
+ }
100
+
101
+ function main(argv) {
102
+ const tolerance = Number(
103
+ argv.find((a) => a.startsWith('--tolerance='))?.slice(12) ?? process.env.MWG_SIZE_TOLERANCE ?? 0.02,
104
+ );
105
+ const target = argv.find((arg) => !arg.startsWith('--'));
106
+ const root = resolve(import.meta.dirname, '..');
107
+ let current;
108
+ let baselinePath;
109
+ if (target) {
110
+ const dist = resolve(target);
111
+ if (!existsSync(join(dist, 'index.html')))
112
+ throw new Error(`${target} holds no index.html - build the game first`);
113
+ current = measureBuild(dist);
114
+ baselinePath = resolve(argv.find((a) => a.startsWith('--baseline='))?.slice(11) ?? 'size-budget.json');
115
+ } else {
116
+ current = measureFramework(root);
117
+ baselinePath = join(import.meta.dirname, 'bundle-size.json');
118
+ }
119
+ const update = target ? 'mwg-size --update' : 'npm run size:update';
120
+
121
+ if (argv.includes('--update') || (target && !existsSync(baselinePath))) {
122
+ writeFileSync(baselinePath, `${JSON.stringify(current, null, '\t')}\n`);
123
+ console.log(`wrote ${relative(process.cwd(), baselinePath)}`);
124
+ for (const [name, metrics] of Object.entries(current)) console.log(` ${name}: ${JSON.stringify(metrics)}`);
125
+ return;
126
+ }
127
+ const { lines, failed } = compareBudget(current, JSON.parse(readFileSync(baselinePath, 'utf8')), tolerance);
128
+ for (const line of lines) console.log(line);
129
+ if (failed) {
130
+ console.error(
131
+ `bundle grew more than ${(tolerance * 100).toFixed(0)}% - trim it, or run "${update}" and explain the growth in the commit`,
132
+ );
133
+ process.exitCode = 1;
134
+ }
135
+ }
136
+
137
+ //realpath, since an npm `bin` shim reaches this file through a symlink
138
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -0,0 +1,7 @@
1
+ import type { CspOptions } from './emit-page.mjs';
2
+
3
+ /** the policy `emitPage` writes, for a page's HTML; `scripts` are extra script texts allowed by hash */
4
+ export function contentSecurityPolicy(html: string, options?: CspOptions & { scripts?: string[] }): string;
5
+
6
+ /** `html` with that policy as the first element of `<head>`, replacing an earlier one */
7
+ export function withContentSecurityPolicy(html: string, options?: CspOptions & { scripts?: string[] }): string;
package/tools/csp.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * The Content-Security-Policy `emitPage` writes into every page it finishes (item 374), as a
5
+ * `<meta http-equiv>` at the top of `<head>`, so a game gets it with no configuration.
6
+ *
7
+ * What it buys, for a page that opens from `file://`: no script from anywhere but the page's own
8
+ * folder and the hashed inline scripts it ships with, no network request except to the
9
+ * endpoints the game declares (`connect`), so a compromised dependency cannot send a save or
10
+ * anything else away, and no exfiltration through an image, font or media URL either, since
11
+ * those only accept `data:`/`blob:`. `base-uri`, `form-action` and `object-src` are `'none'`.
12
+ *
13
+ * `'unsafe-eval'` stays allowed by default, measured rather than assumed: Pixi 8 refuses to start
14
+ * without it ("please use pixi.js/unsafe-eval") unless the game imports `pixi.js/unsafe-eval`,
15
+ * which swaps its generated uniform uploads for slower polyfills. mwg itself never evaluates
16
+ * data, so the eval permission gives nothing to an attacker who does not already have code in
17
+ * the bundle. A game that imports that module passes `eval: false`.
18
+ *
19
+ * @param {string} html the finished page
20
+ * @param {{ connect?: string[], eval?: boolean, scripts?: string[] }} [options] `scripts` are
21
+ * extra script texts to allow by hash (a single-file loader's unpacked scripts)
22
+ */
23
+ export function contentSecurityPolicy(html, { connect = [], eval: allowEval = true, scripts = [] } = {}) {
24
+ //The HTML parser normalises newlines before it hashes an inline script: CRLF and lone CR
25
+ //become LF. A checkout with core.autocrlf=true (every Windows clone) hands the source page
26
+ //over with CRLF, so hashing the bytes on disk here would emit a policy Chrome computes
27
+ //differently from the file it just parsed, and the page refuses its own script while
28
+ //`file://` on Linux stays quiet. Hash the text the browser will see.
29
+ const inline = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)].map((match) =>
30
+ match[1].replace(/\r\n?/g, '\n'),
31
+ );
32
+ //`scripts` are texts the standalone page's unpacker sets on a script element at runtime
33
+ //(`s.text = code`), where no parser runs: those are hashed exactly as they are written.
34
+ const hashes = [...new Set([...inline, ...scripts].map((text) => `'sha256-${sha256(text)}'`))];
35
+ const directives = {
36
+ 'default-src': ["'none'"],
37
+ 'script-src': ["'self'", 'file:', ...hashes, ...(allowEval ? ["'unsafe-eval'"] : [])],
38
+ 'style-src': ["'self'", 'file:', "'unsafe-inline'"],
39
+ 'img-src': ["'self'", 'file:', 'data:', 'blob:'],
40
+ 'media-src': ["'self'", 'file:', 'data:', 'blob:'],
41
+ 'font-src': ["'self'", 'file:', 'data:'],
42
+ //Pixi loads a `data:` texture through fetch, so `data:` is a connect source
43
+ 'connect-src': ['data:', 'blob:', ...connect],
44
+ 'worker-src': ['blob:'],
45
+ 'base-uri': ["'none'"],
46
+ 'form-action': ["'none'"],
47
+ 'object-src': ["'none'"],
48
+ };
49
+ return Object.entries(directives)
50
+ .map(([name, values]) => `${name} ${values.join(' ')}`)
51
+ .join('; ');
52
+ }
53
+
54
+ /** `html` with the policy inserted as the first element of `<head>`, replacing an earlier one */
55
+ export function withContentSecurityPolicy(html, options) {
56
+ const cleaned = html.replace(/[ \t]*<meta http-equiv="Content-Security-Policy"[^>]*>\n?/i, '');
57
+ const policy = contentSecurityPolicy(cleaned, options);
58
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${policy.replace(/"/g, '&quot;')}">`;
59
+ if (!/<head[^>]*>/i.test(cleaned)) throw new Error('page has no <head> to carry a Content-Security-Policy');
60
+ return cleaned.replace(/<head[^>]*>/i, (head) => `${head}\n\t${meta}`);
61
+ }
62
+
63
+ function sha256(text) {
64
+ return createHash('sha256').update(text, 'utf8').digest('base64');
65
+ }