@datamoc/mw_games 0.39.1 → 0.41.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.
- package/CHANGELOG.md +89 -0
- package/REFERENCE.md +2115 -0
- package/api-surface.json +48 -0
- package/data/microgm-opt.PROVENANCE.md +23 -0
- package/data/microgm-opt.sf2 +0 -0
- package/data/soundfont-catalog.json +183 -0
- package/dist/ai/index.d.ts +10 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/lua.d.ts +6 -0
- package/dist/ai/lua.d.ts.map +1 -1
- package/dist/ai/lua.js.map +1 -1
- package/dist/audio/SampleDecoder.d.ts +41 -0
- package/dist/audio/SampleDecoder.d.ts.map +1 -0
- package/dist/audio/SampleDecoder.js +70 -0
- package/dist/audio/SampleDecoder.js.map +1 -0
- package/dist/audio/SoundFont.d.ts +59 -0
- package/dist/audio/SoundFont.d.ts.map +1 -1
- package/dist/audio/SoundFont.js +248 -2
- package/dist/audio/SoundFont.js.map +1 -1
- package/dist/audio/index.d.ts +3 -1
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +2 -1
- package/dist/audio/index.js.map +1 -1
- package/dist/mw_games.global.js +11 -11
- package/dist/mwl/presentation.d.ts +16 -0
- package/dist/mwl/presentation.d.ts.map +1 -1
- package/dist/mwl/presentation.js.map +1 -1
- package/dist/mwl/pyroeis-runtime.d.ts +7 -0
- package/dist/mwl/pyroeis-runtime.d.ts.map +1 -1
- package/dist/mwl/pyroeis-runtime.js.map +1 -1
- package/dist/mwl/pyroeis.d.ts +3 -0
- package/dist/mwl/pyroeis.d.ts.map +1 -1
- package/dist/mwl/pyroeis.js.map +1 -1
- package/dist/physics/PhysicsWorld.d.ts +3 -0
- package/dist/physics/PhysicsWorld.d.ts.map +1 -1
- package/dist/physics/PhysicsWorld.js.map +1 -1
- package/dist/physics/XpbdWorld.d.ts +11 -0
- package/dist/physics/XpbdWorld.d.ts.map +1 -1
- package/dist/physics/XpbdWorld.js.map +1 -1
- package/dist/physics/cloth/index.d.ts +20 -19
- package/dist/physics/cloth/index.d.ts.map +1 -1
- package/dist/physics/cloth/index.js +19 -0
- package/dist/physics/cloth/index.js.map +1 -1
- package/dist/physics/spring/index.d.ts +19 -18
- package/dist/physics/spring/index.d.ts.map +1 -1
- package/dist/physics/spring/index.js +23 -0
- package/dist/physics/spring/index.js.map +1 -1
- package/dist/three-d/Character3D.d.ts +8 -7
- package/dist/three-d/Character3D.d.ts.map +1 -1
- package/dist/three-d/Character3D.js +7 -0
- package/dist/three-d/Character3D.js.map +1 -1
- package/dist/three-d/Collision3D.d.ts +4 -0
- package/dist/three-d/Collision3D.d.ts.map +1 -1
- package/dist/three-d/Collision3D.js.map +1 -1
- package/dist/three-d/Engine3D.d.ts +2 -0
- package/dist/three-d/Engine3D.d.ts.map +1 -1
- package/dist/three-d/Engine3D.js.map +1 -1
- package/dist/three-d/Grid.d.ts +5 -0
- package/dist/three-d/Grid.d.ts.map +1 -1
- package/dist/three-d/Grid.js.map +1 -1
- package/dist/three-d/Heightmap.d.ts +3 -0
- package/dist/three-d/Heightmap.d.ts.map +1 -1
- package/dist/three-d/Heightmap.js.map +1 -1
- package/dist/three-d/TileGrid3D.d.ts +6 -0
- package/dist/three-d/TileGrid3D.d.ts.map +1 -1
- package/dist/three-d/TileGrid3D.js.map +1 -1
- package/dist/three-d/Vox.d.ts +7 -0
- package/dist/three-d/Vox.d.ts.map +1 -1
- package/dist/three-d/Vox.js.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.d.ts +7 -0
- package/dist/two-d/presentation/MwlPresentation.d.ts.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -1
- package/sbom.cdx.json +139 -139
- package/tools/benchmark-browser.mjs +6 -0
- package/tools/bundle-size.mjs +4 -0
- package/tools/compile-resources.d.mts +22 -0
- package/tools/compile-resources.mjs +116 -3
- package/tools/emit-page.d.mts +7 -1
- package/tools/emit-page.mjs +20 -2
- package/tools/init.mjs +3 -1
- package/tools/make-example-assets.mjs +4 -0
- package/tools/multiplayer-server.mjs +4 -0
- package/tools/mwl.mjs +7 -0
- package/tools/play-uci.mjs +4 -0
- package/tools/sbom.mjs +4 -0
- package/tools/soundfonts.d.mts +79 -0
- package/tools/soundfonts.mjs +301 -0
- package/tools/verify-scenarios.mjs +6 -2
- package/tools/vite.mjs +2 -0
- package/tools.md +216 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `mwg-soundfonts`: the developer-side half of choosing a SoundFont. The package ships one
|
|
9
|
+
* small bank (`data/microgm-opt.sf2`); a game that wants another picks it from the catalogue
|
|
10
|
+
* (`data/soundfont-catalog.json`, a licence-classified snapshot of the marmooo free-soundfonts
|
|
11
|
+
* collection), and this tool fetches only the instruments the game's MIDI files use and merges
|
|
12
|
+
* them into one font for the asset folder, where the build subsets it again like any other.
|
|
13
|
+
*
|
|
14
|
+
* The merged font stays a SoundFont 3 (Ogg Vorbis samples, a fraction of the size) by default:
|
|
15
|
+
* nothing is decoded here, and the game decodes it where it runs with `loadSoundFont`.
|
|
16
|
+
* `--sf2` decodes it into a plain SoundFont 2 instead, which is `ffmpeg`'s job: needed on a
|
|
17
|
+
* developer machine only, never by the player, and named when missing. Nothing here runs in a
|
|
18
|
+
* browser.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
export const CATALOG_PATH = join(HERE, '..', 'data', 'soundfont-catalog.json');
|
|
23
|
+
|
|
24
|
+
/** licence classes a font may be fetched under without saying so */
|
|
25
|
+
const FREE_TO_SHIP = new Set(['public-domain', 'permissive', 'attribution']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The SoundFont implementation lives in the library, not in this tool: in this repository it is
|
|
29
|
+
* the TypeScript source, in an installed package the built `dist/` it compiles to (the same
|
|
30
|
+
* lookup `compile-resources` makes).
|
|
31
|
+
*/
|
|
32
|
+
async function loadLibrary() {
|
|
33
|
+
try {
|
|
34
|
+
const [soundFont, midi] = await Promise.all([
|
|
35
|
+
import('../src/audio/SoundFont.ts'),
|
|
36
|
+
import('../src/audio/Midi.ts'),
|
|
37
|
+
]);
|
|
38
|
+
return { ...soundFont, ...midi };
|
|
39
|
+
} catch {
|
|
40
|
+
return import('../dist/audio/index.js');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** the catalogue as parsed JSON: `{ source, default, bundled, fonts }` */
|
|
45
|
+
export function readCatalog(path = CATALOG_PATH) {
|
|
46
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** a catalogue entry (bundled bank included) by name, or undefined */
|
|
50
|
+
export function findFont(catalog, name) {
|
|
51
|
+
return [...catalog.bundled, ...catalog.fonts].find((font) => font.name === name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `"0,24,128:0"` as `[{ bank, program }]`: a bare number is a program of bank 0, `bank:program` names both */
|
|
55
|
+
export function parseProgramList(text) {
|
|
56
|
+
return text
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((item) => item.trim())
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((item) => {
|
|
61
|
+
const [first, second] = item.split(':');
|
|
62
|
+
const bank = second === undefined ? 0 : Number(first);
|
|
63
|
+
const program = Number(second === undefined ? first : second);
|
|
64
|
+
if (
|
|
65
|
+
!Number.isInteger(bank) ||
|
|
66
|
+
!Number.isInteger(program) ||
|
|
67
|
+
bank < 0 ||
|
|
68
|
+
bank > 128 ||
|
|
69
|
+
program < 0 ||
|
|
70
|
+
program > 127
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`invalid program "${item}": expected a program 0-127 or bank:program`);
|
|
73
|
+
}
|
|
74
|
+
return { bank, program };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* mono 16-bit PCM from Ogg Vorbis bytes, through `ffmpeg`, at the rate the stream carries (the
|
|
80
|
+
* `rate` a font states for the sample is the same one). The bytes come from a font file that may
|
|
81
|
+
* have been downloaded, so they are not trusted: anything that does not start with the Ogg magic
|
|
82
|
+
* is refused before a process is spawned, the demuxer is forced to Ogg rather than probed (a
|
|
83
|
+
* probed "sample" could be read as a playlist naming local files), and only the `pipe` protocol
|
|
84
|
+
* is allowed.
|
|
85
|
+
*/
|
|
86
|
+
export function ffmpegDecode(ogg) {
|
|
87
|
+
if (Buffer.from(ogg.subarray(0, 4)).toString('latin1') !== 'OggS') {
|
|
88
|
+
throw new Error('a compressed sample is not Ogg Vorbis data: the font is damaged or not a SoundFont 3');
|
|
89
|
+
}
|
|
90
|
+
const run = spawnSync(
|
|
91
|
+
'ffmpeg',
|
|
92
|
+
[
|
|
93
|
+
'-v',
|
|
94
|
+
'error',
|
|
95
|
+
'-protocol_whitelist',
|
|
96
|
+
'pipe',
|
|
97
|
+
'-f',
|
|
98
|
+
'ogg',
|
|
99
|
+
'-i',
|
|
100
|
+
'pipe:0',
|
|
101
|
+
'-f',
|
|
102
|
+
's16le',
|
|
103
|
+
'-ac',
|
|
104
|
+
'1',
|
|
105
|
+
'pipe:1',
|
|
106
|
+
],
|
|
107
|
+
{ input: ogg, maxBuffer: 256 * 1024 * 1024 },
|
|
108
|
+
);
|
|
109
|
+
if (run.error?.code === 'ENOENT') {
|
|
110
|
+
throw new Error('ffmpeg was not found on PATH: install it to decode SoundFont 3 (developer machines only)');
|
|
111
|
+
}
|
|
112
|
+
if (run.status !== 0) throw new Error(`ffmpeg could not decode a sample: ${run.stderr}`);
|
|
113
|
+
const out = run.stdout;
|
|
114
|
+
return new Int16Array(out.buffer.slice(out.byteOffset, out.byteOffset + out.length - (out.length & 1)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** a SoundFont 3 decoded into SoundFont 2 bytes (`decode` is `ffmpeg` unless given) */
|
|
118
|
+
export async function sf3ToSf2(bytes, decode = ffmpegDecode) {
|
|
119
|
+
const { decodeSoundFont } = await loadLibrary();
|
|
120
|
+
return new Uint8Array(await decodeSoundFont(bytes, decode));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const pad = (value, width) => String(value).padStart(width, '0');
|
|
124
|
+
|
|
125
|
+
/** reads one file of a collection laid out as `<source>/<font>/<bank>/<program>.sf3`, from a folder or a URL; null when absent */
|
|
126
|
+
export async function readCollectionFile(source, path) {
|
|
127
|
+
if (/^https?:\/\//.test(source)) {
|
|
128
|
+
const response = await fetch(
|
|
129
|
+
`${source.replace(/\/$/, '')}/${path.split('/').map(encodeURIComponent).join('/')}`,
|
|
130
|
+
);
|
|
131
|
+
if (response.status === 404) return null;
|
|
132
|
+
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
|
133
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
134
|
+
}
|
|
135
|
+
const file = resolve(source, path);
|
|
136
|
+
return existsSync(file) ? new Uint8Array(readFileSync(file)) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetches `programs` (`[{ bank, program }]`) of the catalogue font `name` and merges them into
|
|
141
|
+
* one font: a SoundFont 3 with the compressed samples untouched, or with `format: 'sf2'` a
|
|
142
|
+
* SoundFont 2 decoded through `decode` (`ffmpeg` by default). Returns
|
|
143
|
+
* `{ bytes, format, found, missing, font }`: `missing` lists the programs the collection does
|
|
144
|
+
* not carry (the renderer falls back for them as it does for any font). A font whose licence
|
|
145
|
+
* class is not free to ship is refused unless `acceptLicence` is set, and a font that is not in
|
|
146
|
+
* the catalogue is refused outright.
|
|
147
|
+
*/
|
|
148
|
+
export async function fetchSoundFont({
|
|
149
|
+
name,
|
|
150
|
+
programs,
|
|
151
|
+
catalog = readCatalog(),
|
|
152
|
+
source,
|
|
153
|
+
acceptLicence = false,
|
|
154
|
+
format = 'sf3',
|
|
155
|
+
decode,
|
|
156
|
+
read,
|
|
157
|
+
}) {
|
|
158
|
+
const font = catalog.fonts.find((entry) => entry.name === name);
|
|
159
|
+
if (!font) {
|
|
160
|
+
const bundled = catalog.bundled.find((entry) => entry.name === name);
|
|
161
|
+
throw new Error(
|
|
162
|
+
bundled
|
|
163
|
+
? `${name} ships in the package as ${bundled.file}: copy that file instead of fetching it`
|
|
164
|
+
: `${name} is not in the catalogue: run "mwg-soundfonts list"`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!FREE_TO_SHIP.has(font.licenceClass) && !acceptLicence) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${name} is ${font.licence} (${font.licenceClass})${font.note ? `: ${font.note}` : ''}. ` +
|
|
170
|
+
'Pass --accept-licence once you have read its terms and are sure the game may ship it.',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (format !== 'sf3' && format !== 'sf2') throw new Error(`unknown format "${format}": expected sf3 or sf2`);
|
|
174
|
+
if (programs.length === 0) throw new Error('no programs requested: pass --midi <files> or --programs <list>');
|
|
175
|
+
const origin = source ?? catalog.source;
|
|
176
|
+
const reader = read ?? ((path) => readCollectionFile(origin, path));
|
|
177
|
+
const { mergeSoundFonts, decodeSoundFont } = await loadLibrary();
|
|
178
|
+
|
|
179
|
+
const found = [];
|
|
180
|
+
const missing = [];
|
|
181
|
+
const files = [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
for (const { bank, program } of programs) {
|
|
184
|
+
const key = `${bank}:${program}`;
|
|
185
|
+
if (seen.has(key)) continue;
|
|
186
|
+
seen.add(key);
|
|
187
|
+
const bytes = await reader(`${name}/${pad(bank, 3)}/${pad(program, 3)}.sf3`);
|
|
188
|
+
if (!bytes) {
|
|
189
|
+
missing.push({ bank, program });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
files.push(bytes);
|
|
193
|
+
found.push({ bank, program });
|
|
194
|
+
}
|
|
195
|
+
if (files.length === 0) throw new Error(`${name} carries none of the requested programs`);
|
|
196
|
+
const merged = mergeSoundFonts(files);
|
|
197
|
+
const bytes = new Uint8Array(format === 'sf2' ? await decodeSoundFont(merged, decode ?? ffmpegDecode) : merged);
|
|
198
|
+
return { bytes, format, found, missing, font };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** the credits note that travels beside a fetched font, so the licence is not lost */
|
|
202
|
+
export function creditsFor(font, programs) {
|
|
203
|
+
return [
|
|
204
|
+
`${font.name}`,
|
|
205
|
+
`Licence: ${font.licence} (${font.licenceClass})`,
|
|
206
|
+
...(font.web ? [`Source: ${font.web}`] : []),
|
|
207
|
+
`Programs: ${programs.map(({ bank, program }) => `${bank}:${program}`).join(', ')}`,
|
|
208
|
+
...(font.licenceClass === 'attribution' ? ['Credit the author of this font in your game.'] : []),
|
|
209
|
+
...(font.licenceClass === 'share-alike' || font.licenceClass === 'copyleft'
|
|
210
|
+
? ['This licence carries sharing obligations: read it before shipping a game that embeds this font.']
|
|
211
|
+
: []),
|
|
212
|
+
'',
|
|
213
|
+
].join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const USAGE = `usage: mwg-soundfonts <command>
|
|
217
|
+
list the bundled bank and every catalogue font, with licence and size
|
|
218
|
+
fetch <font> (--midi <file>... | --programs <list>) [--out <file>] [--source <url|folder>] [--accept-licence] [--sf2]
|
|
219
|
+
one font holding just those instruments (programs: 0,24,128:0),
|
|
220
|
+
a SoundFont 3 the game decodes with loadSoundFont, or with --sf2
|
|
221
|
+
a decoded SoundFont 2 (needs ffmpeg on PATH)
|
|
222
|
+
convert <in.sf3> <out.sf2> SoundFont 3 to SoundFont 2 (needs ffmpeg on PATH)`;
|
|
223
|
+
|
|
224
|
+
function formatList(catalog) {
|
|
225
|
+
const rows = [
|
|
226
|
+
...catalog.bundled.map((font) => ({ ...font, shipped: 'bundled in the package: ' + font.file })),
|
|
227
|
+
...catalog.fonts.map((font) => ({
|
|
228
|
+
...font,
|
|
229
|
+
shipped: `${font.gmMB} MB for a full GM bank, ${font.fullMB} MB in all (sf3)`,
|
|
230
|
+
})),
|
|
231
|
+
];
|
|
232
|
+
return rows
|
|
233
|
+
.map((font) => {
|
|
234
|
+
const free = FREE_TO_SHIP.has(font.licenceClass) ? '' : ' [read the licence first]';
|
|
235
|
+
return `${font.name}${font.name === catalog.default ? ' (default)' : ''}\n ${font.licence} (${font.licenceClass})${free}\n ${font.shipped}`;
|
|
236
|
+
})
|
|
237
|
+
.join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function main(argv) {
|
|
241
|
+
const [command, ...rest] = argv;
|
|
242
|
+
if (!command || command === '--help' || command === '-h') {
|
|
243
|
+
console.log(USAGE);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const flag = (name) => {
|
|
247
|
+
const at = rest.indexOf(name);
|
|
248
|
+
return at === -1 ? undefined : rest[at + 1];
|
|
249
|
+
};
|
|
250
|
+
const valuesAfter = (name) => {
|
|
251
|
+
const at = rest.indexOf(name);
|
|
252
|
+
if (at === -1) return [];
|
|
253
|
+
const out = [];
|
|
254
|
+
for (let index = at + 1; index < rest.length && !rest[index].startsWith('--'); index++) out.push(rest[index]);
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
if (command === 'list') {
|
|
258
|
+
console.log(formatList(readCatalog()));
|
|
259
|
+
} else if (command === 'convert') {
|
|
260
|
+
const [input, output] = rest;
|
|
261
|
+
if (!input || !output) throw new Error('usage: mwg-soundfonts convert <in.sf3> <out.sf2>');
|
|
262
|
+
writeFileSync(output, await sf3ToSf2(readFileSync(input)));
|
|
263
|
+
console.log(`wrote ${output}`);
|
|
264
|
+
} else if (command === 'fetch') {
|
|
265
|
+
const name = rest.find((arg) => !arg.startsWith('--'));
|
|
266
|
+
if (!name) throw new Error('usage: mwg-soundfonts fetch <font> (--midi <file>... | --programs <list>)');
|
|
267
|
+
const programs = flag('--programs') ? parseProgramList(flag('--programs')) : [];
|
|
268
|
+
const midiFiles = valuesAfter('--midi');
|
|
269
|
+
if (midiFiles.length > 0) {
|
|
270
|
+
const { collectSoundFontUsage, parseMidi } = await loadLibrary();
|
|
271
|
+
programs.push(...collectSoundFontUsage(midiFiles.map((file) => parseMidi(readFileSync(file)))));
|
|
272
|
+
}
|
|
273
|
+
const result = await fetchSoundFont({
|
|
274
|
+
name,
|
|
275
|
+
programs,
|
|
276
|
+
source: flag('--source'),
|
|
277
|
+
acceptLicence: rest.includes('--accept-licence'),
|
|
278
|
+
format: rest.includes('--sf2') ? 'sf2' : 'sf3',
|
|
279
|
+
});
|
|
280
|
+
const out = resolve(flag('--out') ?? `${name}.${result.format}`);
|
|
281
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
282
|
+
writeFileSync(out, result.bytes);
|
|
283
|
+
writeFileSync(out.replace(/\.sf[23]$/, '') + '.credits.txt', creditsFor(result.font, result.found));
|
|
284
|
+
console.log(`wrote ${out}: ${result.found.length} programs, ${(result.bytes.length / 1024).toFixed(0)} KB`);
|
|
285
|
+
if (result.missing.length > 0) {
|
|
286
|
+
console.log(
|
|
287
|
+
`not in ${name}: ${result.missing.map(({ bank, program }) => `${bank}:${program}`).join(', ')}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(`unknown command "${command}"\n${USAGE}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
//realpath, since an npm `bin` shim reaches this file through a symlink
|
|
296
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
297
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
298
|
+
console.error(error instanceof Error ? error.message : error);
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -105,9 +105,13 @@ export async function verifyScenarios({ file, turns = 3, timeoutMs = 120000, onl
|
|
|
105
105
|
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
106
106
|
const [file, ...args] = process.argv.slice(2);
|
|
107
107
|
const options = { file };
|
|
108
|
+
const usage = 'usage: mwg-scenarios <built index.html> [--turns N] [--timeout-ms N] [--only prefix]';
|
|
109
|
+
if (file === '--help' || file === '-h') {
|
|
110
|
+
console.log(usage);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
108
113
|
try {
|
|
109
|
-
if (!file)
|
|
110
|
-
throw new Error('usage: mwg-scenarios <built index.html> [--turns N] [--timeout-ms N] [--only prefix]');
|
|
114
|
+
if (!file) throw new Error(usage);
|
|
111
115
|
for (let i = 0; i < args.length; i += 2) {
|
|
112
116
|
const name = { '--turns': 'turns', '--timeout-ms': 'timeoutMs', '--only': 'only' }[args[i]];
|
|
113
117
|
if (!name || args[i + 1] === undefined) throw new Error(`invalid option: ${args[i]}`);
|
package/tools/vite.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { buildArtifactSbom, serialize, shippedFiles } from './sbom.mjs';
|
|
|
14
14
|
* build. `assetFiles: ['characters/hero.svg', 'music/theme.ogg']` embeds only those paths;
|
|
15
15
|
* omit it for all assets, or pass [] for none. An unknown entry fails by name. Filtering
|
|
16
16
|
* affects the build only, so the dev server still serves the asset folder normally.
|
|
17
|
+
* An embedded `.sf2` or `.sf3` is subset at build time to the programs the embedded `.mid` files
|
|
18
|
+
* use (`soundFont: false` embeds it whole, `soundFontUsage` names the programs instead).
|
|
17
19
|
* At the end of the build it runs `emitPage` over the output folder with the same
|
|
18
20
|
* options. A setting the game's own config gives explicitly still wins, since vite merges a
|
|
19
21
|
* plugin's `config` result under the user's.
|
package/tools.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Tools
|
|
2
|
+
|
|
3
|
+
Developer-side scripts in `tools/`. Nothing here reaches the player: these author
|
|
4
|
+
content, verify it, or package it, and the shipped game is still a folder that opens
|
|
5
|
+
by double-clicking. Run from the repo root.
|
|
6
|
+
|
|
7
|
+
## Content and authoring
|
|
8
|
+
|
|
9
|
+
| tool | what it does | run |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `i18n-edit` | Split-screen terminal editor for `mwg/i18n` catalogs: reference language left, translation right, either side any language, per-string sound cues, JSON/FTL auto-detected, with a `--check` mode for CI. Panes preview markdown as bold/italic and flag placeholder drift (`{dmg:03d}` dropped or reshaped); `v` toggles raw source, `Ctrl+P` previews the row's cue through the OS player (`MWG_SFX_PLAYER` overrides) | `npx mwg-i18n <base> <target> [--check]` in a game (shipped, item 384), `npm run i18n:edit -- <base> <target>` here |
|
|
12
|
+
| `make-example-assets` | Generates `examples/assets` (tileset, sounds). Borrowed art stays out for licence reasons, so this draws everything; a game can prototype with the same set before its own art exists (item 389) | `npm run assets` here; `npx mwg-placeholder-assets <folder> [--force]` in a game (refuses to overwrite without `--force`), or `writePlaceholderAssets` from `@datamoc/mw_games/tools/make-example-assets` |
|
|
13
|
+
| `soundfonts` | Picks a SoundFont other than the bundled `microgm-opt` (WTFPL, 2.6 MB, the default). `list` prints the catalogue (`data/soundfont-catalog.json`, a licence-classified snapshot of the marmooo free-soundfonts collection: public domain, MIT and CC-BY fonts fetch freely, share-alike, copyleft and unclear ones need `--accept-licence`); `fetch <font>` downloads only the instruments the game's MIDI files use (`--midi`) or a list (`--programs 0,24,128:0`), and merges them into one SoundFont 3 (the compressed samples untouched, no decoding, no `ffmpeg`) beside a `.credits.txt` that keeps the licence with the file; the game decodes it where it runs with `loadSoundFont`. `fetch --sf2` and `convert <in.sf3> <out.sf2>` decode to a plain SoundFont 2 instead, about eight times larger, which needs `ffmpeg` on PATH (developer machines only). The result goes in the asset folder, where the build subsets it to the programs the game plays | `npx mwg-soundfonts list`, `npx mwg-soundfonts fetch GeneralUser_GS_v2.0.3 --midi assets/*.mid --accept-licence`, or `fetchSoundFont` from `@datamoc/mw_games/tools/soundfonts` |
|
|
14
|
+
| `extract-rgssad` | Decrypts an RPG Maker XP/VX archive to plain files (vendored MIT decoder, developer-side only); an entry whose name would land outside `outDir` is refused (item 389) | `npm run extract:rgssad -- <archive.rgssad> <outDir>` here; `npx mwg-extract-rgssad <archive> <outDir>` or `extractRgssad` from `@datamoc/mw_games/tools/extract-rgssad` in a game |
|
|
15
|
+
| `multiplayer-server` | The reference lockstep server for `core.LockstepClient`: rooms, one shared tick, `null` for a missed input, messages capped at `maxMessageBytes` (64 KB) and read through `parseInbound`, room names bounded, `maxClientsPerRoom` (64). Speaks `ws:`, so a deployment puts it behind a TLS proxy (items 129, 385) | `npx mwg-lockstep-server [port] [--host=]` (127.0.0.1 unless `--host`), or `createLockstepServer(options)` from `@datamoc/mw_games/tools/multiplayer-server`; needs the optional peer `ws` |
|
|
16
|
+
| `mwl` | Compiles MWL content (`[game]`/`[scenario]`/`[side]`/`[unit]`/`[event]`/`[campaign]`) into a game-data module, an i18n catalog and an asset manifest | `npm run mwl -- build <contentDir> -o <outDir>`, or `node node_modules/@datamoc/mw_games/tools/mwl.mjs build <contentDir> -o <outDir>` from an install. It is a command line, not an importable module (it dispatches when loaded), so it has no package subpath; script against the typed library instead: `@datamoc/mw_games/mwl` |
|
|
17
|
+
| `extract-html` | Splits the resources embedded in an HTML file (scripts, CSS, images) back out into files | `npm run extract:html -- <page.html> -o <outDir>`, or `extractHtml` from `@datamoc/mw_games/tools/extract-html` |
|
|
18
|
+
| `compile-openings` | Compiles opening tables (`eco`, `name`, `pgn` TSV rows) into a position-keyed book JSON a game checks in beside its own code: SAN resolved against the real move generator, every position keeping its continuations, the longest line ending there naming it. Rows are vendored CC0 data (`data/openings/`), never fetched | `node tools/compile-openings.mjs <tsv folder> <book.json>`, or `npx mwg-openings <tsv folder> <book.json>` in a game |
|
|
19
|
+
| `build-tablebases` | Solves pawnless white-extras-against-lone-king endings by retrograde analysis and ships them compiled as Huffman-coded JSON under `data/tablebases` (3-piece: `KQK KRK KBK KNK`; 4-piece like `KBNK KBBK` generate the same way but stay a tool run away, full 4-piece tables are tens of megabytes each). One `threads.spawn` task per ending over capped lanes (4-piece lanes stay narrow: hundreds of megabytes of arrays each); captures convert into the already-solved smaller tables, so 3-piece endings solve first. Golden counts: KQK 0/200896/99448/223944, KRK 0/201700/98644/223944, KBK/KNK all draws, KBBK 0/5628080/21071792/6854560, KBNK 0/11188168/8816020/13550244 (unknown/loss/draw/win) | `node tools/build-tablebases.mjs <outDir> [ending ...] [--jobs=N]`, or `npx mwg-tablebases <outDir> [ending ...]` in a game |
|
|
20
|
+
| `play-uci` | A UCI adapter over the tournament engine for GUIs and rating runs: `position`/`go` on stdin, `bestmove` on stdout. Move choice runs book, then tablebase, then `searchTourney`; a Node tool, never part of the browser bundle | `node tools/play-uci.mjs [--book <book.json>] [--tables <dir>] [--depth N]`, or `npx mwg-uci` in a game |
|
|
21
|
+
|
|
22
|
+
## Shipping (`file://`)
|
|
23
|
+
|
|
24
|
+
| tool | what it does | run |
|
|
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]]` |
|
|
27
|
+
| `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
|
+
| `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
|
+
| `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` |
|
|
30
|
+
| `verify-scenarios` | `mwg-scenarios <built index.html>` runs every game-owned scenario in one Chrome page, using registered scene controls for bot turns, assertions and cleanup | `npx mwg-scenarios dist/index.html --turns 3` |
|
|
31
|
+
|
|
32
|
+
| `vite` | The Vite plugin a game uses instead of either: `mwgPage(options)` sets the `file://` build configuration (IIFE `game.js`, `base: './'`, `assets/` as the public folder) and runs `emitPage` when `vite build` finishes (item 381) | `plugins: [mwgPage()]` from `@datamoc/mw_games/tools/vite`; `mwgPage({ singleFile: { compress: true } })` also writes one self-contained `standalone.html` (about 250 KB for Pixi + mwg + a small game) |
|
|
33
|
+
| `browser-smoke` | Opens a built page from `file://` in headless Chrome and fails unless it rendered: no page error, an mwg game ready on a WebGL/WebGPU canvas, non-flat pixels, then a screenshot (item 383). `find-chrome` beside it picks the system Chrome, then the newest Chromium Playwright installed, then PATH; `CHROME_PATH` overrides | `vite build && npx mwg-smoke dist` in a game's CI (needs the optional peer `playwright-core`), or `smokePage(options)` from `@datamoc/mw_games/tools/browser-smoke` |
|
|
34
|
+
| `csp` | The Content-Security-Policy `emitPage` writes into every page by default (item 374): scripts only from the page's own folder plus the hashed inline ones, images/fonts/media only `data:`/`blob:`, network (`connect-src`) only to declared origins, `'unsafe-eval'` kept because Pixi 8 needs it | automatic; `csp: { connect: ['https://api.example'] }` or `csp: false` on `emitPage`/`mwgPage`, `--connect=`/`--no-csp` on `mwg-emit`, or `withContentSecurityPolicy(html)` from `@datamoc/mw_games/tools/csp` |
|
|
35
|
+
| `classic-html` | Rewrites a bundler build's `<script type="module">` entry tag to a classic deferred script, the shipped, reusable half of what `emit-page` does for this repo's own examples (item 306) | `import { toClassicScript } from '@datamoc/mw_games/tools/classic-html'` in a game's own build script, or `node tools/classic-html.mjs dist/index.html` here |
|
|
36
|
+
| `compress-dist` | Writes `.gz`/`.br` siblings for large build outputs, into the folder it is handed, plus `.xz` where smaller behind the opt-in `xz` option (needs the system `xz` binary). Files compress in parallel over async `zlib`, `MWG_COMPRESS_JOBS` names the lane count, rows keep walk order | used by the packaging flow |
|
|
37
|
+
| `single-file` | Inlines every `<script src>` an `emit-page` output loads into one additional standalone HTML file, no sibling `.js` at all; `compress: true` compresses each script first (gzip by default, or `algorithm: 'brotli'`), unpacked at load through the browser's own `DecompressionStream` behind a splash screen. Brotli needs `DecompressionStream('br')`, unsupported in a directly-tested current Chrome (153) as of this writing - gzip is the broadly-supported default, brotli an advanced option that degrades to a clear, catchable error rather than hanging (items 349, 350) | `node tools/single-file.mjs <dist folder> [--compress[=level]] [--brotli[=quality]] [--no-splash] [--output name.html]`, `emit-page --single-file [--single-file-compress[=level]] [--single-file-brotli[=quality]]`, or `buildSingleFile` from `@datamoc/mw_games/tools/single-file`. `output` is a file name written inside `dist`, not a path: one with a separator is refused |
|
|
38
|
+
| `asset-report` | Lists every file a built page ships, with raw/gzip/brotli weights and each file's share of the compressed page, plus the embedded source assets with byte-identical duplicates grouped (item 422) | `mwg-assets dist [--assets=assets] [--json=report.json]`, or `assetReport(options)` from `@datamoc/mw_games/tools/asset-report` |
|
|
39
|
+
|
|
40
|
+
### Scenario verification contract
|
|
41
|
+
|
|
42
|
+
Expose `window.__MWG_SCENARIOS__` in a verification build with `controls` (a
|
|
43
|
+
`createSceneControls()` registry from `@datamoc/mw_games/testing`) and `scenarios`:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
window.__MWG_SCENARIOS__ = {
|
|
47
|
+
controls,
|
|
48
|
+
scenarios: [
|
|
49
|
+
{ id: 'tutorial', enter: 'enterTutorial', turn: ['playHumanBot', 'playEnemyBots', 'advanceDialogue'],
|
|
50
|
+
verify: 'checkTutorial', leave: 'destroyScenario' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each name is a registered step. The game implements scenario entry, bots for all sides,
|
|
56
|
+
dialogue advancement and checks for map, units, outcomes or campaign references. A check
|
|
57
|
+
throws on failure; an optional `leave` step handles cleanup even after a failed entry or
|
|
58
|
+
turn. The tool validates the whole selected plan before entering any scenario, runs each
|
|
59
|
+
turn's steps in order, then checks it. Empty plans and filters matching nothing fail.
|
|
60
|
+
Exceptions and console errors fail the report. A timed-out step aborts the run and closes
|
|
61
|
+
the browser because its promise may still be changing scene state.
|
|
62
|
+
|
|
63
|
+
Run `mwg-scenarios dist/index.html --turns 3 --timeout-ms 120000 --only tutorial`.
|
|
64
|
+
The page opens from `file://` once, and the JSON report names every attempted scenario,
|
|
65
|
+
completed turn count and failure. Install the optional `playwright-core` development peer
|
|
66
|
+
to use this tool. `CHROME_PATH` selects Chrome; `MWG_VISUAL_CHROME_ARGS` supplies runner flags.
|
|
67
|
+
|
|
68
|
+
## Packaging as a native app
|
|
69
|
+
|
|
70
|
+
The packaging scaffolding is part of this repository, not of the published package. Its
|
|
71
|
+
`files` list ships `dist/`, the `tools/*.mjs` helpers (see the table above), the licence and the markdown
|
|
72
|
+
documents, and none of its packaging scripts (`desktop:*`, `mobile:*`, `cap:*`) do: they all
|
|
73
|
+
point into this checkout. A game that installs `@datamoc/mw_games` from the registry
|
|
74
|
+
therefore supplies its own native shell; the recipe for each target is below.
|
|
75
|
+
|
|
76
|
+
Build the game without `compress-dist`'s `.gz`/`.br` siblings for any native target: a
|
|
77
|
+
packaged app has no server to negotiate `Content-Encoding` with, so they are dead weight
|
|
78
|
+
(the opt-in `.xz` siblings are the same), and on Android the asset merge fails outright
|
|
79
|
+
(`Duplicate resources` from AAPT, which reads `game.js` and `game.js.gz` as colliding).
|
|
80
|
+
|
|
81
|
+
### Desktop (Windows, WebView2)
|
|
82
|
+
|
|
83
|
+
The reference host is `desktop/MwgDesktopHost` - two files, `MwgDesktopHost.csproj` and
|
|
84
|
+
`Program.cs`. Copy that one folder into your project and point it at the game's built
|
|
85
|
+
`dist/`:
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
cp -r <a clone of mwg>/desktop/MwgDesktopHost ./desktop/MwgDesktopHost
|
|
89
|
+
dotnet run --project desktop/MwgDesktopHost -- dist/index.html
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The argument is resolved against the directory the command runs from, so it can be relative;
|
|
93
|
+
with no argument the host loads this repository's own tower-defense example, which is what
|
|
94
|
+
`npm run desktop:run` does. It needs the .NET 8 SDK (`winget install Microsoft.DotNet.SDK.8`)
|
|
95
|
+
and the WebView2 Runtime, already present on current Windows.
|
|
96
|
+
|
|
97
|
+
The host serves the page through `CoreWebView2.SetVirtualHostNameToFolderMapping`, mapping
|
|
98
|
+
`https://mwg.local/` to the page's own folder rather than navigating to `file://`. That real
|
|
99
|
+
origin is what lets `assets.fetchWithByteProgress`, a `LockstepClient` WebSocket connection,
|
|
100
|
+
or any other genuine network call work inside the app; `file://` blocks all of them. A game
|
|
101
|
+
that needs none of that can use `desktop/webview2` instead, which opens the same built page
|
|
102
|
+
from `file://` and takes the same page argument.
|
|
103
|
+
|
|
104
|
+
`dotnet publish -c Release -r win-x64 --self-contained true` inside the host project produces
|
|
105
|
+
a distributable build.
|
|
106
|
+
|
|
107
|
+
### Mobile (Capacitor, Android)
|
|
108
|
+
|
|
109
|
+
`android/` and the `cap:*` scripts are this repository's own scaffold, so copy the shape of
|
|
110
|
+
them rather than the files: a game creates its own Capacitor project. Toolchain first, since
|
|
111
|
+
these are the two that stop a clean machine:
|
|
112
|
+
|
|
113
|
+
- **JDK 21** (`winget install EclipseAdoptium.Temurin.21.JDK`), with `JAVA_HOME` pointing at it.
|
|
114
|
+
Capacitor's Android Gradle Plugin will not compile under JDK 17: `invalid source release: 21`.
|
|
115
|
+
- **Android SDK** with `platform-tools`, and either `ANDROID_HOME` set or an
|
|
116
|
+
`android/local.properties` written by hand with `sdk.dir=C:/path/to/Android/Sdk`. Forward
|
|
117
|
+
slashes work; backslashes have to be doubled, and a properties file with single backslashes
|
|
118
|
+
silently loses every separator and fails much later as an unreadable path.
|
|
119
|
+
|
|
120
|
+
Then, from the game's own folder:
|
|
121
|
+
|
|
122
|
+
```powershell
|
|
123
|
+
npm install --save-dev @capacitor/cli @capacitor/core @capacitor/android
|
|
124
|
+
# capacitor.config.json: { "appId": "com.example.yourgame", "appName": "Your Game", "webDir": "dist" }
|
|
125
|
+
npx cap add android # once: scaffolds android/
|
|
126
|
+
npx cap sync android # after every web rebuild: copies dist/ into the project
|
|
127
|
+
cd android && ./gradlew assembleDebug # gradlew.bat on Windows
|
|
128
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
129
|
+
adb shell am start -n com.example.yourgame/.MainActivity
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`assembleDebug` produces an installable APK; a release build needs signing, which is yours to
|
|
133
|
+
arrange. `adb` comes with the SDK's `platform-tools`; an emulator is
|
|
134
|
+
`emulator -avd <name>`, and a cold boot takes a minute before `adb install` will accept it.
|
|
135
|
+
|
|
136
|
+
Three things about the generated project are worth knowing before the first bad build, all
|
|
137
|
+
three because `cap add android` writes them once and never revisits them:
|
|
138
|
+
|
|
139
|
+
- **Do not ship `.gz`/`.br` sidecars.** The asset merge fails outright:
|
|
140
|
+
`mergeDebugAssets FAILED ... [public/game.js.gz]: Duplicate resources`, because AAPT reads
|
|
141
|
+
`game.js` and `game.js.gz` as colliding. Build the web output without `compress-dist` for a
|
|
142
|
+
packaged app anyway: there is no server to negotiate `Content-Encoding`.
|
|
143
|
+
- **`ignoreAssetsPattern`,** in `android/app/build.gradle`: the generated line ends `!*~`. Add
|
|
144
|
+
`:!*.gz:!*.br` to it, which is what this repository's own `android/` carries. That is the
|
|
145
|
+
second half of the same problem, and it is why MWG tracks `android/` in git at all - a fresh
|
|
146
|
+
`cap add android` would lose it.
|
|
147
|
+
- **`versionCode`/`versionName`,** in the same file: written as `1` and `"1.0"` and never
|
|
148
|
+
revised, so an app that ships twice without stamping them cannot be installed over itself
|
|
149
|
+
(`INSTALL_FAILED_VERSION_DOWNGRADE`; Android refuses an update whose `versionCode` has not
|
|
150
|
+
increased). Stamp both from the release that produced the build.
|
|
151
|
+
|
|
152
|
+
On some Windows hosts the Capacitor CLI itself refuses to start: Node 26 can return `ENOMEM`
|
|
153
|
+
from `os.userInfo()`, which Capacitor's terminal helper calls at load. The framework's own
|
|
154
|
+
`cap:cli` script works around it, and a game that hits it can too:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
// tools/capacitor-node-workaround.cjs, then: node --require ./tools/capacitor-node-workaround.cjs
|
|
158
|
+
// node_modules/@capacitor/cli/bin/capacitor add android
|
|
159
|
+
const os = require('node:os');
|
|
160
|
+
try {
|
|
161
|
+
os.userInfo();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.syscall !== 'uv_os_get_passwd') throw error;
|
|
164
|
+
os.userInfo = () => ({
|
|
165
|
+
uid: -1,
|
|
166
|
+
gid: -1,
|
|
167
|
+
username: process.env.USERNAME || 'unknown',
|
|
168
|
+
homedir: process.env.USERPROFILE || process.cwd(),
|
|
169
|
+
shell: process.env.COMSPEC || null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Verified end to end from a folder outside this repository, against the published package: a
|
|
175
|
+
game built with `tools/classic-html`'s `toClassicScript`, `cap add android`, `cap sync`, a real
|
|
176
|
+
`gradlew assembleDebug`, then installed and launched on a `Medium_Phone_API_35` emulator, where
|
|
177
|
+
the game rendered and its pixels were checked rather than assumed. The duplicate-resource
|
|
178
|
+
failure above and the `ignoreAssetsPattern` fix that clears it were both reproduced on that
|
|
179
|
+
same build.
|
|
180
|
+
|
|
181
|
+
## Verification and benchmarks
|
|
182
|
+
|
|
183
|
+
| tool | what it does | run |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `benchmark-browser` | Measures a built page's frame rate in headless Chrome: mean fps, 95th-percentile frame time, JS heap, a per-page history compared with its best run (item 387) | `npm run benchmark:browser` here; `npx mwg-bench dist [--frames=] [--min-fps=] [--max-p95=] [--history=]` in a game (optional peer `playwright-core`), or `measurePage(options)` from `@datamoc/mw_games/tools/benchmark-browser` |
|
|
186
|
+
| `benchmark-simulation` | Headless throughput for `mwg/simulation` (no frame, canvas, or Pixi involved) | `npm run benchmark:simulation` |
|
|
187
|
+
| `benchmark-animation` | CSS/SVG element animation vs Pixi sprites vs both, at increasing counts, sampling rAF intervals in headless Chrome. A measurement, not a gate | `npm run benchmark:animation` |
|
|
188
|
+
| `graphics-capabilities` | Probes the host's WebGL capabilities headless | `npm run graphics:capabilities` |
|
|
189
|
+
| `visual-smoke` | Opens one built example from `file://` in headless Chrome, fails on a page error or a blank canvas, and writes a screenshot. Runs per pull request in CI | `npm run visual:smoke:ui` |
|
|
190
|
+
| `package-smoke` | `npm pack`, installs the tarball outside the repo, builds the tutorial's tiny game and opens both that and the standalone global from `file://` | `npm run package:smoke` |
|
|
191
|
+
| `motion-smoke` | Opens the interface example three ways (no preference, an emulated `prefers-reduced-motion: reduce`, and a flip while the page is open) and asserts the demo's own state: the preference arrives, stops the motion, and is seen when it changes | `npm run motion:smoke` |
|
|
192
|
+
| `build-all-examples` | Runs every `example:*:build` script, so a vite/emit-page regression in any example is caught, not just the benchmarked ones | `npm run examples:build` |
|
|
193
|
+
| `bundle-size` | The size budget: raw and gzipped bytes against a committed baseline, failing past a tolerance (2 % by default). Here, the global bundle and the published `dist`; in a game, every file a player receives plus their total (item 386) | `npm run size:check`/`size:update` here; `npx mwg-size dist [--baseline=size-budget.json] [--update] [--tolerance=0.02]` in a game |
|
|
194
|
+
| `audit-adoptions` | The consumer adoption auditor: reads the `api-surface.json` manifest plus a consumer-authored reasons file and config (type-only directories, subpath deny-lists, per-pattern import budgets), then reports exports neither imported nor reasoned, shadowing collisions, and config violations, with `file:line` and a non-zero exit | `npx mwg-audit-adoptions --manifest api-surface.json --imports imports.json --locals locals.json --config audit.json`, or `auditAdoptions(manifest, imports, locals, config)` from `@datamoc/mw_games/tools/audit-adoptions` |
|
|
195
|
+
| `browser-driver` | The dependency-free browser harness: a self-served static directory on an OS-chosen free port, with Chrome over CDP and Firefox over WebDriver BiDi behind one surface (`goto`/`waitReady`/`tap`/`evaluate`/`script`/`screenshot`/`consoleErrors`/`close`), reusing the `browser-smoke` readiness contract and no pixel-diff baseline | `driveChrome()`/`driveFirefox()` plus `serveDir(root)` from `@datamoc/mw_games/tools/browser-driver` |
|
|
196
|
+
|
|
197
|
+
## Project chores
|
|
198
|
+
|
|
199
|
+
| tool | what it does | run |
|
|
200
|
+
| --- | --- | --- |
|
|
201
|
+
| `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
|
+
| `make-example-diagrams` / `make-architecture-diagrams` | Rebuild the generated diagrams under `webpage/assets` (never hand-drawn) | `npm run webpage:diagrams` |
|
|
204
|
+
| `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
|
+
| `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` |
|
|
206
|
+
| `ci-status` | Waits for every workflow run of `HEAD` and fails if any of them did | `npm run ci:status` |
|
|
207
|
+
| `npm-audit` | Dependency audit that fails for high or critical advisories | `npm run audit` |
|
|
208
|
+
| `sbom` | Two CycloneDX SBOMs, both reproducible (no timestamp, a content-derived serial). The lockfile SBOM of any npm project (`buildSbom`; `mwg-sbom [folder] [--out=] [--check]`; this repository's own committed `sbom.cdx.json`, gated in CI and now shipped in the package). And the SBOM of what a build ships (`buildArtifactSbom`, written as `dist/sbom.cdx.json` by `mwgPage()`): only the packages whose modules reached the bundle, from vite's module graph, plus each shipped file's SHA-256 (items 315, 380) | `npm run sbom`, `npm run sbom:check` here; `npx mwg-sbom` or `mwgPage()` in a game |
|
|
209
|
+
|
|
210
|
+
### Selecting embedded assets
|
|
211
|
+
|
|
212
|
+
The Vite plugin accepts `mwgPage({ assets: 'assets', assetFiles: ['hero.svg', 'levels/one.json'] })`.
|
|
213
|
+
Paths are relative to the asset folder. Omission embeds every supported file as before; an
|
|
214
|
+
empty list embeds none. Unknown files and unsupported formats named explicitly fail the build.
|
|
215
|
+
The same `assetFiles` option is available on `emitPage` and `compileResources`. Dev serving is
|
|
216
|
+
unchanged. Include `credits.json` in an explicit list to generate its `CREDITS.txt` attribution.
|