@auraindustry/aurajs 0.0.4 → 0.0.6
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 +6 -20
- package/benchmarks/perf-thresholds.json +27 -0
- package/package.json +2 -1
- package/src/build-contract.mjs +1644 -56
- package/src/cli.mjs +1048 -321
- package/src/conformance.mjs +356 -11
- package/src/cutscene.mjs +205 -0
- package/src/game-state-runtime.mjs +260 -0
- package/src/headless-test.mjs +92 -9
- package/src/perf-benchmark.mjs +103 -0
- package/src/scaffold.mjs +413 -13
- package/src/state-artifacts.mjs +321 -0
- package/src/state-dev-reload.mjs +120 -0
- package/templates/create/2d-survivor/aura.config.json +28 -0
- package/templates/create/2d-survivor/src/main.js +344 -0
- package/templates/create/3d-collectathon/aura.config.json +28 -0
- package/templates/create/3d-collectathon/src/main.js +367 -0
- package/templates/skills/aurajs/api-contract-3d.md +1 -1
- package/templates/skills/aurajs/api-contract.md +1 -1
- package/src/.gitkeep +0 -0
package/src/cutscene.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
function asFiniteNumber(value, fallback = 0) {
|
|
2
|
+
return Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function cloneCue(cue, index) {
|
|
6
|
+
if (!cue || typeof cue !== 'object') {
|
|
7
|
+
throw new TypeError(`cutscene cue at index ${index} must be an object`);
|
|
8
|
+
}
|
|
9
|
+
const time = Number(cue.time);
|
|
10
|
+
if (!Number.isFinite(time) || time < 0) {
|
|
11
|
+
throw new TypeError(`cutscene cue at index ${index} requires a non-negative finite time`);
|
|
12
|
+
}
|
|
13
|
+
const type = typeof cue.type === 'string' && cue.type.trim().length > 0
|
|
14
|
+
? cue.type.trim()
|
|
15
|
+
: 'event';
|
|
16
|
+
const id = typeof cue.id === 'string' && cue.id.trim().length > 0
|
|
17
|
+
? cue.id.trim()
|
|
18
|
+
: `${type}:${index}`;
|
|
19
|
+
return {
|
|
20
|
+
...cue,
|
|
21
|
+
id,
|
|
22
|
+
type,
|
|
23
|
+
time,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeCues(cues) {
|
|
28
|
+
if (!Array.isArray(cues)) return [];
|
|
29
|
+
return cues
|
|
30
|
+
.map((cue, index) => cloneCue(cue, index))
|
|
31
|
+
.sort((a, b) => (a.time - b.time) || a.id.localeCompare(b.id));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectCueIdsUpTo(cues, time) {
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const cue of cues) {
|
|
37
|
+
if (cue.time <= time) ids.add(cue.id);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class VideoCutsceneController {
|
|
43
|
+
constructor(videoApi, handle, options = {}) {
|
|
44
|
+
if (!videoApi || typeof videoApi !== 'object') {
|
|
45
|
+
throw new TypeError('VideoCutsceneController requires an aura.video-compatible API object.');
|
|
46
|
+
}
|
|
47
|
+
const requiredMethods = ['play', 'pause', 'stop', 'seek', 'getInfo'];
|
|
48
|
+
for (const method of requiredMethods) {
|
|
49
|
+
if (typeof videoApi[method] !== 'function') {
|
|
50
|
+
throw new TypeError(`VideoCutsceneController requires videoApi.${method}()`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const numericHandle = Number(handle);
|
|
54
|
+
if (!Number.isInteger(numericHandle) || numericHandle <= 0) {
|
|
55
|
+
throw new TypeError('VideoCutsceneController requires a positive integer video handle.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.videoApi = videoApi;
|
|
59
|
+
this.handle = numericHandle;
|
|
60
|
+
this.options = {
|
|
61
|
+
cues: normalizeCues(options.cues),
|
|
62
|
+
onCue: typeof options.onCue === 'function' ? options.onCue : null,
|
|
63
|
+
onSubtitle: typeof options.onSubtitle === 'function' ? options.onSubtitle : null,
|
|
64
|
+
onCheckpoint: typeof options.onCheckpoint === 'function' ? options.onCheckpoint : null,
|
|
65
|
+
onStateChange: typeof options.onStateChange === 'function' ? options.onStateChange : null,
|
|
66
|
+
skipTarget: Number.isFinite(Number(options.skipTarget)) ? Math.max(0, Number(options.skipTarget)) : null,
|
|
67
|
+
};
|
|
68
|
+
this.firedCueIds = new Set();
|
|
69
|
+
this.lastInfo = null;
|
|
70
|
+
this.lastTime = 0;
|
|
71
|
+
this.lastState = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getInfo() {
|
|
75
|
+
return this.videoApi.getInfo(this.handle);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getState() {
|
|
79
|
+
const info = this.getInfo();
|
|
80
|
+
const currentTime = info ? asFiniteNumber(info.currentTime || info.currentTimeSecs, 0) : 0;
|
|
81
|
+
const duration = info ? asFiniteNumber(info.duration || info.durationSecs, 0) : 0;
|
|
82
|
+
const currentCue = this.options.cues.find((cue) => cue.time >= currentTime) || null;
|
|
83
|
+
return {
|
|
84
|
+
handle: this.handle,
|
|
85
|
+
info,
|
|
86
|
+
currentTime,
|
|
87
|
+
duration,
|
|
88
|
+
firedCueCount: this.firedCueIds.size,
|
|
89
|
+
nextCue: currentCue,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
exportCheckpointState() {
|
|
94
|
+
const info = this.getInfo();
|
|
95
|
+
return {
|
|
96
|
+
handle: this.handle,
|
|
97
|
+
currentTime: info ? asFiniteNumber(info.currentTime || info.currentTimeSecs, 0) : this.lastTime,
|
|
98
|
+
firedCueIds: [...this.firedCueIds],
|
|
99
|
+
state: info && typeof info.state === 'string' ? info.state : this.lastState,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
restoreCheckpointState(snapshot) {
|
|
104
|
+
if (!snapshot || typeof snapshot !== 'object') {
|
|
105
|
+
throw new TypeError('VideoCutsceneController.restoreCheckpointState(snapshot) requires an object snapshot.');
|
|
106
|
+
}
|
|
107
|
+
const targetTime = Number(snapshot.currentTime);
|
|
108
|
+
if (!Number.isFinite(targetTime) || targetTime < 0) {
|
|
109
|
+
throw new TypeError('VideoCutsceneController.restoreCheckpointState(snapshot) requires a non-negative finite currentTime.');
|
|
110
|
+
}
|
|
111
|
+
const firedCueIds = Array.isArray(snapshot.firedCueIds) ? snapshot.firedCueIds : [];
|
|
112
|
+
this.videoApi.seek(this.handle, targetTime);
|
|
113
|
+
this.firedCueIds = new Set(firedCueIds.filter((value) => typeof value === 'string' && value.length > 0));
|
|
114
|
+
this.lastTime = targetTime;
|
|
115
|
+
this.lastState = typeof snapshot.state === 'string' ? snapshot.state : this.lastState;
|
|
116
|
+
return this.sync();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sync() {
|
|
120
|
+
const info = this.getInfo();
|
|
121
|
+
if (!info || typeof info !== 'object') {
|
|
122
|
+
throw new Error(`VideoCutsceneController could not read info for handle ${this.handle}.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const nextState = typeof info.state === 'string' ? info.state : 'unknown';
|
|
126
|
+
const currentTime = asFiniteNumber(info.currentTime || info.currentTimeSecs, 0);
|
|
127
|
+
if (this.lastInfo && currentTime < this.lastTime) {
|
|
128
|
+
this.firedCueIds = collectCueIdsUpTo(this.options.cues, currentTime);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const cue of this.options.cues) {
|
|
132
|
+
if (cue.time <= currentTime && !this.firedCueIds.has(cue.id)) {
|
|
133
|
+
this.firedCueIds.add(cue.id);
|
|
134
|
+
if (this.options.onCue) this.options.onCue(cue, info, this);
|
|
135
|
+
if (cue.type === 'subtitle' && this.options.onSubtitle) this.options.onSubtitle(cue, info, this);
|
|
136
|
+
if (cue.type === 'checkpoint' && this.options.onCheckpoint) this.options.onCheckpoint(cue, info, this);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (nextState !== this.lastState && this.options.onStateChange) {
|
|
141
|
+
this.options.onStateChange({
|
|
142
|
+
previousState: this.lastState,
|
|
143
|
+
state: nextState,
|
|
144
|
+
info,
|
|
145
|
+
controller: this,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.lastInfo = info;
|
|
150
|
+
this.lastTime = currentTime;
|
|
151
|
+
this.lastState = nextState;
|
|
152
|
+
return info;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
play() {
|
|
156
|
+
this.videoApi.play(this.handle);
|
|
157
|
+
return this.sync();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pause() {
|
|
161
|
+
this.videoApi.pause(this.handle);
|
|
162
|
+
return this.sync();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
resume() {
|
|
166
|
+
return this.play();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
stop() {
|
|
170
|
+
this.videoApi.stop(this.handle);
|
|
171
|
+
this.firedCueIds.clear();
|
|
172
|
+
this.lastTime = 0;
|
|
173
|
+
return this.sync();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
seek(timeSecs) {
|
|
177
|
+
const target = Number(timeSecs);
|
|
178
|
+
if (!Number.isFinite(target) || target < 0) {
|
|
179
|
+
throw new TypeError('VideoCutsceneController.seek(timeSecs) requires a non-negative finite time.');
|
|
180
|
+
}
|
|
181
|
+
this.videoApi.seek(this.handle, target);
|
|
182
|
+
this.firedCueIds = collectCueIdsUpTo(this.options.cues, target);
|
|
183
|
+
this.lastTime = target;
|
|
184
|
+
return this.sync();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
skip(targetTime = null) {
|
|
188
|
+
const info = this.getInfo();
|
|
189
|
+
const fallbackDuration = info ? asFiniteNumber(info.duration || info.durationSecs, 0) : 0;
|
|
190
|
+
const resolvedTarget = targetTime != null && Number.isFinite(Number(targetTime))
|
|
191
|
+
? Math.max(0, Number(targetTime))
|
|
192
|
+
: (this.options.skipTarget != null ? this.options.skipTarget : fallbackDuration);
|
|
193
|
+
return this.seek(resolvedTarget);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
update() {
|
|
197
|
+
return this.sync();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createVideoCutsceneController(videoApi, handle, options = {}) {
|
|
202
|
+
return new VideoCutsceneController(videoApi, handle, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default createVideoCutsceneController;
|
|
@@ -28,6 +28,266 @@ export const DEFAULT_STATE_MUTATION_GUARDRAILS = Object.freeze({
|
|
|
28
28
|
allowlistPrefixes: STATE_MUTATION_ALLOWLIST_PREFIXES,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
export function installStateRestoreRuntimeBootstrap(aura) {
|
|
32
|
+
const schemaVersion = 'aurajs.game-state.v1';
|
|
33
|
+
const knownTopLevel = Object.freeze(['schemaVersion', 'export', 'state']);
|
|
34
|
+
const knownStateSections = Object.freeze(['globals', 'camera', 'scene3d', 'physics', 'ecs', 'tilemap']);
|
|
35
|
+
|
|
36
|
+
const isObject = (value) => (
|
|
37
|
+
!!value
|
|
38
|
+
&& typeof value === 'object'
|
|
39
|
+
&& !Array.isArray(value)
|
|
40
|
+
);
|
|
41
|
+
const sanitizeJsonValueLocal = (value) => {
|
|
42
|
+
if (value === null) return null;
|
|
43
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
|
44
|
+
if (typeof value === 'number') {
|
|
45
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((entry) => sanitizeJsonValueLocal(entry));
|
|
49
|
+
}
|
|
50
|
+
if (isObject(value)) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
|
|
53
|
+
const next = sanitizeJsonValueLocal(value[key]);
|
|
54
|
+
if (next === undefined) continue;
|
|
55
|
+
out[key] = next;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
const sanitizeObjectLocal = (value) => {
|
|
62
|
+
const out = {};
|
|
63
|
+
const source = isObject(value) ? value : {};
|
|
64
|
+
for (const key of Object.keys(source).sort((a, b) => a.localeCompare(b))) {
|
|
65
|
+
const next = sanitizeJsonValueLocal(source[key]);
|
|
66
|
+
if (next === undefined) continue;
|
|
67
|
+
out[key] = next;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
};
|
|
71
|
+
const collectCanonicalStateFromPayloadLocal = (inputState) => {
|
|
72
|
+
const out = {};
|
|
73
|
+
out.globals = sanitizeObjectLocal(inputState?.globals || {});
|
|
74
|
+
for (const sectionName of knownStateSections) {
|
|
75
|
+
if (sectionName === 'globals') continue;
|
|
76
|
+
if (!Object.prototype.hasOwnProperty.call(inputState, sectionName)) continue;
|
|
77
|
+
const value = sanitizeJsonValueLocal(inputState[sectionName]);
|
|
78
|
+
if (value == null) continue;
|
|
79
|
+
out[sectionName] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
const resolveGlobalsMapLocal = () => {
|
|
84
|
+
const storage = aura?.storage && typeof aura.storage === 'object' ? aura.storage : null;
|
|
85
|
+
if (storage && storage._store instanceof Map) {
|
|
86
|
+
return storage._store;
|
|
87
|
+
}
|
|
88
|
+
if (aura && aura.__gameStateGlobals instanceof Map) {
|
|
89
|
+
return aura.__gameStateGlobals;
|
|
90
|
+
}
|
|
91
|
+
if (aura && typeof aura === 'object') {
|
|
92
|
+
aura.__gameStateGlobals = new Map();
|
|
93
|
+
return aura.__gameStateGlobals;
|
|
94
|
+
}
|
|
95
|
+
return new Map();
|
|
96
|
+
};
|
|
97
|
+
const applyGlobalsSectionLocal = (sectionValue) => {
|
|
98
|
+
if (!isObject(sectionValue)) {
|
|
99
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'globals must be an object' };
|
|
100
|
+
}
|
|
101
|
+
const globalsMap = resolveGlobalsMapLocal();
|
|
102
|
+
globalsMap.clear();
|
|
103
|
+
const normalized = sanitizeObjectLocal(sectionValue);
|
|
104
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
105
|
+
globalsMap.set(key, value);
|
|
106
|
+
}
|
|
107
|
+
const storage = aura?.storage && typeof aura.storage === 'object' ? aura.storage : null;
|
|
108
|
+
if (storage && typeof storage.set === 'function') {
|
|
109
|
+
for (const [key, value] of globalsMap.entries()) {
|
|
110
|
+
storage.set(key, value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
114
|
+
};
|
|
115
|
+
const applyCameraSectionLocal = (sectionValue) => {
|
|
116
|
+
if (!isObject(sectionValue)) {
|
|
117
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera must be an object' };
|
|
118
|
+
}
|
|
119
|
+
if (!aura?.camera || typeof aura.camera !== 'object') {
|
|
120
|
+
return { ok: false, reasonCode: 'unsupported_state_section', detail: 'camera surface unavailable' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const nextX = sectionValue.x;
|
|
124
|
+
const nextY = sectionValue.y;
|
|
125
|
+
const nextZoom = sectionValue.zoom;
|
|
126
|
+
const nextRotation = sectionValue.rotation;
|
|
127
|
+
if (nextX != null && !Number.isFinite(nextX)) {
|
|
128
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.x must be finite' };
|
|
129
|
+
}
|
|
130
|
+
if (nextY != null && !Number.isFinite(nextY)) {
|
|
131
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.y must be finite' };
|
|
132
|
+
}
|
|
133
|
+
if (nextZoom != null && (!Number.isFinite(nextZoom) || nextZoom <= 0)) {
|
|
134
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.zoom must be > 0' };
|
|
135
|
+
}
|
|
136
|
+
if (nextRotation != null && !Number.isFinite(nextRotation)) {
|
|
137
|
+
return { ok: false, reasonCode: 'invalid_state_section', detail: 'camera.rotation must be finite' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (nextX != null) aura.camera.x = Number(nextX);
|
|
141
|
+
if (nextY != null) aura.camera.y = Number(nextY);
|
|
142
|
+
if (nextZoom != null) aura.camera.zoom = Number(nextZoom);
|
|
143
|
+
if (nextRotation != null) aura.camera.rotation = Number(nextRotation);
|
|
144
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
145
|
+
};
|
|
146
|
+
const resolveSectionApplyHandlerLocal = (sectionName) => {
|
|
147
|
+
const section = aura?.[sectionName];
|
|
148
|
+
if (!section || typeof section !== 'object') return null;
|
|
149
|
+
if (typeof section.applyState === 'function') {
|
|
150
|
+
return (value) => section.applyState(value);
|
|
151
|
+
}
|
|
152
|
+
if (typeof section.importState === 'function') {
|
|
153
|
+
return (value) => section.importState(value);
|
|
154
|
+
}
|
|
155
|
+
if (sectionName === 'physics' && typeof section.restoreSnapshot === 'function') {
|
|
156
|
+
return (value) => section.restoreSnapshot(value);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
const applyStateSectionLocal = (sectionName, sectionValue) => {
|
|
161
|
+
if (sectionName === 'globals') return applyGlobalsSectionLocal(sectionValue);
|
|
162
|
+
if (sectionName === 'camera') return applyCameraSectionLocal(sectionValue);
|
|
163
|
+
|
|
164
|
+
const handler = resolveSectionApplyHandlerLocal(sectionName);
|
|
165
|
+
if (!handler) {
|
|
166
|
+
return { ok: false, reasonCode: 'unsupported_state_section' };
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const result = handler(sectionValue);
|
|
170
|
+
if (result === false) {
|
|
171
|
+
return { ok: false, reasonCode: 'state_apply_failed' };
|
|
172
|
+
}
|
|
173
|
+
if (result && typeof result === 'object' && result.ok === false) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
reasonCode: typeof result.reasonCode === 'string' ? result.reasonCode : 'state_apply_failed',
|
|
177
|
+
detail: result.detail || result.error || null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, reasonCode: 'state_apply_ok' };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
reasonCode: 'state_apply_failed',
|
|
185
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const validateApplyPayloadLocal = (payload) => {
|
|
190
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
191
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'payload must be an object' };
|
|
192
|
+
}
|
|
193
|
+
if (payload.schemaVersion !== schemaVersion) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
reasonCode: 'schema_version_mismatch',
|
|
197
|
+
detail: `expected ${schemaVersion}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (!isObject(payload.state)) {
|
|
201
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'state must be an object' };
|
|
202
|
+
}
|
|
203
|
+
if (!isObject(payload.export)) {
|
|
204
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'export must be an object' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const unknownTopLevel = Object.keys(payload).filter((key) => !knownTopLevel.includes(key));
|
|
208
|
+
if (unknownTopLevel.length > 0) {
|
|
209
|
+
return { ok: false, reasonCode: 'unknown_top_level_key', unknownKeys: unknownTopLevel.sort() };
|
|
210
|
+
}
|
|
211
|
+
if (!isObject(payload.state.globals)) {
|
|
212
|
+
return { ok: false, reasonCode: 'missing_required_field', detail: 'state.globals is required' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const unknownSections = Object.keys(payload.state).filter((key) => !knownStateSections.includes(key));
|
|
216
|
+
if (unknownSections.length > 0) {
|
|
217
|
+
return { ok: false, reasonCode: 'unknown_state_section', unknownSections: unknownSections.sort() };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
state: collectCanonicalStateFromPayloadLocal(payload.state),
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!aura || typeof aura !== 'object') {
|
|
227
|
+
return {
|
|
228
|
+
apply() {
|
|
229
|
+
return { ok: false, reasonCode: 'invalid_schema_payload', detail: 'aura runtime unavailable' };
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stateNamespace = aura.state && typeof aura.state === 'object' ? aura.state : {};
|
|
235
|
+
if (typeof stateNamespace.apply === 'function' && typeof stateNamespace.applyState === 'function') {
|
|
236
|
+
stateNamespace.schemaVersion = schemaVersion;
|
|
237
|
+
aura.state = stateNamespace;
|
|
238
|
+
return stateNamespace;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const applyPayload = (payload, options = {}) => {
|
|
242
|
+
const validated = validateApplyPayloadLocal(payload);
|
|
243
|
+
if (!validated.ok) {
|
|
244
|
+
return validated;
|
|
245
|
+
}
|
|
246
|
+
if (options?.dryRun === true) {
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
reasonCode: 'state_dry_run_ok',
|
|
250
|
+
appliedSections: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const appliedSections = [];
|
|
255
|
+
for (const sectionName of knownStateSections) {
|
|
256
|
+
if (!Object.prototype.hasOwnProperty.call(validated.state, sectionName)) continue;
|
|
257
|
+
const result = applyStateSectionLocal(sectionName, validated.state[sectionName]);
|
|
258
|
+
if (!result.ok) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
reasonCode: result.reasonCode || 'state_apply_failed',
|
|
262
|
+
failedSection: sectionName,
|
|
263
|
+
detail: result.detail || null,
|
|
264
|
+
appliedSections,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
appliedSections.push(sectionName);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
reasonCode: 'state_apply_ok',
|
|
272
|
+
appliedSections,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
stateNamespace.schemaVersion = schemaVersion;
|
|
277
|
+
if (typeof stateNamespace.apply !== 'function') {
|
|
278
|
+
stateNamespace.apply = applyPayload;
|
|
279
|
+
}
|
|
280
|
+
if (typeof stateNamespace.applyState !== 'function') {
|
|
281
|
+
stateNamespace.applyState = applyPayload;
|
|
282
|
+
}
|
|
283
|
+
aura.state = stateNamespace;
|
|
284
|
+
return stateNamespace;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function buildStateRestoreRuntimeBootstrapSource() {
|
|
288
|
+
return `(${installStateRestoreRuntimeBootstrap.toString()})(aura);`;
|
|
289
|
+
}
|
|
290
|
+
|
|
31
291
|
export function createGameStateRuntimeHooks({ aura, mode = 'native' } = {}) {
|
|
32
292
|
if (!aura || typeof aura !== 'object') {
|
|
33
293
|
throw new Error('createGameStateRuntimeHooks requires an aura runtime object.');
|
package/src/headless-test.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import vm from 'node:vm';
|
|
|
4
4
|
|
|
5
5
|
import { bundleProject } from './bundler.mjs';
|
|
6
6
|
import {
|
|
7
|
+
GAME_STATE_SCHEMA_VERSION,
|
|
7
8
|
createGameStateRuntimeHooks,
|
|
8
9
|
diffCanonicalGameState,
|
|
9
10
|
applyCanonicalStatePatch,
|
|
@@ -163,13 +164,13 @@ export async function runHeadlessStateExport(options = {}) {
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
if (!aura.state || typeof aura.state !== 'object' || typeof aura.state.
|
|
167
|
-
throw new HeadlessTestError('Headless state export failed: aura.state.
|
|
167
|
+
if (!aura.state || typeof aura.state !== 'object' || typeof aura.state.exportState !== 'function') {
|
|
168
|
+
throw new HeadlessTestError('Headless state export failed: aura.state.exportState hook is unavailable.');
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
let exportResult;
|
|
171
172
|
try {
|
|
172
|
-
exportResult = aura.state.
|
|
173
|
+
exportResult = aura.state.exportState({
|
|
173
174
|
mode: options.mode,
|
|
174
175
|
seed: options.seed,
|
|
175
176
|
frameIndex: options.frameIndex ?? frames,
|
|
@@ -274,15 +275,15 @@ export async function runHeadlessStateApply(options = {}) {
|
|
|
274
275
|
if (!aura.state || typeof aura.state !== 'object') {
|
|
275
276
|
throw new HeadlessTestError('Headless state apply failed: aura.state surface is unavailable.');
|
|
276
277
|
}
|
|
277
|
-
if (typeof aura.state.
|
|
278
|
-
throw new HeadlessTestError('Headless state apply failed: aura.state.
|
|
278
|
+
if (typeof aura.state.exportState !== 'function') {
|
|
279
|
+
throw new HeadlessTestError('Headless state apply failed: aura.state.exportState hook is unavailable.');
|
|
279
280
|
}
|
|
280
281
|
if (typeof aura.state.apply !== 'function') {
|
|
281
282
|
throw new HeadlessTestError('Headless state apply failed: aura.state.apply hook is unavailable.');
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
const captureExport = () => {
|
|
285
|
-
const result = aura.state.
|
|
286
|
+
const result = aura.state.exportState({
|
|
286
287
|
mode: options.mode,
|
|
287
288
|
seed: options.seed,
|
|
288
289
|
frameIndex: options.frameIndex ?? frames,
|
|
@@ -631,7 +632,7 @@ function createRuntimeContext(aura, testState) {
|
|
|
631
632
|
};
|
|
632
633
|
}
|
|
633
634
|
|
|
634
|
-
function createHeadlessAura({ width, height, testState }) {
|
|
635
|
+
export function createHeadlessAura({ width, height, testState }) {
|
|
635
636
|
const drawNoop = () => {
|
|
636
637
|
testState.drawCalls += 1;
|
|
637
638
|
};
|
|
@@ -3059,9 +3060,21 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
3059
3060
|
let nextMaterialHandle = 1;
|
|
3060
3061
|
const defaultMaterialState = Object.freeze({
|
|
3061
3062
|
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
3063
|
+
emissive: { r: 0, g: 0, b: 0 },
|
|
3062
3064
|
metallic: 0,
|
|
3063
3065
|
roughness: 1,
|
|
3064
3066
|
texture: null,
|
|
3067
|
+
metallicRoughnessTexture: null,
|
|
3068
|
+
occlusionTexture: null,
|
|
3069
|
+
emissiveTexture: null,
|
|
3070
|
+
alphaMode: 'opaque',
|
|
3071
|
+
alphaCutoff: 0.5,
|
|
3072
|
+
doubleSided: false,
|
|
3073
|
+
clearcoat: 0,
|
|
3074
|
+
clearcoatRoughness: 0,
|
|
3075
|
+
sheenColor: { r: 0, g: 0, b: 0 },
|
|
3076
|
+
sheenRoughness: 0,
|
|
3077
|
+
occlusionStrength: 1,
|
|
3065
3078
|
});
|
|
3066
3079
|
const materialStore = new Map([[0, { ...defaultMaterialState }]]);
|
|
3067
3080
|
const normalizeMaterialHandle = (value) => (
|
|
@@ -3073,6 +3086,21 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
3073
3086
|
if (![r, g, b, a].every((entry) => Number.isFinite(entry))) return null;
|
|
3074
3087
|
return { r: Number(r), g: Number(g), b: Number(b), a: Number(a) };
|
|
3075
3088
|
};
|
|
3089
|
+
const normalizeMaterialRgb = (value) => {
|
|
3090
|
+
if (!value || typeof value !== 'object') return null;
|
|
3091
|
+
const { r, g, b } = value;
|
|
3092
|
+
if (![r, g, b].every((entry) => Number.isFinite(entry))) return null;
|
|
3093
|
+
return { r: Number(r), g: Number(g), b: Number(b) };
|
|
3094
|
+
};
|
|
3095
|
+
const normalizeTexturePath = (value) => (
|
|
3096
|
+
typeof value === 'string' && value.length > 0 ? value : null
|
|
3097
|
+
);
|
|
3098
|
+
const normalizeAlphaMode = (value) => {
|
|
3099
|
+
if (typeof value !== 'string') return defaultMaterialState.alphaMode;
|
|
3100
|
+
const normalized = value.trim().toLowerCase();
|
|
3101
|
+
if (normalized === 'mask' || normalized === 'blend') return normalized;
|
|
3102
|
+
return 'opaque';
|
|
3103
|
+
};
|
|
3076
3104
|
const clampUnit = (value, fallback) => {
|
|
3077
3105
|
if (!Number.isFinite(value)) return fallback;
|
|
3078
3106
|
return Math.max(0, Math.min(1, Number(value)));
|
|
@@ -3089,9 +3117,27 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
3089
3117
|
}
|
|
3090
3118
|
return {
|
|
3091
3119
|
color: normalizeMaterialColor(options.color) || { ...defaultMaterialState.color },
|
|
3120
|
+
emissive: normalizeMaterialRgb(options.emissive) || { ...defaultMaterialState.emissive },
|
|
3092
3121
|
metallic: clampUnit(options.metallic, defaultMaterialState.metallic),
|
|
3093
3122
|
roughness: clampUnit(options.roughness, defaultMaterialState.roughness),
|
|
3094
|
-
texture:
|
|
3123
|
+
texture: normalizeTexturePath(options.texture),
|
|
3124
|
+
metallicRoughnessTexture: normalizeTexturePath(options.metallicRoughnessTexture),
|
|
3125
|
+
occlusionTexture: normalizeTexturePath(options.aoTexture ?? options.occlusionTexture),
|
|
3126
|
+
emissiveTexture: normalizeTexturePath(options.emissiveTexture),
|
|
3127
|
+
alphaMode: normalizeAlphaMode(options.alphaMode),
|
|
3128
|
+
alphaCutoff: clampUnit(options.alphaCutoff, defaultMaterialState.alphaCutoff),
|
|
3129
|
+
doubleSided: options.doubleSided === true,
|
|
3130
|
+
clearcoat: clampUnit(options.clearcoat, defaultMaterialState.clearcoat),
|
|
3131
|
+
clearcoatRoughness: clampUnit(
|
|
3132
|
+
options.clearcoatRoughness,
|
|
3133
|
+
defaultMaterialState.clearcoatRoughness,
|
|
3134
|
+
),
|
|
3135
|
+
sheenColor: normalizeMaterialRgb(options.sheenColor) || { ...defaultMaterialState.sheenColor },
|
|
3136
|
+
sheenRoughness: clampUnit(options.sheenRoughness, defaultMaterialState.sheenRoughness),
|
|
3137
|
+
occlusionStrength: clampUnit(
|
|
3138
|
+
options.occlusionStrength ?? options.aoStrength,
|
|
3139
|
+
defaultMaterialState.occlusionStrength,
|
|
3140
|
+
),
|
|
3095
3141
|
};
|
|
3096
3142
|
};
|
|
3097
3143
|
|
|
@@ -4293,6 +4339,8 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
4293
4339
|
aura.window.height = Number(h) || aura.window.height;
|
|
4294
4340
|
},
|
|
4295
4341
|
setFullscreen: () => {},
|
|
4342
|
+
setCursorVisible: () => {},
|
|
4343
|
+
setCursorLocked: () => {},
|
|
4296
4344
|
getSize: () => ({ width: aura.window.width, height: aura.window.height }),
|
|
4297
4345
|
getPixelRatio: () => aura.window.pixelRatio,
|
|
4298
4346
|
getFPS: () => aura.window.fps,
|
|
@@ -4375,9 +4423,21 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
4375
4423
|
const material = getMaterialState(handle);
|
|
4376
4424
|
if (!material) return;
|
|
4377
4425
|
material.color = { ...defaultMaterialState.color };
|
|
4426
|
+
material.emissive = { ...defaultMaterialState.emissive };
|
|
4378
4427
|
material.metallic = defaultMaterialState.metallic;
|
|
4379
4428
|
material.roughness = defaultMaterialState.roughness;
|
|
4380
4429
|
material.texture = defaultMaterialState.texture;
|
|
4430
|
+
material.metallicRoughnessTexture = defaultMaterialState.metallicRoughnessTexture;
|
|
4431
|
+
material.occlusionTexture = defaultMaterialState.occlusionTexture;
|
|
4432
|
+
material.emissiveTexture = defaultMaterialState.emissiveTexture;
|
|
4433
|
+
material.alphaMode = defaultMaterialState.alphaMode;
|
|
4434
|
+
material.alphaCutoff = defaultMaterialState.alphaCutoff;
|
|
4435
|
+
material.doubleSided = defaultMaterialState.doubleSided;
|
|
4436
|
+
material.clearcoat = defaultMaterialState.clearcoat;
|
|
4437
|
+
material.clearcoatRoughness = defaultMaterialState.clearcoatRoughness;
|
|
4438
|
+
material.sheenColor = { ...defaultMaterialState.sheenColor };
|
|
4439
|
+
material.sheenRoughness = defaultMaterialState.sheenRoughness;
|
|
4440
|
+
material.occlusionStrength = defaultMaterialState.occlusionStrength;
|
|
4381
4441
|
},
|
|
4382
4442
|
clone: (handle) => {
|
|
4383
4443
|
const material = getMaterialState(handle);
|
|
@@ -4385,9 +4445,21 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
4385
4445
|
const newHandle = nextMaterialHandle++;
|
|
4386
4446
|
materialStore.set(newHandle, {
|
|
4387
4447
|
color: { ...material.color },
|
|
4448
|
+
emissive: { ...material.emissive },
|
|
4388
4449
|
metallic: material.metallic,
|
|
4389
4450
|
roughness: material.roughness,
|
|
4390
4451
|
texture: material.texture,
|
|
4452
|
+
metallicRoughnessTexture: material.metallicRoughnessTexture,
|
|
4453
|
+
occlusionTexture: material.occlusionTexture,
|
|
4454
|
+
emissiveTexture: material.emissiveTexture,
|
|
4455
|
+
alphaMode: material.alphaMode,
|
|
4456
|
+
alphaCutoff: material.alphaCutoff,
|
|
4457
|
+
doubleSided: material.doubleSided,
|
|
4458
|
+
clearcoat: material.clearcoat,
|
|
4459
|
+
clearcoatRoughness: material.clearcoatRoughness,
|
|
4460
|
+
sheenColor: { ...material.sheenColor },
|
|
4461
|
+
sheenRoughness: material.sheenRoughness,
|
|
4462
|
+
occlusionStrength: material.occlusionStrength,
|
|
4391
4463
|
});
|
|
4392
4464
|
return newHandle;
|
|
4393
4465
|
},
|
|
@@ -4421,6 +4493,7 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
4421
4493
|
isMousePressed: (...args) => aura.input.mouse.isPressed(...args),
|
|
4422
4494
|
isMouseReleased: (...args) => aura.input.mouse.isReleased(...args),
|
|
4423
4495
|
getMousePosition: () => ({ x: aura.input.mouse.x, y: aura.input.mouse.y }),
|
|
4496
|
+
getMouseDelta: () => ({ x: 0, y: 0 }),
|
|
4424
4497
|
isGamepadConnected: () => Boolean(aura.input.gamepad.connected),
|
|
4425
4498
|
mouse: {
|
|
4426
4499
|
x: 0,
|
|
@@ -4780,8 +4853,18 @@ function createHeadlessAura({ width, height, testState }) {
|
|
|
4780
4853
|
|
|
4781
4854
|
const gameStateHooks = createGameStateRuntimeHooks({ aura, mode: 'headless' });
|
|
4782
4855
|
aura.state = {
|
|
4783
|
-
|
|
4856
|
+
schemaVersion: GAME_STATE_SCHEMA_VERSION,
|
|
4857
|
+
export: (options = {}) => {
|
|
4858
|
+
const result = gameStateHooks.exportState(options);
|
|
4859
|
+
return result && result.ok === true ? result.payload : result;
|
|
4860
|
+
},
|
|
4784
4861
|
apply: (payload, options = {}) => gameStateHooks.applyState(payload, options),
|
|
4862
|
+
diff: (beforePayload, afterPayload) => gameStateHooks.diffState(beforePayload, afterPayload),
|
|
4863
|
+
patch: (payload, patchPayload, options = {}) => gameStateHooks.patchState(payload, patchPayload, options),
|
|
4864
|
+
exportState: (options = {}) => gameStateHooks.exportState(options),
|
|
4865
|
+
applyState: (payload, options = {}) => gameStateHooks.applyState(payload, options),
|
|
4866
|
+
diffState: (beforePayload, afterPayload) => gameStateHooks.diffState(beforePayload, afterPayload),
|
|
4867
|
+
patchState: (payload, patchPayload, options = {}) => gameStateHooks.patchState(payload, patchPayload, options),
|
|
4785
4868
|
};
|
|
4786
4869
|
|
|
4787
4870
|
return aura;
|