@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.
@@ -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.');
@@ -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.export !== 'function') {
167
- throw new HeadlessTestError('Headless state export failed: aura.state.export hook is unavailable.');
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.export({
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.export !== 'function') {
278
- throw new HeadlessTestError('Headless state apply failed: aura.state.export hook is unavailable.');
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.export({
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: typeof options.texture === 'string' && options.texture.length > 0 ? options.texture : null,
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
- export: (options = {}) => gameStateHooks.exportState(options),
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;