Skip to content

Commit ea2ac27

Browse files
author
DavidQ
committed
Add overlay profile export/import.
- Enables sharing and portability of user settings
1 parent 46c5c1e commit ea2ac27

7 files changed

Lines changed: 284 additions & 44 deletions

File tree

docs/dev/CODEX_COMMANDS.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ MODEL: GPT-5.4
22
REASONING: medium
33

44
COMMAND:
5-
Implement overlay user preferences and persistence:
6-
- Store preferences (visibility, layout, keybind profile)
7-
- Restore preferences on runtime load
8-
- Ensure compatibility with adaptive UI and contextual input
9-
- Use lightweight storage (local or existing state system)
5+
Implement profile export/import:
6+
- Export overlay preferences to JSON
7+
- Import with validation
8+
- Ensure compatibility with persistence system
109
- Update roadmap status only

docs/dev/COMMIT_COMMENT.txt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
Add user preferences and persistence for overlays.
1+
Add overlay profile export/import.
22

3-
PR Details:
4-
- Enables saving and restoring overlay configuration
5-
- Supports personalization across sessions
3+
- Enables sharing and portability of user settings
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
[ ] Preferences save correctly
2-
[ ] Preferences restore correctly
3-
[ ] No regression in behavior
4-
[ ] Compatible with adaptive UI
1+
[ ] Export works
2+
[ ] Import works
3+
[ ] Validation prevents bad data
54
[ ] Roadmap updated

docs/dev/roadmaps/MASTER_ROADMAP_HIGH_LEVEL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@
639639
- [x] Level 22 contextual input mapping added (active overlay/layer stack-aware resolution across keybind and gesture actions)
640640
- [x] Level 22 adaptive overlay UI rules added (visibility/size/emphasis responsive to gameplay state, overlay context, and telemetry)
641641
- [x] Level 22 overlay preferences persistence added (visibility, layout overrides, and keybind profile restored on runtime load)
642+
- [x] Level 22 overlay profile export/import added (validated JSON portability with persistence-system compatibility)
642643

643644
### Sample Phase Tracks
644645
- [x] 3D phase normalized

docs/pr/BUILD_PR.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
# BUILD_PR_LEVEL_22_5_OVERLAY_USER_PREFERENCES_AND_PERSISTENCE
1+
# BUILD_PR_LEVEL_22_6_OVERLAY_PROFILE_EXPORT_IMPORT
22

33
## Purpose
4-
Persist user preferences for overlay behavior and customization.
5-
6-
## Roadmap Improvement
7-
Completes Level 22 by adding persistence and personalization.
4+
Enable export and import of overlay user profiles.
85

96
## Scope
10-
- Store user preferences (visibility, layout, keybind profile)
11-
- Restore preferences on load
12-
- Ensure compatibility with adaptive UI and contextual input systems
7+
- Export preferences to JSON
8+
- Import preferences from JSON
9+
- Validate schema and compatibility
1310

1411
## Test Steps
15-
1. Modify overlay preferences
16-
2. Reload runtime
17-
3. Verify preferences persist
12+
1. Export profile
13+
2. Import profile
14+
3. Verify settings applied
1815

1916
## Expected
20-
- Preferences saved and restored
21-
- No regression in overlay behavior
17+
- Profiles portable across sessions/devices

samples/phase-17/shared/overlayGameplayRuntime.js

Lines changed: 195 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,149 @@ function writeOverlayRuntimePreferencePayloadToStorage(preferenceStorageKey, pay
249249
return true;
250250
}
251251

252+
function validateOverlayRuntimePreferencePayload(payload) {
253+
const errors = [];
254+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
255+
return {
256+
valid: false,
257+
errors: Object.freeze(['Overlay runtime profile payload must be an object.']),
258+
value: null,
259+
};
260+
}
261+
262+
const hasVisibility = Object.prototype.hasOwnProperty.call(payload, 'visibility');
263+
const hasLayout = Object.prototype.hasOwnProperty.call(payload, 'layout');
264+
const hasKeybindProfile = Object.prototype.hasOwnProperty.call(payload, 'keybindProfile');
265+
if (!hasVisibility && !hasLayout && !hasKeybindProfile) {
266+
errors.push('Overlay runtime profile payload must include at least one of visibility, layout, or keybindProfile.');
267+
}
268+
269+
const versionRaw = payload.version;
270+
if (versionRaw !== undefined) {
271+
const version = Number(versionRaw);
272+
if (!Number.isInteger(version) || version <= 0) {
273+
errors.push('Overlay runtime profile version must be a positive integer when provided.');
274+
}
275+
}
276+
277+
let visibility = null;
278+
if (hasVisibility) {
279+
if (payload.visibility === true || payload.visibility === false) {
280+
visibility = payload.visibility;
281+
} else {
282+
errors.push('Overlay runtime profile visibility must be a boolean.');
283+
}
284+
}
285+
286+
let layout = null;
287+
if (hasLayout) {
288+
if (!payload.layout || typeof payload.layout !== 'object' || Array.isArray(payload.layout)) {
289+
errors.push('Overlay runtime profile layout must be an object.');
290+
} else {
291+
layout = {};
292+
const entries = Object.entries(payload.layout);
293+
for (let i = 0; i < entries.length; i += 1) {
294+
const [layoutKey, layoutRect] = entries[i];
295+
const normalizedLayoutKey = String(layoutKey || '').trim();
296+
const normalizedRect = normalizeLayoutOverrideRect(layoutRect);
297+
if (!normalizedLayoutKey || !normalizedRect) {
298+
errors.push(`Overlay runtime profile layout entry "${normalizedLayoutKey || '<empty>'}" is invalid.`);
299+
continue;
300+
}
301+
layout[normalizedLayoutKey] = normalizedRect;
302+
}
303+
}
304+
}
305+
306+
let keybindProfile = null;
307+
if (hasKeybindProfile) {
308+
if (!payload.keybindProfile || typeof payload.keybindProfile !== 'object' || Array.isArray(payload.keybindProfile)) {
309+
errors.push('Overlay runtime profile keybindProfile must be an object.');
310+
} else {
311+
keybindProfile = {};
312+
if (Object.prototype.hasOwnProperty.call(payload.keybindProfile, 'id')) {
313+
keybindProfile.id = String(payload.keybindProfile.id || '').trim();
314+
}
315+
if (Object.prototype.hasOwnProperty.call(payload.keybindProfile, 'cycleKey')) {
316+
const cycleKey = String(payload.keybindProfile.cycleKey || '').trim();
317+
if (!cycleKey) {
318+
errors.push('Overlay runtime profile keybindProfile.cycleKey must be a non-empty string when provided.');
319+
} else {
320+
keybindProfile.cycleKey = cycleKey;
321+
}
322+
}
323+
if (Object.prototype.hasOwnProperty.call(payload.keybindProfile, 'contextInputMap')) {
324+
const contextInputMap = payload.keybindProfile.contextInputMap;
325+
if (contextInputMap === null) {
326+
keybindProfile.contextInputMap = null;
327+
keybindProfile.contextInputMapSpecified = true;
328+
} else if (contextInputMap && typeof contextInputMap === 'object' && !Array.isArray(contextInputMap)) {
329+
const clonedContextInputMap = cloneJsonCompatibleValue(contextInputMap);
330+
if (clonedContextInputMap && typeof clonedContextInputMap === 'object') {
331+
keybindProfile.contextInputMap = clonedContextInputMap;
332+
keybindProfile.contextInputMapSpecified = true;
333+
} else {
334+
errors.push('Overlay runtime profile keybindProfile.contextInputMap must be JSON-compatible.');
335+
}
336+
} else {
337+
errors.push('Overlay runtime profile keybindProfile.contextInputMap must be an object or null.');
338+
}
339+
}
340+
}
341+
}
342+
343+
if (errors.length > 0) {
344+
return {
345+
valid: false,
346+
errors: Object.freeze(errors),
347+
value: null,
348+
};
349+
}
350+
351+
return {
352+
valid: true,
353+
errors: Object.freeze([]),
354+
value: Object.freeze({
355+
version: Number.isInteger(Number(payload.version)) && Number(payload.version) > 0
356+
? Number(payload.version)
357+
: 1,
358+
hasVisibility,
359+
hasLayout,
360+
hasKeybindProfile,
361+
visibility,
362+
layout: layout || {},
363+
keybindProfile: keybindProfile || {},
364+
}),
365+
};
366+
}
367+
368+
function applyOverlayRuntimePreferencePayload(runtime, validatedPayload) {
369+
if (!runtime || !validatedPayload || typeof validatedPayload !== 'object') {
370+
return false;
371+
}
372+
if (validatedPayload.hasVisibility && (validatedPayload.visibility === true || validatedPayload.visibility === false)) {
373+
runtime.interactionVisible = validatedPayload.visibility;
374+
}
375+
if (validatedPayload.hasLayout) {
376+
applyOverlayRuntimeLayoutPreferences(runtime, validatedPayload.layout);
377+
}
378+
if (validatedPayload.hasKeybindProfile) {
379+
const keybindProfile = validatedPayload.keybindProfile || {};
380+
if (Object.prototype.hasOwnProperty.call(keybindProfile, 'id')) {
381+
runtime.interactionKeybindProfileId = String(keybindProfile.id || '').trim();
382+
}
383+
if (Object.prototype.hasOwnProperty.call(keybindProfile, 'cycleKey')) {
384+
runtime.interactionCycleKey = String(keybindProfile.cycleKey || '').trim() || LEVEL17_OVERLAY_CYCLE_KEY;
385+
}
386+
if (keybindProfile.contextInputMapSpecified === true) {
387+
runtime.interactionContextInputMap = keybindProfile.contextInputMap === null
388+
? null
389+
: (cloneJsonCompatibleValue(keybindProfile.contextInputMap) ?? null);
390+
}
391+
}
392+
return true;
393+
}
394+
252395
function buildOverlayRuntimeLayoutPreferenceSnapshot(runtime) {
253396
const snapshot = {};
254397
const overrides = runtime?.interactionLayoutOverrides;
@@ -1439,6 +1582,17 @@ export function getOverlayGameplayRuntimePreferencesSnapshot(runtime) {
14391582
});
14401583
}
14411584

1585+
export function exportOverlayGameplayRuntimeProfile(runtime, { pretty = false } = {}) {
1586+
const snapshot = getOverlayGameplayRuntimePreferencesSnapshot(runtime);
1587+
const payload = {
1588+
version: 1,
1589+
visibility: snapshot.visibility,
1590+
layout: snapshot.layout,
1591+
keybindProfile: snapshot.keybindProfile,
1592+
};
1593+
return JSON.stringify(payload, null, pretty ? 2 : 0);
1594+
}
1595+
14421596
export function saveOverlayGameplayRuntimePreferences(runtime, options = {}) {
14431597
if (!runtime) {
14441598
return false;
@@ -1476,28 +1630,51 @@ export function loadOverlayGameplayRuntimePreferences(runtime, options = {}) {
14761630
if (!payload || typeof payload !== 'object') {
14771631
return false;
14781632
}
1633+
const validated = validateOverlayRuntimePreferencePayload(payload);
1634+
if (!validated.valid || !validated.value) {
1635+
return false;
1636+
}
1637+
return applyOverlayRuntimePreferencePayload(runtime, validated.value);
1638+
}
14791639

1480-
if (payload.visibility === true || payload.visibility === false) {
1481-
runtime.interactionVisible = payload.visibility;
1640+
export function importOverlayGameplayRuntimeProfile(runtime, profileInput, options = {}) {
1641+
if (!runtime) {
1642+
return Object.freeze({
1643+
success: false,
1644+
errors: Object.freeze(['Overlay runtime is required for profile import.']),
1645+
});
14821646
}
1483-
applyOverlayRuntimeLayoutPreferences(runtime, payload.layout);
1484-
const keybindProfile = payload.keybindProfile && typeof payload.keybindProfile === 'object'
1485-
? payload.keybindProfile
1486-
: null;
1487-
if (keybindProfile) {
1488-
runtime.interactionKeybindProfileId = String(keybindProfile.id || '').trim();
1489-
const cycleKey = String(keybindProfile.cycleKey || '').trim();
1490-
if (cycleKey) {
1491-
runtime.interactionCycleKey = cycleKey;
1492-
}
1493-
if (keybindProfile.contextInputMap && typeof keybindProfile.contextInputMap === 'object') {
1494-
const clonedContextInputMap = cloneJsonCompatibleValue(keybindProfile.contextInputMap);
1495-
if (clonedContextInputMap && typeof clonedContextInputMap === 'object') {
1496-
runtime.interactionContextInputMap = clonedContextInputMap;
1497-
}
1647+
1648+
let parsedInput = null;
1649+
if (typeof profileInput === 'string') {
1650+
try {
1651+
parsedInput = JSON.parse(profileInput);
1652+
} catch {
1653+
return Object.freeze({
1654+
success: false,
1655+
errors: Object.freeze(['Overlay runtime profile JSON is invalid.']),
1656+
});
14981657
}
1658+
} else {
1659+
parsedInput = cloneJsonCompatibleValue(profileInput);
14991660
}
1500-
return true;
1661+
1662+
const validated = validateOverlayRuntimePreferencePayload(parsedInput);
1663+
if (!validated.valid || !validated.value) {
1664+
return Object.freeze({
1665+
success: false,
1666+
errors: validated.errors,
1667+
});
1668+
}
1669+
1670+
applyOverlayRuntimePreferencePayload(runtime, validated.value);
1671+
if (options?.persist !== false) {
1672+
saveOverlayGameplayRuntimePreferences(runtime, { silent: true });
1673+
}
1674+
return Object.freeze({
1675+
success: true,
1676+
errors: Object.freeze([]),
1677+
});
15011678
}
15021679

15031680
export function setOverlayGameplayRuntimeContextInputMap(runtime, contextInputMap) {

tests/runtime/Phase19OverlayExpansionFramework.test.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import {
1313
LEVEL19_OVERLAY_RUNTIME_TOGGLE_MODIFIERS,
1414
} from '../../samples/phase-17/shared/overlayCycleInput.js';
1515
import {
16+
exportOverlayGameplayRuntimeProfile,
1617
enqueueOverlayGameplayRuntimeSyncEvent,
1718
getOverlayGameplayRuntimeCompositionSnapshot,
1819
getOverlayGameplayRuntimeInteractionSnapshot,
20+
importOverlayGameplayRuntimeProfile,
1921
saveOverlayGameplayRuntimePreferences,
2022
renderOverlayGameplayRuntime,
2123
resolveOverlayGameplayRuntimeInputAction,
@@ -952,6 +954,73 @@ function assertOverlayPreferencesPersistenceCompatibility() {
952954
assert.equal(runtimeAFrame.adaptiveEmphasis > 1, true, 'Adaptive UI emphasis should remain active after preference restoration.');
953955
}
954956

957+
function assertOverlayProfileExportImportValidation() {
958+
const framework = createPhase19OverlayExpansionFramework();
959+
framework.registerExtension(definePhase19OverlayExtension({
960+
id: 'phase19-overlay-profile-export-import',
961+
overlays: [
962+
{ id: 'runtime-a', label: 'Runtime A' },
963+
{ id: 'runtime-b', label: 'Runtime B' },
964+
],
965+
initialOverlayId: 'runtime-a',
966+
persistenceKey: 'phase19:overlay-profile-export-import',
967+
runtimeExtensions: [
968+
{ overlayId: 'runtime-a', compose: true, layerOrder: 10, visualPriority: 10, panelWidth: 220, panelHeight: 96, onRender() {} },
969+
{ overlayId: 'runtime-b', compose: true, layerOrder: 20, visualPriority: 20, panelWidth: 220, panelHeight: 96, onRender() {} },
970+
],
971+
}));
972+
973+
const sourceRuntime = framework.createRuntimeForExtension('phase19-overlay-profile-export-import');
974+
setOverlayGameplayRuntimeVisible(sourceRuntime, false);
975+
setOverlayGameplayRuntimeKeybindProfile(sourceRuntime, {
976+
id: 'portable-profile',
977+
cycleKey: 'KeyJ',
978+
contextInputMap: {
979+
byOverlayId: {
980+
'runtime-b': {
981+
'cycle-next': 'toggle-visibility',
982+
},
983+
},
984+
},
985+
});
986+
sourceRuntime.interactionLayoutOverrides['id:runtime-b'] = {
987+
x: 70,
988+
y: 102,
989+
width: 260,
990+
height: 118,
991+
};
992+
993+
const exportedJson = exportOverlayGameplayRuntimeProfile(sourceRuntime);
994+
const exportedPayload = JSON.parse(exportedJson);
995+
assert.equal(exportedPayload.version, 1, 'Exported overlay profile should include schema version.');
996+
assert.equal(exportedPayload.visibility, false, 'Exported overlay profile should include visibility preference.');
997+
assert.equal(exportedPayload.keybindProfile.cycleKey, 'KeyJ', 'Exported overlay profile should include keybind cycle key.');
998+
assert.equal(exportedPayload.layout['id:runtime-b'].width, 260, 'Exported overlay profile should include layout override width.');
999+
1000+
const importedRuntime = framework.createRuntimeForExtension('phase19-overlay-profile-export-import');
1001+
const importResult = importOverlayGameplayRuntimeProfile(importedRuntime, exportedJson);
1002+
assert.equal(importResult.success, true, 'Overlay profile import should succeed for valid exported JSON.');
1003+
assert.equal(importResult.errors.length, 0, 'Overlay profile import should not report errors for valid exported JSON.');
1004+
assert.equal(importedRuntime.interactionVisible, false, 'Imported overlay profile should restore visibility preference.');
1005+
assert.equal(importedRuntime.interactionCycleKey, 'KeyJ', 'Imported overlay profile should restore cycle key preference.');
1006+
assert.equal(importedRuntime.interactionKeybindProfileId, 'portable-profile', 'Imported overlay profile should restore keybind profile id.');
1007+
assert.equal(importedRuntime.interactionLayoutOverrides['id:runtime-b'].x, 70, 'Imported overlay profile should restore layout override X position.');
1008+
assert.equal(importedRuntime.interactionLayoutOverrides['id:runtime-b'].height, 118, 'Imported overlay profile should restore layout override height.');
1009+
1010+
importedRuntime.interactionIndex = 1;
1011+
const contextualAction = resolveOverlayGameplayRuntimeInputAction(importedRuntime, 'cycle-next');
1012+
assert.equal(contextualAction.action, 'toggle-visibility', 'Imported profile context mapping should remain compatible with contextual input resolver.');
1013+
1014+
const invalidImportResult = importOverlayGameplayRuntimeProfile(importedRuntime, '{"version":1,"visibility":"yes"}');
1015+
assert.equal(invalidImportResult.success, false, 'Overlay profile import should fail validation for invalid payload types.');
1016+
assert.equal(invalidImportResult.errors.length > 0, true, 'Overlay profile import should report validation errors for invalid payloads.');
1017+
1018+
const reloadedRuntime = framework.createRuntimeForExtension('phase19-overlay-profile-export-import');
1019+
assert.equal(reloadedRuntime.interactionVisible, false, 'Imported overlay profile should persist and restore visibility on runtime load.');
1020+
assert.equal(reloadedRuntime.interactionCycleKey, 'KeyJ', 'Imported overlay profile should persist and restore cycle key on runtime load.');
1021+
assert.equal(reloadedRuntime.interactionLayoutOverrides['id:runtime-b'].y, 102, 'Imported overlay profile should persist and restore layout override Y position.');
1022+
}
1023+
9551024
export function run() {
9561025
assertExpansionRegistrationAndCompatibility();
9571026
assertExtensionLifecycleMutations();
@@ -964,4 +1033,5 @@ export function run() {
9641033
assertContextualInputMappingUsesOverlayContextAndStack();
9651034
assertAdaptiveOverlayUiRulesReactToGameplayTelemetryAndContext();
9661035
assertOverlayPreferencesPersistenceCompatibility();
1036+
assertOverlayProfileExportImportValidation();
9671037
}

0 commit comments

Comments
 (0)