From 1622f0893c123b26b6265cf95eb6497c3b737f2a Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 9 Oct 2026 18:41:57 -0400 Subject: [PATCH] Type generated WoW talent legality keys as strings (#4) --- docs/WOW_TALENT_GATE_TYPES_2026-10-09.md | 46 ++++++ scripts/check-wow-talent-gate-types.mjs | 196 +++++++++++++++++++++++ src/game/wow335AbilityCatalog.ts | 2 +- 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 docs/WOW_TALENT_GATE_TYPES_2026-10-09.md create mode 100644 scripts/check-wow-talent-gate-types.mjs diff --git a/docs/WOW_TALENT_GATE_TYPES_2026-10-09.md b/docs/WOW_TALENT_GATE_TYPES_2026-10-09.md new file mode 100644 index 00000000..a8d2b7c7 --- /dev/null +++ b/docs/WOW_TALENT_GATE_TYPES_2026-10-09.md @@ -0,0 +1,46 @@ +# WoW talent-gate Set typing — 2026-10-09 + +This narrow source correction addresses shared-source issue [#4](https://git.whoagland.com/phenom/healer-man/issues/4), coordinated with native issue [#236](https://git.whoagland.com/Codex/healer-man-godot/issues/236). It changes only the inferred TypeScript type of the legal talent-node ID set in `src/game/wow335AbilityCatalog.ts`: + +```ts +const SOURCE_WOW335_TALENT_NODE_IDS = new Set(WOW335_TALENT_NODES.map((node) => node.id)); +``` + +The runtime membership check and its inputs are unchanged. The explicit `string` type lets the imported talent-node ID pass strict `Set.has` checking without changing any legal node, exemption, ability rank, rotation, or gameplay value. + +## Before and after + +- Baseline catalog SHA-256: `6a7cf9b934026ec4b5beb0de3fe64eda4872efad561d63d25b2c911676d249a4` +- Corrected catalog SHA-256: `3ea007068d8a8b6cd0c49d2d4b780cf176dc567e23fe20537e40f49fded14314` +- The focused strict baseline emitted exactly one expected `TS2345` at `wow335AbilityCatalog.ts(2605,75)`, on `SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)`. +- The corrected source passed strict TypeScript 5.8.3 checking with zero diagnostics. +- Three focused regressions passed: cold native import followed by talent preload, legal talent gates plus source-only-node exemptions, and equality with the full legal talent-node set. The targeted Vitest invocation selected these three tests; 41 other tests in that file were skipped. + +The review run is recorded in `../diagnostics/strict-types/after-regression-tool-final2.json`. The sparse authoritative mirror strict check is recorded in `../diagnostics/strict-types/actual-mirror-types-final.json`; that run used TypeScript 5.8.3 with zero diagnostics and skipped runtime tests because the mirror does not contain the runtime fixture/dependencies. The baseline proof is `../diagnostics/strict-types/baseline-regression-tool.json`. + +## Reproduction checker + +`../scripts/check-wow-talent-gate-types.mjs` checks the catalog and its imported TypeScript dependency graph in strict mode. It can also run the three focused regressions when the project root has `vite.config.ts` and the cold-start fixture, and the toolchain root provides TypeScript, Vite, and Vitest. + +From the source review checkout, run both type and runtime checks with: + +```sh +node scripts/check-wow-talent-gate-types.mjs \ + --project-root /path/to/source-review \ + --toolchain-root /path/to/source-review +``` + +For a sparse source mirror with an external dependency tree, point the roots separately and omit only the runtime tests: + +```sh +node /path/to/source-mirror/scripts/check-wow-talent-gate-types.mjs \ + --project-root /path/to/source-mirror \ + --toolchain-root /path/to/toolchain \ + --skip-runtime +``` + +`--expect baseline` is available to verify the recorded pre-fix `TS2345`; the default `--expect fixed` requires a clean strict check. The checker is focused evidence, not a full project build, runtime gameplay validation, balance certification, or GPU validation. + +## Scope record + +Coordinator: `/root`, session `01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6`. The Set typing correction was the source-owned change; independent strict-type and focused-regression validation was performed by the validation worker. All workers used GPT-6 Luna with xhigh reasoning for this scoped work. diff --git a/scripts/check-wow-talent-gate-types.mjs b/scripts/check-wow-talent-gate-types.mjs new file mode 100644 index 00000000..6384e21a --- /dev/null +++ b/scripts/check-wow-talent-gate-types.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function parseArgs(args) { + const result = { + projectRoot: process.cwd(), + toolchainRoot: null, + expectation: "fixed", + skipRuntime: false, + }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--project-root") result.projectRoot = args[++index]; + else if (argument.startsWith("--project-root=")) result.projectRoot = argument.slice("--project-root=".length); + else if (argument === "--toolchain-root") result.toolchainRoot = args[++index]; + else if (argument.startsWith("--toolchain-root=")) result.toolchainRoot = argument.slice("--toolchain-root=".length); + else if (argument === "--expect") result.expectation = args[++index]; + else if (argument.startsWith("--expect=")) result.expectation = argument.slice("--expect=".length); + else if (argument === "--skip-runtime") result.skipRuntime = true; + else if (argument === "--help" || argument === "-h") { + console.log("Usage: node scripts/check-wow-talent-gate-types.mjs [--project-root DIR] [--toolchain-root DIR] [--expect baseline|fixed] [--skip-runtime]"); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + assert.ok(["baseline", "fixed"].includes(result.expectation), `invalid --expect value: ${result.expectation}`); + result.projectRoot = path.resolve(result.projectRoot); + result.toolchainRoot = path.resolve(result.toolchainRoot ?? result.projectRoot); + return result; +} + +function typecheckTalentCatalog(projectRoot, toolchainRoot) { + const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json")); + const ts = requireFromToolchain("typescript"); + const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts"); + const viteClientPathCandidates = [ + path.join(projectRoot, "node_modules", "vite", "client.d.ts"), + path.join(toolchainRoot, "node_modules", "vite", "client.d.ts"), + ]; + const viteClientPath = viteClientPathCandidates.find(existsSync); + assert.ok(viteClientPath, "strict catalog check requires Vite's client.d.ts in the project or toolchain dependency tree"); + const configPath = path.join(projectRoot, "tsconfig.app.json"); + let parsedOptions; + if (existsSync(configPath)) { + const configText = readFileSync(configPath, "utf8"); + const parsedConfig = ts.parseConfigFileTextToJson(configPath, configText); + assert.equal(parsedConfig.error, undefined, `could not parse ${configPath}`); + const parsed = ts.parseJsonConfigFileContent(parsedConfig.config, ts.sys, projectRoot, undefined, configPath); + assert.equal(parsed.errors.length, 0, `could not resolve ${configPath}: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; ")}`); + parsedOptions = parsed.options; + } else { + parsedOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + resolveJsonModule: true, + allowImportingTsExtensions: true, + paths: {}, + }; + } + + const shimDirectory = mkdtempSync(path.join(os.tmpdir(), "wow-talent-gate-types-")); + const aliasDeclarationPath = path.join(shimDirectory, "dungeonAssetModules.d.ts"); + writeFileSync(aliasDeclarationPath, [ + 'declare module "@dungeonAssetModules" {', + ' export const dungeonModules: Readonly>;', + '}', + '', + ].join("\n")); + + try { + const compilerOptions = { + ...parsedOptions, + strict: true, + noEmit: true, + skipLibCheck: true, + incremental: false, + composite: false, + types: ["node"], + baseUrl: projectRoot, + typeRoots: [ + path.join(projectRoot, "node_modules", "@types"), + path.join(toolchainRoot, "node_modules", "@types"), + ], + paths: { ...(parsedOptions.paths ?? {}) }, + }; + delete compilerOptions.paths["@dungeonAssetModules"]; + const rootNames = [catalogPath, viteClientPath, aliasDeclarationPath]; + const program = ts.createProgram({ rootNames, options: compilerOptions }); + const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); + const simplified = diagnostics.map((diagnostic) => { + const location = diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + : undefined; + const sourceLine = diagnostic.file && location + ? diagnostic.file.text.split(/\r?\n/u)[location.line] + : undefined; + return { + code: diagnostic.code, + file: diagnostic.file ? path.relative(projectRoot, diagnostic.file.fileName).split(path.sep).join("/") : null, + line: location ? location.line + 1 : null, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + sourceLine: sourceLine?.trim() ?? null, + }; + }); + return { typescriptVersion: ts.version, diagnostics: simplified }; + } finally { + rmSync(shimDirectory, { recursive: true, force: true }); + } +} + +function runTalentRuntimeRegressions(projectRoot, toolchainRoot) { + assert.ok(existsSync(path.join(projectRoot, "vite.config.ts")), "focused runtime checks require vite.config.ts; use --skip-runtime for a source-only mirror without it"); + assert.ok(existsSync(path.join(projectRoot, "src", "game", "wow335AbilityCatalog.cold-start.fixture.ts")), "focused runtime checks require the cold-start fixture; use --skip-runtime when it is not present"); + const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json")); + const vitestPath = requireFromToolchain.resolve("vitest/vitest.mjs"); + const testPath = path.join("src", "game", "wow335AbilityCatalog.test.ts"); + const matchingTests = [ + "preserves talent gates across a cold native import and a later talent preload", + "uses legal talent gates for learned builds and leaves source-only nodes unlocked", + "uses the same legal talent-node set as the full catalog", + ]; + const testPattern = matchingTests.join("|"); + try { + const output = execFileSync(process.execPath, [ + vitestPath, + "run", + testPath, + "--config", + "vite.config.ts", + "--maxWorkers=1", + "--testTimeout=15000", + "--testNamePattern", + testPattern, + ], { + cwd: projectRoot, + encoding: "utf8", + stdio: "pipe", + maxBuffer: 8 * 1024 * 1024, + }); + const passedMatch = output.match(/Tests\s+(\d+)\s+passed/u); + assert.ok(passedMatch, `Vitest did not report a passing test count:\n${output}`); + assert.equal(Number(passedMatch[1]), matchingTests.length, `expected all focused talent gate tests to run:\n${output}`); + return { passed: Number(passedMatch[1]), tests: matchingTests, output: output.trim() }; + } catch (error) { + const stdout = error.stdout?.toString() ?? ""; + const stderr = error.stderr?.toString() ?? ""; + throw new Error(`focused talent runtime tests failed\n${stdout}\n${stderr}`, { cause: error }); + } +} + +const args = parseArgs(process.argv.slice(2)); +const projectRoot = args.projectRoot; +const toolchainRoot = args.toolchainRoot; +const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts"); +const catalogText = readFileSync(catalogPath, "utf8"); +assert.match(catalogText, /SOURCE_WOW335_TALENT_NODE_IDS\.has\(chain\.talentNodeId\)/u, "the actual source catalog must retain the legal-node gate"); + +const typecheck = typecheckTalentCatalog(projectRoot, toolchainRoot); +const expectedDiagnostic = typecheck.diagnostics.length === 1 + && typecheck.diagnostics[0].code === 2345 + && typecheck.diagnostics[0].file === "src/game/wow335AbilityCatalog.ts" + && typecheck.diagnostics[0].sourceLine?.includes("SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)"); +if (args.expectation === "baseline") { + assert.ok(expectedDiagnostic, `expected exactly the pre-fix TS2345 at the actual talent Set gate; got ${JSON.stringify(typecheck.diagnostics, null, 2)}`); +} else { + assert.deepEqual(typecheck.diagnostics, [], `the corrected catalog and its focused dependency graph must type-check cleanly`); +} + +const runtime = args.skipRuntime ? null : runTalentRuntimeRegressions(projectRoot, toolchainRoot); +const result = { + projectRoot, + toolchainRoot, + catalogSha256: createHash("sha256").update(catalogText).digest("hex"), + expectation: args.expectation, + typecheck: { + typescriptVersion: typecheck.typescriptVersion, + strict: true, + diagnostics: typecheck.diagnostics, + result: expectedDiagnostic ? "expected-baseline-ts2345" : "clean", + }, + runtime, + limits: [ + "Strict-checks the actual catalog and its imported TypeScript dependency graph, with only the bundler's dungeon asset glob import given its declared Readonly> type.", + "Runs only the three legal talent gate, source-only exemption, and cold/warm preload regressions; it does not run a full project build or balance sweep.", + ], +}; +console.log(JSON.stringify(result, null, 2)); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index 6a42f460..20f131e8 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -43,7 +43,7 @@ import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335Deterrence import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence"; // This generated source list is available synchronously when the abilities below are constructed. -const SOURCE_WOW335_TALENT_NODE_IDS = new Set(WOW335_TALENT_NODES.map((node) => node.id)); +const SOURCE_WOW335_TALENT_NODE_IDS = new Set(WOW335_TALENT_NODES.map((node) => node.id)); export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;