Skip to content

Commit 0ab7f1c

Browse files
committed
Fix Audio SFX Workspace dirty state and align manifest schema integration - PR_26145_018-audio-sfx-workspace-dirty-and-manifest-schema-alignment
1 parent 58a0d37 commit 0ab7f1c

6 files changed

Lines changed: 262 additions & 7 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# PR_26145_018 Audio / SFX Workspace Dirty and Manifest Schema
2+
3+
Date: 2026-05-25
4+
5+
## Targeted validation
6+
7+
- `node --check` passed for changed Audio / SFX and Workspace V2 JavaScript files.
8+
- JSON parse validation passed for:
9+
- `tools/schemas/game.manifest.schema.json`
10+
- `tools/schemas/tools/audio-sfx-playground-v2.schema.json`
11+
- HTML external asset guard passed for `tools/audio-sfx-playground-v2/index.html`.
12+
- Workspace schema fragment validation passed for `tools/schemas/tools/audio-sfx-playground-v2.schema.json#/$defs/audioSfxPayload`.
13+
- Generated Workspace context validation passed with an Audio / SFX payload.
14+
- Game manifest schema validation passed with `root.tools.audio-sfx-playground-v2`.
15+
16+
## npm run test:workspace-v2
17+
18+
Blocked by local Playwright browser installation:
19+
20+
```text
21+
browserType.launch: Executable doesn't exist at C:\Users\DavidQ\AppData\Local\ms-playwright\chromium-1217\chrome-win64\chrome.exe
22+
```
23+
24+
Attempted `npx.cmd playwright install chromium`, but the sandbox cannot create the browser cache outside the repo:
25+
26+
```text
27+
EPERM: operation not permitted, mkdir 'C:\Users\DavidQ\AppData\Local\ms-playwright'
28+
```
29+
30+
## Focused Playwright validation
31+
32+
Ran a focused Playwright script with installed Microsoft Edge.
33+
34+
Validated:
35+
36+
- Audio / SFX launches from Workspace mode with no console errors.
37+
- Add SFX marks `workspace.tools.audio-sfx-playground-v2.dirty.isDirty=true`.
38+
- Slider edits mark dirty.
39+
- Waveform changes mark dirty.
40+
- Add Noise Layer changes mark dirty.
41+
- Rename marks dirty.
42+
- Delete marks dirty.
43+
- Valid Import JSON marks dirty.
44+
- Copy JSON, Export JSON, and Play do not mark dirty.
45+
- Copy JSON copies the current exported payload.
46+
- Workspace V2 refresh includes the Audio / SFX payload for Save context.
47+
- Workspace V2 schema references validate the Audio / SFX payload schema fragment.
48+
- Game manifest schema validates `root.tools.audio-sfx-playground-v2`.
49+
50+
Result: focused impacted validation passed.

tools/audio-sfx-playground-v2/js/AudioSfxPlaygroundV2App.js

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,20 @@ function activeSoundFromToolState(toolState) {
4747
}
4848

4949
export class AudioSfxPlaygroundV2App {
50-
constructor({ accordions, actionNav, audioEngine, controls, inspector, preview, serializer, shell, statusLog, tileList, windowRef = window }) {
50+
constructor({
51+
accordions,
52+
actionNav,
53+
audioEngine,
54+
controls,
55+
inspector,
56+
preview,
57+
serializer,
58+
shell,
59+
statusLog,
60+
tileList,
61+
windowRef = window,
62+
workspaceDirtyBridge = null
63+
}) {
5164
this.accordions = accordions;
5265
this.actionNav = actionNav;
5366
this.activeSoundId = "";
@@ -62,6 +75,7 @@ export class AudioSfxPlaygroundV2App {
6275
this.statusLog = statusLog;
6376
this.tileList = tileList;
6477
this.window = windowRef;
78+
this.workspaceDirtyBridge = workspaceDirtyBridge;
6579
}
6680

6781
start() {
@@ -94,11 +108,35 @@ export class AudioSfxPlaygroundV2App {
94108
}
95109
});
96110
this.statusLog.mount();
111+
this.loadWorkspacePayload();
97112
this.renderSoundList();
98113
this.refreshPreview();
99114
this.statusLog.write("Audio / SFX Playground V2 ready.");
100115
}
101116

117+
loadWorkspacePayload() {
118+
if (!this.workspaceDirtyBridge) {
119+
return;
120+
}
121+
const result = this.workspaceDirtyBridge.readPayload();
122+
if (result.skipped) {
123+
return;
124+
}
125+
if (!result.ok) {
126+
this.statusLog.error(`Workspace payload load failed: ${result.message}`);
127+
return;
128+
}
129+
const nextSoundEntries = result.value.soundEntries.map((entry) => ({
130+
id: entry.id,
131+
sound: cloneSound(entry.sound)
132+
}));
133+
this.activeSoundId = result.value.activeSoundId;
134+
this.soundEntries = nextSoundEntries;
135+
this.nextSoundNumber = nextSoundNumberAfter(nextSoundEntries);
136+
this.controls.loadSound(result.value.sound);
137+
this.statusLog.write(`Loaded ${result.value.sound.name} from Workspace V2.`);
138+
}
139+
102140
currentToolState() {
103141
const validation = this.controls.validate({ nameOverride: this.activeSoundName() });
104142
if (!validation.valid) {
@@ -130,10 +168,33 @@ export class AudioSfxPlaygroundV2App {
130168
const validation = this.controls.validate({ nameOverride: this.activeSoundName() });
131169
if (validation.valid && this.updateActiveSound(this.soundForActiveEditorValue(validation.value))) {
132170
this.renderSoundList();
171+
this.syncWorkspaceDirty("audio-sfx-editor-change", ["data.sounds"]);
133172
}
134173
this.refreshPreview();
135174
}
136175

176+
syncWorkspaceDirty(reason, changedKeys) {
177+
if (!this.workspaceDirtyBridge) {
178+
return;
179+
}
180+
const { toolState, validation } = this.currentToolState();
181+
if (!validation.valid || !toolState) {
182+
this.statusLog.error(`Workspace dirty sync skipped: ${validation.message}`);
183+
return;
184+
}
185+
const result = this.workspaceDirtyBridge.syncToolState(toolState, { reason, changedKeys });
186+
if (result.skipped) {
187+
return;
188+
}
189+
if (!result.ok) {
190+
this.statusLog.error(`Workspace dirty sync failed: ${result.message}`);
191+
return;
192+
}
193+
if (result.changed) {
194+
this.statusLog.write(`Workspace dirty state updated: ${result.reason}.`);
195+
}
196+
}
197+
137198
hasDuplicateSoundName(name, excludedSoundId = "") {
138199
const normalizedName = normalizeSoundName(name);
139200
return this.soundEntries.some((entry) => entry.id !== excludedSoundId && normalizeSoundName(entry.sound.name) === normalizedName);
@@ -205,6 +266,7 @@ export class AudioSfxPlaygroundV2App {
205266
const entry = this.createSoundEntry(validation.value);
206267
this.renderSoundList();
207268
this.refreshPreview();
269+
this.syncWorkspaceDirty("audio-sfx-sound-added", ["data.sounds", "data.activeSoundId"]);
208270
this.statusLog.write(`Added ${entry.sound.name}.`);
209271
}
210272

@@ -229,6 +291,7 @@ export class AudioSfxPlaygroundV2App {
229291
entry.sound.name = nextName;
230292
this.renderSoundList();
231293
this.refreshPreview();
294+
this.syncWorkspaceDirty("audio-sfx-sound-renamed", ["data.sounds"]);
232295
this.statusLog.write(`Renamed SFX to ${entry.sound.name}.`);
233296
}
234297

@@ -292,6 +355,7 @@ export class AudioSfxPlaygroundV2App {
292355
}
293356
this.renderSoundList();
294357
this.refreshPreview();
358+
this.syncWorkspaceDirty("audio-sfx-sound-deleted", ["data.sounds", "data.activeSoundId"]);
295359
this.statusLog.write(`Deleted ${entry.sound.name}.`);
296360
}
297361

@@ -358,6 +422,7 @@ export class AudioSfxPlaygroundV2App {
358422
this.controls.loadSound(result.value.sound);
359423
this.renderSoundList();
360424
this.refreshPreview();
425+
this.syncWorkspaceDirty("audio-sfx-json-imported", ["data.sounds", "data.activeSoundId"]);
361426
this.statusLog.write(`Imported JSON from ${file.name}.`);
362427
} catch (error) {
363428
this.statusLog.error(`Import JSON failed: ${error.message}`);

tools/audio-sfx-playground-v2/js/bootstrap.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { StatusLogControl } from "./controls/StatusLogControl.js";
99
import { ToolShellControl } from "./controls/ToolShellControl.js";
1010
import { AudioSfxEngine } from "./services/AudioSfxEngine.js";
1111
import { ToolStateSerializer } from "./services/ToolStateSerializer.js";
12+
import { WorkspaceDirtyBridge } from "./services/WorkspaceDirtyBridge.js";
1213

1314
function requireElement(selector) {
1415
const element = document.querySelector(selector);
@@ -20,6 +21,7 @@ function requireElement(selector) {
2021

2122
window.addEventListener("DOMContentLoaded", () => {
2223
const accordions = Array.from(document.querySelectorAll(".accordion-v2"), (section) => new AccordionSection(section));
24+
const serializer = new ToolStateSerializer();
2325
const statusLog = new StatusLogControl({
2426
log: requireElement("#statusLog"),
2527
clearButton: requireElement("#clearStatusButton")
@@ -73,12 +75,16 @@ window.addEventListener("DOMContentLoaded", () => {
7375
}),
7476
inspector: new InspectorControl(requireElement("#inspectorOutput")),
7577
preview: new SfxPreviewControl(requireElement("#previewOutput")),
76-
serializer: new ToolStateSerializer(),
78+
serializer,
7779
shell: new ToolShellControl(),
7880
statusLog,
7981
tileList: new SfxTileListControl({
8082
list: requireElement("#sfxTileList")
8183
}),
84+
workspaceDirtyBridge: new WorkspaceDirtyBridge({
85+
serializer,
86+
windowRef: window
87+
}),
8288
windowRef: window
8389
});
8490

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
const TOOL_ID = "audio-sfx-playground-v2";
2+
const TOOL_SCHEMA_PATH = "tools/schemas/tools/audio-sfx-playground-v2.schema.json";
3+
const WORKSPACE_TOOL_SESSION_KEY = `workspace.tools.${TOOL_ID}`;
4+
5+
function isPlainObject(value) {
6+
return typeof value === "object" && value !== null && !Array.isArray(value);
7+
}
8+
9+
function cloneJson(value) {
10+
return JSON.parse(JSON.stringify(value));
11+
}
12+
13+
function toolStateFromPayload(payload) {
14+
return {
15+
$schema: TOOL_SCHEMA_PATH,
16+
schema: "html-js-gaming.tool-state",
17+
version: 1,
18+
toolId: TOOL_ID,
19+
payload
20+
};
21+
}
22+
23+
function uniqueChangedKeys(changedKeys, existingChangedKeys = []) {
24+
return Array.from(new Set([
25+
...existingChangedKeys.filter((key) => typeof key === "string" && key.trim()),
26+
...changedKeys.filter((key) => typeof key === "string" && key.trim())
27+
]));
28+
}
29+
30+
export class WorkspaceDirtyBridge {
31+
constructor({ serializer, windowRef = window }) {
32+
this.serializer = serializer;
33+
this.window = windowRef;
34+
}
35+
36+
isWorkspaceLaunch() {
37+
const params = new URLSearchParams(this.window.location?.search || "");
38+
return params.get("launch") === "workspace" && params.get("fromTool") === "workspace-manager-v2";
39+
}
40+
41+
readSession() {
42+
if (!this.isWorkspaceLaunch()) {
43+
return { ok: true, skipped: true, message: "Not launched from Workspace V2." };
44+
}
45+
const rawSession = this.window.sessionStorage?.getItem(WORKSPACE_TOOL_SESSION_KEY);
46+
if (!rawSession) {
47+
return { ok: false, message: `Workspace session is missing: ${WORKSPACE_TOOL_SESSION_KEY}.` };
48+
}
49+
try {
50+
const session = JSON.parse(rawSession);
51+
if (!isPlainObject(session)) {
52+
return { ok: false, message: "Workspace session must be an object." };
53+
}
54+
return { ok: true, session };
55+
} catch (error) {
56+
return { ok: false, message: `Workspace session JSON is invalid: ${error.message}` };
57+
}
58+
}
59+
60+
readPayload() {
61+
const sessionResult = this.readSession();
62+
if (!sessionResult.ok || sessionResult.skipped) {
63+
return sessionResult;
64+
}
65+
if (sessionResult.session.data === null || typeof sessionResult.session.data === "undefined") {
66+
return { ok: true, skipped: true, message: "Workspace session has no Audio / SFX payload yet." };
67+
}
68+
if (!isPlainObject(sessionResult.session.data)) {
69+
return { ok: false, message: "Workspace Audio / SFX payload must be an object." };
70+
}
71+
const validation = this.serializer.readToolState(toolStateFromPayload(sessionResult.session.data));
72+
return validation.valid
73+
? { ok: true, value: validation.value }
74+
: { ok: false, message: validation.message };
75+
}
76+
77+
syncToolState(toolState, { reason = "audio-sfx-edited", changedKeys = ["data.sounds"] } = {}) {
78+
const sessionResult = this.readSession();
79+
if (!sessionResult.ok || sessionResult.skipped) {
80+
return sessionResult;
81+
}
82+
const validation = this.serializer.readToolState(toolState);
83+
if (!validation.valid) {
84+
return { ok: false, message: validation.message };
85+
}
86+
const payload = cloneJson(toolState.payload);
87+
const session = sessionResult.session;
88+
if (JSON.stringify(session.data) === JSON.stringify(payload)) {
89+
return { ok: true, changed: false, message: "Workspace Audio / SFX payload is already current." };
90+
}
91+
const dirty = isPlainObject(session.dirty) ? session.dirty : {};
92+
const nextSession = {
93+
...session,
94+
data: payload,
95+
dirty: {
96+
isDirty: true,
97+
reason,
98+
changedAt: new Date().toISOString(),
99+
changedKeys: uniqueChangedKeys(changedKeys, Array.isArray(dirty.changedKeys) ? dirty.changedKeys : [])
100+
}
101+
};
102+
this.window.sessionStorage.setItem(WORKSPACE_TOOL_SESSION_KEY, JSON.stringify(nextSession));
103+
return {
104+
ok: true,
105+
changed: true,
106+
reason: nextSession.dirty.reason,
107+
changedKeys: nextSession.dirty.changedKeys
108+
};
109+
}
110+
}

tools/schemas/game.manifest.schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@
8989
"required": ["asset-manager-v2"],
9090
"additionalProperties": false,
9191
"properties": {
92+
"audio-sfx-playground-v2": {
93+
"$ref": "tools/audio-sfx-playground-v2.schema.json#/$defs/audioSfxPayload"
94+
},
9295
"asset-manager-v2": {
9396
"$ref": "tools/asset-manager-v2.schema.json"
9497
},

0 commit comments

Comments
 (0)