Skip to content

Commit 07a942a

Browse files
author
DavidQ
committed
Polish Collision Inspector V2 as a shared manifest-driven tool - PR_26139_005-collision-inspector-shared-tool-polish
1 parent 0bcb424 commit 07a942a

23 files changed

Lines changed: 700 additions & 135 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# PR_26139_005-collision-inspector-shared-tool-polish Report
2+
3+
## Summary
4+
5+
Polished Collision Inspector V2 into a shared manifest-driven tool. The tool now loads manifests through a reusable shared loader, removes Asteroids-specific UI/docs wording, preserves viewport aspect ratio, supports Workspace Manager launch context automatically, and uses manifest-owned screen dimensions shared with Asteroids runtime boot.
6+
7+
Playwright impacted: Yes.
8+
9+
## Scope Completed
10+
11+
- Added shared manifest loading under `src/tools/common/GameManifestLoader.js`.
12+
- Updated Collision Inspector V2 to use the shared loader for file, URL path, and Workspace Manager launch context loading.
13+
- Removed Asteroids-specific wording and direct Asteroids load controls from Collision Inspector V2 UI/docs.
14+
- Moved the manifest file picker into the tool header and hid it for Workspace Manager launches.
15+
- Added Workspace Manager return behavior consistent with workspace-launched tool headers.
16+
- Maintained Collision Inspector viewport aspect ratio from the active manifest screen dimensions.
17+
- Updated fullscreen layout so the left column pins left, the right column pins right, and the center viewport fills available horizontal space.
18+
- Added `screen.width` and `screen.height` to the Asteroids game manifest and schemas.
19+
- Updated Asteroids runtime boot and scene setup to consume manifest screen dimensions instead of hardcoded runtime dimensions.
20+
- Propagated manifest screen dimensions through Workspace Manager V2 synthesized contexts and Asset Manager workspace context validation.
21+
- Added targeted screen-dimension/Asteroids launch smoke coverage.
22+
23+
## Guardrails
24+
25+
- Collision Inspector V2 still uses the shared engine collision path.
26+
- No hardcoded Asteroids geometry was added.
27+
- No fallback/default vector maps were added.
28+
- Collision Inspector V2 uses manifest `objects[].shapes[]` geometry only.
29+
- Existing intentional ship flame flicker and asteroid scale tuning were not changed.
30+
31+
## Validation
32+
33+
PASS:
34+
35+
- `npm run build:manifest`
36+
- Passed. This repo does not define a plain `npm run build` script.
37+
- `node --check` on touched Collision Inspector V2 modules, shared loader, Asteroids runtime modules, Workspace Manager service, Asset Manager workspace bridge, and touched Playwright specs.
38+
- JSON parse check for:
39+
- `games/Asteroids/game.manifest.json`
40+
- `tools/schemas/game.manifest.schema.json`
41+
- `tools/schemas/workspace.manifest.schema.json`
42+
- `npx playwright test tests/playwright/tools/CollisionInspectorV2.spec.mjs --project=playwright --workers=1 --reporter=list`
43+
- 2 passed.
44+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list --grep "uses header lifecycle controls and launches tools from fixed Workspace Manager V2 tiles"`
45+
- 1 passed.
46+
- `node -e "import('./tests/games/AsteroidsManifestScreenDimensions.test.mjs').then(({ run }) => run())"`
47+
- Passed. Confirmed Asteroids runtime and scene receive manifest screen dimensions.
48+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list --grep "loads Object Vector Studio V2 runtime assets into Asteroids gameplay rendering"`
49+
- 1 passed. Used as targeted Asteroids launch smoke validation for the manifest dimension change.
50+
- `git diff --check`
51+
- Passed with line-ending warnings only.
52+
- `rg "Asteroids|loadAsteroids|ASTEROIDS" tools/collision-inspector-v2 -n`
53+
- No matches.
54+
55+
FAIL, broader existing gate:
56+
57+
- `npm test`
58+
- Fails in `pretest` at `tools/dev/checkSharedExtractionGuard.mjs`.
59+
- Reported `185 unexpected violation(s)`, `baseline_expected=609`, `baseline_resolved=6`, `total_violations=848`.
60+
- Remaining failures are broad repository guard drift also present before this PR_005 scope.
61+
62+
FAIL, broader existing Workspace V2 suite:
63+
64+
- `npm run test:workspace-v2`
65+
- 54 passed, 2 failed.
66+
- Failing test: `validates optional Text to Speech V2 schema contract through Workspace Manager V2 schema`
67+
- Expected `activeContext.tools` to include `text2speech-V2`; received false.
68+
- Failing test: `tracks Object Vector Studio V2 dirty state through persisted edits and save outcomes`
69+
- Expected generated manifest schema validation failure; save succeeded.
70+
- Collision Inspector V2 and Workspace Manager launch paths passed inside this run.
71+
72+
FAIL, broader Asteroids fixture not used as final targeted gate:
73+
74+
- `node -e "import('./tests/games/AsteroidsValidation.test.mjs').then(({ run }) => run())"`
75+
- Failed on an existing stale bullet geometry point-order assertion unrelated to screen dimensions or Collision Inspector V2.
76+
- A focused `AsteroidsManifestScreenDimensions.test.mjs` smoke test was added and passed for this PR scope.
77+
78+
## Notes
79+
80+
- Full samples smoke test was not run. The request called for targeted Asteroids launch smoke only because manifest dimensions affect runtime.
81+
- The `npm run build:manifest` command wrote a generated `docs/build/` artifact during validation; it was removed from the delta because it is build output and not part of this PR scope.

games/Asteroids/game.manifest.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
"name": "Asteroids",
88
"folder": "Asteroids"
99
},
10+
"screen": {
11+
"width": 960,
12+
"height": 720
13+
},
1014
"launch": {
1115
"directPath": "/games/Asteroids/index.html",
1216
"workspaceManagerPath": "/tools/workspace-manager-v2/index.html?gameId=Asteroids",

games/Asteroids/game/AsteroidsGameScene.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const SCORE_TWO_X = 824;
3232
const LIFE_SPACING = 22;
3333
const PAUSE_OVERLAY_COLOR = 'rgba(2, 6, 23, 0.58)';
3434
const INITIALS_OVERLAY_COLOR = 'rgba(1, 6, 19, 0.62)';
35+
const DEFAULT_SCREEN_DIMENSIONS = Object.freeze({ width: 960, height: 720 });
3536
const ATTRACT_INPUT_CODES = [
3637
'Digit1',
3738
'Digit2',
@@ -60,6 +61,18 @@ function logSceneBootStage(stage, details = null) {
6061
}
6162
}
6263

64+
function positiveInteger(value, fallback) {
65+
const parsed = Math.floor(Number(value));
66+
return Number.isNaN(parsed) || Math.abs(parsed) === Infinity || parsed <= 0 ? fallback : parsed;
67+
}
68+
69+
function screenDimensionsFromOptions(options) {
70+
return {
71+
width: positiveInteger(options?.screenDimensions?.width, DEFAULT_SCREEN_DIMENSIONS.width),
72+
height: positiveInteger(options?.screenDimensions?.height, DEFAULT_SCREEN_DIMENSIONS.height),
73+
};
74+
}
75+
6376
function getBeatInterval(asteroidCount) {
6477
if (asteroidCount <= 1) {
6578
return 0.18;
@@ -124,7 +137,8 @@ export default class AsteroidsGameScene extends Scene {
124137
debugMode: 'prod',
125138
debugEnabled: Boolean(this.devConsoleIntegration),
126139
};
127-
this.world = new AsteroidsWorld({ width: 960, height: 720 }, {
140+
this.screenDimensions = screenDimensionsFromOptions(options);
141+
this.world = new AsteroidsWorld(this.screenDimensions, {
128142
asteroidGeometryProfiles: this.asteroidGeometryProfiles,
129143
objectGeometry: this.objectGeometry,
130144
});

games/Asteroids/index.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ async function loadAsteroidsManifestPayload(manifestPath, manifestPayload = null
7272
return response.json();
7373
}
7474

75+
function positiveInteger(value) {
76+
const parsed = Math.floor(Number(value));
77+
return Number.isNaN(parsed) || Math.abs(parsed) === Infinity || parsed <= 0 ? 0 : parsed;
78+
}
79+
80+
export function resolveAsteroidsScreenDimensions(manifest) {
81+
const width = positiveInteger(manifest?.screen?.width);
82+
const height = positiveInteger(manifest?.screen?.height);
83+
if (!width || !height) {
84+
throw new Error("Asteroids game.manifest.json requires screen.width and screen.height.");
85+
}
86+
return { width, height };
87+
}
88+
7589
export function loadAsteroidsWorldModule() {
7690
return import("./game/AsteroidsWorld.js");
7791
}
@@ -159,6 +173,7 @@ export async function bootAsteroidsNew({
159173
stage = "load-game-manifest";
160174
traceBoot(stage);
161175
const asteroidsManifest = await loadAsteroidsManifestPayload(ASTEROIDS_MANIFEST_PATH, manifestPayload);
176+
const screenDimensions = resolveAsteroidsScreenDimensions(asteroidsManifest);
162177
const objectGeometryValidation = loadAsteroidsObjectGeometryFromManifest(asteroidsManifest, {
163178
sourceLabel: "Asteroids game.manifest.json"
164179
});
@@ -189,8 +204,8 @@ export async function bootAsteroidsNew({
189204
traceBoot(stage);
190205
const engine = new EngineClass({
191206
canvas,
192-
width: 960,
193-
height: 720,
207+
width: screenDimensions.width,
208+
height: screenDimensions.height,
194209
fixedStepMs: 1000 / 60,
195210
input
196211
});
@@ -226,6 +241,7 @@ export async function bootAsteroidsNew({
226241
objectGeometry: objectGeometryValidation.objectGeometry,
227242
objectVectorAssets,
228243
objectVectorRuntime,
244+
screenDimensions,
229245
}));
230246

231247
stage = "start-engine";
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
export function isRecord(value) {
2+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3+
}
4+
5+
function parseJson(rawValue, sourceLabel) {
6+
try {
7+
const parsed = JSON.parse(rawValue);
8+
return isRecord(parsed)
9+
? { ok: true, manifest: parsed, sourceLabel }
10+
: { ok: false, message: `${sourceLabel} must contain a JSON object.` };
11+
} catch (error) {
12+
return { ok: false, message: `${sourceLabel} contains invalid JSON: ${error.message}` };
13+
}
14+
}
15+
16+
function numberValue(value, fallback = 0) {
17+
const parsed = Number(value);
18+
return Number.isNaN(parsed) || Math.abs(parsed) === Infinity ? fallback : parsed;
19+
}
20+
21+
function positiveInteger(value) {
22+
const parsed = Math.floor(numberValue(value));
23+
return parsed > 0 ? parsed : 0;
24+
}
25+
26+
export function resolveManifestScreenDimensions(manifest) {
27+
const screen = isRecord(manifest?.screen) ? manifest.screen : null;
28+
const width = positiveInteger(screen?.width);
29+
const height = positiveInteger(screen?.height);
30+
if (!width || !height) {
31+
return {
32+
ok: false,
33+
message: "Manifest screen dimensions are required at root.screen.width and root.screen.height."
34+
};
35+
}
36+
return { ok: true, width, height };
37+
}
38+
39+
export class GameManifestLoader {
40+
constructor({
41+
fetchRef = null,
42+
pathParams = ["manifestPath", "gameManifestPath"],
43+
sessionStorageRef = null,
44+
windowRef = window
45+
} = {}) {
46+
this.fetch = fetchRef || windowRef.fetch?.bind(windowRef) || null;
47+
this.pathParams = pathParams;
48+
this.sessionStorage = sessionStorageRef || windowRef.sessionStorage || null;
49+
this.window = windowRef;
50+
}
51+
52+
isWorkspaceLaunch(params = new URLSearchParams(this.window.location.search || "")) {
53+
return params.get("launch") === "workspace"
54+
&& params.get("fromTool") === "workspace-manager-v2"
55+
&& Boolean(params.get("hostContextId"));
56+
}
57+
58+
async loadInitialManifest() {
59+
const params = new URLSearchParams(this.window.location.search || "");
60+
if (this.isWorkspaceLaunch(params)) {
61+
return this.loadFromWorkspaceContext(params.get("hostContextId") || "");
62+
}
63+
const manifestPath = this.pathParams.map((key) => params.get(key) || "").find(Boolean) || "";
64+
if (manifestPath) {
65+
return this.loadFromPath(manifestPath, "URL manifest path");
66+
}
67+
return { ok: false, skipped: true };
68+
}
69+
70+
loadFromWorkspaceContext(hostContextId) {
71+
if (!hostContextId) {
72+
return { ok: false, message: "Workspace launch did not include hostContextId." };
73+
}
74+
const rawValue = this.sessionStorage?.getItem(hostContextId) || "";
75+
if (!rawValue) {
76+
return { ok: false, message: `Workspace manifest context was not found in sessionStorage: ${hostContextId}.` };
77+
}
78+
return parseJson(rawValue, `workspace:${hostContextId}`);
79+
}
80+
81+
async loadFromPath(manifestPath, sourceLabel = manifestPath) {
82+
if (typeof this.fetch !== "function") {
83+
return { ok: false, message: "Fetch API is unavailable for manifest loading." };
84+
}
85+
try {
86+
const response = await this.fetch(manifestPath, { cache: "no-store" });
87+
if (!response.ok) {
88+
return { ok: false, message: `Manifest load failed from ${manifestPath}: ${response.status} ${response.statusText}` };
89+
}
90+
const manifest = await response.json();
91+
return isRecord(manifest)
92+
? { ok: true, manifest, sourceLabel: sourceLabel || manifestPath }
93+
: { ok: false, message: `${sourceLabel || manifestPath} must contain a JSON object.` };
94+
} catch (error) {
95+
return { ok: false, message: `Manifest load failed from ${manifestPath}: ${error.message}` };
96+
}
97+
}
98+
99+
async loadFromFile(file) {
100+
if (!file) {
101+
return { ok: false, skipped: true };
102+
}
103+
try {
104+
return parseJson(await file.text(), file.name || "selected manifest file");
105+
} catch (error) {
106+
return { ok: false, message: `Manifest file could not be read: ${error.message}` };
107+
}
108+
}
109+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from 'node:assert/strict';
2+
import { bootAsteroidsNew as bootAsteroids } from '../../games/Asteroids/index.js';
3+
import { loadAsteroidsManifest } from './asteroidsManifestObjectGeometry.mjs';
4+
5+
function createCanvas() {
6+
return {
7+
addEventListener() {},
8+
getContext() {
9+
return {};
10+
},
11+
style: {},
12+
};
13+
}
14+
15+
export async function run() {
16+
const manifestPayload = loadAsteroidsManifest();
17+
assert.deepEqual(manifestPayload.screen, { width: 960, height: 720 });
18+
19+
let engineOptions = null;
20+
let sceneOptions = null;
21+
const canvas = createCanvas();
22+
const bootedEngine = await bootAsteroids({
23+
documentRef: {
24+
body: { style: {} },
25+
documentElement: { style: {} },
26+
getElementById(id) {
27+
return id === 'game' ? canvas : null;
28+
},
29+
},
30+
EngineClass: class {
31+
constructor(options) {
32+
engineOptions = options;
33+
this.canvas = options.canvas;
34+
this.fullscreen = {
35+
getState() {
36+
return { active: false, available: false };
37+
},
38+
};
39+
}
40+
41+
setScene(scene) {
42+
this.scene = scene;
43+
}
44+
45+
start() {}
46+
},
47+
InputServiceClass: class {},
48+
ObjectVectorRuntimeClass: class {
49+
async loadFromManifest() {
50+
const objects = manifestPayload.tools['object-vector-studio-v2'].objects;
51+
return {
52+
objectsById: new Map(objects.map((object) => [object.id, object])),
53+
};
54+
}
55+
56+
getDiagnostics() {
57+
return {};
58+
}
59+
},
60+
SceneClass: class {
61+
constructor(options) {
62+
sceneOptions = options;
63+
}
64+
},
65+
manifestPayload,
66+
});
67+
68+
assert.equal(bootedEngine.canvas, canvas);
69+
assert.equal(engineOptions.width, manifestPayload.screen.width);
70+
assert.equal(engineOptions.height, manifestPayload.screen.height);
71+
assert.deepEqual(sceneOptions.screenDimensions, manifestPayload.screen);
72+
}

tests/games/asteroidsManifestObjectGeometry.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export function loadAsteroidsObjectGeometry() {
2323
return result.objectGeometry;
2424
}
2525

26+
export function loadAsteroidsScreenDimensions() {
27+
const manifest = loadAsteroidsManifest();
28+
return {
29+
width: manifest.screen.width,
30+
height: manifest.screen.height,
31+
};
32+
}
33+
2634
export function createAsteroidsTestGeometryProfiles() {
2735
return createAsteroidGeometryProfilesFromObjectVectorPayload(loadAsteroidsObjectVectorPayload());
2836
}
@@ -31,6 +39,7 @@ export function createAsteroidsTestSceneOptions(options = {}) {
3139
return {
3240
asteroidGeometryProfiles: createAsteroidsTestGeometryProfiles(),
3341
objectGeometry: loadAsteroidsObjectGeometry(),
42+
screenDimensions: loadAsteroidsScreenDimensions(),
3443
...options,
3544
};
3645
}

0 commit comments

Comments
 (0)