Skip to content

Commit 367441b

Browse files
author
DavidQ
committed
refactor(tool-hints): enforce games.index.metadata.json as single source of truth and derive sync from workspace game JSON
1 parent e589f80 commit 367441b

3 files changed

Lines changed: 268 additions & 7 deletions

File tree

games/metadata/games.index.metadata.json

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"skin-editor",
3232
"physics-sandbox",
3333
"state-inspector",
34-
"replay-visualizer"
34+
"replay-visualizer",
35+
"palette-browser"
3536
]
3637
},
3738
{
@@ -57,7 +58,8 @@
5758
"skin-editor",
5859
"sprite-editor",
5960
"tile-map-editor",
60-
"replay-visualizer"
61+
"replay-visualizer",
62+
"palette-browser"
6163
],
6264
"engineClassesUsed": [
6365
"engine/core/Engine",
@@ -90,7 +92,8 @@
9092
"skin-editor",
9193
"physics-sandbox",
9294
"performance-profiler",
93-
"state-inspector"
95+
"state-inspector",
96+
"palette-browser"
9497
],
9598
"engineClassesUsed": [
9699
"engine/core/Engine",
@@ -157,7 +160,8 @@
157160
"skin-editor",
158161
"sprite-editor",
159162
"tile-map-editor",
160-
"replay-visualizer"
163+
"replay-visualizer",
164+
"palette-browser"
161165
],
162166
"engineClassesUsed": [
163167
"engine/core/Engine",
@@ -189,7 +193,12 @@
189193
"toolHints": [
190194
"vector-asset-studio",
191195
"vector-map-editor",
192-
"asset-pipeline-tool"
196+
"asset-pipeline-tool",
197+
"palette-browser",
198+
"asset-browser",
199+
"sprite-editor",
200+
"tile-map-editor",
201+
"parallax-editor"
193202
],
194203
"engineClassesUsed": [
195204
"engine/audio/index/GaplessLoopPlayer",
@@ -233,7 +242,9 @@
233242
"toolHints": [
234243
"sprite-editor",
235244
"tile-map-editor",
236-
"asset-pipeline-tool"
245+
"asset-pipeline-tool",
246+
"palette-browser",
247+
"asset-browser"
237248
],
238249
"engineClassesUsed": [
239250
"engine/core/Engine",
@@ -267,7 +278,9 @@
267278
"toolHints": [
268279
"vector-asset-studio",
269280
"replay-visualizer",
270-
"state-inspector"
281+
"state-inspector",
282+
"palette-browser",
283+
"asset-browser"
271284
],
272285
"engineClassesUsed": [
273286
"engine/core/Engine",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"pretest": "node tools/dev/checkSharedExtractionGuard.mjs",
55
"test": "node ./scripts/run-node-tests.mjs",
66
"build:manifest": "node ./scripts/generate-sample-manifest.mjs",
7+
"sync:tool-hints": "node ./scripts/sync-tool-hints-from-workspace-manager.mjs",
78
"normalize:games-presentation": "node ./scripts/normalize-games-presentation.mjs",
89
"check:shared-extraction-guard": "node tools/dev/checkSharedExtractionGuard.mjs",
910
"check:phase24-closeout-guard": "node tools/dev/checkPhase24CloseoutExecutionGuard.mjs",
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
04/25/2026
5+
6+
sync-tool-hints-from-workspace-manager.mjs
7+
8+
How to run:
9+
1) npm run sync:tool-hints
10+
- Sync toolHints into games/metadata/games.index.metadata.json from Workspace Manager game JSON
11+
2) node ./scripts/sync-tool-hints-from-workspace-manager.mjs --dry-run
12+
- Validate and print what would change without writing
13+
*/
14+
15+
import fs from "node:fs";
16+
import path from "node:path";
17+
import { getToolRegistry } from "../tools/toolRegistry.js";
18+
19+
const ROOT = process.cwd();
20+
const METADATA_PATH = path.join(ROOT, "games", "metadata", "games.index.metadata.json");
21+
const GAME_ASSET_CATALOG_FILENAME = "workspace.asset-catalog.json";
22+
const GAME_ASSET_CATALOG_SCHEMA = "html-js-gaming.game-asset-catalog";
23+
const GAME_ASSET_CATALOG_VERSION = 1;
24+
const GAME_TOOLS_MANIFEST_FILENAME = "tools.manifest.json";
25+
const GAME_TOOLS_MANIFEST_SCHEMA = "html-js-gaming.game-asset-manifest";
26+
const GAME_TOOLS_MANIFEST_VERSION = 1;
27+
28+
const KIND_TO_TOOL_HINTS = Object.freeze({
29+
palette: ["palette-browser"],
30+
skin: ["skin-editor"],
31+
sprite: ["sprite-editor"],
32+
tilemap: ["tile-map-editor"],
33+
parallax: ["parallax-editor"],
34+
vector: ["vector-asset-studio"],
35+
image: ["asset-browser"],
36+
audio: ["asset-browser"],
37+
video: ["asset-browser"],
38+
shader: ["asset-browser"],
39+
data: ["asset-browser"]
40+
});
41+
42+
function readJson(filePath) {
43+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
44+
}
45+
46+
function writeJson(filePath, value) {
47+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
48+
}
49+
50+
function normalizeText(value) {
51+
return typeof value === "string" ? value.trim() : "";
52+
}
53+
54+
function normalizeToken(value) {
55+
return normalizeText(value).toLowerCase();
56+
}
57+
58+
function normalizeToolHints(value) {
59+
if (!Array.isArray(value)) {
60+
return [];
61+
}
62+
const out = [];
63+
const seen = new Set();
64+
for (const entry of value) {
65+
const token = normalizeToken(entry);
66+
if (!token || seen.has(token)) {
67+
continue;
68+
}
69+
seen.add(token);
70+
out.push(token);
71+
}
72+
return out;
73+
}
74+
75+
function parseArgs(argv) {
76+
const args = {
77+
dryRun: false
78+
};
79+
for (let i = 0; i < argv.length; i += 1) {
80+
const value = argv[i];
81+
if (value === "--dry-run") {
82+
args.dryRun = true;
83+
continue;
84+
}
85+
throw new Error(`Unknown argument: ${value}`);
86+
}
87+
return args;
88+
}
89+
90+
function loadMetadata() {
91+
if (!fs.existsSync(METADATA_PATH)) {
92+
throw new Error(`Missing metadata file: ${path.relative(ROOT, METADATA_PATH)}`);
93+
}
94+
const metadata = readJson(METADATA_PATH);
95+
if (!metadata || typeof metadata !== "object" || !Array.isArray(metadata.games)) {
96+
throw new Error('Metadata must contain a "games" array.');
97+
}
98+
return metadata;
99+
}
100+
101+
function normalizeGameHref(value) {
102+
const href = normalizeText(value).replace(/\\/g, "/");
103+
if (!href || !href.startsWith("/games/") || href.includes("..")) {
104+
return "";
105+
}
106+
return href;
107+
}
108+
109+
function getGameDirFromHref(gameHref) {
110+
const href = normalizeGameHref(gameHref);
111+
if (!href) {
112+
return "";
113+
}
114+
const stripped = href.endsWith("/index.html")
115+
? href.slice(0, -"/index.html".length)
116+
: href.endsWith("/")
117+
? href.slice(0, -1)
118+
: href;
119+
const relative = stripped.replace(/^\/+/, "");
120+
if (!relative) {
121+
return "";
122+
}
123+
return path.join(ROOT, relative);
124+
}
125+
126+
function readWorkspaceAssetKinds(gameDir) {
127+
if (!gameDir) {
128+
return [];
129+
}
130+
const catalogPath = path.join(gameDir, "assets", GAME_ASSET_CATALOG_FILENAME);
131+
if (!fs.existsSync(catalogPath)) {
132+
return [];
133+
}
134+
const source = readJson(catalogPath);
135+
const schema = normalizeText(source?.schema);
136+
const version = Number(source?.version);
137+
if (schema !== GAME_ASSET_CATALOG_SCHEMA || version !== GAME_ASSET_CATALOG_VERSION) {
138+
return [];
139+
}
140+
const assets = source?.assets && typeof source.assets === "object" ? source.assets : {};
141+
const kinds = [];
142+
Object.values(assets).forEach((entry) => {
143+
const kind = normalizeToken(entry?.kind);
144+
if (kind) {
145+
kinds.push(kind);
146+
}
147+
});
148+
return [...new Set(kinds)];
149+
}
150+
151+
function readToolHintsFromToolsManifest(gameDir) {
152+
if (!gameDir) {
153+
return [];
154+
}
155+
const toolsManifestPath = path.join(gameDir, "assets", GAME_TOOLS_MANIFEST_FILENAME);
156+
if (!fs.existsSync(toolsManifestPath)) {
157+
return [];
158+
}
159+
const source = readJson(toolsManifestPath);
160+
const schema = normalizeText(source?.schema);
161+
const version = Number(source?.version);
162+
if (schema !== GAME_TOOLS_MANIFEST_SCHEMA || version !== GAME_TOOLS_MANIFEST_VERSION) {
163+
return [];
164+
}
165+
const domains = source?.domains && typeof source.domains === "object" ? source.domains : {};
166+
const hints = [];
167+
let hasDomainRecords = false;
168+
Object.values(domains).forEach((records) => {
169+
if (!Array.isArray(records) || records.length === 0) {
170+
return;
171+
}
172+
hasDomainRecords = true;
173+
records.forEach((record) => {
174+
const sourceToolId = normalizeToken(record?.sourceToolId);
175+
if (sourceToolId) {
176+
hints.push(sourceToolId);
177+
}
178+
});
179+
});
180+
if (hasDomainRecords) {
181+
hints.push("asset-pipeline-tool");
182+
}
183+
return normalizeToolHints(hints);
184+
}
185+
186+
function syncToolHints(metadata) {
187+
const knownToolIds = new Set(getToolRegistry().map((tool) => normalizeToken(tool?.id)).filter(Boolean));
188+
let updated = 0;
189+
const warnings = [];
190+
191+
for (const game of metadata.games) {
192+
const gameId = normalizeText(game?.id) || "<unknown>";
193+
const gameDir = getGameDirFromHref(game?.href);
194+
const existing = normalizeToolHints(game?.toolHints);
195+
const derivedFromKinds = [];
196+
197+
readWorkspaceAssetKinds(gameDir).forEach((kind) => {
198+
const mapped = KIND_TO_TOOL_HINTS[kind] || [];
199+
mapped.forEach((toolId) => derivedFromKinds.push(toolId));
200+
});
201+
202+
const derivedFromToolsManifest = readToolHintsFromToolsManifest(gameDir);
203+
const merged = normalizeToolHints([...existing, ...derivedFromKinds, ...derivedFromToolsManifest]);
204+
const invalid = merged.filter((toolId) => !knownToolIds.has(toolId));
205+
if (invalid.length > 0) {
206+
throw new Error(`${gameId}: unknown tool id(s): ${invalid.join(", ")}`);
207+
}
208+
209+
if (!gameDir) {
210+
warnings.push(`${gameId}: skipped derivation (missing/invalid href)`);
211+
} else if (!fs.existsSync(path.join(gameDir, "assets", GAME_ASSET_CATALOG_FILENAME))) {
212+
warnings.push(`${gameId}: no ${GAME_ASSET_CATALOG_FILENAME}`);
213+
}
214+
215+
if (JSON.stringify(existing) !== JSON.stringify(merged) || !Array.isArray(game.toolHints)) {
216+
game.toolHints = merged;
217+
updated += 1;
218+
}
219+
}
220+
221+
return { updated, warnings };
222+
}
223+
224+
function main() {
225+
const args = parseArgs(process.argv.slice(2));
226+
const metadata = loadMetadata();
227+
const result = syncToolHints(metadata);
228+
229+
if (!args.dryRun) {
230+
writeJson(METADATA_PATH, metadata);
231+
}
232+
233+
const warningText = result.warnings.length ? ` warnings=${result.warnings.length}` : "";
234+
console.log(`OK updated=${result.updated}${warningText} dryRun=${args.dryRun ? "true" : "false"}`);
235+
if (result.warnings.length) {
236+
result.warnings.forEach((warning) => {
237+
console.log(`WARN ${warning}`);
238+
});
239+
}
240+
}
241+
242+
try {
243+
main();
244+
} catch (error) {
245+
console.error(`FAIL ${error instanceof Error ? error.message : String(error)}`);
246+
process.exit(1);
247+
}

0 commit comments

Comments
 (0)