@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,27 @@
1
+ import type { WebSocketServer } from 'ws';
2
+
3
+ export interface LockstepServerOptions {
4
+ port?: number;
5
+ /** the address to listen on; every interface when omitted (the `mwg-lockstep-server` command defaults to 127.0.0.1) */
6
+ host?: string;
7
+ tickTimeoutMs?: number;
8
+ /** the room seed sent in every `welcome` */
9
+ seed?: number;
10
+ /** the JSON-safe state every peer starts from, sent in every `welcome` */
11
+ initialState?: unknown;
12
+ /** returning `false` or a reason rejects an input */
13
+ validateInput?: (payload: unknown) => boolean | string;
14
+ /** largest client message accepted, in bytes; defaults to 64 KB */
15
+ maxMessageBytes?: number;
16
+ /** further connections to a full room are closed with 1013; defaults to 64 */
17
+ maxClientsPerRoom?: number;
18
+ }
19
+
20
+ export interface LockstepServer {
21
+ wss: WebSocketServer;
22
+ ready: Promise<void>;
23
+ address: () => ReturnType<WebSocketServer['address']>;
24
+ close: () => Promise<void>;
25
+ }
26
+
27
+ export function createLockstepServer(options?: LockstepServerOptions): LockstepServer;
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ //the inbound pipeline every client-side reader uses: the TypeScript source inside this
6
+ //repository, the built `dist` inside an installed package, which ships no `src`
7
+ const { parseInbound } = await import(
8
+ existsSync(new URL('../src/core/Sanitize.ts', import.meta.url))
9
+ ? '../src/core/Sanitize.ts'
10
+ : '../dist/core/index.js'
11
+ );
12
+
13
+ /** `ws`, an optional peer of the published package: only a game running this server needs it */
14
+ const { WebSocketServer } = await import('ws').catch(() => {
15
+ throw new Error('the lockstep server needs ws: npm install ws');
16
+ });
17
+
18
+ /** a room name a client can choose: short, printable, so one client cannot mint unbounded keys */
19
+ const ROOM_NAME = /^[\w-]{1,64}$/;
20
+
21
+ /**
22
+ * A reference lockstep server for `core.LockstepClient` (item 129 of ROADMAP.md).
23
+ *
24
+ * `mwg` ships no backend anywhere else - `FeedbackClient`/`NewsClient`/`SaveSyncClient` all
25
+ * assume a game brings its own - but real-time multiplayer has no one-shot request/response
26
+ * shape to hide a server behind, so this is a genuine exception, not a precedent for the rest
27
+ * of the project: a game using this either runs it as-is or replaces it with its own server
28
+ * speaking the same three-message protocol (`welcome`/`input`/`tick`).
29
+ *
30
+ * Model: each `room` (a `?room=` query string, default `"default"`) advances one shared tick
31
+ * counter. A client's `{type: 'input', payload}` is buffered for the room's current tick; once
32
+ * every currently-connected client in the room has submitted one, the server broadcasts
33
+ * `{type: 'tick', tick, inputs}` (every client id mapped to its input, or `null` for anyone
34
+ * who missed the deadline) and starts the next tick. A per-room timer also forces the
35
+ * broadcast after `tickTimeoutMs` regardless of who has submitted, so one slow or dropped
36
+ * client cannot stall everyone else forever - the reconciliation rule item 129 asked for,
37
+ * kept as simple as a rule can be while still being real: nothing here guesses a missing
38
+ * input, it is reported as `null` and left to the game's own rules to interpret (skip that
39
+ * actor's turn, repeat its last input, whatever the game decides).
40
+ */
41
+ export function createLockstepServer(options = {}) {
42
+ const {
43
+ port = 0,
44
+ host,
45
+ tickTimeoutMs = 200,
46
+ seed,
47
+ initialState,
48
+ validateInput,
49
+ maxMessageBytes = 64 * 1024,
50
+ maxClientsPerRoom = 64,
51
+ } = options;
52
+ //ws accepts 100 MiB messages unless told otherwise; an input is a few bytes
53
+ const wss = new WebSocketServer({ port, host, maxPayload: maxMessageBytes });
54
+
55
+ const rooms = new Map();
56
+ let nextId = 1;
57
+
58
+ function room(name) {
59
+ let entry = rooms.get(name);
60
+ if (!entry) {
61
+ //submittedCount tracks how many of entry.clients currently have hasSubmitted set,
62
+ //kept in step by every place that changes hasSubmitted, rather than counting the
63
+ //whole client map on every single input message (checked on every message, not
64
+ //just once per tick, so it is worth not being O(clients) each time)
65
+ entry = { clients: new Map(), tick: 0, timer: null, submittedCount: 0 };
66
+ rooms.set(name, entry);
67
+ }
68
+ return entry;
69
+ }
70
+
71
+ function scheduleTimeout(entry) {
72
+ if (entry.timer) clearTimeout(entry.timer);
73
+ entry.timer = entry.clients.size > 0 ? setTimeout(() => advance(entry), tickTimeoutMs) : null;
74
+ }
75
+
76
+ function advance(entry) {
77
+ const inputs = {};
78
+ const checksums = {};
79
+ for (const [id, client] of entry.clients) {
80
+ inputs[id] = client.hasSubmitted ? client.pendingInput : null;
81
+ if (client.hasSubmitted && Number.isSafeInteger(client.pendingChecksum))
82
+ checksums[id] = client.pendingChecksum;
83
+ client.pendingInput = null;
84
+ client.pendingChecksum = null;
85
+ client.hasSubmitted = false;
86
+ }
87
+ entry.submittedCount = 0;
88
+ const message = JSON.stringify({
89
+ type: 'tick',
90
+ tick: entry.tick,
91
+ inputs,
92
+ ...(Object.keys(checksums).length === 0 ? {} : { checksums }),
93
+ });
94
+ for (const client of entry.clients.values()) client.socket.send(message);
95
+ entry.tick += 1;
96
+ scheduleTimeout(entry);
97
+ }
98
+
99
+ function maybeAdvanceEarly(entry) {
100
+ if (entry.clients.size > 0 && entry.submittedCount === entry.clients.size) advance(entry);
101
+ }
102
+
103
+ wss.on('connection', (socket, request) => {
104
+ const url = new URL(request.url ?? '/', 'http://localhost');
105
+ const roomName = url.searchParams.get('room') ?? 'default';
106
+ if (!ROOM_NAME.test(roomName)) return socket.close(1008, 'invalid room name');
107
+ if ((rooms.get(roomName)?.clients.size ?? 0) >= maxClientsPerRoom) return socket.close(1013, 'room is full');
108
+ const id = `p${nextId++}`;
109
+ const entry = room(roomName);
110
+ entry.clients.set(id, { socket, pendingInput: null, hasSubmitted: false });
111
+
112
+ socket.send(
113
+ JSON.stringify({
114
+ type: 'welcome',
115
+ id,
116
+ ...(seed === undefined ? {} : { seed }),
117
+ ...(initialState === undefined ? {} : { initialState }),
118
+ }),
119
+ );
120
+ if (!entry.timer) scheduleTimeout(entry);
121
+
122
+ //ws reports an oversized or malformed frame as an 'error' event and closes the socket itself;
123
+ //with no listener, that event would throw and stop the whole server, every room with it
124
+ socket.on('error', () => {});
125
+
126
+ socket.on('message', (raw) => {
127
+ let message;
128
+ try {
129
+ message = parseInbound(String(raw), { maxBytes: maxMessageBytes, label: 'lockstep input' });
130
+ } catch {
131
+ return; //malformed input from one client must not take the room down
132
+ }
133
+ if (message?.type !== 'input') return;
134
+ const client = entry.clients.get(id);
135
+ if (!client) return;
136
+ const validation = validateInput?.(message.payload);
137
+ if (validation === false || typeof validation === 'string') {
138
+ socket.send(
139
+ JSON.stringify({
140
+ type: 'rejected',
141
+ reason: validation === false ? 'input rejected by server validation' : validation,
142
+ }),
143
+ );
144
+ return;
145
+ }
146
+ client.pendingInput = message.payload ?? null;
147
+ client.pendingChecksum = message.checksum;
148
+ if (!client.hasSubmitted) {
149
+ client.hasSubmitted = true;
150
+ entry.submittedCount += 1;
151
+ }
152
+ maybeAdvanceEarly(entry);
153
+ });
154
+
155
+ socket.on('close', () => {
156
+ const client = entry.clients.get(id);
157
+ if (client?.hasSubmitted) entry.submittedCount -= 1;
158
+ entry.clients.delete(id);
159
+ if (entry.clients.size === 0) {
160
+ if (entry.timer) clearTimeout(entry.timer);
161
+ rooms.delete(roomName);
162
+ } else {
163
+ maybeAdvanceEarly(entry);
164
+ }
165
+ });
166
+ });
167
+
168
+ return {
169
+ wss,
170
+ /** resolves once the server is actually listening; `address().port` is only valid after this */
171
+ ready: new Promise((resolve) => wss.once('listening', resolve)),
172
+ address: () => wss.address(),
173
+ close: () =>
174
+ new Promise((resolve) => {
175
+ for (const entry of rooms.values()) if (entry.timer) clearTimeout(entry.timer);
176
+ wss.close(() => resolve());
177
+ }),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * `mwg-lockstep-server [port] [--host=<address>]`: listens on 127.0.0.1 unless `--host` names
183
+ * another address, so running it never exposes a port by accident. It speaks plain `ws:`, and
184
+ * `LockstepClient` refuses `ws:` to anything but this machine: a deployment puts it behind a TLS
185
+ * reverse proxy and connects with `wss:`.
186
+ */
187
+ //realpath, since an npm `bin` shim reaches this file through a symlink
188
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
189
+ const args = process.argv.slice(2);
190
+ const port = Number(args.find((arg) => !arg.startsWith('--')) ?? 8787);
191
+ const host = args.find((arg) => arg.startsWith('--host='))?.slice(7) ?? '127.0.0.1';
192
+ const server = createLockstepServer({ port, host });
193
+ await server.ready;
194
+ console.log(`mwg reference lockstep server listening on ws://${host}:${server.address().port}`);
195
+ }
package/tools/mwl.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import fs from 'node:fs';
2
3
  import { createRequire } from 'node:module';
3
4
  import path from 'node:path';
@@ -0,0 +1,83 @@
1
+ export interface CycloneDxHash {
2
+ alg: string;
3
+ content: string;
4
+ }
5
+
6
+ export interface CycloneDxLicense {
7
+ license: { id?: string; name?: string };
8
+ }
9
+
10
+ export interface CycloneDxComponent {
11
+ type: 'library' | 'application' | 'file';
12
+ 'bom-ref': string;
13
+ name: string;
14
+ /** absent on a `file` component, which is identified by its hash */
15
+ version?: string;
16
+ purl?: string;
17
+ scope?: 'required' | 'optional' | 'excluded';
18
+ hashes?: CycloneDxHash[];
19
+ licenses?: CycloneDxLicense[];
20
+ description?: string;
21
+ }
22
+
23
+ export interface CycloneDxBom {
24
+ bomFormat: 'CycloneDX';
25
+ specVersion: string;
26
+ serialNumber: string;
27
+ version: number;
28
+ metadata: {
29
+ tools: { components: CycloneDxComponent[] };
30
+ component: CycloneDxComponent;
31
+ };
32
+ components: CycloneDxComponent[];
33
+ dependencies: Array<{ ref: string; dependsOn: string[] }>;
34
+ }
35
+
36
+ export interface LockfilePackage {
37
+ version?: string;
38
+ integrity?: string;
39
+ license?: string;
40
+ name?: string;
41
+ dev?: boolean;
42
+ optional?: boolean;
43
+ link?: boolean;
44
+ dependencies?: Record<string, string>;
45
+ devDependencies?: Record<string, string>;
46
+ optionalDependencies?: Record<string, string>;
47
+ peerDependencies?: Record<string, string>;
48
+ }
49
+
50
+ export interface Lockfile {
51
+ packages?: Record<string, LockfilePackage>;
52
+ }
53
+
54
+ export interface PackageJson {
55
+ name: string;
56
+ version: string;
57
+ description?: string;
58
+ license?: string;
59
+ private?: boolean;
60
+ }
61
+
62
+ export function purlFor(name: string, version: string): string;
63
+ export function hashesFromIntegrity(integrity: unknown): CycloneDxHash[];
64
+ /** `type`: `'library'` (the default) or `'application'` for a game */
65
+ export function buildSbom(
66
+ packageJson: PackageJson,
67
+ lockfile: Lockfile,
68
+ options?: { type?: 'library' | 'application' },
69
+ ): CycloneDxBom;
70
+ export function serialize(bom: CycloneDxBom): string;
71
+
72
+ /** the npm package a bundled module id belongs to, or null for the game's own source */
73
+ export function packageOfModule(id: string): { name: string; dir: string } | null;
74
+
75
+ /** the SBOM of what a build ships: the packages its modules came from, and its files with their SHA-256 */
76
+ export function buildArtifactSbom(input: {
77
+ packageJson: PackageJson;
78
+ modules: string[];
79
+ files: Array<{ path: string; sha256: string }>;
80
+ }): CycloneDxBom;
81
+
82
+ /** SHA-256 of every shipped file under `dist`, precompressed siblings and SBOMs left out */
83
+ export function shippedFiles(dist: string): Promise<Array<{ path: string; sha256: string }>>;
package/tools/sbom.mjs ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, realpathSync } from 'node:fs';
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { join, relative, resolve, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const OUTPUT = 'sbom.cdx.json';
9
+ /** this tool's own package, named in every document's `metadata.tools` whichever project it describes */
10
+ const TOOL = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
+ const SPEC_VERSION = '1.6';
12
+
13
+ const HASH_ALGORITHMS = { sha512: 'SHA-512', sha384: 'SHA-384', sha256: 'SHA-256', sha1: 'SHA-1' };
14
+
15
+ /** the RFC 4122 DNS namespace, undashed because it is only ever fed to the hash as bytes */
16
+ const DNS_NAMESPACE = '6ba7b8109dad11d180b400c04fd430c8';
17
+
18
+ /**
19
+ * Package URL for an npm package, the SBOM's `bom-ref` as well. A scoped name's `@` is
20
+ * percent-encoded but its `/` is not, which is what the purl spec's own npm examples do
21
+ * (`pkg:npm/%40angular/animation@12.3.1`).
22
+ */
23
+ export function purlFor(name, version) {
24
+ const encoded = name.startsWith('@') ? `%40${name.slice(1)}` : name;
25
+ return `pkg:npm/${encoded}@${version}`;
26
+ }
27
+
28
+ /**
29
+ * npm's `integrity` field (`sha512-<base64>`, sometimes several space-separated) as CycloneDX
30
+ * hashes. CycloneDX carries a hash as lowercase hex, so the base64 is decoded and re-encoded.
31
+ */
32
+ export function hashesFromIntegrity(integrity) {
33
+ if (typeof integrity !== 'string') return [];
34
+ const hashes = [];
35
+ for (const part of integrity.split(/\s+/)) {
36
+ const dash = part.indexOf('-');
37
+ if (dash < 0) continue;
38
+ const alg = HASH_ALGORITHMS[part.slice(0, dash).toLowerCase()];
39
+ if (alg === undefined) continue;
40
+ hashes.push({ alg, content: Buffer.from(part.slice(dash + 1), 'base64').toString('hex') });
41
+ }
42
+ return hashes;
43
+ }
44
+
45
+ /** an SPDX id stays an `id`; anything with spaces or an expression stays a `name` instead */
46
+ function licenseFor(license) {
47
+ if (typeof license !== 'string' || license.trim() === '') return undefined;
48
+ const value = license.trim();
49
+ return /^[A-Za-z0-9.+-]+$/.test(value) ? { license: { id: value } } : { license: { name: value } };
50
+ }
51
+
52
+ /** the package a lockfile location holds: `node_modules/@scope/name` is `@scope/name` */
53
+ function nameFromLocation(location) {
54
+ const marker = 'node_modules/';
55
+ return location.slice(location.lastIndexOf(marker) + marker.length);
56
+ }
57
+
58
+ /** node resolution over a lockfile: the nearest `node_modules/<name>` at or above `location` */
59
+ function resolveDependency(packages, location, name) {
60
+ let dir = location;
61
+ for (;;) {
62
+ const candidate = dir === '' ? `node_modules/${name}` : `${dir}/node_modules/${name}`;
63
+ if (packages[candidate] !== undefined) return candidate;
64
+ if (dir === '') return undefined;
65
+ const marker = dir.lastIndexOf('/node_modules/');
66
+ dir = marker < 0 ? '' : dir.slice(0, marker);
67
+ }
68
+ }
69
+
70
+ function scopeOf(entry) {
71
+ if (entry.dev === true) return 'excluded';
72
+ if (entry.optional === true) return 'optional';
73
+ return 'required';
74
+ }
75
+
76
+ /** required beats optional beats excluded when one purl is reachable more than one way */
77
+ const SCOPE_RANK = { required: 0, optional: 1, excluded: 2 };
78
+
79
+ /**
80
+ * A version-5 (RFC 4122 name-based) UUID in the DNS namespace. The same name always hashes to the
81
+ * same UUID, which is what lets `serialNumber` below be derived instead of invented; twelve lines
82
+ * of SHA-1 beats a dependency for the one UUID this tool will ever need.
83
+ */
84
+ function uuidv5(name) {
85
+ const bytes = createHash('sha1')
86
+ .update(Buffer.from(DNS_NAMESPACE, 'hex'))
87
+ .update(name, 'utf8')
88
+ .digest()
89
+ .subarray(0, 16);
90
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
91
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
92
+ const hex = bytes.toString('hex');
93
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
94
+ }
95
+
96
+ /**
97
+ * Builds a CycloneDX document from `package.json` and `package-lock.json` alone, so the SBOM is
98
+ * reproducible and needs no network access. Every locked package is a component (dev-only ones
99
+ * marked `excluded`, since they are not part of a published install) and the lockfile's own
100
+ * resolution is turned into the dependency graph. Output is deterministic: components and edges
101
+ * are sorted and `serialNumber` is a UUID derived from the document's own content rather than
102
+ * from the clock, so the committed file can be checked for drift the way `API_REPORT.md` and
103
+ * `PROJECT_STATS.json` already are: two BOMs that share a serial are the same BOM, and any change
104
+ * to a component, a hash or an edge produces a new one.
105
+ *
106
+ * `metadata.timestamp` is deliberately absent. A generation time stamped into a committed file is
107
+ * either a drift-check failure on every run or one stale moment every later read is dated to,
108
+ * while `CHANGELOG.md` already dates each version.
109
+ *
110
+ * `type` is the described component's CycloneDX type: `'library'` for this framework,
111
+ * `'application'` for a game (`mwg-sbom` picks it from whether the project is `private`).
112
+ */
113
+ export function buildSbom(packageJson, lockfile, { type = 'library' } = {}) {
114
+ const packages = lockfile.packages ?? {};
115
+ const rootPurl = purlFor(packageJson.name, packageJson.version);
116
+
117
+ const byPurl = new Map();
118
+ for (const [location, entry] of Object.entries(packages)) {
119
+ if (location === '' || entry.link === true || typeof entry.version !== 'string') continue;
120
+ const name = typeof entry.name === 'string' ? entry.name : nameFromLocation(location);
121
+ const purl = purlFor(name, entry.version);
122
+ const scope = scopeOf(entry);
123
+ const existing = byPurl.get(purl);
124
+ if (existing !== undefined) {
125
+ if (SCOPE_RANK[scope] < SCOPE_RANK[existing.scope]) existing.scope = scope;
126
+ continue;
127
+ }
128
+ const component = { type: 'library', 'bom-ref': purl, name, version: entry.version, purl, scope };
129
+ const hashes = hashesFromIntegrity(entry.integrity);
130
+ if (hashes.length > 0) component.hashes = hashes;
131
+ const licenses = licenseFor(entry.license);
132
+ if (licenses !== undefined) component.licenses = [licenses];
133
+ byPurl.set(purl, component);
134
+ }
135
+
136
+ const components = [...byPurl.values()].sort((a, b) => (a.purl < b.purl ? -1 : a.purl > b.purl ? 1 : 0));
137
+
138
+ const edges = new Map();
139
+ const addEdge = (from, to) => {
140
+ if (!edges.has(from)) edges.set(from, new Set());
141
+ edges.get(from).add(to);
142
+ };
143
+ for (const [location, entry] of Object.entries(packages)) {
144
+ if (location !== '' && (entry.link === true || typeof entry.version !== 'string')) continue;
145
+ const from =
146
+ location === ''
147
+ ? rootPurl
148
+ : purlFor(typeof entry.name === 'string' ? entry.name : nameFromLocation(location), entry.version);
149
+ const declared = {
150
+ ...(entry.dependencies ?? {}),
151
+ ...(entry.devDependencies ?? {}),
152
+ ...(entry.optionalDependencies ?? {}),
153
+ ...(entry.peerDependencies ?? {}),
154
+ };
155
+ for (const name of Object.keys(declared)) {
156
+ const target = resolveDependency(packages, location, name);
157
+ const targetEntry = target === undefined ? undefined : packages[target];
158
+ if (targetEntry === undefined || typeof targetEntry.version !== 'string') continue;
159
+ const targetName = typeof targetEntry.name === 'string' ? targetEntry.name : nameFromLocation(target);
160
+ addEdge(from, purlFor(targetName, targetEntry.version));
161
+ }
162
+ }
163
+
164
+ const refs = [rootPurl, ...components.map((component) => component['bom-ref'])];
165
+ const dependencies = [...new Set(refs)]
166
+ .sort()
167
+ .map((ref) => ({ ref, dependsOn: [...(edges.get(ref) ?? [])].sort() }));
168
+
169
+ return document(packageJson, type, components, dependencies);
170
+ }
171
+
172
+ /** the envelope both builders share, with the content-derived serial */
173
+ function document(packageJson, type, components, dependencies) {
174
+ const rootPurl = purlFor(packageJson.name, packageJson.version);
175
+ const rootLicenses = licenseFor(packageJson.license);
176
+ const metadata = {
177
+ tools: {
178
+ components: [{ type: 'application', group: 'datamoc', name: 'tools/sbom.mjs', version: TOOL.version }],
179
+ },
180
+ component: {
181
+ type,
182
+ 'bom-ref': rootPurl,
183
+ name: packageJson.name,
184
+ version: packageJson.version,
185
+ purl: rootPurl,
186
+ ...(typeof packageJson.description === 'string' ? { description: packageJson.description } : {}),
187
+ ...(rootLicenses === undefined ? {} : { licenses: [rootLicenses] }),
188
+ },
189
+ };
190
+ return {
191
+ bomFormat: 'CycloneDX',
192
+ specVersion: SPEC_VERSION,
193
+ serialNumber: `urn:uuid:${uuidv5(JSON.stringify({ metadata, components, dependencies }))}`,
194
+ version: 1,
195
+ metadata,
196
+ components,
197
+ dependencies,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * The package a bundled module came from: the last `node_modules/<name>` (or `<@scope/name>`) in
203
+ * its path, with the directory holding that package's `package.json`. Null for the game's own
204
+ * source and for bundler-virtual modules.
205
+ */
206
+ export function packageOfModule(id) {
207
+ const path = id.replace(/^\0/, '').split('?')[0].split(sep).join('/');
208
+ const marker = path.lastIndexOf('/node_modules/');
209
+ if (marker < 0) return null;
210
+ const parts = path.slice(marker + '/node_modules/'.length).split('/');
211
+ const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
212
+ if (!name || name.startsWith('.')) return null;
213
+ return { name, dir: `${path.slice(0, marker)}/node_modules/${name}` };
214
+ }
215
+
216
+ /**
217
+ * The SBOM of what a build actually ships, rather than of `node_modules` (item 380): a game is
218
+ * one tree-shaken bundle, so the lockfile lists tooling that never reaches a player and cannot say
219
+ * which packages did. `modules` are the bundle's module ids (vite's module graph, collected by
220
+ * `mwgPage()`), each package they come from becomes a `required` library component with its
221
+ * version and licence read from its own `package.json`, and `files` are the shipped files as
222
+ * `file` components carrying their SHA-256.
223
+ *
224
+ * @param {{ packageJson: object, modules: string[], files: { path: string, sha256: string }[] }} input
225
+ */
226
+ export function buildArtifactSbom({ packageJson, modules, files }) {
227
+ const packages = new Map();
228
+ for (const id of modules) {
229
+ const found = packageOfModule(id);
230
+ if (!found || packages.has(found.dir)) continue;
231
+ let manifest;
232
+ try {
233
+ manifest = JSON.parse(readFileSync(join(found.dir, 'package.json'), 'utf8'));
234
+ } catch {
235
+ continue;
236
+ }
237
+ packages.set(found.dir, {
238
+ name: manifest.name ?? found.name,
239
+ version: manifest.version,
240
+ license: manifest.license,
241
+ });
242
+ }
243
+ const libraries = new Map();
244
+ for (const { name, version, license } of packages.values()) {
245
+ if (typeof version !== 'string') continue;
246
+ const purl = purlFor(name, version);
247
+ const licenses = licenseFor(license);
248
+ libraries.set(purl, {
249
+ type: 'library',
250
+ 'bom-ref': purl,
251
+ name,
252
+ version,
253
+ purl,
254
+ scope: 'required',
255
+ ...(licenses === undefined ? {} : { licenses: [licenses] }),
256
+ });
257
+ }
258
+ const fileComponents = [...files]
259
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
260
+ .map(({ path, sha256 }) => ({
261
+ type: 'file',
262
+ 'bom-ref': `file:${path}`,
263
+ name: path,
264
+ hashes: [{ alg: 'SHA-256', content: sha256 }],
265
+ }));
266
+ const components = [...[...libraries.values()].sort((a, b) => (a.purl < b.purl ? -1 : 1)), ...fileComponents];
267
+ const rootPurl = purlFor(packageJson.name, packageJson.version);
268
+ const dependencies = [{ ref: rootPurl, dependsOn: [...libraries.keys()].sort() }];
269
+ return document(packageJson, 'application', components, dependencies);
270
+ }
271
+
272
+ /** SHA-256 of every file under `dist` a player receives: precompressed siblings and the SBOM itself left out */
273
+ export async function shippedFiles(dist) {
274
+ const { walk } = await import('./compile-resources.mjs');
275
+ const files = [];
276
+ for await (const file of walk(dist)) {
277
+ const path = relative(dist, file).split(sep).join('/');
278
+ if (/\.(gz|br|xz)$/.test(path) || path.endsWith('.cdx.json')) continue;
279
+ files.push({
280
+ path,
281
+ sha256: createHash('sha256')
282
+ .update(await readFile(file))
283
+ .digest('hex'),
284
+ });
285
+ }
286
+ return files;
287
+ }
288
+
289
+ /** the exact committed bytes: two-space JSON and a trailing newline */
290
+ export function serialize(bom) {
291
+ return `${JSON.stringify(bom, null, 2)}\n`;
292
+ }
293
+
294
+ async function readJson(path) {
295
+ return JSON.parse(await readFile(path, 'utf8'));
296
+ }
297
+
298
+ /**
299
+ * `mwg-sbom [project folder] [--out=<file>] [--check]`: the lockfile SBOM of any npm project,
300
+ * this framework included (`npm run sbom` here). A `private` project is described as an
301
+ * `application`, anything else as a `library`. `--check` compares instead of writing, for CI.
302
+ */
303
+ async function main(argv) {
304
+ const root = resolve(argv.find((arg) => !arg.startsWith('--')) ?? '.');
305
+ const out = resolve(root, argv.find((arg) => arg.startsWith('--out='))?.slice(6) ?? OUTPUT);
306
+ const packageJson = await readJson(join(root, 'package.json'));
307
+ const bom = buildSbom(packageJson, await readJson(join(root, 'package-lock.json')), {
308
+ type: packageJson.private === true ? 'application' : 'library',
309
+ });
310
+ const json = serialize(bom);
311
+ const name = relative(root, out) || out;
312
+ const summary = `CycloneDX ${SPEC_VERSION}, ${bom.components.length} components`;
313
+
314
+ if (argv.includes('--check')) {
315
+ const committed = await readFile(out, 'utf8').catch(() => undefined);
316
+ //CRLF is a Windows checkout's doing, not a difference in the BOM itself (and a missing
317
+ //file still reads as out of date: '' never equals the serialized BOM)
318
+ if ((committed ?? '').replace(/\r\n/g, '\n') !== json.replace(/\r\n/g, '\n')) {
319
+ console.error(`${name} is out of date with package-lock.json. Run "npm run sbom" and commit the result.`);
320
+ process.exitCode = 1;
321
+ } else {
322
+ console.log(`${name} matches package-lock.json (${summary}).`);
323
+ }
324
+ } else {
325
+ await writeFile(out, json, 'utf8');
326
+ console.log(`wrote ${name} (${summary})`);
327
+ }
328
+ }
329
+
330
+ //realpath, since an npm `bin` shim reaches this file through a symlink
331
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
332
+ await main(process.argv.slice(2));
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Nathaniel Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ # rgssad-wasm (vendored)
2
+
3
+ RGSSAD/RGSS2A archive decoder (RPG Maker XP / VX archives), compiled to
4
+ WebAssembly. Used by `tools/extract-rgssad.mjs`.
5
+
6
+ - Source: <https://github.com/nathaniel-daniel/rgssad-rs> (`rgssad-wasm`)
7
+ - Licence: MIT (see `LICENSE-MIT`), © 2022 Nathaniel Daniel.
8
+ Upstream offers the crate under either MIT or Apache-2.0 at your option;
9
+ this project uses it under the MIT option.
10
+ - Files vendored here: `rgssad_wasm.js`, `rgssad_wasm_bg.js`,
11
+ `rgssad_wasm_bg.wasm`, `rgssad_wasm.d.ts`, `rgssad_wasm_bg.wasm.d.ts`,
12
+ `package.json`.
13
+
14
+ Nothing else in this repository is covered by this licence; `mwg` itself
15
+ remains MPL-2.0 (see the root `LICENSE`).