@auraindustry/aurajs 0.0.2 → 0.0.4
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/README.md +52 -0
- package/package.json +13 -1
- package/src/build-contract.mjs +1359 -2
- package/src/cli.mjs +1584 -13
- package/src/conformance.mjs +1013 -86
- package/src/game-state-runtime.mjs +1067 -0
- package/src/headless-test.mjs +525 -10
- package/src/host-binary.mjs +33 -10
- package/src/react/aura-game.mjs +310 -0
- package/src/react/index.mjs +1 -0
- package/src/scaffold.mjs +267 -13
- package/src/web-api.mjs +454 -0
- package/templates/create/2d/aura.config.json +28 -0
- package/templates/create/2d/src/main.js +196 -0
- package/templates/create/3d/aura.config.json +28 -0
- package/templates/create/3d/src/main.js +306 -0
- package/templates/create/blank/aura.config.json +28 -0
- package/templates/create/blank/src/main.js +28 -0
- package/templates/create/shared/src/starter-utils/core.js +114 -0
- package/templates/create/shared/src/starter-utils/enemy-archetypes-2d.js +68 -0
- package/templates/create/shared/src/starter-utils/index.js +6 -0
- package/templates/create/shared/src/starter-utils/platformer-3d.js +101 -0
- package/templates/create/shared/src/starter-utils/wave-director.js +101 -0
- package/templates/skills/aurajs/SKILL.md +40 -0
- package/templates/skills/aurajs/api-contract-3d.md +7 -0
- package/templates/skills/aurajs/api-contract.md +7 -0
- package/templates/starter/src/main.js +48 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const GAME_STATE_SCHEMA_VERSION = 'aurajs.game-state.v1';
|
|
4
|
+
|
|
5
|
+
const CANONICAL_TOP_LEVEL_ORDER = ['schemaVersion', 'export', 'state'];
|
|
6
|
+
const CANONICAL_EXPORT_ORDER = ['mode', 'seed', 'frameIndex', 'elapsedSeconds', 'fingerprint', 'capturedAt'];
|
|
7
|
+
const CANONICAL_STATE_SECTION_ORDER = ['globals', 'camera', 'scene3d', 'physics', 'ecs', 'tilemap'];
|
|
8
|
+
const SUPPORTED_TOP_LEVEL_KEYS = new Set(CANONICAL_TOP_LEVEL_ORDER);
|
|
9
|
+
const SUPPORTED_STATE_SECTION_KEYS = new Set(CANONICAL_STATE_SECTION_ORDER);
|
|
10
|
+
const SUPPORTED_PATCH_MUTATION_OPS = new Set(['set', 'delete']);
|
|
11
|
+
const DEFAULT_MUTATION_MAX_OPS = 128;
|
|
12
|
+
const DEFAULT_MUTATION_MAX_BYTES = 262144;
|
|
13
|
+
const DEFAULT_MUTATION_MAX_RUNTIME_MS = 200;
|
|
14
|
+
|
|
15
|
+
export const STATE_MUTATION_ALLOWLIST_PREFIXES = Object.freeze([
|
|
16
|
+
'/state/globals',
|
|
17
|
+
'/state/camera',
|
|
18
|
+
'/state/scene3d',
|
|
19
|
+
'/state/physics',
|
|
20
|
+
'/state/ecs',
|
|
21
|
+
'/state/tilemap',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_STATE_MUTATION_GUARDRAILS = Object.freeze({
|
|
25
|
+
maxMutations: DEFAULT_MUTATION_MAX_OPS,
|
|
26
|
+
maxPayloadBytes: DEFAULT_MUTATION_MAX_BYTES,
|
|
27
|
+
maxRuntimeMs: DEFAULT_MUTATION_MAX_RUNTIME_MS,
|
|
28
|
+
allowlistPrefixes: STATE_MUTATION_ALLOWLIST_PREFIXES,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function createGameStateRuntimeHooks({ aura, mode = 'native' } = {}) {
|
|
32
|
+
if (!aura || typeof aura !== 'object') {
|
|
33
|
+
throw new Error('createGameStateRuntimeHooks requires an aura runtime object.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
exportState(options = {}) {
|
|
38
|
+
return exportCanonicalGameState(aura, { ...options, mode: options.mode || mode });
|
|
39
|
+
},
|
|
40
|
+
applyState(payload, options = {}) {
|
|
41
|
+
return applyCanonicalGameState(aura, payload, options);
|
|
42
|
+
},
|
|
43
|
+
diffState(beforePayload, afterPayload) {
|
|
44
|
+
return diffCanonicalGameState(beforePayload, afterPayload);
|
|
45
|
+
},
|
|
46
|
+
patchState(payload, patchPayload, options = {}) {
|
|
47
|
+
return applyCanonicalStatePatch(payload, patchPayload, options);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function exportCanonicalGameState(aura, options = {}) {
|
|
53
|
+
const normalizedMode = normalizeMode(options.mode);
|
|
54
|
+
const seed = normalizeInt(options.seed, 0);
|
|
55
|
+
const frameIndex = normalizeInt(options.frameIndex, 0);
|
|
56
|
+
const elapsedSeconds = normalizeElapsedSeconds(options.elapsedSeconds, aura);
|
|
57
|
+
const capturedAt = normalizeCapturedAt(options.capturedAt);
|
|
58
|
+
|
|
59
|
+
const state = collectStateSections(aura);
|
|
60
|
+
const fingerprint = sha256(stableStringify(state));
|
|
61
|
+
|
|
62
|
+
const payload = {
|
|
63
|
+
schemaVersion: GAME_STATE_SCHEMA_VERSION,
|
|
64
|
+
export: {
|
|
65
|
+
mode: normalizedMode,
|
|
66
|
+
seed,
|
|
67
|
+
frameIndex,
|
|
68
|
+
elapsedSeconds,
|
|
69
|
+
fingerprint,
|
|
70
|
+
capturedAt,
|
|
71
|
+
},
|
|
72
|
+
state,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
reasonCode: 'state_export_ok',
|
|
78
|
+
payload: orderPayload(payload),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function applyCanonicalGameState(aura, payload, _options = {}) {
|
|
83
|
+
const validated = validateApplyPayload(payload);
|
|
84
|
+
if (!validated.ok) {
|
|
85
|
+
return validated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const state = validated.state;
|
|
89
|
+
const unsupportedSections = [];
|
|
90
|
+
|
|
91
|
+
for (const sectionName of Object.keys(state)) {
|
|
92
|
+
if (sectionName === 'globals' || sectionName === 'camera') continue;
|
|
93
|
+
const handler = resolveSectionApplyHandler(aura, sectionName);
|
|
94
|
+
if (!handler) {
|
|
95
|
+
unsupportedSections.push(sectionName);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (unsupportedSections.length > 0) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reasonCode: 'unsupported_state_section',
|
|
103
|
+
unsupportedSections: unsupportedSections.sort(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const appliedSections = [];
|
|
108
|
+
for (const sectionName of CANONICAL_STATE_SECTION_ORDER) {
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(state, sectionName)) continue;
|
|
110
|
+
const sectionValue = state[sectionName];
|
|
111
|
+
const result = applyStateSection(aura, sectionName, sectionValue);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
reasonCode: result.reasonCode || 'state_apply_failed',
|
|
116
|
+
failedSection: sectionName,
|
|
117
|
+
detail: result.detail || null,
|
|
118
|
+
appliedSections,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
appliedSections.push(sectionName);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
reasonCode: 'state_apply_ok',
|
|
127
|
+
appliedSections,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function diffCanonicalGameState(beforePayload, afterPayload) {
|
|
132
|
+
const beforeValidated = validateApplyPayload(beforePayload);
|
|
133
|
+
if (!beforeValidated.ok) {
|
|
134
|
+
return beforeValidated;
|
|
135
|
+
}
|
|
136
|
+
const afterValidated = validateApplyPayload(afterPayload);
|
|
137
|
+
if (!afterValidated.ok) {
|
|
138
|
+
return afterValidated;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mutations = [];
|
|
142
|
+
collectDiffMutations('/state', beforeValidated.state, afterValidated.state, mutations);
|
|
143
|
+
const orderedMutations = mutations.map((mutation, index) => ({
|
|
144
|
+
order: (index + 1) * 10,
|
|
145
|
+
...mutation,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
reasonCode: 'state_diff_ok',
|
|
151
|
+
patch: {
|
|
152
|
+
schemaVersion: GAME_STATE_SCHEMA_VERSION,
|
|
153
|
+
baseFingerprint: resolveStateFingerprint(beforePayload, beforeValidated.state),
|
|
154
|
+
mutations: orderedMutations,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function applyCanonicalStatePatch(payload, patchPayload, options = {}) {
|
|
160
|
+
const guardrails = normalizeMutationGuardrails(options);
|
|
161
|
+
|
|
162
|
+
const payloadBytes = jsonByteSize(payload);
|
|
163
|
+
const patchBytes = jsonByteSize(patchPayload);
|
|
164
|
+
if (payloadBytes > guardrails.maxPayloadBytes || patchBytes > guardrails.maxPayloadBytes) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reasonCode: 'state_payload_too_large',
|
|
168
|
+
detail: `payload bytes exceed maxPayloadBytes=${guardrails.maxPayloadBytes}`,
|
|
169
|
+
payloadBytes,
|
|
170
|
+
patchBytes,
|
|
171
|
+
maxPayloadBytes: guardrails.maxPayloadBytes,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const validatedPayload = validateApplyPayload(payload);
|
|
176
|
+
if (!validatedPayload.ok) {
|
|
177
|
+
return validatedPayload;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const validatedPatch = validatePatchPayload(patchPayload, guardrails);
|
|
181
|
+
if (!validatedPatch.ok) {
|
|
182
|
+
return validatedPatch;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const orderedPayload = orderPayload({
|
|
186
|
+
schemaVersion: GAME_STATE_SCHEMA_VERSION,
|
|
187
|
+
export: sanitizeExportPayload(payload.export),
|
|
188
|
+
state: validatedPayload.state,
|
|
189
|
+
});
|
|
190
|
+
const fingerprint = resolveStateFingerprint(orderedPayload, orderedPayload.state);
|
|
191
|
+
if (validatedPatch.baseFingerprint !== fingerprint) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reasonCode: 'mutation_conflict',
|
|
195
|
+
detail: 'Patch baseFingerprint does not match source payload.',
|
|
196
|
+
expectedFingerprint: fingerprint,
|
|
197
|
+
actualFingerprint: validatedPatch.baseFingerprint,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const working = cloneJson(orderedPayload);
|
|
202
|
+
const startedAt = Date.now();
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < validatedPatch.mutations.length; i += 1) {
|
|
205
|
+
if ((Date.now() - startedAt) >= guardrails.maxRuntimeMs) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reasonCode: 'state_apply_timeout',
|
|
209
|
+
detail: `patch apply exceeded maxRuntimeMs=${guardrails.maxRuntimeMs}`,
|
|
210
|
+
maxRuntimeMs: guardrails.maxRuntimeMs,
|
|
211
|
+
failedMutationIndex: i,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const mutation = validatedPatch.mutations[i];
|
|
215
|
+
const applyResult = applySinglePatchMutation(working, mutation);
|
|
216
|
+
if (!applyResult.ok) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reasonCode: applyResult.reasonCode,
|
|
220
|
+
detail: applyResult.detail || null,
|
|
221
|
+
failedMutationIndex: i,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const postValidate = validateApplyPayload(working);
|
|
227
|
+
if (!postValidate.ok) {
|
|
228
|
+
return postValidate;
|
|
229
|
+
}
|
|
230
|
+
working.state = postValidate.state;
|
|
231
|
+
working.export.fingerprint = sha256(stableStringify(working.state));
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
reasonCode: 'state_patch_ok',
|
|
236
|
+
appliedMutations: validatedPatch.mutations.length,
|
|
237
|
+
maxRuntimeMs: guardrails.maxRuntimeMs,
|
|
238
|
+
payload: orderPayload(working),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function validateApplyPayload(payload) {
|
|
243
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
244
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'payload must be an object' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (payload.schemaVersion !== GAME_STATE_SCHEMA_VERSION) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
reasonCode: 'schema_version_mismatch',
|
|
251
|
+
detail: `expected ${GAME_STATE_SCHEMA_VERSION}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const topLevelKeys = Object.keys(payload);
|
|
256
|
+
const unknownTopLevel = topLevelKeys.filter((key) => !SUPPORTED_TOP_LEVEL_KEYS.has(key));
|
|
257
|
+
if (unknownTopLevel.length > 0) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
reasonCode: 'unknown_top_level_key',
|
|
261
|
+
unknownKeys: unknownTopLevel.sort(),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!payload.state || typeof payload.state !== 'object' || Array.isArray(payload.state)) {
|
|
266
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'state must be an object' };
|
|
267
|
+
}
|
|
268
|
+
if (!payload.export || typeof payload.export !== 'object' || Array.isArray(payload.export)) {
|
|
269
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'export must be an object' };
|
|
270
|
+
}
|
|
271
|
+
if (!payload.state.globals || typeof payload.state.globals !== 'object' || Array.isArray(payload.state.globals)) {
|
|
272
|
+
return { ok: false, reasonCode: 'missing_required_field', detail: 'state.globals is required' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const stateKeys = Object.keys(payload.state);
|
|
276
|
+
const unknownStateSections = stateKeys.filter((key) => !SUPPORTED_STATE_SECTION_KEYS.has(key));
|
|
277
|
+
if (unknownStateSections.length > 0) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
reasonCode: 'unknown_state_section',
|
|
281
|
+
unknownSections: unknownStateSections.sort(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
state: collectCanonicalStateFromPayload(payload.state),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function applyStateSection(aura, sectionName, sectionValue) {
|
|
292
|
+
if (sectionName === 'globals') {
|
|
293
|
+
return applyGlobalsSection(aura, sectionValue);
|
|
294
|
+
}
|
|
295
|
+
if (sectionName === 'camera') {
|
|
296
|
+
return applyCameraSection(aura, sectionValue);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const handler = resolveSectionApplyHandler(aura, sectionName);
|
|
300
|
+
if (!handler) {
|
|
301
|
+
return { ok: false, reasonCode: 'unsupported_state_section' };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const result = handler(sectionValue);
|
|
306
|
+
if (result === false) {
|
|
307
|
+
return { ok: false, reasonCode: 'state_apply_failed' };
|
|
308
|
+
}
|
|
309
|
+
if (result && typeof result === 'object' && result.ok === false) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
reasonCode: typeof result.reasonCode === 'string' ? result.reasonCode : 'state_apply_failed',
|
|
313
|
+
detail: result.detail || result.error || null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
reasonCode: 'state_apply_failed',
|
|
321
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyGlobalsSection(aura, sectionValue) {
|
|
327
|
+
if (!sectionValue || typeof sectionValue !== 'object' || Array.isArray(sectionValue)) {
|
|
328
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'globals must be an object' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const globalsMap = resolveGlobalsMap(aura);
|
|
332
|
+
globalsMap.clear();
|
|
333
|
+
|
|
334
|
+
const normalized = sanitizeObject(sectionValue);
|
|
335
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
336
|
+
globalsMap.set(key, value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const storage = aura.storage && typeof aura.storage === 'object' ? aura.storage : null;
|
|
340
|
+
if (storage && typeof storage.set === 'function') {
|
|
341
|
+
for (const [key, value] of globalsMap.entries()) {
|
|
342
|
+
storage.set(key, value);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function applyCameraSection(aura, sectionValue) {
|
|
350
|
+
if (!sectionValue || typeof sectionValue !== 'object' || Array.isArray(sectionValue)) {
|
|
351
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera must be an object' };
|
|
352
|
+
}
|
|
353
|
+
if (!aura.camera || typeof aura.camera !== 'object') {
|
|
354
|
+
return { ok: false, reasonCode: 'unsupported_state_section', detail: 'camera surface unavailable' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const camera = aura.camera;
|
|
358
|
+
const nextX = sectionValue.x;
|
|
359
|
+
const nextY = sectionValue.y;
|
|
360
|
+
const nextZoom = sectionValue.zoom;
|
|
361
|
+
const nextRotation = sectionValue.rotation;
|
|
362
|
+
|
|
363
|
+
if (nextX != null && !Number.isFinite(nextX)) {
|
|
364
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.x must be finite' };
|
|
365
|
+
}
|
|
366
|
+
if (nextY != null && !Number.isFinite(nextY)) {
|
|
367
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.y must be finite' };
|
|
368
|
+
}
|
|
369
|
+
if (nextZoom != null && (!Number.isFinite(nextZoom) || nextZoom <= 0)) {
|
|
370
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.zoom must be > 0' };
|
|
371
|
+
}
|
|
372
|
+
if (nextRotation != null && !Number.isFinite(nextRotation)) {
|
|
373
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.rotation must be finite' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (nextX != null) camera.x = Number(nextX);
|
|
377
|
+
if (nextY != null) camera.y = Number(nextY);
|
|
378
|
+
if (nextZoom != null) camera.zoom = Number(nextZoom);
|
|
379
|
+
if (nextRotation != null) camera.rotation = Number(nextRotation);
|
|
380
|
+
|
|
381
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function resolveSectionApplyHandler(aura, sectionName) {
|
|
385
|
+
const section = aura[sectionName];
|
|
386
|
+
if (!section || typeof section !== 'object') {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (typeof section.applyState === 'function') {
|
|
390
|
+
return (value) => section.applyState(value);
|
|
391
|
+
}
|
|
392
|
+
if (typeof section.importState === 'function') {
|
|
393
|
+
return (value) => section.importState(value);
|
|
394
|
+
}
|
|
395
|
+
if (sectionName === 'physics' && typeof section.restoreSnapshot === 'function') {
|
|
396
|
+
return (value) => section.restoreSnapshot(value);
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function collectStateSections(aura) {
|
|
402
|
+
const globals = mapToObject(resolveGlobalsMap(aura));
|
|
403
|
+
const camera = collectCameraSnapshot(aura);
|
|
404
|
+
|
|
405
|
+
const raw = {
|
|
406
|
+
globals,
|
|
407
|
+
camera,
|
|
408
|
+
scene3d: collectObjectSection(aura.scene3d),
|
|
409
|
+
physics: collectObjectSection(aura.physics),
|
|
410
|
+
ecs: collectObjectSection(aura.ecs),
|
|
411
|
+
tilemap: collectObjectSection(aura.tilemap),
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return collectCanonicalStateFromPayload(raw);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function collectCanonicalStateFromPayload(inputState) {
|
|
418
|
+
const out = {};
|
|
419
|
+
|
|
420
|
+
const globals = sanitizeObject(inputState.globals || {});
|
|
421
|
+
out.globals = globals;
|
|
422
|
+
|
|
423
|
+
for (const sectionName of CANONICAL_STATE_SECTION_ORDER) {
|
|
424
|
+
if (sectionName === 'globals') continue;
|
|
425
|
+
if (!Object.prototype.hasOwnProperty.call(inputState, sectionName)) continue;
|
|
426
|
+
const value = sanitizeJsonValue(inputState[sectionName]);
|
|
427
|
+
if (value == null) continue;
|
|
428
|
+
out[sectionName] = value;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function collectCameraSnapshot(aura) {
|
|
435
|
+
if (!aura.camera || typeof aura.camera !== 'object') {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let source = null;
|
|
440
|
+
if (typeof aura.camera.getState === 'function') {
|
|
441
|
+
try {
|
|
442
|
+
source = aura.camera.getState();
|
|
443
|
+
} catch {
|
|
444
|
+
source = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!source || typeof source !== 'object') {
|
|
449
|
+
source = {
|
|
450
|
+
x: aura.camera.x,
|
|
451
|
+
y: aura.camera.y,
|
|
452
|
+
zoom: aura.camera.zoom,
|
|
453
|
+
rotation: aura.camera.rotation,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!source || typeof source !== 'object') {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const camera = {};
|
|
462
|
+
if (Number.isFinite(source.x)) camera.x = Number(source.x);
|
|
463
|
+
if (Number.isFinite(source.y)) camera.y = Number(source.y);
|
|
464
|
+
if (Number.isFinite(source.zoom) && source.zoom > 0) camera.zoom = Number(source.zoom);
|
|
465
|
+
if (Number.isFinite(source.rotation)) camera.rotation = Number(source.rotation);
|
|
466
|
+
if (typeof source.following === 'boolean') camera.following = source.following;
|
|
467
|
+
if (Number.isInteger(source.activeEffects) && source.activeEffects >= 0) {
|
|
468
|
+
camera.activeEffects = Number(source.activeEffects);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return Object.keys(camera).length > 0 ? camera : null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function collectObjectSection(section) {
|
|
475
|
+
if (!section || typeof section !== 'object') {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
if (typeof section.getState === 'function') {
|
|
479
|
+
try {
|
|
480
|
+
const state = section.getState();
|
|
481
|
+
if (state && typeof state === 'object') {
|
|
482
|
+
return state;
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function resolveGlobalsMap(aura) {
|
|
492
|
+
const storage = aura.storage && typeof aura.storage === 'object' ? aura.storage : null;
|
|
493
|
+
if (storage && storage._store instanceof Map) {
|
|
494
|
+
return storage._store;
|
|
495
|
+
}
|
|
496
|
+
if (aura.__gameStateGlobals instanceof Map) {
|
|
497
|
+
return aura.__gameStateGlobals;
|
|
498
|
+
}
|
|
499
|
+
aura.__gameStateGlobals = new Map();
|
|
500
|
+
return aura.__gameStateGlobals;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function mapToObject(map) {
|
|
504
|
+
const out = {};
|
|
505
|
+
const rows = [...map.entries()].map(([key, value]) => [String(key), sanitizeJsonValue(value)]);
|
|
506
|
+
rows.sort((a, b) => a[0].localeCompare(b[0]));
|
|
507
|
+
for (const [key, value] of rows) {
|
|
508
|
+
out[key] = value;
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function sanitizeObject(value) {
|
|
514
|
+
const out = {};
|
|
515
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
516
|
+
const keys = Object.keys(source).sort((a, b) => a.localeCompare(b));
|
|
517
|
+
for (const key of keys) {
|
|
518
|
+
const next = sanitizeJsonValue(source[key]);
|
|
519
|
+
if (next === undefined) continue;
|
|
520
|
+
out[key] = next;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function sanitizeJsonValue(value) {
|
|
526
|
+
if (value === null) return null;
|
|
527
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
|
528
|
+
if (typeof value === 'number') {
|
|
529
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
530
|
+
}
|
|
531
|
+
if (Array.isArray(value)) {
|
|
532
|
+
return value.map((entry) => sanitizeJsonValue(entry));
|
|
533
|
+
}
|
|
534
|
+
if (value && typeof value === 'object') {
|
|
535
|
+
return sanitizeObject(value);
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function collectDiffMutations(path, beforeValue, afterValue, mutations) {
|
|
541
|
+
if (isDeepEqual(beforeValue, afterValue)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const beforeIsObject = isPlainObject(beforeValue);
|
|
546
|
+
const afterIsObject = isPlainObject(afterValue);
|
|
547
|
+
if (beforeIsObject && afterIsObject) {
|
|
548
|
+
const beforeKeys = Object.keys(beforeValue).sort((a, b) => a.localeCompare(b));
|
|
549
|
+
const afterKeys = Object.keys(afterValue).sort((a, b) => a.localeCompare(b));
|
|
550
|
+
const afterKeySet = new Set(afterKeys);
|
|
551
|
+
const beforeKeySet = new Set(beforeKeys);
|
|
552
|
+
|
|
553
|
+
for (const key of beforeKeys) {
|
|
554
|
+
if (afterKeySet.has(key)) continue;
|
|
555
|
+
mutations.push({
|
|
556
|
+
op: 'delete',
|
|
557
|
+
path: `${path}/${escapeJsonPointerToken(key)}`,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const key of afterKeys) {
|
|
562
|
+
const nextPath = `${path}/${escapeJsonPointerToken(key)}`;
|
|
563
|
+
if (!beforeKeySet.has(key)) {
|
|
564
|
+
mutations.push({
|
|
565
|
+
op: 'set',
|
|
566
|
+
path: nextPath,
|
|
567
|
+
value: sanitizeJsonValue(afterValue[key]),
|
|
568
|
+
});
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
collectDiffMutations(nextPath, beforeValue[key], afterValue[key], mutations);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (Array.isArray(beforeValue) && Array.isArray(afterValue)) {
|
|
577
|
+
mutations.push({
|
|
578
|
+
op: 'set',
|
|
579
|
+
path,
|
|
580
|
+
value: sanitizeJsonValue(afterValue),
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
mutations.push({
|
|
586
|
+
op: 'set',
|
|
587
|
+
path,
|
|
588
|
+
value: sanitizeJsonValue(afterValue),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function validatePatchPayload(patchPayload, guardrails) {
|
|
593
|
+
if (!patchPayload || typeof patchPayload !== 'object' || Array.isArray(patchPayload)) {
|
|
594
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'patch payload must be an object' };
|
|
595
|
+
}
|
|
596
|
+
if (patchPayload.schemaVersion !== GAME_STATE_SCHEMA_VERSION) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
reasonCode: 'schema_version_mismatch',
|
|
600
|
+
detail: `expected ${GAME_STATE_SCHEMA_VERSION}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const baseFingerprint = typeof patchPayload.baseFingerprint === 'string'
|
|
605
|
+
? patchPayload.baseFingerprint.trim()
|
|
606
|
+
: '';
|
|
607
|
+
if (!/^[a-f0-9]{64}$/.test(baseFingerprint)) {
|
|
608
|
+
return {
|
|
609
|
+
ok: false,
|
|
610
|
+
reasonCode: 'invalid_schema_payload',
|
|
611
|
+
detail: 'baseFingerprint must be a lowercase 64-char sha256 hex string',
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!Array.isArray(patchPayload.mutations)) {
|
|
616
|
+
return {
|
|
617
|
+
ok: false,
|
|
618
|
+
reasonCode: 'missing_required_field',
|
|
619
|
+
detail: 'mutations must be an array',
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (patchPayload.mutations.length > guardrails.maxMutations) {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
reasonCode: 'mutation_budget_exceeded',
|
|
626
|
+
detail: `mutation count ${patchPayload.mutations.length} exceeds maxMutations=${guardrails.maxMutations}`,
|
|
627
|
+
mutationCount: patchPayload.mutations.length,
|
|
628
|
+
maxMutations: guardrails.maxMutations,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const normalizedMutations = [];
|
|
633
|
+
for (let i = 0; i < patchPayload.mutations.length; i += 1) {
|
|
634
|
+
const raw = patchPayload.mutations[i];
|
|
635
|
+
const normalized = normalizePatchMutation(raw, i, guardrails);
|
|
636
|
+
if (!normalized.ok) {
|
|
637
|
+
return normalized;
|
|
638
|
+
}
|
|
639
|
+
normalizedMutations.push(normalized.mutation);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
normalizedMutations.sort((a, b) => {
|
|
643
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
644
|
+
return a.originalIndex - b.originalIndex;
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
ok: true,
|
|
649
|
+
baseFingerprint,
|
|
650
|
+
mutations: normalizedMutations,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function normalizePatchMutation(rawMutation, index, guardrails) {
|
|
655
|
+
if (!rawMutation || typeof rawMutation !== 'object' || Array.isArray(rawMutation)) {
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
reasonCode: 'invalid_schema_payload',
|
|
659
|
+
detail: `mutation at index ${index} must be an object`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const order = Number(rawMutation.order);
|
|
664
|
+
if (!Number.isInteger(order) || order < 0) {
|
|
665
|
+
return {
|
|
666
|
+
ok: false,
|
|
667
|
+
reasonCode: 'invalid_schema_payload',
|
|
668
|
+
detail: `mutation at index ${index} has invalid order`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const op = typeof rawMutation.op === 'string' ? rawMutation.op : '';
|
|
673
|
+
if (!SUPPORTED_PATCH_MUTATION_OPS.has(op)) {
|
|
674
|
+
return {
|
|
675
|
+
ok: false,
|
|
676
|
+
reasonCode: 'unsupported_mutation_op',
|
|
677
|
+
detail: `mutation at index ${index} uses unsupported op "${op}"`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const path = typeof rawMutation.path === 'string' ? rawMutation.path : '';
|
|
682
|
+
const tokens = parseJsonPointer(path);
|
|
683
|
+
if (!tokens) {
|
|
684
|
+
return {
|
|
685
|
+
ok: false,
|
|
686
|
+
reasonCode: 'invalid_json_pointer',
|
|
687
|
+
detail: `mutation at index ${index} has invalid path`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
const allowlistCheck = validateMutationPathAgainstGuards(tokens, path, index, guardrails);
|
|
691
|
+
if (!allowlistCheck.ok) {
|
|
692
|
+
return allowlistCheck;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const mutation = {
|
|
696
|
+
order,
|
|
697
|
+
op,
|
|
698
|
+
path,
|
|
699
|
+
originalIndex: index,
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
if (op === 'set') {
|
|
703
|
+
if (!Object.prototype.hasOwnProperty.call(rawMutation, 'value')) {
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
reasonCode: 'missing_required_field',
|
|
707
|
+
detail: `mutation at index ${index} requires value for op=set`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
mutation.value = sanitizeJsonValue(rawMutation.value);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return { ok: true, mutation };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function validateMutationPathAgainstGuards(tokens, path, index, guardrails) {
|
|
717
|
+
if (tokens.length === 0 || tokens[0] !== 'state') {
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
reasonCode: 'immutable_path',
|
|
721
|
+
detail: `mutation at index ${index} targets immutable path "${path}"`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (tokens.length < 2) {
|
|
725
|
+
return {
|
|
726
|
+
ok: false,
|
|
727
|
+
reasonCode: 'immutable_path',
|
|
728
|
+
detail: `mutation at index ${index} targets immutable path "${path}"`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const normalizedPrefix = `/${escapeJsonPointerToken(tokens[0])}/${escapeJsonPointerToken(tokens[1])}`;
|
|
733
|
+
if (!guardrails.allowlistPrefixSet.has(normalizedPrefix)) {
|
|
734
|
+
return {
|
|
735
|
+
ok: false,
|
|
736
|
+
reasonCode: 'mutation_path_not_allowed',
|
|
737
|
+
detail: `mutation at index ${index} targets non-allowlisted path "${path}"`,
|
|
738
|
+
allowlistPrefixes: guardrails.allowlistPrefixes,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return { ok: true };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function applySinglePatchMutation(payload, mutation) {
|
|
746
|
+
const tokens = parseJsonPointer(mutation.path);
|
|
747
|
+
if (!tokens) {
|
|
748
|
+
return { ok: false, reasonCode: 'invalid_json_pointer' };
|
|
749
|
+
}
|
|
750
|
+
if (tokens.length === 0 || tokens[0] !== 'state') {
|
|
751
|
+
return { ok: false, reasonCode: 'immutable_path' };
|
|
752
|
+
}
|
|
753
|
+
if (mutation.op === 'delete' && (tokens.length === 1 || (tokens.length === 2 && tokens[1] === 'globals'))) {
|
|
754
|
+
return { ok: false, reasonCode: 'immutable_path' };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const target = resolvePointerTarget(payload, tokens);
|
|
758
|
+
if (!target.ok) {
|
|
759
|
+
return target;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (mutation.op === 'set') {
|
|
763
|
+
return applySetMutation(target.parent, target.key, mutation.value);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (mutation.op === 'delete') {
|
|
767
|
+
return applyDeleteMutation(target.parent, target.key);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return { ok: false, reasonCode: 'unsupported_mutation_op' };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function resolvePointerTarget(root, tokens) {
|
|
774
|
+
let cursor = root;
|
|
775
|
+
for (let i = 0; i < tokens.length - 1; i += 1) {
|
|
776
|
+
const token = tokens[i];
|
|
777
|
+
if (Array.isArray(cursor)) {
|
|
778
|
+
const index = parseArrayIndexToken(token);
|
|
779
|
+
if (index === null) return { ok: false, reasonCode: 'type_mismatch' };
|
|
780
|
+
if (index < 0 || index >= cursor.length) return { ok: false, reasonCode: 'path_not_found' };
|
|
781
|
+
cursor = cursor[index];
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (!isPlainObject(cursor)) {
|
|
785
|
+
return { ok: false, reasonCode: 'type_mismatch' };
|
|
786
|
+
}
|
|
787
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, token)) {
|
|
788
|
+
return { ok: false, reasonCode: 'path_not_found' };
|
|
789
|
+
}
|
|
790
|
+
cursor = cursor[token];
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
ok: true,
|
|
794
|
+
parent: cursor,
|
|
795
|
+
key: tokens[tokens.length - 1],
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function applySetMutation(parent, key, value) {
|
|
800
|
+
if (Array.isArray(parent)) {
|
|
801
|
+
const index = parseArrayIndexToken(key);
|
|
802
|
+
if (index === null) return { ok: false, reasonCode: 'type_mismatch' };
|
|
803
|
+
if (index < 0 || index >= parent.length) return { ok: false, reasonCode: 'out_of_bounds' };
|
|
804
|
+
parent[index] = sanitizeJsonValue(value);
|
|
805
|
+
return { ok: true };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (!isPlainObject(parent)) {
|
|
809
|
+
return { ok: false, reasonCode: 'type_mismatch' };
|
|
810
|
+
}
|
|
811
|
+
parent[key] = sanitizeJsonValue(value);
|
|
812
|
+
return { ok: true };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function applyDeleteMutation(parent, key) {
|
|
816
|
+
if (Array.isArray(parent)) {
|
|
817
|
+
const index = parseArrayIndexToken(key);
|
|
818
|
+
if (index === null) return { ok: false, reasonCode: 'type_mismatch' };
|
|
819
|
+
if (index < 0 || index >= parent.length) return { ok: false, reasonCode: 'out_of_bounds' };
|
|
820
|
+
parent.splice(index, 1);
|
|
821
|
+
return { ok: true };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (!isPlainObject(parent)) {
|
|
825
|
+
return { ok: false, reasonCode: 'type_mismatch' };
|
|
826
|
+
}
|
|
827
|
+
if (!Object.prototype.hasOwnProperty.call(parent, key)) {
|
|
828
|
+
return { ok: false, reasonCode: 'path_not_found' };
|
|
829
|
+
}
|
|
830
|
+
delete parent[key];
|
|
831
|
+
return { ok: true };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function parseJsonPointer(path) {
|
|
835
|
+
if (typeof path !== 'string' || path.length === 0 || path[0] !== '/') {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
const rawTokens = path.slice(1).split('/');
|
|
839
|
+
const tokens = [];
|
|
840
|
+
for (const token of rawTokens) {
|
|
841
|
+
if (/~(?![01])/u.test(token)) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
tokens.push(token.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
845
|
+
}
|
|
846
|
+
return tokens;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function parseArrayIndexToken(token) {
|
|
850
|
+
if (!/^(0|[1-9][0-9]*)$/u.test(token)) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
return Number(token);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function escapeJsonPointerToken(token) {
|
|
857
|
+
return String(token).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function sanitizeExportPayload(value) {
|
|
861
|
+
const source = isPlainObject(value) ? value : {};
|
|
862
|
+
const out = {};
|
|
863
|
+
for (const key of CANONICAL_EXPORT_ORDER) {
|
|
864
|
+
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
865
|
+
if (key === 'mode') {
|
|
866
|
+
out.mode = normalizeMode(source.mode);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (key === 'seed' || key === 'frameIndex') {
|
|
870
|
+
out[key] = normalizeInt(source[key], 0);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (key === 'elapsedSeconds') {
|
|
874
|
+
out.elapsedSeconds = Number.isFinite(source.elapsedSeconds) && source.elapsedSeconds >= 0
|
|
875
|
+
? Number(source.elapsedSeconds)
|
|
876
|
+
: null;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (key === 'fingerprint') {
|
|
880
|
+
out.fingerprint = typeof source.fingerprint === 'string' ? source.fingerprint : '';
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (key === 'capturedAt') {
|
|
884
|
+
out.capturedAt = normalizeCapturedAt(source.capturedAt);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'mode')) out.mode = 'native';
|
|
888
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'seed')) out.seed = 0;
|
|
889
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'frameIndex')) out.frameIndex = 0;
|
|
890
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'elapsedSeconds')) out.elapsedSeconds = null;
|
|
891
|
+
if (!Object.prototype.hasOwnProperty.call(out, 'capturedAt')) out.capturedAt = null;
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function resolveStateFingerprint(payload, canonicalState) {
|
|
896
|
+
const fingerprint = payload?.export?.fingerprint;
|
|
897
|
+
if (typeof fingerprint === 'string' && /^[a-f0-9]{64}$/.test(fingerprint)) {
|
|
898
|
+
return fingerprint;
|
|
899
|
+
}
|
|
900
|
+
return sha256(stableStringify(canonicalState));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function normalizeMutationGuardrails(options) {
|
|
904
|
+
const source = options && typeof options === 'object' ? options : {};
|
|
905
|
+
const maxMutations = normalizeNonNegativeInteger(
|
|
906
|
+
source.maxMutations,
|
|
907
|
+
DEFAULT_STATE_MUTATION_GUARDRAILS.maxMutations,
|
|
908
|
+
);
|
|
909
|
+
const maxPayloadBytes = normalizeNonNegativeInteger(
|
|
910
|
+
source.maxPayloadBytes,
|
|
911
|
+
DEFAULT_STATE_MUTATION_GUARDRAILS.maxPayloadBytes,
|
|
912
|
+
);
|
|
913
|
+
const maxRuntimeMs = normalizeNonNegativeInteger(
|
|
914
|
+
source.maxRuntimeMs,
|
|
915
|
+
DEFAULT_STATE_MUTATION_GUARDRAILS.maxRuntimeMs,
|
|
916
|
+
);
|
|
917
|
+
const allowlistPrefixes = normalizeAllowlistPrefixes(
|
|
918
|
+
source.allowlistPrefixes,
|
|
919
|
+
DEFAULT_STATE_MUTATION_GUARDRAILS.allowlistPrefixes,
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
maxMutations,
|
|
924
|
+
maxPayloadBytes,
|
|
925
|
+
maxRuntimeMs,
|
|
926
|
+
allowlistPrefixes,
|
|
927
|
+
allowlistPrefixSet: new Set(allowlistPrefixes),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function normalizeAllowlistPrefixes(value, fallback) {
|
|
932
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
933
|
+
return [...fallback];
|
|
934
|
+
}
|
|
935
|
+
const normalized = [];
|
|
936
|
+
for (const raw of value) {
|
|
937
|
+
if (typeof raw !== 'string') continue;
|
|
938
|
+
const trimmed = raw.trim();
|
|
939
|
+
if (trimmed.length === 0 || !trimmed.startsWith('/')) continue;
|
|
940
|
+
normalized.push(trimmed);
|
|
941
|
+
}
|
|
942
|
+
if (normalized.length === 0) {
|
|
943
|
+
return [...fallback];
|
|
944
|
+
}
|
|
945
|
+
return [...new Set(normalized)].sort((a, b) => a.localeCompare(b));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function normalizeNonNegativeInteger(value, fallback) {
|
|
949
|
+
const parsed = Number(value);
|
|
950
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
951
|
+
return fallback;
|
|
952
|
+
}
|
|
953
|
+
return parsed;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function jsonByteSize(value) {
|
|
957
|
+
let text = '';
|
|
958
|
+
try {
|
|
959
|
+
text = JSON.stringify(value);
|
|
960
|
+
} catch {
|
|
961
|
+
text = '';
|
|
962
|
+
}
|
|
963
|
+
return Buffer.byteLength(text || '', 'utf8');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function isPlainObject(value) {
|
|
967
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function isDeepEqual(a, b) {
|
|
971
|
+
return stableStringify(a) === stableStringify(b);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function cloneJson(value) {
|
|
975
|
+
return JSON.parse(JSON.stringify(value));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function normalizeMode(value) {
|
|
979
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
980
|
+
if (normalized === 'native' || normalized === 'headless' || normalized === 'shim') {
|
|
981
|
+
return normalized;
|
|
982
|
+
}
|
|
983
|
+
return 'native';
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function normalizeInt(value, fallback) {
|
|
987
|
+
const parsed = Number(value);
|
|
988
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
989
|
+
return fallback;
|
|
990
|
+
}
|
|
991
|
+
return parsed;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function normalizeElapsedSeconds(value, aura) {
|
|
995
|
+
if (Number.isFinite(value) && value >= 0) {
|
|
996
|
+
return Number(value);
|
|
997
|
+
}
|
|
998
|
+
if (aura?.timer && typeof aura.timer.getTime === 'function') {
|
|
999
|
+
const timerValue = Number(aura.timer.getTime());
|
|
1000
|
+
if (Number.isFinite(timerValue) && timerValue >= 0) {
|
|
1001
|
+
return timerValue;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function normalizeCapturedAt(value) {
|
|
1008
|
+
if (typeof value !== 'string') {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
const trimmed = value.trim();
|
|
1012
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function stableStringify(value) {
|
|
1016
|
+
return JSON.stringify(value, buildStableReplacer());
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function buildStableReplacer() {
|
|
1020
|
+
const seen = new WeakSet();
|
|
1021
|
+
return function stableReplacer(_key, value) {
|
|
1022
|
+
if (Array.isArray(value) || !value || typeof value !== 'object') {
|
|
1023
|
+
return value;
|
|
1024
|
+
}
|
|
1025
|
+
if (seen.has(value)) {
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
seen.add(value);
|
|
1029
|
+
const out = {};
|
|
1030
|
+
for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
|
|
1031
|
+
out[key] = value[key];
|
|
1032
|
+
}
|
|
1033
|
+
return out;
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function sha256(value) {
|
|
1038
|
+
return createHash('sha256').update(value).digest('hex');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function orderPayload(payload) {
|
|
1042
|
+
const orderedExport = {};
|
|
1043
|
+
for (const key of CANONICAL_EXPORT_ORDER) {
|
|
1044
|
+
if (!Object.prototype.hasOwnProperty.call(payload.export, key)) continue;
|
|
1045
|
+
orderedExport[key] = payload.export[key];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const orderedState = {};
|
|
1049
|
+
for (const key of CANONICAL_STATE_SECTION_ORDER) {
|
|
1050
|
+
if (!Object.prototype.hasOwnProperty.call(payload.state, key)) continue;
|
|
1051
|
+
orderedState[key] = payload.state[key];
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const out = {};
|
|
1055
|
+
for (const key of CANONICAL_TOP_LEVEL_ORDER) {
|
|
1056
|
+
if (key === 'export') {
|
|
1057
|
+
out.export = orderedExport;
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (key === 'state') {
|
|
1061
|
+
out.state = orderedState;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
out[key] = payload[key];
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|