From 3c1ed16bf9cd420503f94ae46f8a93bf4ecaeda1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 06:04:04 +0000 Subject: [PATCH] chore(tools): drop acts.html-only single-player browser verifiers These CDP scripts drove the long-deleted acts.html page and read SP globals (__d2webAct, __d2webHud, __d2webHudInstance) that no code publishes anymore: verify-{animation,ground-combat,controlbar,issue-503,issue-504,skill, tooltip,ui}-browser, verify-char-appearance, verify-vendor-ui and execute-r3-all-221-skills. Remove them together with the package.json scripts verify:animation, verify:ground-browser and verify:char-appearance. audit-levels-browser.ts and verify-automap.ts are kept: they are the 136-level audits referenced by AGENTS.md / tests and still need a port to play.html. --- package.json | 3 - tools/execute-r3-all-221-skills.ts | 697 ---------------- tools/verify-animation-browser.ts | 1070 ------------------------- tools/verify-char-appearance.ts | 691 ---------------- tools/verify-controlbar-browser.ts | 296 ------- tools/verify-ground-combat-browser.ts | 817 ------------------- tools/verify-issue-503-browser.ts | 381 --------- tools/verify-issue-504-browser.ts | 594 -------------- tools/verify-skill-browser.ts | 255 ------ tools/verify-tooltip-browser.ts | 501 ------------ tools/verify-ui-browser.ts | 223 ------ tools/verify-vendor-ui.ts | 346 -------- 12 files changed, 5874 deletions(-) delete mode 100644 tools/execute-r3-all-221-skills.ts delete mode 100644 tools/verify-animation-browser.ts delete mode 100644 tools/verify-char-appearance.ts delete mode 100644 tools/verify-controlbar-browser.ts delete mode 100644 tools/verify-ground-combat-browser.ts delete mode 100644 tools/verify-issue-503-browser.ts delete mode 100644 tools/verify-issue-504-browser.ts delete mode 100644 tools/verify-skill-browser.ts delete mode 100644 tools/verify-tooltip-browser.ts delete mode 100644 tools/verify-ui-browser.ts delete mode 100644 tools/verify-vendor-ui.ts diff --git a/package.json b/package.json index 7856dcb..71e78cd 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,8 @@ "pack:overlays": "tsx src/baker/packers/pack-overlays.ts", "verify:entity-packs": "tsx src/baker/audit/verify-entity-packs.ts", "verify:entities": "tsx src/baker/audit/verify-entity-packs.ts", - "verify:char-appearance": "tsx tools/verify-char-appearance.ts", "verify:packs": "tsx src/baker/audit/verify-packs.ts", "verify:bundle-no-mpq": "tsx tools/verify-bundle-no-mpq.ts", - "verify:animation": "tsx tools/verify-animation-browser.ts", - "verify:ground-browser": "tsx tools/verify-ground-combat-browser.ts", "verify:deploy": "tsx tools/verify-deploy.ts", "verify:listfile": "tsx tools/verify-listfile.ts", "verify:object-lookup": "tsx tools/verify-object-lookup.ts", diff --git a/tools/execute-r3-all-221-skills.ts b/tools/execute-r3-all-221-skills.ts deleted file mode 100644 index 6636a3c..0000000 --- a/tools/execute-r3-all-221-skills.ts +++ /dev/null @@ -1,697 +0,0 @@ -/** - * Complete R3 Execution Pipeline for All 221 Player Skills (Milestones M3 & M4). - * - * Performs: - * 1. Persistent Vite + Headless Chromium (`chromium-1228`) WebGL2 arena casting & PNG screenshot - * capture (`/tmp/d2-skill-screenshots/skill--.png`) across all 221 player skills. - * 2. Strict isolated `git worktree` lifecycle (`../wt-skill-` on branch `feat/skill-`): - * - Generates `src/server/skills/impl//skill--.ts` (`SkillModule`) - * - Generates `tests/skills//skill--.test.ts` (1.13c `slvl` 1/10/20 + `blvl` synergy + `WorldArena` runtime effect) - * - Commits on `feat/skill-` inside `../wt-skill-` - * - Uploads genuine PNG screenshot to Gitea (`POST /issues/{issueNumber}/assets`) - * - Posts 1.13c numerical + Vitest + Chromium telemetry comment (`POST /issues/{issueNumber}/comments`) - * - Merges `feat/skill-` into `main` (`--no-ff`, `fixes #`) - * - Immediately deletes worktree (`git worktree remove ../wt-skill-`) and branch (`git branch -d feat/skill-`) - * - Pushes `main` to `origin/main` in batches of 25 skills and verifies Milestone #9 has 0 open issues and 221 closed issues. - */ -import { execFileSync, spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { createServer } from 'vite' -import { getSharedDataRegistry, type SkillRecord } from '../src/baker/mpq-archive-source.ts' -import { UnitStatList } from '../src/server/engine/stat-list.ts' -import { evaluateSkillCore113c } from '../src/server/skills/registry.ts' -import { closeGiteaIssue, postGiteaIssueComment, uploadPngToGiteaIssue } from './gitea-skill-workflow.ts' - -const REPO_ROOT = '/usr/local/google/home/taodao/diablo2-web' -const SCREENSHOT_DIR = '/tmp/d2-skill-screenshots' -const TELEMETRY_CACHE_PATH = '/tmp/d2-skill-screenshots/telemetry-221.json' -const PROGRESS_MD_PATH = '/usr/local/google/home/taodao/diablo2-web/.agents/sub_orch_m3_m4_r3/progress.md' -const GITEA_BASE_URL = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web' -const GITEA_TOKEN = '94c84cc3bd2eab804346cb889650c843d0cda244' - -const CHROME_CANDIDATES = [ - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/usr/bin/google-chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) - -interface IssueEntry { - skillId: number - issueNumber: number - issueId: number - title: string - charClass: string - slug: string - milestoneId: number -} - -interface ArenaTelemetrySnap { - skillId: number - skillName: string - classCode: string - charToken: string - slvl: number - castCount: number - playerAnimMode: string - playerAnimFrame: number - manaCost: number - minDmg: number - maxDmg: number - totalDamageDealt: number - missilesSpawned: number - petsCount: number - corpsesRemaining: number - activeStates: string[] - activeOverlays: string[] - pngBytes: number -} - -function runGit(args: string[], cwd = REPO_ROOT): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -} - -function findFirstSynergySkillId(expr: string, registry: Awaited>): number | null { - const m = /skill\('([^']+)'\.blvl\)/.exec(expr) - if (!m?.[1]) return null - const rec = registry.getSkillByName(m[1]) - return rec ? rec.id : null -} - -function generateSkillImplSource(entry: IssueEntry, skill: SkillRecord, id3: string): string { - const escapedName = JSON.stringify(skill.name) - const escapedMissile = JSON.stringify(skill.srvMissile || skill.srvMissileA || skill.cltMissile || '') - const escapedState = JSON.stringify(skill.auraTgtState || skill.auraState || skill.passiveState || '') - const escapedSummon = JSON.stringify(skill.summon || '') - - return `/** - * Diablo II: Lord of Destruction v1.13c — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()}) - * - * Authoritative 1.13c Ground Truth (` + '`Patch_D2.mpq`' + ` -> ` + '`data\\\\global\\\\excel\\\\Skills.txt`' + `): - * - Skill ID: ${String(skill.id)} (${escapedName}) - * - Class: ${JSON.stringify(entry.charClass)} - * - Required Level: ${String(skill.reqLevel)} - * - Server Functions: srvstfunc=${String(skill.srvStFunc)}, srvdofunc=${String(skill.srvDoFunc)}, srvprgfunc1=${String(skill.srvPrgFunc1)}, srvprgfunc2=${String(skill.srvPrgFunc2)}, srvprgfunc3=${String(skill.srvPrgFunc3)} - * - Associated Missile / State / Summon: missile=${escapedMissile}, state=${escapedState}, summon=${escapedSummon} - * - 1.13c Synergy Formulas: EDmgSymPerCalc=${JSON.stringify(skill.eDmgSymPerCalc)}, DmgSymPerCalc=${JSON.stringify(skill.dmgSymPerCalc)}, ELenSymPerCalc=${JSON.stringify(skill.eLenSymPerCalc)} - */ -import type { - SkillEvalContext, - SkillEvalResult, - SkillExecContext, - SkillExecOutcome, - SkillModule, -} from '../../types.ts' -import { evaluateSkillCore113c, executeSkillCore113c } from '../../registry.ts' - -export const skillModule: SkillModule = { - skillId: ${String(skill.id)}, - name: ${escapedName}, - charClass: ${JSON.stringify(entry.charClass)}, - srvStFunc: ${String(skill.srvStFunc)}, - srvDoFunc: ${String(skill.srvDoFunc)}, - - evaluate(ctx: SkillEvalContext): SkillEvalResult { - return evaluateSkillCore113c(ctx) - }, - - executeDo(ctx: SkillExecContext): SkillExecOutcome { - const outcome = executeSkillCore113c(ctx) - return { - ...outcome, - notes: [ - ...outcome.notes, - 'skill_module:skill-${id3}-${entry.slug}', - ], - } - }, -} - -export default skillModule -` -} - -function generateSkillTestSource( - entry: IssueEntry, - skill: SkillRecord, - id3: string, - ev1: ReturnType, - ev10: ReturnType, - ev20: ReturnType, - synSkillId: number | null, - ev20WithSyn: ReturnType, -): string { - const escapedName = JSON.stringify(skill.name) - return `/** - * 1.13c Numerical & Runtime Verification Suite — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()}) - * Gitea Tracking Issue: #${String(entry.issueNumber)} - */ -import { describe, expect, it } from 'vitest' -import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' -import { UnitStatList } from '../../../src/server/engine/stat-list.ts' -import { WorldArena } from '../../../src/server/engine/world-arena.ts' -import { skillModule } from '../../../src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts' - -describe('[Skill #${id3}] ${skill.name.replace(/'/g, "\\'")} (${entry.charClass.toUpperCase()}) — 1.13c Parity (Issue #${String(entry.issueNumber)})', () => { - it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(${String(skill.id)})! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(${String(skill.id)}) - expect(skillModule.name).toBe(${escapedName}) - expect(skillModule.charClass).toBe(${JSON.stringify(entry.charClass)}) - - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(${String(skill.id)}, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(${String(ev1.manaCost)}) - expect(r1.manaCost256).toBe(${String(ev1.manaCost256)}) - expect(r1.minPhysDmg).toBe(${String(ev1.minPhysDmg)}) - expect(r1.maxPhysDmg).toBe(${String(ev1.maxPhysDmg)}) - expect(r1.minElemDmg256).toBe(${String(ev1.minElemDmg256)}) - expect(r1.maxElemDmg256).toBe(${String(ev1.maxElemDmg256)}) - - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(${String(skill.id)}, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(${String(ev10.manaCost)}) - expect(r10.manaCost256).toBe(${String(ev10.manaCost256)}) - expect(r10.minPhysDmg).toBe(${String(ev10.minPhysDmg)}) - expect(r10.maxPhysDmg).toBe(${String(ev10.maxPhysDmg)}) - expect(r10.minElemDmg256).toBe(${String(ev10.minElemDmg256)}) - expect(r10.maxElemDmg256).toBe(${String(ev10.maxElemDmg256)}) - - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(${String(skill.id)}, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(${String(ev20.manaCost)}) - expect(r20.manaCost256).toBe(${String(ev20.manaCost256)}) - expect(r20.minPhysDmg).toBe(${String(ev20.minPhysDmg)}) - expect(r20.maxPhysDmg).toBe(${String(ev20.maxPhysDmg)}) - expect(r20.minElemDmg256).toBe(${String(ev20.minElemDmg256)}) - expect(r20.maxElemDmg256).toBe(${String(ev20.maxElemDmg256)}) - expect(r20.durationFrames).toBe(${String(ev20.durationFrames)}) - expect(r20.radiusSubtiles).toBe(${String(ev20.radiusSubtiles)}) - expect(r20.petMax).toBe(${String(ev20.petMax)}) - expect(r20.cooldownFrames).toBe(${String(ev20.cooldownFrames)}) - }) - - it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(${String(skill.id)})! - const synId = ${synSkillId !== null ? String(synSkillId) : 'null'} - - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(${String(skill.id)}, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, - }) - expect(withHardPoints.synergyBonusPct).toBe(${String(ev20WithSyn.synergyBonusPct)}) - - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(${String(skill.id)}, 20) - statsSoftOnly.addStat('item_allskills', 20) - if (synId !== null) { - statsSoftOnly.setChargedSkillLevel(synId, 33) - } - const withSoftOnly = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsSoftOnly, - }) - expect(withSoftOnly.synergyBonusPct).toBe(0) - }) - - it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => { - const registry = await getSharedDataRegistry() - const arena = new WorldArena({ - registry, - classCode: ${JSON.stringify(entry.charClass)}, - skillId: ${String(skill.id)}, - slvl: 20, - }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(${String(skill.id)}) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) - }) -}) -` -} - -async function captureAll221ScreenshotsInHeadlessChrome( - entries: IssueEntry[], -): Promise> { - mkdirSync(SCREENSHOT_DIR, { recursive: true }) - let cached: Record = {} - if (existsSync(TELEMETRY_CACHE_PATH)) { - try { - cached = JSON.parse(readFileSync(TELEMETRY_CACHE_PATH, 'utf8')) as Record - } catch {} - } - - const missingEntries = entries.filter(e => { - const id3 = String(e.skillId).padStart(3, '0') - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${e.slug}.png` - return !existsSync(pngPath) || statSync(pngPath).size < 10_000 || !cached[e.skillId] - }) - - if (missingEntries.length === 0) { - console.log(`All 221 Chromium PNG screenshots already exist in ${SCREENSHOT_DIR}.`) - return cached - } - - console.log(`Capturing ${String(missingEntries.length)} genuine Chromium screenshots via persistent Vite + chromium-1228...`) - const chosenPort = 5280 + Math.floor(Math.random() * 400) - const viteServer = await createServer({ - root: REPO_ROOT, - logLevel: 'silent', - server: { - host: '127.0.0.1', - port: chosenPort, - strictPort: false, - }, - }) - await viteServer.listen() - const addr = viteServer.httpServer?.address() - const port = typeof addr === 'object' && addr ? addr.port : chosenPort - const baseUrl = `http://127.0.0.1:${String(port)}` - const initialUrl = `${baseUrl}/acts.html?arena=1&class=sor&skill=47&slvl=20&autocast=1` - - const chromeBin = resolveChromeBinary() - const debugPort = 9400 + Math.floor(Math.random() * 400) - const chrome = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1024,680', - `--remote-debugging-port=${String(debugPort)}`, - initialUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - try { - let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null - for (let i = 0; i < 80; i += 1) { - await sleep(150) - try { - const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as { - type: string - url: string - webSocketDebuggerUrl: string - }[] - const matched = list.find(x => x.type === 'page' && x.url.includes('acts.html')) - if (matched?.webSocketDebuggerUrl) { - pageTarget = matched - break - } - } catch {} - } - if (!pageTarget) { - throw new Error(`Headless Chromium failed to open ${initialUrl}`) - } - - const ws = new WebSocket(pageTarget.webSocketDebuggerUrl) - await new Promise((res, rej) => { - ws.onopen = res - ws.onerror = rej - }) - - let msgId = 0 - const pending = new Map) => void>() - const consoleErrors: string[] = [] - - ws.onmessage = event => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['method'] === 'Runtime.exceptionThrown') { - consoleErrors.push(JSON.stringify(msg['params'])) - } - const id = msg['id'] as number | undefined - if (id && pending.has(id)) { - pending.get(id)!(msg) - pending.delete(id) - } - } - - const send = (method: string, cdpParams: Record = {}) => - new Promise>(res => { - const id = ++msgId - pending.set(id, res) - ws.send(JSON.stringify({ id, method, params: cdpParams })) - }) - - await send('Runtime.enable') - await send('Page.enable') - - // Wait for initial arena ready - for (let i = 0; i < 50; i += 1) { - await sleep(250) - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') { - await window.__initBrowserArenaMode(); - } - return Boolean(window.__d2webArena && window.__d2webArena.ready); - })()`, - awaitPromise: true, - returnByValue: true, - }) - const ready = (evalRes['result'] as { result?: { value?: boolean } } | undefined)?.result?.value - if (ready) break - } - // Wait 500ms for WebGL2 map background tiles to finish rendering - await sleep(500) - - for (let idx = 0; idx < missingEntries.length; idx += 1) { - const entry = missingEntries[idx]! - const id3 = String(entry.skillId).padStart(3, '0') - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png` - - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - const a = window.__d2webArena; - if (!a || typeof a.switchAndCastSkill !== 'function') { - throw new Error('window.__d2webArena.switchAndCastSkill missing'); - } - const dbg = a.switchAndCastSkill(${JSON.stringify(entry.charClass)}, ${String(entry.skillId)}, 20); - await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); - return { - skillId: dbg.skillId, - skillName: dbg.skillName, - classCode: dbg.classCode, - charToken: dbg.charToken, - slvl: dbg.slvl, - castCount: dbg.castCount, - playerAnimMode: dbg.playerAnimMode, - playerAnimFrame: dbg.playerAnimFrame, - manaCost: dbg.tooltipStats.manaCost, - minDmg: dbg.tooltipStats.minDmg, - maxDmg: dbg.tooltipStats.maxDmg, - totalDamageDealt: dbg.totalDamageDealt, - missilesSpawned: dbg.missilesSpawned, - petsCount: dbg.petsActive.length, - corpsesRemaining: dbg.corpsesRemaining, - activeStates: dbg.activeStates, - activeOverlays: dbg.activeOverlays, - }; - })()`, - awaitPromise: true, - returnByValue: true, - }) - - const snap = (evalRes['result'] as { result?: { value?: Omit } } | undefined) - ?.result?.value - if (!snap || snap.skillId !== entry.skillId || snap.castCount < 1) { - throw new Error(`Arena switchAndCastSkill failed for skill #${id3} (${entry.slug})`) - } - - const shot = await send('Page.captureScreenshot', { format: 'png' }) - const base64Data = (shot['result'] as { data?: string } | undefined)?.data - if (!base64Data) { - throw new Error(`Empty screenshot for skill #${id3}`) - } - const pngBuf = Buffer.from(base64Data, 'base64') - writeFileSync(pngPath, pngBuf) - - cached[entry.skillId] = { - ...snap, - pngBytes: pngBuf.byteLength, - } - - if ((idx + 1) % 25 === 0 || idx === missingEntries.length - 1) { - console.log(` [Chromium Screenshot] ${String(idx + 1)}/${String(missingEntries.length)} captured (latest: skill-${id3}-${entry.slug}.png, ${String(pngBuf.byteLength)} bytes)`) - writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2)) - } - } - - if (consoleErrors.length > 0) { - throw new Error(`Chromium console/runtime errors occurred: ${consoleErrors.slice(0, 3).join('; ')}`) - } - ws.close() - } finally { - chrome.kill() - await viteServer.close() - } - - writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2)) - return cached -} - -function updateProgressFile(completedCount: number, totalCount: number, lastSkillLabel: string): void { - const now = new Date().toISOString() - const md = `# Progress — worker_m3_m4_r3_all_skills - -Last visited: ${now} - -## Status -- Completed isolated worktree + Vitest + Chromium screenshot + Gitea upload/comment/merge/cleanup: **${String(completedCount)} / ${String(totalCount)}** skills -- Latest completed skill: \`${lastSkillLabel}\` -` - writeFileSync(PROGRESS_MD_PATH, md) -} - -async function main(): Promise { - const registry = await getSharedDataRegistry() - const issueMap = JSON.parse( - readFileSync(resolve(REPO_ROOT, 'src/data/gitea-skill-issues.json'), 'utf8'), - ) as Record - - const entries = Object.values(issueMap).sort((a, b) => a.skillId - b.skillId) - console.log(`Loaded ${String(entries.length)} player skill entries from src/data/gitea-skill-issues.json.`) - - // Step 1: Capture all 221 genuine Chromium screenshots - const telemetryBySkill = await captureAll221ScreenshotsInHeadlessChrome(entries) - - // Step 2: Execute strict isolated git worktree lifecycle for every remaining skill - const remaining = entries.filter(entry => { - const id3 = String(entry.skillId).padStart(3, '0') - const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts` - const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts` - return !existsSync(resolve(REPO_ROOT, implRelPath)) || !existsSync(resolve(REPO_ROOT, testRelPath)) - }) - - console.log(`Remaining skills to complete via isolated worktree + Gitea upload/comment/merge: ${String(remaining.length)} / ${String(entries.length)}`) - - // Precompute all evaluation results for all 221 skills - const precomputed = new Map< - number, - { - id3: string - skill: SkillRecord - implRelPath: string - testRelPath: string - ev1: ReturnType - ev10: ReturnType - ev20: ReturnType - synSkillId: number | null - ev20WithSyn: ReturnType - } - >() - - for (const entry of entries) { - const id3 = String(entry.skillId).padStart(3, '0') - const skill = registry.getSkillById(entry.skillId)! - const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts` - const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts` - - const s1 = new UnitStatList() - s1.setBaseSkillLevel(skill.id, 1) - const ev1 = evaluateSkillCore113c({ registry, skill, slvl: 1, blvl: 1, statList: s1 }) - - const s10 = new UnitStatList() - s10.setBaseSkillLevel(skill.id, 10) - const ev10 = evaluateSkillCore113c({ registry, skill, slvl: 10, blvl: 10, statList: s10 }) - - const s20 = new UnitStatList() - s20.setBaseSkillLevel(skill.id, 20) - const ev20 = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20 }) - - const synExpr = skill.eDmgSymPerCalc || skill.dmgSymPerCalc || (skill.id === 260 ? "skill('Claw Mastery'.blvl)*par8" : '') - const synSkillId = findFirstSynergySkillId(synExpr, registry) - const s20Syn = new UnitStatList() - s20Syn.setBaseSkillLevel(skill.id, 20) - if (synSkillId !== null) s20Syn.setBaseSkillLevel(synSkillId, 20) - const ev20WithSyn = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20Syn }) - - precomputed.set(entry.skillId, { - id3, - skill, - implRelPath, - testRelPath, - ev1, - ev10, - ev20, - synSkillId, - ev20WithSyn, - }) - } - - // 2a. Execute isolated git worktree add -> write -> commit -> merge --no-ff -> worktree remove -> branch -d - let completedTotal = entries.length - remaining.length - let batchSincePush = 0 - for (let i = 0; i < remaining.length; i += 1) { - const entry = remaining[i]! - const data = precomputed.get(entry.skillId)! - const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, synSkillId, ev20WithSyn } = data - const wtPath = `/usr/local/google/home/taodao/wt-skill-${id3}` - const branchName = `feat/skill-${id3}` - - try { - if (existsSync(wtPath)) runGit(['worktree', 'remove', '--force', wtPath]) - } catch {} - try { - runGit(['branch', '-D', branchName]) - } catch {} - - // 1. Create isolated git worktree from main - runGit(['worktree', 'add', wtPath, '-b', branchName, 'main']) - - // 2. Write implementation & Vitest file inside worktree - const wtImplAbs = resolve(wtPath, implRelPath) - const wtTestAbs = resolve(wtPath, testRelPath) - mkdirSync(resolve(wtPath, `src/server/skills/impl/${entry.charClass}`), { recursive: true }) - mkdirSync(resolve(wtPath, `tests/skills/${entry.charClass}`), { recursive: true }) - writeFileSync(wtImplAbs, generateSkillImplSource(entry, skill, id3)) - writeFileSync(wtTestAbs, generateSkillTestSource(entry, skill, id3, ev1, ev10, ev20, synSkillId, ev20WithSyn)) - - // 3. Stage and commit on branch feat/skill- - runGit(['add', implRelPath, testRelPath], wtPath) - runGit( - ['commit', '-m', `feat(skill-${id3}): implement 1.13c ${skill.name} module and Vitest suite (#${String(entry.issueNumber)})`], - wtPath, - ) - - // 4. Merge feat/skill- into main (--no-ff, fixes #) - runGit([ - 'merge', - '--no-ff', - branchName, - '-m', - `feat(skills): implement 1.13c ${skill.name} [Skill #${id3}] (fixes #${String(entry.issueNumber)})`, - ]) - - // 5. Immediately remove worktree and branch - runGit(['worktree', 'remove', wtPath]) - runGit(['branch', '-d', branchName]) - - completedTotal += 1 - batchSincePush += 1 - - if (batchSincePush >= 25 || i === remaining.length - 1) { - runGit(['push', 'origin', 'main']) - batchSincePush = 0 - updateProgressFile(completedTotal, entries.length, `[Skill #${id3}] ${skill.name} (Issue #${String(entry.issueNumber)})`) - console.log(` [R3 Worktree+Merge+Push] Completed ${String(completedTotal)}/${String(entries.length)} (latest: #${id3} ${skill.name} -> Issue #${String(entry.issueNumber)})`) - } - } - - // 2b. Query Gitea Milestone #9 for any issues that still need PNG screenshot + verification comment or closure - const uploadedTrackerPath = `${SCREENSHOT_DIR}/gitea-commented-221.json` - const commentedIssues = new Set( - existsSync(uploadedTrackerPath) ? (JSON.parse(readFileSync(uploadedTrackerPath, 'utf8')) as number[]) : [], - ) - // Mark the first 35 issues that already had comments posted during Phase 1 - for (let i = 0; i < 35; i += 1) { - commentedIssues.add(entries[i]!.issueNumber) - } - - const toUpload = entries.filter(e => !commentedIssues.has(e.issueNumber)) - console.log(`Uploading PNG screenshot + posting verification comment for ${String(toUpload.length)} remaining Gitea issues (4-way concurrency)...`) - - const CONCURRENCY = 4 - let cursor = 0 - let uploadedCount = 0 - async function giteaWorker(): Promise { - while (cursor < toUpload.length) { - const idx = cursor++ - const entry = toUpload[idx]! - const data = precomputed.get(entry.skillId)! - const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, ev20WithSyn } = data - const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png` - const asset = await uploadPngToGiteaIssue(entry.issueNumber, pngPath) - const snap = telemetryBySkill[entry.skillId] - - const commentMd = `### ✅ Completed: [Skill #${id3}] ${skill.name} (\`${entry.charClass.toUpperCase()}\`) — 1.13c Parity Verified - -#### 1. Isolated Worktree & Module Implementation -- **Worktree & Branch**: \`../wt-skill-${id3}\` (\`feat/skill-${id3}\`) -- **Skill Module**: \`${implRelPath}\` -- **Vitest Suite**: \`${testRelPath}\` -- **1.13c Dispatch**: \`srvstfunc=${String(skill.srvStFunc)}\`, \`srvdofunc=${String(skill.srvDoFunc)}\`, \`missile=${skill.srvMissile || skill.srvMissileA || 'none'}\`, \`state=${skill.auraTgtState || skill.auraState || skill.passiveState || 'none'}\`, \`summon=${skill.summon || 'none'}\` - -#### 2. 1.13c Numerical & Synergy Verification Table -| Metric | \`slvl = 1\` | \`slvl = 10\` | \`slvl = 20\` (0 Syn) | \`slvl = 20\` (+20 \`blvl\` Syn) | -| :--- | :---: | :---: | :---: | :---: | -| **Mana Cost (\`usmc\`)** | \`${String(ev1.manaCost)}\` (\`${String(ev1.manaCost256)}\`) | \`${String(ev10.manaCost)}\` (\`${String(ev10.manaCost256)}\`) | \`${String(ev20.manaCost)}\` (\`${String(ev20.manaCost256)}\`) | \`${String(ev20WithSyn.manaCost)}\` (\`${String(ev20WithSyn.manaCost256)}\`) | -| **Phys Dmg (\`Min-Max\`)** | \`${String(ev1.minPhysDmg)}-${String(ev1.maxPhysDmg)}\` | \`${String(ev10.minPhysDmg)}-${String(ev10.maxPhysDmg)}\` | \`${String(ev20.minPhysDmg)}-${String(ev20.maxPhysDmg)}\` | \`${String(ev20WithSyn.minPhysDmg)}-${String(ev20WithSyn.maxPhysDmg)}\` | -| **Elem Dmg (\`Min-Max\`)** | \`${String(ev1.minElemDmg)}-${String(ev1.maxElemDmg)}\` (\`${ev1.elemType}\`) | \`${String(ev10.minElemDmg)}-${String(ev10.maxElemDmg)}\` (\`${ev10.elemType}\`) | \`${String(ev20.minElemDmg)}-${String(ev20.maxElemDmg)}\` (\`${ev20.elemType}\`) | \`${String(ev20WithSyn.minElemDmg)}-${String(ev20WithSyn.maxElemDmg)}\` (+${String(ev20WithSyn.synergyBonusPct)}%) | -| **AR % / Duration / Radius** | \`+${String(ev1.toHitBonusPct)}%\` / \`${String(ev1.durationFrames)}f\` / \`${String(ev1.radiusSubtiles)}\` | \`+${String(ev10.toHitBonusPct)}%\` / \`${String(ev10.durationFrames)}f\` / \`${String(ev10.radiusSubtiles)}\` | \`+${String(ev20.toHitBonusPct)}%\` / \`${String(ev20.durationFrames)}f\` / \`${String(ev20.radiusSubtiles)}\` | \`+${String(ev20WithSyn.toHitBonusPct)}%\` / \`${String(ev20WithSyn.durationFrames)}f\` / \`${String(ev20WithSyn.radiusSubtiles)}\` | - -#### 3. Headless Chromium (\`chromium-1228\`) Runtime Telemetry & Screenshot -- **Arena State**: \`ready=true\`, \`classCode=${snap?.classCode ?? entry.charClass}\` (\`${snap?.charToken ?? 'so'}\`), \`castCount=${String(snap?.castCount ?? 1)}\`, \`anim=${snap?.playerAnimMode ?? 'SC'}:${String(snap?.playerAnimFrame ?? 5)}\`, \`totalDamageDealt=${String(snap?.totalDamageDealt ?? 0)}\`, \`consoleErrors=0\` -- **Captured PNG Size**: \`${String(snap?.pngBytes ?? 0)}\` bytes - -![Skill #${id3} ${skill.name}](${asset.browser_download_url}) -` - await postGiteaIssueComment(entry.issueNumber, commentMd) - await closeGiteaIssue(entry.issueNumber) - commentedIssues.add(entry.issueNumber) - writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2)) - uploadedCount += 1 - if (uploadedCount % 10 === 0 || uploadedCount === toUpload.length) { - console.log(` [Gitea Upload+Comment+Close] ${String(uploadedCount)}/${String(toUpload.length)} completed`) - } - } - } - - await Promise.all(Array.from({ length: CONCURRENCY }, () => giteaWorker())) - writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2)) - - // Ensure any remaining open issues in Milestone #9 are closed - for (let page = 1; page <= 5; page += 1) { - const openRes = await fetch(`${GITEA_BASE_URL}/issues?state=open&type=issues&milestones=9&limit=50&page=1`, { - headers: { Authorization: `token ${GITEA_TOKEN}` }, - signal: AbortSignal.timeout(10000), - }) - const openList = (await openRes.json()) as { number: number }[] - if (openList.length === 0) break - for (const iss of openList) { - await closeGiteaIssue(iss.number) - } - } - - // Final check on Milestone #9 open/closed count - const msRes = await fetch(`${GITEA_BASE_URL}/milestones/9`, { - headers: { Authorization: `token ${GITEA_TOKEN}` }, - }) - const msJson = (await msRes.json()) as { open_issues: number; closed_issues: number } - console.log(`Milestone #9 status: open_issues=${String(msJson.open_issues)}, closed_issues=${String(msJson.closed_issues)}`) -} - -void main() diff --git a/tools/verify-animation-browser.ts b/tools/verify-animation-browser.ts deleted file mode 100644 index e173d2f..0000000 --- a/tools/verify-animation-browser.ts +++ /dev/null @@ -1,1070 +0,0 @@ -/** - * Headless audit of the SHIPPED GAME BUNDLE (`dist-game/`). - * - * WHAT THIS AUDITS, AND WHY IT IS `dist-game/` AND NOT A DEV SERVER - * ----------------------------------------------------------------- - * The Zero-Runtime-MPQ invariant (Iron Law #2) constrains the artifact that - * actually ships. Auditing a dev server would prove "no MPQ requests in - * development form" while the production bundle could still contain the - * `httpRangeSource('/d2char.mpq')` path — a fully green report over a violated - * invariant, which is the worst available false negative. So this harness - * serves the real built output over a plain `node:http` static server and - * never transforms a source file. - * - * The static server is deliberately dependency-free (`node:http`, not vite): - * the dependency tree is part of what is under test, and if it were also the - * test carrier then "dependencies broke" would masquerade as "no requests were - * made". - * - * This pairs with `verify-bundle-no-mpq.ts`: - * - * bundle grep -> the bundle *cannot* download an archive (structural) - * this harness -> the bundle *did not* download one while being played - * - * Neither alone is sufficient. The grep cannot see a runtime fetch assembled - * from fragments; the runtime count cannot see a path that this particular run - * did not reach. - * - * ANTI-VACUITY: THE POINT OF THE `precondition` FIELD - * --------------------------------------------------- - * Most of these assertions are of the form "this counter is zero". Such an - * assertion passes trivially when the thing being counted never had a chance to - * happen: `missingMonsterArt.length === 0` is equally true on a level that - * rendered no monsters at all. A harness that reports success in that state is - * worse than no harness, because it manufactures confidence. - * - * So every assertion carries preconditions that establish the measurement was - * live — the scene rendered, the sim ticked, monsters were planned, the - * character draw path ran. An assertion whose preconditions are unmet is - * reported as **VACUOUS** and counts as a harness FAILURE, never as a pass. - * - * `act-scene.ts:254` is the cautionary tale: `state.characterGroup` is declared - * and published but never written, so any audit asserting on it would have been - * asserting on a constant. This harness checks that trap explicitly. - * - * NEGATIVE CONTROLS - * ----------------- - * An assertion never observed failing is not evidence. `--negative=` injects - * a specific, real fault and the run is then expected to go red on exactly the - * targeted assertion. See NEGATIVE_CONTROLS below. - * - * npm run verify:animation # audit current dist-game/ - * npm run verify:animation -- --negative=list # show the controls - * npm run verify:animation -- --negative=mpq-request - * - * OUTPUT PATH SAFETY - * ------------------ - * `scripts/verify-challenger-m3.ts` L28-29 hardcodes an output directory into a - * different worktree and writes screenshots there (L155/L159). That worktree is - * off-limits and is actively being worked in by another team. Every path this - * script writes is therefore passed through `assertWritablePath`, which throws - * unless the resolved path is inside this worktree. It is a guard rather than a - * convention so it cannot regress silently. - */ - -import { spawn } from 'node:child_process' -import { createServer } from 'node:http' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { extname, join, relative, resolve } from 'node:path' - -// --------------------------------------------------------------------------- -// Paths -// --------------------------------------------------------------------------- - -/** Repo root. This script lives in `/scripts`. */ -const ROOT = resolve(process.cwd()) -const DIST_DIR = join(ROOT, 'dist-game') -const PACKS_DIR = join(ROOT, 'samples', 'd2-packs') -const SRC_DIR = join(ROOT, 'src') - -/** - * Refuse to write anywhere outside this worktree. - * - * The forked-from script wrote screenshots into a sibling worktree that is - * explicitly off-limits and currently has another team's processes running in - * it. Enforcing this in code, on every write, is the only version of that rule - * that cannot rot. - */ -function assertWritablePath(candidate: string): string { - const full = resolve(candidate) - const rel = relative(ROOT, full) - if (rel.startsWith('..') || resolve(rel) === rel) { - throw new Error( - `verify-animation-browser: refusing to write outside the worktree.\n` + - ` requested: ${full}\n worktree : ${ROOT}`, - ) - } - return full -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -interface Options { - readonly negative: string - readonly outDir: string - readonly keepOpenMs: number -} - -function parseArgs(argv: readonly string[]): Options { - let negative = '' - let outDir = join(ROOT, '.agents', 'worker_mv', 'evidence') - let keepOpenMs = 0 - for (const arg of argv) { - if (arg.startsWith('--negative=')) negative = arg.slice('--negative='.length) - else if (arg.startsWith('--out=')) outDir = resolve(arg.slice('--out='.length)) - else if (arg.startsWith('--keep-open-ms=')) keepOpenMs = Number(arg.slice('--keep-open-ms='.length)) - } - return { negative, outDir, keepOpenMs } -} - -/** - * The deliberate faults. Each names the assertion it must turn red. - * - * `page-*` controls run inside the page; `artifact-*` controls would require - * mutating `dist-game/`, which is done by the separate bundle-gate controls - * rather than here, so that this harness never writes to the build output. - */ -const NEGATIVE_CONTROLS: Readonly> = { - 'mpq-request': 'page fetches a *.mpq URL -> must turn ZERO_MPQ_DLL_REQUESTS red', - 'dll-request': 'page fetches a *.dll URL -> must turn ZERO_MPQ_DLL_REQUESTS red', - 'console-error': 'page emits console.error -> must turn ZERO_CONSOLE_ERRORS red', - 'missing-art': 'push a fake id into missingMonsterArt -> must turn NO_RED_PLACEHOLDER_SQUARES red', - 'monster-art-error': 'bump monsterArtErrors -> must turn NO_RED_PLACEHOLDER_SQUARES red', - 'freeze-facing': 'ignore facing writes -> must turn EIGHT_DIRECTIONS_EXERCISED red', - 'broken-page': 'navigate to a URL that does not exist -> must turn SCENE_BOOTED red', -} - -// --------------------------------------------------------------------------- -// Static server for the built artifact -// --------------------------------------------------------------------------- - -const MIME: Readonly> = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.wasm': 'application/wasm', - '.map': 'application/json; charset=utf-8', - '.r8': 'application/octet-stream', - '.bin': 'application/octet-stream', - '.dat': 'application/octet-stream', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.ogg': 'audio/ogg', -} - -interface ServedRequest { - readonly url: string - readonly status: number -} - -/** - * Serve the production layout. - * - * The game is built with `--base=/diablo2/`, so the bundle must live under - * `/diablo2/`. `DEFAULT_PACKS` (`act-scene.ts`) resolves its second entry to - * `/diablo2/packs`, so the baked packs are mounted there — this reproduces the - * deployed shape rather than inventing one. - * - * Note what is deliberately NOT mounted: nothing serves `samples/d2`, so no - * `*.mpq` is reachable. That does not weaken the audit — a forbidden request is - * counted when it is *issued*, regardless of the response — and it matches a - * real deployment, where the archives are not published. - */ -function createStaticServer(served: ServedRequest[]): ReturnType { - const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ - { prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR }, - { prefix: '/diablo2/packs/', dir: PACKS_DIR }, - { prefix: '/diablo2/', dir: DIST_DIR }, - { prefix: '/packs/', dir: PACKS_DIR }, - { prefix: '/', dir: DIST_DIR }, - ] - - return createServer((req: IncomingMessage, res: ServerResponse) => { - const rawUrl = req.url ?? '/' - const path = decodeURIComponent(rawUrl.split('?')[0] ?? '/') - - const finish = (status: number, body: Buffer | string, type: string): void => { - served.push({ url: rawUrl, status }) - res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }) - res.end(body) - } - - if (path === '/favicon.ico') { - finish(204, '', 'image/x-icon') - return - } - - if (path === '/' || path === '/index.html') { - finish(302, '', 'text/plain') - return - } - - for (const mount of mounts) { - if (!path.startsWith(mount.prefix)) continue - const rest = path.slice(mount.prefix.length) - // Path traversal guard: the resolved file must stay inside the mount. - const target = resolve(mount.dir, rest) - if (!target.startsWith(mount.dir)) { - finish(403, 'forbidden', 'text/plain') - return - } - if (!existsSync(target) || !statSync(target).isFile()) continue - const body = readFileSync(target) - finish(200, body, MIME[extname(target).toLowerCase()] ?? 'application/octet-stream') - return - } - - finish(404, 'not found', 'text/plain') - }) -} - -// --------------------------------------------------------------------------- -// Assertion framework -// --------------------------------------------------------------------------- - -type Verdict = 'PASS' | 'FAIL' | 'VACUOUS' - -interface Assertion { - readonly id: string - /** What acceptance-criterion text this maps to. */ - readonly criterion: string - readonly verdict: Verdict - readonly detail: string - /** Precondition results, so a reader can see the measurement was live. */ - readonly preconditions: readonly { readonly name: string; readonly met: boolean; readonly value: string }[] - /** - * True when the assertion is expected to be red on current `main` because it - * documents a pre-existing defect another milestone owns. Reported loudly and - * excluded from the exit code, so a genuine regression is not buried under a - * known baseline. Turning green is reported as "baseline improved". - */ - readonly knownRedBaseline?: string -} - -class AssertionLog { - readonly items: Assertion[] = [] - - add(a: Assertion): void { - this.items.push(a) - } - - /** - * Build an assertion, evaluating preconditions first. - * - * If any precondition is unmet the verdict is VACUOUS regardless of the - * predicate, because a green from an unexercised code path is not evidence. - */ - check(args: { - id: string - criterion: string - preconditions: readonly { name: string; met: boolean; value: string }[] - pass: boolean - detail: string - knownRedBaseline?: string - }): void { - const unmet = args.preconditions.filter(p => !p.met) - const verdict: Verdict = unmet.length > 0 ? 'VACUOUS' : args.pass ? 'PASS' : 'FAIL' - const detail = - unmet.length > 0 - ? `${args.detail} | VACUOUS: preconditions unmet -> ${unmet.map(p => `${p.name}=${p.value}`).join(', ')}` - : args.detail - this.add({ - id: args.id, - criterion: args.criterion, - verdict, - detail, - preconditions: args.preconditions, - ...(args.knownRedBaseline === undefined ? {} : { knownRedBaseline: args.knownRedBaseline }), - }) - } -} - -// --------------------------------------------------------------------------- -// CDP plumbing -// --------------------------------------------------------------------------- - -const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) - -interface NetworkRequestLog { - url: string - method: string - resourceType: string -} - -interface ConsoleErrorLog { - source: string - text: string -} - -interface CdpSession { - send: (method: string, params?: Record, timeoutMs?: number) => Promise - evalJs: (expression: string) => Promise - close: () => void -} - -async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { - let wsUrl: string | null = null - let lastErr = 'none' - for (let i = 0; i < 80; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page') - if (page?.webSocketDebuggerUrl !== undefined) { - wsUrl = page.webSocketDebuggerUrl - break - } - lastErr = 'no page target yet' - } catch (err) { - // Bounded retry while Chrome boots. This is not a silent swallow: the - // last error is retained and thrown if the loop never succeeds. - lastErr = err instanceof Error ? err.message : String(err) - } - } - if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) - - const ws = new WebSocket(wsUrl) - await new Promise((res, rej) => { - ws.onopen = () => res() - ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) - }) - - let idCounter = 1 - const send = ( - method: string, - params: Record = {}, - timeoutMs = 20000, - ): Promise => - new Promise((res, rej) => { - const id = idCounter++ - const timer = setTimeout(() => { - ws.removeEventListener('message', handler) - rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) - }, timeoutMs) - const handler = (event: MessageEvent): void => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['id'] === id) { - clearTimeout(timer) - ws.removeEventListener('message', handler) - if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) - else res(msg['result'] as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - onEvent(JSON.parse(String(event.data)) as Record) - }) - - const evalJs = async (expression: string): Promise => { - const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails !== undefined) { - throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) - } - return res.result.value - } - - return { send, evalJs, close: () => ws.close() } -} - -// --------------------------------------------------------------------------- -// Scene state shape (the subset this audit reads) -// --------------------------------------------------------------------------- - -interface SceneSnapshot { - present: boolean - ready: boolean - error: string | null - frames: number - tick: number - character: boolean - characterFrames: number - characterGroup: number - missingMonsterArt: string[] - monsterArtErrors: number - monsterArtLayerFailures: number - monstersPlanned: number - facing: number - x: number - y: number - npcs: number - playerDrawRect?: { x: number; y: number; w: number; h: number } | null - weaponClass?: string | null - playerClip?: string | null -} - -const SNAPSHOT_EXPR = `(() => { - const s = window.__d2webAct - if (!s) return { present: false } - return { - present: true, - ready: !!s.ready, - error: s.error ?? null, - frames: s.frames ?? -1, - tick: s.tick ?? -1, - character: !!s.character, - characterFrames: s.characterFrames ?? -1, - characterGroup: s.characterGroup ?? -999, - missingMonsterArt: Array.isArray(s.missingMonsterArt) ? s.missingMonsterArt.slice() : null, - monsterArtErrors: s.monsterArtErrors ?? -1, - monsterArtLayerFailures: s.monsterArtLayerFailures ?? -1, - monstersPlanned: s.monstersPlanned ?? -1, - facing: s.facing ?? -1, - x: s.x ?? -1, - y: s.y ?? -1, - npcs: s.npcs ?? -1, - playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null, - weaponClass: s.weaponClass ?? null, - playerClip: s.playerClip ?? null, - } -})()` - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main(): Promise { - const opts = parseArgs(process.argv.slice(2)) - - if (opts.negative === 'list') { - console.log('Negative controls:') - for (const [id, what] of Object.entries(NEGATIVE_CONTROLS)) console.log(` --negative=${id.padEnd(20)} ${what}`) - process.exit(0) - } - if (opts.negative !== '' && NEGATIVE_CONTROLS[opts.negative] === undefined) { - console.error(`Unknown negative control: ${opts.negative}`) - console.error(`Known: ${Object.keys(NEGATIVE_CONTROLS).join(', ')}`) - process.exit(2) - } - - console.log('======================================================================') - console.log('HEADLESS AUDIT of the SHIPPED BUNDLE (dist-game/)') - if (opts.negative !== '') console.log(`NEGATIVE CONTROL ACTIVE: ${opts.negative} — ${NEGATIVE_CONTROLS[opts.negative]}`) - console.log('======================================================================') - - // --- Precondition: a real, fresh build exists ----------------------------- - // Auditing a missing or stale artifact is the silent-nothing failure mode. - assertBuildPresentAndFresh() - - const outDir = assertWritablePath(opts.negative === '' ? opts.outDir : join(opts.outDir, `negative-${opts.negative}`)) - mkdirSync(outDir, { recursive: true }) - console.log(`Evidence directory: ${outDir}`) - - const log = new AssertionLog() - const served: ServedRequest[] = [] - const networkRequests: NetworkRequestLog[] = [] - const consoleErrors: ConsoleErrorLog[] = [] - - const server = createStaticServer(served) - await new Promise(r => server.listen(0, '127.0.0.1', r)) - const addr = server.address() - const port = typeof addr === 'object' && addr !== null ? addr.port : 0 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`Static server (node:http, serving dist-game/): ${baseUrl}/diablo2/`) - - const debugPort = 9246 - const chrome = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - 'about:blank', - ]) - - let exitCode = 0 - try { - const cdp = await connectCdp(debugPort, msg => { - const method = msg['method'] as string | undefined - if (method === 'Network.requestWillBeSent') { - const req = msg['params'].request as { url: string; method: string } - networkRequests.push({ url: req.url, method: req.method, resourceType: String(msg['params'].type) }) - } else if (method === 'Runtime.consoleAPICalled') { - // This capture does not exist anywhere else in the repo; it is the - // whole mechanism behind the "console.error count is 0" criterion. - if (msg['params'].type === 'error') { - const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] - const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') - consoleErrors.push({ source: 'console.error', text }) - } - } else if (method === 'Runtime.exceptionThrown') { - const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } - consoleErrors.push({ source: 'uncaught', text: d.exception?.description ?? d.text ?? 'unknown exception' }) - } else if (method === 'Log.entryAdded') { - // Browser-level errors (failed subresource loads, CORS, WebGL) never - // reach `consoleAPICalled`. Without this the count would miss exactly - // the failures that matter most for an asset-loading invariant. - const e = msg['params'].entry as { level: string; text: string; source: string } - if (e.level === 'error') consoleErrors.push({ source: `log:${e.source}`, text: e.text }) - } - }) - - await cdp.send('Page.enable') - await cdp.send('Runtime.enable') - await cdp.send('Network.enable') - await cdp.send('Log.enable') - - const saveScreenshot = async (filename: string): Promise => { - const shot = await cdp.send<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = assertWritablePath(join(outDir, filename)) - const buf = Buffer.from(shot.data, 'base64') - writeFileSync(filePath, buf) - console.log(` screenshot: ${filename} (${buf.length} bytes)`) - return filePath - } - - // --- Navigate ----------------------------------------------------------- - const targetUrl = - opts.negative === 'broken-page' - ? `${baseUrl}/diablo2/this-page-does-not-exist.html` - : `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4` - console.log(`\n--- Navigating to ${targetUrl}`) - await cdp.send('Page.navigate', { url: targetUrl }) - - // The freeze-facing control must be installed before the scene can be - // driven, so it goes in as soon as the document exists. - if (opts.negative === 'freeze-facing') { - await sleep(1500) - await cdp.evalJs(`(() => { window.__mvFreezeFacing = true; return 1 })()`) - } - - let snap: SceneSnapshot = { present: false } as SceneSnapshot - for (let i = 0; i < 150; i++) { - snap = await cdp.evalJs(SNAPSHOT_EXPR) - if (snap.present && (snap.ready || snap.error !== null)) break - await sleep(400) - } - - console.log(` scene present=${snap.present} ready=${snap.ready} error=${String(snap.error)}`) - - log.check({ - id: 'SCENE_BOOTED', - criterion: 'audit precondition: the scene under test actually loaded and rendered', - preconditions: [{ name: '__d2webAct published', met: snap.present === true, value: String(snap.present) }], - pass: snap.ready === true && snap.error === null, - detail: `ready=${snap.ready} error=${String(snap.error)} frames=${snap.frames} tick=${snap.tick}`, - }) - - if (snap.present && snap.ready) { - // --- Drive gameplay --------------------------------------------------- - // Walking and swinging is what makes the audit cover "during play" rather - // than "at load": it exercises the walk clip, the attack path, and the - // monster draw path that produces the red placeholder square. - console.log('\n--- Driving gameplay (walk + attack)') - await cdp.evalJs(`(() => { - const sleep = ms => new Promise(r => setTimeout(r, ms)) - const press = (code, down) => - window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true })) - window.__mvDrive = (async () => { - const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'] - for (let round = 0; round < 4; round += 1) { - const dir = dirs[round % dirs.length] - press(dir, true) - await sleep(700) - press(dir, false) - for (let i = 0; i < 3; i += 1) { - press('Space', true); await sleep(160); press('Space', false); await sleep(80) - } - } - return true - })() - return true - })()`) - await cdp.evalJs(`window.__mvDrive`) - await sleep(500) - - const played = await cdp.evalJs(SNAPSHOT_EXPR) - console.log( - ` after play: frames=${played.frames} tick=${played.tick} characterFrames=${played.characterFrames} ` + - `monstersPlanned=${played.monstersPlanned} missingMonsterArt=${JSON.stringify(played.missingMonsterArt)}`, - ) - - // --- Inject the requested fault -------------------------------------- - await injectNegativeControl(cdp, opts.negative) - await sleep(600) - - // --- Assertions ------------------------------------------------------- - const live = await cdp.evalJs(SNAPSHOT_EXPR) - const rendered = { name: 'frames rendered', met: live.frames > 0, value: String(live.frames) } - const ticked = { name: 'sim ticked', met: live.tick > 0, value: String(live.tick) } - - // 1. Zero runtime MPQ / DLL requests. - const forbidden = networkRequests.filter(r => { - const u = r.url.toLowerCase().split('?')[0] ?? '' - return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq/') || u.includes('/dll/') - }) - log.check({ - id: 'ZERO_MPQ_DLL_REQUESTS', - criterion: 'AC: 加载并游玩场景期间,对 *.mpq / *.dll 的网络请求数为 0', - preconditions: [ - rendered, - ticked, - // Without this, "0 forbidden requests" could simply mean the network - // layer was never observed at all. - { name: 'network capture live', met: networkRequests.length > 0, value: String(networkRequests.length) }, - ], - pass: forbidden.length === 0, - detail: - `${forbidden.length} forbidden of ${networkRequests.length} total requests` + - (forbidden.length > 0 ? ` -> ${forbidden.map(f => f.url).join(', ')}` : ''), - }) - - // 2. Zero console.error. - log.check({ - id: 'ZERO_CONSOLE_ERRORS', - criterion: 'AC: 审计期间 console.error 计数为 0', - preconditions: [rendered, ticked], - pass: consoleErrors.length === 0, - detail: - `${consoleErrors.length} error-level messages` + - (consoleErrors.length > 0 - ? ` -> ${consoleErrors.slice(0, 8).map(e => `[${e.source}] ${e.text.slice(0, 200)}`).join(' || ')}` - : ''), - }) - - // 3. No red placeholder squares. - // - // Read from published state, not pixels: `act-scene.ts:3352` already - // records every monster id drawn as a red box. The precondition that - // monsters were actually planned is what stops this being vacuous on a - // town level, where zero red squares is true but meaningless. - const missing = live.missingMonsterArt ?? [] - log.check({ - id: 'NO_RED_PLACEHOLDER_SQUARES', - criterion: 'AC: 无红色占位方块', - preconditions: [ - rendered, - ticked, - { name: 'monsters planned on this level', met: live.monstersPlanned > 0, value: String(live.monstersPlanned) }, - { name: 'missingMonsterArt field exists', met: live.missingMonsterArt !== null, value: String(live.missingMonsterArt !== null) }, - ], - pass: missing.length === 0 && live.monsterArtErrors === 0 && live.monsterArtLayerFailures === 0, - detail: - `missingMonsterArt=${JSON.stringify(missing)} monsterArtErrors=${live.monsterArtErrors} ` + - `monsterArtLayerFailures=${live.monsterArtLayerFailures}`, - }) - - // 3b. The published-state trap. - // - // `state.characterGroup` is declared (L254) and initialised to -1 (L328) - // and never written. It is proof that a published field can be a - // constant, so any audit reading state must verify the fields it trusts - // are actually written. This assertion documents the trap rather than - // depending on it. - log.add({ - id: 'PUBLISHED_STATE_TRAP_CHARACTERGROUP', - criterion: 'harness self-check: a published field can lie (act-scene.ts:254 characterGroup)', - verdict: live.characterGroup === -1 ? 'PASS' : 'PASS', - detail: - `characterGroup=${live.characterGroup} (init -1 at act-scene.ts:328, never written). ` + - `${live.characterGroup === -1 ? 'Confirmed still dead — no assertion may depend on it.' : 'It is now written; it became usable.'}`, - preconditions: [rendered], - }) - - // 4. Eight-direction foot-anchor evidence. - await captureEightDirections(cdp, log, saveScreenshot, live, opts) - - // 5. Weapon-swap evidence. - await captureWeaponSwap(cdp, log, saveScreenshot, outDir) - } - - // --- Report ------------------------------------------------------------- - exitCode = report(log, opts, outDir, { networkRequests, consoleErrors, served }) - - if (opts.keepOpenMs > 0) await sleep(opts.keepOpenMs) - cdp.close() - } finally { - chrome.kill('SIGKILL') - await new Promise(r => server.close(() => r())) - } - - process.exit(exitCode) -} - -/** - * Fail loudly when there is nothing real to audit. - * - * A missing `dist-game/` must not degrade into "audited nothing, all green", - * and a stale one silently audits code that no longer exists. - */ -function assertBuildPresentAndFresh(): void { - if (!existsSync(DIST_DIR) || !existsSync(join(DIST_DIR, 'acts.html'))) { - console.error(`\nFAIL: no built artifact at ${DIST_DIR} (acts.html missing).`) - console.error(' This harness audits the SHIPPED bundle. Build it first:') - console.error(' npm run build:game') - process.exit(1) - } - const newest = (dir: string): number => { - let best = 0 - const walk = (d: string): void => { - for (const e of readdirSync(d)) { - const f = join(d, e) - const st = statSync(f) - if (st.isDirectory()) walk(f) - else best = Math.max(best, st.mtimeMs) - } - } - walk(dir) - return best - } - const distTime = newest(DIST_DIR) - const srcTime = newest(SRC_DIR) - if (srcTime > distTime) { - console.error('\nFAIL: dist-game/ is STALE — a file under src/ is newer than the build.') - console.error(` newest src/ : ${new Date(srcTime).toISOString()}`) - console.error(` newest dist-game/ : ${new Date(distTime).toISOString()}`) - console.error(' Auditing a stale bundle proves nothing about the current code.') - console.error(' Rebuild: npm run build:game') - process.exit(1) - } - console.log(`Artifact OK: dist-game/ built ${new Date(distTime).toISOString()} (newer than src/)`) -} - -async function injectNegativeControl(cdp: CdpSession, negative: string): Promise { - if (negative === '') return - console.log(`\n--- Injecting negative control: ${negative}`) - switch (negative) { - case 'mpq-request': - // A genuine network request for an archive, issued by the page. It is - // expected to 404 — the invariant is about the request being made. - await cdp.evalJs(`fetch('/diablo2/data/d2char.mpq').then(()=>1).catch(()=>1)`) - break - case 'dll-request': - await cdp.evalJs(`fetch('/diablo2/data/D2Common.dll').then(()=>1).catch(()=>1)`) - break - case 'console-error': - await cdp.evalJs(`(() => { console.error('MV negative control: synthetic console.error'); return 1 })()`) - break - case 'missing-art': - await cdp.evalJs( - `(() => { window.__d2webAct.missingMonsterArt.push('mv-synthetic-missing-monster'); return 1 })()`, - ) - break - case 'monster-art-error': - await cdp.evalJs(`(() => { window.__d2webAct.monsterArtErrors += 1; return 1 })()`) - break - case 'freeze-facing': - case 'broken-page': - // Handled at their own point in the flow. - break - default: - throw new Error(`unhandled negative control ${negative}`) - } -} - -/** - * Drive the player through all eight facings and capture one screenshot each. - * - * WHY THE MACHINE ASSERTION IS NOT "the screenshots differ". - * The background has animated tiles and independently moving monsters, so any - * two full-frame captures differ even if the player never turned. An assertion - * on image difference would therefore pass unconditionally — vacuous in the - * most dangerous way, since it *looks* like pixel evidence. - * - * What is actually checkable is the mechanism: that all eight distinct facings - * were really reached, and that the character draw path was live at each one - * (`characterFrames` strictly increasing). The screenshots are then human - * review material for anchor drift, which is a judgement a script cannot make - * without a per-frame draw rect the runtime does not yet publish. - */ -async function captureEightDirections( - cdp: CdpSession, - log: AssertionLog, - saveScreenshot: (name: string) => Promise, - before: SceneSnapshot, - opts: Options, -): Promise { - console.log('\n--- Capturing 8 facings') - const observed: number[] = [] - const charFrames: number[] = [] - const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = [] - const playerPositions: Array<{ x: number; y: number }> = [] - - for (let dir = 0; dir < 8; dir++) { - await cdp.evalJs(`(() => { - if (window.__mvFreezeFacing) return 1 - const e = window.__d2webEngine - if (!e) throw new Error('__d2webEngine is not published; cannot set facing') - e.world.player.facing = ${dir} - return 1 - })()`) - // Two sim ticks at 25 Hz plus a render. - await sleep(160) - const s = await cdp.evalJs(SNAPSHOT_EXPR) - observed.push(s.facing) - charFrames.push(s.characterFrames) - playerDrawRects.push(s.playerDrawRect ?? null) - playerPositions.push({ x: s.x, y: s.y }) - await saveScreenshot(`anchor_facing_${dir}.png`) - } - - const uniqueFacings = new Set(observed) - const charPathLive = charFrames.every((v, i) => i === 0 || v > (charFrames[i - 1] ?? -1)) - - console.log(` facings observed: ${JSON.stringify(observed)} (unique=${uniqueFacings.size})`) - console.log(` characterFrames : ${JSON.stringify(charFrames)} (strictly increasing=${charPathLive})`) - - log.check({ - id: 'EIGHT_DIRECTIONS_EXERCISED', - criterion: 'AC: 8 个方向下角色脚底位置稳定(锚点正确),有截图证据 — mechanism half', - preconditions: [ - { name: '__d2webEngine published', met: true, value: 'act-scene.ts:2677' }, - { name: 'character art loaded (not the marker box)', met: before.character === true, value: String(before.character) }, - ], - pass: uniqueFacings.size === 8 && charPathLive, - detail: - `observed facings=${JSON.stringify(observed)} unique=${uniqueFacings.size}/8; ` + - `characterFrames=${JSON.stringify(charFrames)} strictlyIncreasing=${charPathLive}; ` + - `8 screenshots written (anchor_facing_0..7.png)`, - }) - - const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null) - let maxFootDelta = -1 - if (allRectsPublished) { - const footDeltas = playerDrawRects.map((r, i) => { - const py = playerPositions[i]!.y - return Math.abs((r!.y + r!.h) - py) - }) - maxFootDelta = Math.max(...footDeltas) - } - - log.check({ - id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', - criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', - preconditions: [ - { name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' }, - ], - pass: allRectsPublished && playerDrawRects.every((r, i) => { - const p = playerPositions[i]! - return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15 - }), - detail: allRectsPublished - ? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` + - `max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor` - : 'playerDrawRect missing on some facings', - }) - - if (opts.negative === 'freeze-facing') { - console.log(' (freeze-facing control active: facing writes were suppressed in-page)') - } -} - -/** - * Capture evidence for "swapping weapon class changes the attack animation". - * - * On current `main` the player COF is hardcoded to `hth` (tier-3 char load - * passes the literal `'hth'`, `act-scene.ts:2388-2389`), and no weapon class - * reaches the clip choice at all, so this is a known-red baseline that M3 owns. - * The harness still captures the before/after so the change is reviewable, and - * records the equipped codes structurally rather than claiming a screenshot - * proves something it does not. - */ -async function captureWeaponSwap( - cdp: CdpSession, - log: AssertionLog, - saveScreenshot: (name: string) => Promise, - outDir: string, -): Promise { - console.log('\n--- Weapon-swap evidence') - - const readWeapon = `(() => { - const hud = window.__d2webHudInstance - const inv = hud && hud.inventory - const eq = inv && inv.equipped - const w1 = eq && eq.weapon1 - return { - hudPresent: !!hud, - inventoryPresent: !!inv, - weapon1: w1 ? { code: w1.code, name: w1.name } : null, - // The runtime does not publish a resolved weapon class or the COF clip in - // use. Recorded as null so the gap is visible in the artifact rather than - // implied by omission. - resolvedWeaponClass: (window.__d2webAct && window.__d2webAct.weaponClass) ?? null, - activeClip: (window.__d2webAct && window.__d2webAct.playerClip) ?? null, - } - })()` - - interface WeaponEvidence { - hudPresent: boolean - inventoryPresent: boolean - weapon1: { code: string; name: string } | null - resolvedWeaponClass: string | null - activeClip: string | null - } - - const before = await cdp.evalJs(readWeapon) - await saveScreenshot('weapon_before.png') - - // Swap to a two-handed weapon so the weapon class genuinely differs. `2hs` - // vs `hth` is the coarsest possible distinction, which is the right one for a - // first regression signal. - const swapped = await cdp.evalJs(`(() => { - const inv = window.__d2webHudInstance && window.__d2webHudInstance.inventory - if (!inv) return false - inv.equipped.weapon1 = { - id: 'mv-swap-2hs', code: '7wa', invFile: 'invgpa', name: 'Colossus Blade', - quality: 'normal', invWidth: 2, invHeight: 4, allowedSlots: ['weapon1'], - } - return true - })()`) - await sleep(600) - const after = await cdp.evalJs(readWeapon) - await saveScreenshot('weapon_after.png') - - const evidencePath = assertWritablePath(join(outDir, 'weapon-swap-evidence.json')) - writeFileSync(evidencePath, JSON.stringify({ swapped, before, after }, null, 2)) - console.log(` weapon evidence: ${relative(ROOT, evidencePath)}`) - console.log(` before=${JSON.stringify(before)}`) - console.log(` after =${JSON.stringify(after)}`) - - log.check({ - id: 'WEAPON_SWAP_EVIDENCE_CAPTURED', - criterion: 'AC: 换装不同武器类后…有截图或日志证据 — capture half', - preconditions: [{ name: 'HUD inventory reachable', met: before.inventoryPresent, value: String(before.inventoryPresent) }], - pass: swapped && before.weapon1 !== null && after.weapon1 !== null && before.weapon1.code !== after.weapon1.code, - detail: `equipped weapon1 ${before.weapon1?.code ?? 'none'} -> ${after.weapon1?.code ?? 'none'}; screenshots + JSON written`, - }) - - log.check({ - id: 'WEAPON_SWAP_CHANGES_ANIMATION', - criterion: 'AC: 换装不同武器类后,攻击动作随之变化', - preconditions: [ - { - name: 'runtime publishes the resolved weapon class / active clip', - met: after.resolvedWeaponClass !== null || after.activeClip !== null, - value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, - }, - ], - pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip, - detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`, - }) -} - -function report( - log: AssertionLog, - opts: Options, - outDir: string, - raw: { networkRequests: NetworkRequestLog[]; consoleErrors: ConsoleErrorLog[]; served: ServedRequest[] }, -): number { - console.log('\n======================================================================') - console.log('RESULTS') - console.log('======================================================================') - - let hardFail = 0 - let vacuous = 0 - let knownRed = 0 - let baselineImproved = 0 - - for (const a of log.items) { - const isKnownRed = a.knownRedBaseline !== undefined - let tag: string - if (a.verdict === 'PASS') { - if (isKnownRed) { - tag = 'BASELINE-IMPROVED' - baselineImproved++ - } else tag = 'PASS' - } else if (a.verdict === 'VACUOUS') { - if (isKnownRed) { - tag = 'KNOWN-RED' - knownRed++ - } else { - tag = 'VACUOUS(=FAIL)' - vacuous++ - } - } else { - if (isKnownRed) { - tag = 'KNOWN-RED' - knownRed++ - } else { - tag = 'FAIL' - hardFail++ - } - } - console.log(`\n[${tag}] ${a.id}`) - console.log(` criterion: ${a.criterion}`) - console.log(` detail : ${a.detail}`) - for (const p of a.preconditions) { - console.log(` precond : ${p.met ? 'met' : 'UNMET'} — ${p.name} = ${p.value}`) - } - if (a.knownRedBaseline !== undefined) console.log(` baseline : ${a.knownRedBaseline}`) - } - - const summaryPath = assertWritablePath(join(outDir, 'audit-report.json')) - writeFileSync( - summaryPath, - JSON.stringify( - { - negativeControl: opts.negative === '' ? null : opts.negative, - generated: new Date().toISOString(), - assertions: log.items, - totals: { hardFail, vacuous, knownRed, baselineImproved }, - networkRequestCount: raw.networkRequests.length, - forbiddenRequests: raw.networkRequests - .filter(r => { - const u = r.url.toLowerCase().split('?')[0] ?? '' - return u.endsWith('.mpq') || u.endsWith('.dll') - }) - .map(r => r.url), - consoleErrors: raw.consoleErrors, - serverRequestCount: raw.served.length, - server404s: raw.served.filter(s => s.status === 404).map(s => s.url), - }, - null, - 2, - ), - ) - - console.log('\n----------------------------------------------------------------------') - console.log( - `SUMMARY: ${log.items.length} assertions | FAIL=${hardFail} VACUOUS=${vacuous} ` + - `KNOWN-RED=${knownRed} BASELINE-IMPROVED=${baselineImproved}`, - ) - console.log(`Report: ${relative(ROOT, summaryPath)}`) - - if (baselineImproved > 0) { - console.log('\nNOTE: a known-red baseline turned green. Update the baseline annotation in this script.') - } - - if (opts.negative !== '') { - // Under a negative control the run is SUPPOSED to be red. Report the - // inversion explicitly so a control that failed to bite is not mistaken - // for a clean run. - const red = hardFail + vacuous - console.log('\n----------------------------------------------------------------------') - if (red > 0) { - console.log(`NEGATIVE CONTROL '${opts.negative}': harness went RED as required (${red} failing assertion(s)).`) - return 0 - } - console.log(`NEGATIVE CONTROL '${opts.negative}': ***HARNESS STAYED GREEN — THE CONTROL DID NOT BITE***`) - console.log('This means the assertion it targets cannot detect its own failure mode.') - return 1 - } - - return hardFail + vacuous > 0 ? 1 : 0 -} - -main().catch((err: unknown) => { - console.error('\nHARNESS ERROR:', err) - process.exit(1) -}) diff --git a/tools/verify-char-appearance.ts b/tools/verify-char-appearance.ts deleted file mode 100644 index 77dce47..0000000 --- a/tools/verify-char-appearance.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { spawn, type ChildProcess } from 'node:child_process' -import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/common/items/gems-data.ts' -import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { resolveItemColorTransform } from '../src/common/items/item-bridge.ts' -import { - SorceressLayerCompositor, - resolveSorceressAppearance, -} from '../src/common/units/char-appearance.ts' -import type { ResolvableEquippedItem } from '../src/common/units/char-appearance.ts' - -const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) - -function assert(cond: boolean, message: string): void { - if (!cond) { - throw new Error(`ASSERTION FAILED: ${message}`) - } -} - -function makeItem(overrides: Partial & { code: string; name: string }): ResolvableEquippedItem { - return { - quality: 'normal', - identified: true, - ...overrides, - } -} - -interface CdpSession { - send: (method: string, params?: Record, timeoutMs?: number) => Promise - evalJs: (expression: string) => Promise - close: () => void -} - -async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { - let wsUrl: string | null = null - let lastErr = 'none' - for (let i = 0; i < 80; i++) { - await sleep(200) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page') - if (page?.webSocketDebuggerUrl !== undefined) { - wsUrl = page.webSocketDebuggerUrl - break - } - lastErr = 'no page target yet' - } catch (err) { - lastErr = err instanceof Error ? err.message : String(err) - } - } - if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) - - const ws = new WebSocket(wsUrl) - await new Promise((res, rej) => { - ws.onopen = () => res() - ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) - }) - - let idCounter = 1 - const send = ( - method: string, - params: Record = {}, - timeoutMs = 20000, - ): Promise => - new Promise((res, rej) => { - const id = idCounter++ - const timer = setTimeout(() => { - ws.removeEventListener('message', handler) - rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) - }, timeoutMs) - const handler = (event: MessageEvent): void => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['id'] === id) { - clearTimeout(timer) - ws.removeEventListener('message', handler) - if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) - else res(msg['result'] as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - onEvent(JSON.parse(String(event.data)) as Record) - }) - - const evalJs = async (expression: string): Promise => { - const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails !== undefined) { - throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) - } - return res.result.value - } - - return { send, evalJs, close: () => ws.close() } -} - -async function runUnitAndCompositorChecks(): Promise { - console.log('--- 1. Verifying Item Color & Palette Tables ---') - assert(COLOR_CODES.length === 21, `Expected 21 COLOR_CODES, got ${COLOR_CODES.length}`) - assert(COLOR_CODES[0] === 'whit' && COLOR_CODES[20] === 'bwht', 'COLOR_CODES boundary mismatch') - assert(Object.keys(GEM_TRANSFORM_BY_CODE).length === 35, 'Expected 35 gem transform entries') - - const dropTables = getEmbeddedDropTables() - - // Unidentified item -> undefined - const unidUnique = resolveItemColorTransform( - { name: 'The Oculus', identified: false } as any, - 'unique', - 'oba', - dropTables, - ) - assert( - unidUnique.chrtransform === undefined, - `Unidentified item must resolve to undefined chrtransform, got ${String(unidUnique.chrtransform)}`, - ) - - // Unique items - assert( - resolveItemColorTransform({ name: "Ginther's Rift", identified: true } as any, 'unique', '9cr', dropTables).chrtransform === 'cblu', - "Ginther's Rift should resolve to cblu", - ) - assert( - resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables).chrtransform === 'cgrn', - 'Harlequin Crest should resolve to cgrn', - ) - assert( - resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables).chrtransform === 'dblu', - 'Skin of the Vipermagi should resolve to dblu', - ) - - // Set items (including display name vs internal index fallback) - assert( - resolveItemColorTransform({ name: "Tal Rasha's Horadric Crest", identified: true } as any, 'set', 'xsk', dropTables).chrtransform === 'dpur', - "Tal Rasha's Horadric Crest should resolve to dpur", - ) - assert( - resolveItemColorTransform({ name: "Tal Rasha's Guardianship", identified: true } as any, 'set', 'uth', dropTables).chrtransform === 'dpur', - "Tal Rasha's Guardianship (uth) should resolve to dpur", - ) - - // Magic/Rare suffix > prefix priority - assert( - resolveItemColorTransform( - { - name: 'Cobalt Cap of the Mammoth', - identified: true, - rolledMagicAffixes: { - prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], - suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }], - }, - } as any, - 'magic', - 'cap', - dropTables, - ).chrtransform === 'cred', - 'Magic suffix color (cred) must override prefix color (cblu)', - ) - assert( - resolveItemColorTransform( - { - name: 'Cobalt Cap', - identified: true, - rolledMagicAffixes: { - prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }], - suffixes: [{ name: 'of Dexterity', transform: false, transformColor: '' }], - }, - } as any, - 'magic', - 'cap', - dropTables, - ).chrtransform === 'cblu', - 'Magic prefix color (cblu) must apply when suffix has no transform', - ) - - // Socketed gems vs Runeword exclusion - assert( - resolveItemColorTransform( - { - name: 'Socketed Cap', - identified: true, - socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], - } as any, - 'normal', - 'cap', - dropTables, - ).chrtransform === 'whit', - 'Socketed Perfect Diamond (gpw) must dye normal item whit', - ) - assert( - resolveItemColorTransform( - { - name: 'Enigma', - identified: true, - runewordRunes: "'JahIthBer'", - socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }], - } as any, - 'rune', - 'utp', - dropTables, - ).chrtransform === undefined, - 'Runeword must not inherit Unique or Gem color transform', - ) - - // AutoMagic - assert( - resolveItemColorTransform( - { - name: 'Plate Mail', - identified: true, - autoAffix: { name: 'Cobalt', transform: true, transformColor: 'cblu' }, - } as any, - 'normal', - 'plt', - dropTables, - ).chrtransform === 'cblu', - 'AutoMagic Cobalt must resolve to cblu', - ) - console.log('✓ Item color resolution (ITEMS_GetColor) passed all checks.') - - console.log('--- 2. Verifying Sorceress 10-Component Appearance Mapping ---') - // Unarmed - const unarmed = resolveSorceressAppearance({}, dropTables) - assert(unarmed.wclass === 'hth', `Unarmed wclass should be hth, got ${unarmed.wclass}`) - assert(unarmed.layers.hd.variant === 'lit', 'Unarmed hd should be lit') - assert(unarmed.layers.tr.variant === 'lit', 'Unarmed tr should be lit') - assert( - unarmed.layers.rh.variant === null && - unarmed.layers.lh.variant === null && - unarmed.layers.sh.variant === null, - 'Unarmed hands/shield must be null', - ) - - // Circlet exemption (D2Inventory.cpp:2624) - const circletSpec = resolveSorceressAppearance( - { helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) }, - dropTables, - ) - assert( - circletSpec.layers.hd.variant === 'lit' && circletSpec.layers.hd.color === null, - 'Circlet (ci3) must keep hd=lit with null color', - ) - - // Asymmetric Studded Leather (stu) vs Breast Plate (brs) - const stuSpec = resolveSorceressAppearance( - { armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) }, - dropTables, - ) - assert( - stuSpec.layers.tr.variant === 'lit' && - stuSpec.layers.lg.variant === 'med' && - stuSpec.layers.ra.variant === 'med' && - stuSpec.layers.la.variant === 'lit' && - stuSpec.layers.s1.variant === 'med' && - stuSpec.layers.s2.variant === 'med' && - stuSpec.layers.tr.color === 'cgrn', - 'Studded Leather (stu) asymmetric component mapping mismatch', - ) - - const brsSpec = resolveSorceressAppearance( - { armor: makeItem({ code: 'brs', name: 'Breast Plate', chrtransform: 'lgld' }) }, - dropTables, - ) - assert( - brsSpec.layers.tr.variant === 'hvy' && - brsSpec.layers.lg.variant === 'lit' && - brsSpec.layers.ra.variant === 'lit' && - brsSpec.layers.la.variant === 'lit' && - brsSpec.layers.s1.variant === 'hvy' && - brsSpec.layers.s2.variant === 'hvy' && - brsSpec.layers.tr.color === 'lgld', - 'Breast Plate (brs) component/color mapping mismatch', - ) - - // Weapons: Orb + Shield, Bow (lh!), Crossbow (rh!) - const orbShieldSpec = resolveSorceressAppearance( - { - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), - }, - dropTables, - ) - assert( - orbShieldSpec.wclass === '1hs' && - orbShieldSpec.layers.rh.variant === 'ob4' && - orbShieldSpec.layers.rh.color === 'whit' && - orbShieldSpec.layers.sh.variant === 'kit' && - orbShieldSpec.layers.sh.color === 'cblu', - `Orb + Monarch shield appearance mismatch: ${JSON.stringify(orbShieldSpec)}`, - ) - - const bowSpec = resolveSorceressAppearance( - { - weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }), - }, - dropTables, - ) - assert( - bowSpec.wclass === 'bow' && - bowSpec.layers.lh.variant === 'sbw' && - bowSpec.layers.rh.variant === null && - bowSpec.layers.sh.variant === null, - 'Short Bow must set wclass=bow, lh=sbw, rh=null, sh=null', - ) - - const xbwSpec = resolveSorceressAppearance( - { weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) }, - dropTables, - ) - assert( - xbwSpec.wclass === 'xbw' && xbwSpec.layers.rh.variant === 'lxb' && xbwSpec.layers.lh.variant === null, - 'Crossbow must set wclass=xbw, rh=lxb, lh=null', - ) - console.log('✓ Sorceress 10-component appearance mapping passed all checks.') - - console.log('--- 3. Verifying SorceressLayerCompositor Offline Pack & Palette Shifts ---') - const origFetch = globalThis.fetch - const rootDir = resolve('.') - globalThis.fetch = (async (input: RequestInfo | URL) => { - const urlStr = String(input) - const relPath = urlStr.replace(/^\/+/, '') - const buf = await readFile(join(rootDir, relPath)) - return new Response(buf, { status: 200 }) - }) as typeof fetch - - try { - const compositor = new SorceressLayerCompositor('samples/d2-packs/chars/so') - const compUnarmed = await compositor.composite(unarmed) - const compOrbShield = await compositor.composite(orbShieldSpec) - const compOrbShieldRed = await compositor.composite( - resolveSorceressAppearance( - { - helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cred' }), - armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cred' }), - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cred' }), - }, - dropTables, - ), - ) - const compOrbShieldBlue = await compositor.composite( - resolveSorceressAppearance( - { - helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cblu' }), - armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cblu' }), - weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }), - weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }), - }, - dropTables, - ), - ) - - assert(compUnarmed.members === 47, `Expected 47 unarmed members across 11 modes, got ${compUnarmed.members}`) - assert(compOrbShield.members > compUnarmed.members, `Expected orb+shield members > unarmed, got ${compOrbShield.members}`) - assert(compOrbShieldRed.members > compOrbShield.members, `Expected full plate+crown+orb+shield members > orb+shield, got ${compOrbShieldRed.members}`) - - // Compare palette indices between Red and Blue dyed identical gear - assert( - compOrbShieldRed.r8.length === compOrbShieldBlue.r8.length, - 'Identical gear with different dye colors must produce same atlas dimensions', - ) - let diffPixels = 0 - for (let i = 0; i < compOrbShieldRed.r8.length; i += 1) { - if (compOrbShieldRed.r8[i] !== compOrbShieldBlue.r8[i]) diffPixels += 1 - } - assert( - diffPixels > 50_000, - `Expected >50,000 palette-shifted pixels between cred and cblu gear, got ${diffPixels}`, - ) - console.log(`✓ SorceressLayerCompositor verified (${diffPixels.toLocaleString()} dyed pixels shifted between cred and cblu).`) - } finally { - globalThis.fetch = origFetch - } -} - -async function startViteServer(port: number): Promise { - const child = spawn('npx', ['vite', '--host', '127.0.0.1', '--port', String(port)], { - cwd: resolve('.'), - stdio: ['ignore', 'pipe', 'pipe'], - }) - const start = Date.now() - while (Date.now() - start < 20_000) { - try { - const resp = await fetch(`http://127.0.0.1:${String(port)}/acts.html`) - if (resp.ok) return child - } catch { - // wait - } - await sleep(250) - } - child.kill('SIGTERM') - throw new Error('Timed out waiting for Vite dev server') -} - -async function runBrowserE2ECheck(): Promise { - console.log('--- 4. Verifying Live WebGL Browser E2E Appearance & Dye Updates ---') - const port = 5173 - let server: ChildProcess | null = null - const debugPort = 9249 - let chrome: ChildProcess | null = null - try { - const probe = await fetch(`http://127.0.0.1:${String(port)}/acts.html`).catch(() => null) - if (!probe || !probe.ok) { - server = await startViteServer(port) - } - - const consoleErrors: string[] = [] - const mpqRequests: string[] = [] - - chrome = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${String(debugPort)}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,720', - 'about:blank', - ]) - - const cdp = await connectCdp(debugPort, msg => { - const method = msg['method'] as string | undefined - if (method === 'Network.requestWillBeSent') { - const req = msg['params'].request as { url: string } - if (/\.mpq(\?|$)/i.test(req.url)) mpqRequests.push(req.url) - } else if (method === 'Runtime.consoleAPICalled') { - if (msg['params'].type === 'error') { - const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] - const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') - consoleErrors.push(text) - } - } else if (method === 'Runtime.exceptionThrown') { - const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } - consoleErrors.push(d.exception?.description ?? d.text ?? 'unknown exception') - } - }) - - try { - await cdp.send('Page.enable') - await cdp.send('Runtime.enable') - await cdp.send('Network.enable') - - const targetUrl = `http://127.0.0.1:${String(port)}/acts.html?source=pack&base=samples/d2-packs&act=1&hero=so` - await cdp.send('Page.navigate', { url: targetUrl }) - - let ready = false - for (let i = 0; i < 120; i += 1) { - ready = await cdp.evalJs(`Boolean(window.__d2webAct?.ready)`) - if (ready) break - await sleep(300) - } - assert(ready, 'Scene did not become ready within timeout') - - // 1. Check initial Sorceress starter equipment appearance (Shako + Tal Armor + Oculus + Spirit Monarch) - const initialInfo = await cdp.evalJs<{ - heroToken: string - weaponClass: string - members: number - key: string - appearance: any - }>(`(() => { - const act = window.__d2webAct - return { - heroToken: act?.heroToken, - weaponClass: act?.weaponClass, - members: act?.characterMembers, - key: act?.characterAppearanceKey, - appearance: act?.characterAppearance, - } - })()`) - - assert(initialInfo.heroToken === 'so', `Expected heroToken=so, got ${String(initialInfo.heroToken)}`) - assert(initialInfo.weaponClass === '1hs', `Expected starter weaponClass=1hs, got ${String(initialInfo.weaponClass)}`) - assert( - initialInfo.appearance?.layers?.hd?.variant === 'msk' && - initialInfo.appearance?.layers?.hd?.color === 'dpur', - `Expected starter hd=msk:dpur (Tal Rasha's Horadric Crest), got ${JSON.stringify(initialInfo.appearance?.layers?.hd)}`, - ) - assert( - initialInfo.appearance?.layers?.tr?.variant === 'med' && - initialInfo.appearance?.layers?.tr?.color === 'dpur' && - initialInfo.appearance?.layers?.ra?.variant === 'hvy', - `Expected starter tr=med:dpur, ra=hvy (Tal Rasha's Guardianship), got ${JSON.stringify(initialInfo.appearance?.layers?.tr)}`, - ) - assert( - initialInfo.appearance?.layers?.rh?.variant === 'ob4' && - initialInfo.appearance?.layers?.rh?.color === 'dpur', - `Expected starter rh=ob4:dpur (Tal Rasha's Lidless Eye), got ${JSON.stringify(initialInfo.appearance?.layers?.rh)}`, - ) - assert( - initialInfo.appearance?.layers?.sh?.variant === null && - initialInfo.appearance?.layers?.sh?.color === null, - `Expected starter sh=null:null (5-piece Tal Rasha set has no shield), got ${JSON.stringify(initialInfo.appearance?.layers?.sh)}`, - ) - - // 1b. Equip Monarch Shield (uit -> kit, lgld) into weapon2 alongside Tal Rasha's Lidless Eye - const gearUpdateShield = await cdp.evalJs<{ - changed: boolean - weaponClass: string - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - ...hud.inventory.equipped, - weapon2: { - id: 'gold-monarch', - code: 'uit', - name: "Holy Monarch", - nameZh: '神圣统治者大盾', - invFile: 'invuit', - quality: 'magic', - chrtransform: 'lgld', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon2'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdateShield.changed === true, 'Expected syncAppearance() to return true after equipping Monarch shield') - assert( - gearUpdateShield.appearance?.layers?.sh?.variant === 'kit' && - gearUpdateShield.appearance?.layers?.sh?.color === 'lgld', - `Expected sh=kit:lgld (Holy Monarch), got ${JSON.stringify(gearUpdateShield.appearance?.layers?.sh)}`, - ) - - // 2. Equip Crown (cred) + Studded Leather (cblu) + Short Staff (sst) - const gearUpdate1 = await cdp.evalJs<{ - changed: boolean - weaponClass: string - members: number - key: string - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - helm: { - id: 'red-crown', - code: 'crn', - name: 'Ruby Crown', - nameZh: '红宝石皇冠', - invFile: 'invcrn', - quality: 'magic', - chrtransform: 'cred', - identified: true, - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - armor: { - id: 'blue-stu', - code: 'stu', - name: 'Cobalt Studded Leather', - nameZh: '钴蓝镶嵌皮甲', - invFile: 'invstu', - quality: 'magic', - chrtransform: 'cblu', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['armor'], - stats: [], - }, - weapon1: { - id: 'short-staff', - code: 'sst', - name: 'Short Staff', - nameZh: '短棍', - invFile: 'invsst', - quality: 'normal', - identified: true, - invWidth: 1, - invHeight: 3, - allowedSlots: ['weapon1'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - members: act.characterMembers, - key: act.characterAppearanceKey, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdate1.changed === true, 'Expected syncAppearance() to return true after equipping Crown + Studded Leather + Short Staff') - assert(gearUpdate1.weaponClass === 'stf', `Expected weaponClass=stf, got ${String(gearUpdate1.weaponClass)}`) - assert( - gearUpdate1.appearance.layers.hd.variant === 'crn' && - gearUpdate1.appearance.layers.hd.color === 'cred', - 'Ruby Crown should resolve to crn:cred', - ) - assert( - gearUpdate1.appearance.layers.tr.variant === 'lit' && - gearUpdate1.appearance.layers.tr.color === 'cblu' && - gearUpdate1.appearance.layers.lg.variant === 'med' && - gearUpdate1.appearance.layers.ra.variant === 'med' && - gearUpdate1.appearance.layers.la.variant === 'lit', - `Cobalt Studded Leather should resolve to tr=lit:cblu, lg=med, ra=med, la=lit, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, - ) - assert( - gearUpdate1.appearance.layers.rh.variant === 'bst' && - gearUpdate1.appearance.layers.sh.variant === null, - `Short Staff (sst -> alternateGfx=bst) should resolve to rh=bst and sh=null, got ${JSON.stringify(gearUpdate1.appearance.layers)}`, - ) - - // 3. Equip Bow (Short Bow) and verify wclass='bow' and lh='sbw' - const gearUpdateBow = await cdp.evalJs<{ - changed: boolean - weaponClass: string - members: number - appearance: any - }>(`(async () => { - const hud = window.__d2webHudInstance - const scene = window.__d2webActScene - const act = window.__d2webAct - hud.inventory.equipped = { - weapon1: { - id: 'test-bow', - code: 'sbw', - name: 'Short Bow', - nameZh: '短弓', - invFile: 'invsbw', - quality: 'normal', - identified: true, - invWidth: 2, - invHeight: 3, - allowedSlots: ['weapon1'], - stats: [], - }, - } - const changed = await scene.syncAppearance() - return { - changed, - weaponClass: act.weaponClass, - members: act.characterMembers, - appearance: act.characterAppearance, - } - })()`) - - assert(gearUpdateBow.changed === true, 'Expected syncAppearance() to return true when equipping Short Bow') - assert(gearUpdateBow.weaponClass === 'bow', `Expected weaponClass=bow, got ${String(gearUpdateBow.weaponClass)}`) - assert(gearUpdateBow.appearance.layers.lh?.variant === 'sbw', 'Short Bow must equip on LH layer') - assert(gearUpdateBow.appearance.layers.rh?.variant === null, 'Short Bow must leave RH layer null') - - assert(consoleErrors.length === 0, `Browser console/page errors: ${consoleErrors.join('; ')}`) - assert(mpqRequests.length === 0, `Forbidden runtime MPQ requests: ${mpqRequests.join('; ')}`) - console.log('✓ Live WebGL Browser E2E appearance & dye verification passed with 0 errors and 0 MPQ requests.') - } finally { - cdp.close() - } - } finally { - if (chrome) chrome.kill('SIGKILL') - if (server) server.kill('SIGTERM') - } -} - -async function main(): Promise { - await runUnitAndCompositorChecks() - await runBrowserE2ECheck() - console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!') -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/tools/verify-controlbar-browser.ts b/tools/verify-controlbar-browser.ts deleted file mode 100644 index 792797b..0000000 --- a/tools/verify-controlbar-browser.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * scripts/verify-controlbar-browser.ts - * - * Headless Chrome browser verification of ControlBar XP bar rendering & hover interactions: - * 1. Offscreen HTML Canvas Pixel Audit: - * - Real OffscreenCanvas in Google Chrome. - * - Renders 800CtrlPnl7 panel + ControlBarHud. - * - Extracts real pixel data via getImageData(). - * - Verifies groove [256, 560, 120, 4] colors. - * - Verifies gold trim borders at y=559 and y=564 have ZERO pixel bleed. - * 2. Real DOM Mouse Event Projection across DPRs & Viewports: - * - Viewports: 800x600, 1920x1080. - * - DPRs: 1.0, 1.25, 1.5, 2.0. - * - Inspects window.__d2webHudInstance.controlBar.hoveredElement. - */ - -import { spawn } from 'node:child_process' -import { createServer } from 'vite' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -async function main(): Promise { - console.log('=== Headless Chrome ControlBar Stress Test ===') - - // 1. Start Vite dev server - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite server running at ${baseUrl}`) - - // 2. Launch headless Chrome with CDP - const debugPort = 9248 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--window-size=1920,1080', - `${baseUrl}/acts.html?act=1`, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - - if (!wsUrl) throw new Error('Failed to connect to Chrome CDP') - console.log('2. Connected to Chrome CDP:', wsUrl) - - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { ws.onopen = () => resolve() }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - // Wait for HudManager to be ready - for (let i = 0; i < 100; i++) { - const ready = await evalJs(`Boolean(window.__d2webHud?.ready && window.__d2webHudInstance)`) - if (ready) break - await sleep(300) - } - - console.log('3. HudManager ready in Chrome!') - - // Test 1: Real OffscreenCanvas rendering & pixel audit inside Chrome - console.log('\n--- Test 1: Chrome OffscreenCanvas Pixel Audit ---') - const pixelAuditResult = await evalJs<{ - bleedTop559Count: number - bleedBot564Count: number - grooveBackgroundRgb: number[] - grooveFilledRgb: number[] - tickNotchRgb: number[] - tickPositions: number[] - }>(`(() => { - const hudInst = window.__d2webHudInstance; - const ctrlBar = hudInst.controlBar; - const ctrlImg = hudInst.images.get('ctrlPnl'); - - // Create base OffscreenCanvas with only background image - const baseCanvas = new OffscreenCanvas(800, 600); - const baseCtx = baseCanvas.getContext('2d'); - baseCtx.drawImage(ctrlImg, 0, 496); - const baseImgData = baseCtx.getImageData(0, 0, 800, 600).data; - - // Create test OffscreenCanvas and render ControlBar with 50% XP - const testCanvas = new OffscreenCanvas(800, 600); - const testCtx = testCanvas.getContext('2d'); - const testState = { - level: 1, - xp: 1500, - currentLevelXp: 1000, - nextLevelXp: 2000, - stamina: 100, - maxStamina: 100, - isRunning: false, - unspentStatPoints: 0, - unspentSkillPoints: 0, - }; - - const assetBundle = { - ctrlPnlImg: hudInst.images.get('ctrlPnl') ?? null, - runBtnImg: hudInst.images.get('runbutton') ?? null, - menuBtnImg: hudInst.images.get('menubutton') ?? null, - miniPanelImg: hudInst.images.get('minipanel') ?? null, - miniPanelBtnsImg: hudInst.images.get('minipanelBtns') ?? null, - levelBtnImg: hudInst.images.get('levelBtn') ?? null, - levelSocketImg: hudInst.images.get('levelSocket') ?? null, - }; - - ctrlBar.draw(testCtx, testState, assetBundle, hudInst.font); - const testImgData = testCtx.getImageData(0, 0, 800, 600).data; - - let bleedTop559Count = 0; - let bleedBot564Count = 0; - - // Check row y=559 for x in [256..375] - for (let x = 256; x <= 375; x++) { - const idx = (559 * 800 + x) * 4; - if ( - testImgData[idx] !== baseImgData[idx] || - testImgData[idx + 1] !== baseImgData[idx + 1] || - testImgData[idx + 2] !== baseImgData[idx + 2] - ) { - bleedTop559Count++; - } - } - - // Check row y=564 for x in [256..375] - for (let x = 256; x <= 375; x++) { - const idx = (564 * 800 + x) * 4; - if ( - testImgData[idx] !== baseImgData[idx] || - testImgData[idx + 1] !== baseImgData[idx + 1] || - testImgData[idx + 2] !== baseImgData[idx + 2] - ) { - bleedBot564Count++; - } - } - - // Sample groove pixels - // Filled area (x=260, y=561) - const filledIdx = (561 * 800 + 260) * 4; - const grooveFilledRgb = [testImgData[filledIdx], testImgData[filledIdx + 1], testImgData[filledIdx + 2]]; - - // Unfilled area (x=350, y=561) - const unfilledIdx = (561 * 800 + 350) * 4; - const grooveBackgroundRgb = [testImgData[unfilledIdx], testImgData[unfilledIdx + 1], testImgData[unfilledIdx + 2]]; - - // Tick notch at x=268, y=561 - const notchIdx = (561 * 800 + 268) * 4; - const tickNotchRgb = [testImgData[notchIdx], testImgData[notchIdx + 1], testImgData[notchIdx + 2]]; - - // Verify all 9 ticks - const tickPositions = []; - for (let i = 1; i < 10; i++) { - const tx = 256 + i * 12; - const tidx = (561 * 800 + tx) * 4; - tickPositions.push(testImgData[tidx]); - } - - return { - bleedTop559Count, - bleedBot564Count, - grooveBackgroundRgb, - grooveFilledRgb, - tickNotchRgb, - tickPositions, - }; - })()`) - - console.log('Pixel audit result from Chrome:', JSON.stringify(pixelAuditResult, null, 2)) - - // Test 2: Hover Hit-Testing in real Chrome across DPRs & Resolutions - console.log('\n--- Test 2: Hover Hit-Testing in Chrome across DPRs & Resolutions ---') - const resolutions = [ - { w: 800, h: 600 }, - { w: 1920, h: 1080 }, - ] - const dprs = [1.0, 1.25, 1.5, 2.0] - - for (const res of resolutions) { - for (const dpr of dprs) { - // Set emulation device metrics - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: res.w, - height: res.h, - deviceScaleFactor: dpr, - mobile: false, - }) - await sleep(100) - - // Evaluate hover at key points - const hoverResult = await evalJs<{ - centerHover: string | null - outsideHover: string | null - leftEdgeHover: string | null - rightEdgeHover: string | null - }>(`(() => { - const inst = window.__d2webHudInstance; - const rect = inst.hudCanvas.getBoundingClientRect(); - - // Calculate client coords for center - const scale = Math.min(rect.width / 800, rect.height / 600); - const offX = Math.max(0, (rect.width - 800 * scale) / 2); - const offY = Math.max(0, rect.height - 600 * scale); - - const clientCenterX = 316 * scale + offX; - const clientCenterY = 562 * scale + offY; - const mappedCenter = inst.clientToLogical(clientCenterX, clientCenterY); - inst.controlBar.handleMouseMove(mappedCenter.x, mappedCenter.y); - const centerHover = inst.controlBar.hoveredElement; - - // Test point outside (above groove) - const clientOutsideY = 550 * scale + offY; - const mappedOutside = inst.clientToLogical(clientCenterX, clientOutsideY); - inst.controlBar.handleMouseMove(mappedOutside.x, mappedOutside.y); - const outsideHover = inst.controlBar.hoveredElement; - - // Left edge (x = 256.5 to avoid IEEE 754 epsilon boundary) - const clientLeftX = 256.5 * scale + offX; - const mappedLeft = inst.clientToLogical(clientLeftX, clientCenterY); - inst.controlBar.handleMouseMove(mappedLeft.x, mappedLeft.y); - const leftEdgeHover = inst.controlBar.hoveredElement; - - // Right edge (x = 375.5) - const clientRightX = 375.5 * scale + offX; - const mappedRight = inst.clientToLogical(clientRightX, clientCenterY); - inst.controlBar.handleMouseMove(mappedRight.x, mappedRight.y); - const rightEdgeHover = inst.controlBar.hoveredElement; - - return { - centerHover, - outsideHover, - leftEdgeHover, - rightEdgeHover, - }; - })()`) - - console.log(`Resolution ${res.w}x${res.h} @ DPR ${dpr}:`, hoverResult) - } - } - - } finally { - chromeProc.kill('SIGKILL') - await server.close() - } -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/tools/verify-ground-combat-browser.ts b/tools/verify-ground-combat-browser.ts deleted file mode 100644 index a5f9733..0000000 --- a/tools/verify-ground-combat-browser.ts +++ /dev/null @@ -1,817 +0,0 @@ -/** - * Headless Browser Verification Audit for Ground Items & Combat Drop Lifecycle (M11.5 / Issue #408 & Issue #449). - * - * Verifies end-to-end authentic combat drop parity in acts.html: - * 1. Scene & HUD initialization with fail-fast pack error detection. - * 2. Authentic monster kill & death state transition, unique elite multi-drops (Act 1 Unique A -> 4 potions + 1 item/gold). - * 3. Isometric ground drop scattering (findIsometricDropPosition). - * 4. Flippy bounce parabolic animation trajectory & WebGL canvas rendering. - * 5. Alt floating labels, Chinese localization, quality colors, and ladder collision resolution. - * 6. Dynamic ground item collection & inventory placement without invbox fallbacks. - * 7. Negative control: Inventory full refusal feedback & secondary bounce. - * 8. Zero runtime errors, zero MPQ requests, and 500-kill statistical drop assertions (3-sigma Binomial confidence). - * - * Run: - * npx tsx scripts/verify-ground-combat-browser.ts [--port=8080] [--out=/tmp/ground-audit] [--packs-dir=...] - */ - -import { execSync, spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { createServer, type ViteDevServer } from 'vite' -import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' -import { executeDropPipeline } from '../src/common/items/drop-tables.ts' -import { D2Rng } from '../src/common/rng/d2-rng.ts' -import { resolveGroundItemSpriteRect, resolveItemSpriteRect } from '../src/client/ui/inventory.ts' -import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' -import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -const CHROME_CANDIDATES = [ - '/usr/bin/google-chrome', - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -/** - * Preflight check ensuring `samples/d2-packs` is accessible. - * In a git worktree, automatically detects the parent repository via `git rev-parse --git-common-dir` - * and creates a symlink to prevent 404s and 20-second timeout hangs. - */ -export function ensurePacksAvailable(customPacksDir?: string): string { - // 1. Explicit CLI argument or environment variable - const envDir = customPacksDir ?? process.env.D2_PACKS_DIR - if (envDir && existsSync(join(envDir, 'index.json'))) { - return envDir - } - - // 2. Local samples/d2-packs - const localPacks = resolve('samples/d2-packs') - if (existsSync(join(localPacks, 'index.json'))) { - return localPacks - } - - // 3. Resolve via git common dir (for git worktrees) - try { - const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim() - if (gitCommonDir) { - const mainRepoDir = resolve(gitCommonDir, '..') - const parentPacks = join(mainRepoDir, 'samples/d2-packs') - if (existsSync(join(parentPacks, 'index.json'))) { - mkdirSync(resolve('samples'), { recursive: true }) - if (!existsSync(localPacks)) { - symlinkSync(parentPacks, localPacks) - console.log(`[Preflight] Auto-symlinked samples/d2-packs from main repository: ${parentPacks} -> ${localPacks}`) - } - return localPacks - } - } - } catch { - // Ignore git command error - } - - // 4. Fail-fast - console.error('\n[FATAL] Missing required pre-baked pack assets at samples/d2-packs/index.json.') - console.error('When running in a git worktree, ensure samples/d2-packs is symlinked from the main repo or set D2_PACKS_DIR.') - console.error('To generate packs, run: npm run pack:data\n') - process.exit(1) -} - -function ensureD2Available(): void { - const localD2 = resolve('samples/d2') - if (existsSync(localD2)) return - try { - const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim() - if (gitCommonDir) { - const parentD2 = join(resolve(gitCommonDir, '..'), 'samples/d2') - if (existsSync(parentD2)) { - mkdirSync(resolve('samples'), { recursive: true }) - symlinkSync(parentD2, localD2) - console.log(`[Preflight] Auto-symlinked samples/d2 from main repository: ${parentD2} -> ${localD2}`) - return - } - } - } catch {} - if (existsSync('/usr/local/google/home/taodao/d2-data')) { - mkdirSync(resolve('samples'), { recursive: true }) - symlinkSync('/usr/local/google/home/taodao/d2-data', localD2) - console.log(`[Preflight] Auto-symlinked samples/d2 from /usr/local/google/home/taodao/d2-data -> ${localD2}`) - } -} - -export interface GroundAuditResult { - readonly success: boolean - readonly durationMs: number - readonly stages: { - readonly stage1_sceneReady: boolean - readonly stage2_monsterKill: boolean - readonly stage3_groundScatter: boolean - readonly stage4_flippyBounce: boolean - readonly stage5_altLabelsAndLadder: boolean - readonly stage6_itemAndGoldPickup: boolean - readonly stage7_negativeControlFullBag: boolean - readonly stage8_zeroErrors: boolean - } - readonly metrics: Record - readonly consoleErrors: string[] - readonly webGlErrors: number[] - readonly mpqRequests: string[] - readonly screenshots: string[] -} - -export async function runGroundCombatBrowserAudit(options?: { - port?: number | undefined - outputDir?: string | undefined - packsDir?: string | undefined -}): Promise { - const startTime = Date.now() - const port = options?.port ?? (5300 + Math.floor(Math.random() * 500)) - const outputDir = options?.outputDir ?? resolve('/tmp/ground-audit-' + Date.now()) - mkdirSync(outputDir, { recursive: true }) - - // Worktree asset preflight check - ensurePacksAvailable(options?.packsDir) - ensureD2Available() - - console.log('======================================================================') - console.log(' Diablo II Web · Ground Combat & Drop Lifecycle Browser Audit') - console.log(' Milestone M11.5 (Issue #408) & Issue #449 · Target: acts.html') - console.log(` Artifacts: ${outputDir}`) - console.log('======================================================================\n') - - // 1. Launch Vite Dev Server - console.log(`[1/8] Starting Vite dev server on port ${port}...`) - const viteServer: ViteDevServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { - host: '127.0.0.1', - port, - strictPort: false, - }, - }) - await viteServer.listen() - const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port - const baseUrl = `http://127.0.0.1:${actualPort}` - console.log(` Vite server ready at ${baseUrl}/acts.html\n`) - - // 2. Launch Headless Chromium with Remote Debugging - const chromeBin = resolveChromeBinary() - const debugPort = 9400 + Math.floor(Math.random() * 500) - const targetUrl = `${baseUrl}/acts.html?act=1&level=2-act-1-wilderness-1-var1` - console.log(`[2/8] Spawning Headless Chromium (${chromeBin})`) - console.log(` Target URL: ${targetUrl}`) - - const chromeProc: ChildProcess = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1280,840', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--no-proxy-server', - `--remote-debugging-port=${debugPort}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - const consoleErrors: string[] = [] - const webGlErrors: number[] = [] - const mpqRequests: string[] = [] - const screenshots: string[] = [] - - let ws: WebSocket | null = null - - try { - // 3. Connect to Chrome DevTools Protocol - let wsDebuggerUrl: string | null = null - for (let i = 0; i < 80; i++) { - await sleep(150) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json`) - const pages = (await res.json()) as any[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsDebuggerUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - - if (!wsDebuggerUrl) throw new Error(`Failed to attach to Chrome CDP on debug port ${debugPort}`) - ws = new WebSocket(wsDebuggerUrl) - await new Promise((res, rej) => { - ws!.onopen = () => res(true) - ws!.onerror = rej - }) - - let cmdId = 1 - const pendingCmds = new Map void>() - ws.onmessage = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Runtime.exceptionThrown') { - consoleErrors.push(`[Exception] ${JSON.stringify(msg.params)}`) - } else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { - consoleErrors.push(`[Console.error] ${JSON.stringify(msg.params.args)}`) - } else if (msg.method === 'Network.requestWillBeSent') { - const url = msg.params?.request?.url ?? '' - if (/\.(mpq|dll)(\?|$)/i.test(url)) { - mpqRequests.push(url) - } - } - if (msg.id && pendingCmds.has(msg.id)) { - pendingCmds.get(msg.id)!(msg) - pendingCmds.delete(msg.id) - } - } - - const cdp = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = cmdId++ - pendingCmds.set(id, (msg: any) => { - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - }) - ws!.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`Eval error in browser: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const takeScreenshot = async (name: string): Promise => { - const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(outputDir, `${name}.png`) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - screenshots.push(filePath) - console.log(` Saved screenshot: ${name}.png`) - return filePath - } - - await cdp('Runtime.enable') - await cdp('Page.enable') - await cdp('Network.enable') - - // ========================================================================= - // STAGE 1: Wait for Scene & HUD Ready (Fast Error Abort) - // ========================================================================= - console.log('\n[STAGE 1] Waiting for ActScene and HudManager initialization...') - let ready = false - for (let i = 0; i < 100; i++) { - const actError = await evalJs(`window.__d2webAct?.error ?? null`) - if (actError) { - throw new Error(`ActScene initialization failed with error: ${actError}`) - } - ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (ready) break - await sleep(200) - } - if (!ready) throw new Error('Timeout waiting for window.__d2webAct & window.__d2webHud') - - const initialMetrics = await evalJs(`(() => { - const gl = document.querySelector('#view')?.getContext('webgl2'); - const err = gl ? gl.getError() : -1; - return { - level: window.__d2webAct?.level, - tick: window.__d2webAct?.tick, - monstersCount: window.__d2webEngine?.world?.monsters?.length ?? 0, - playerPos: { x: window.__d2webEngine?.world?.player?.x, y: window.__d2webEngine?.world?.player?.y }, - glError: err, - }; - })()`) - if (initialMetrics.glError !== 0) webGlErrors.push(initialMetrics.glError) - console.log(` Scene loaded level: ${initialMetrics.level}, monsters: ${initialMetrics.monstersCount}`) - console.log(` Player spawn: (${initialMetrics.playerPos.x}, ${initialMetrics.playerPos.y})`) - await takeScreenshot('01-scene-ready') - - // ========================================================================= - // STAGE 2: Monster Spawn, Authentic Player Combat & Monster Kill - // ========================================================================= - console.log('\n[STAGE 2] Executing authentic combat and killing unique elite monster...') - const combatResult = await evalJs(`(() => { - const engine = window.__d2webEngine; - const player = engine.world.player; - - // Locate or place a test monster in front of player - let target = engine.world.monsters.find(m => m.state !== 'dead' && Math.hypot(m.x - player.x, m.y - player.y) < 160); - if (!target && engine.world.monsters.length > 0) { - target = engine.world.monsters.find(m => m.state !== 'dead'); - } - if (!target) { - // Fallback spawn for deterministic combat testing with unique rank - target = { - index: engine.world.monsters.length, - stats: { id: 'zombie1', name: '僵尸 (Zombie)', hp: 10, xp: 50, rank: 'unique', level: 2 }, - state: 'idle', - x: player.x + 25, - y: player.y + 10, - hp: 10, - maxHp: 10, - facing: 0, - cooldown: 0, - hitFlash: 0, - corpseTicks: 0, - dropRolled: false, - }; - engine.world.monsters.push(target); - } else { - // Upgrade to unique elite monster in Act 1 (rolls Act 1 Unique A -> 4 potions + 1 equip/gold) - target.stats.rank = 'unique'; - if (!target.stats.level || target.stats.level < 2) target.stats.level = 2; - if (!target.stats.id) target.stats.id = 'zombie1'; - target.hp = Math.max(target.hp, 10); - target.state = 'idle'; - target.dropRolled = false; - target.x = player.x + 25; - target.y = player.y + 10; - } - - const preKills = engine.world.kills || 0; - const preDrops = engine.metrics.dropsRolled || 0; - const preGroundCount = engine.groundItems.count || 0; - - // Route damage through authentic combat execution: - // Player cooldown reset, player damage set to lethal, attacking tick executed - player.cooldown = 0; - engine.opts.combatOptions.playerDamage = 9999; - engine.tick({ movement: { x: 0, y: 0 }, attacking: true, digits: [] }); - - // If target wasn't finished in first swing (e.g. if out of reach), execute lethal hit via damageMonster - if (target.state !== 'dead') { - if (typeof window.__d2webDamageMonster === 'function') { - window.__d2webDamageMonster(engine.world, target.index, 9999, player); - } else if (typeof engine.damageMonster === 'function') { - engine.damageMonster(target.index, 9999, player); - } - engine.tick({ movement: { x: 0, y: 0 }, attacking: false, digits: [] }); - } - - return { - kills: engine.world.kills, - killsDelta: (engine.world.kills || 0) - preKills, - dropsRolled: engine.metrics.dropsRolled, - dropsDelta: (engine.metrics.dropsRolled || 0) - preDrops, - groundCount: engine.groundItems.count, - newGroundCount: engine.groundItems.count - preGroundCount, - targetState: target.state, - targetRank: target.stats?.rank, - items: engine.groundItems.all.map(g => ({ - id: g.id, - code: g.item?.code ?? g.code, - name: g.item?.name ?? g.name, - nameZh: g.nameZh, - isGold: g.isGold, - amount: g.amount, - x: g.x, - y: g.y, - hasBounce: Boolean(g.bounceState), - })), - }; - })()`) - - console.log(` Kills: ${combatResult.kills} (+${combatResult.killsDelta}), Drops rolled: ${combatResult.dropsRolled} (+${combatResult.dropsDelta})`) - console.log(` Ground items spawned: ${combatResult.groundCount} (New drops: ${combatResult.newGroundCount})`) - if (combatResult.killsDelta < 1) throw new Error('Monster was not killed by combat execution') - if (combatResult.dropsDelta < 1) throw new Error('Drop pipeline was not rolled on monster kill') - if (combatResult.newGroundCount < 5) throw new Error(`Expected at least 5 drops from unique elite kill, got ${combatResult.newGroundCount}`) - await takeScreenshot('02-monster-killed-and-dropped') - - // ========================================================================= - // STAGE 3 & 4: Ground Scatter Spacing & Flippy Bounce Parabolic Flight - // ========================================================================= - console.log('\n[STAGE 3 & 4] Verifying Isometric Scatter & Flippy Bounce trajectory...') - const bounceMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const items = engine.groundItems.all; - const itemA = items[0]; - const itemB = items[1]; - const dist = Math.hypot(itemA.x - itemB.x, itemA.y - itemB.y); - - // Verify parabolic bounce heights - const bState = itemA.bounceState ?? { - startTime: 0, - durationMs: 400, - peakHeightPx: 24, - phase: 'primary', - }; - const calcHeight = (t) => { - const elapsed = t - bState.startTime; - if (elapsed <= 0 || elapsed >= bState.durationMs) return 0; - const p = elapsed / bState.durationMs; - return 4 * bState.peakHeightPx * p * (1 - p); - }; - - const hStart = calcHeight(bState.startTime); - const hMid = calcHeight(bState.startTime + bState.durationMs / 2); - const hEnd = calcHeight(bState.startTime + bState.durationMs + 10); - - // Canvas WebGL render check - const gl = document.querySelector('#view')?.getContext('webgl2'); - const glErr = gl ? gl.getError() : -1; - - return { - distBetweenItems: dist, - durationMs: bState.durationMs, - peakHeightPx: bState.peakHeightPx, - hStart, - hMid, - hEnd, - glErr, - }; - })()`) - - if (bounceMetrics.glErr !== 0) webGlErrors.push(bounceMetrics.glErr) - console.log(` Distance between scattered items: ${bounceMetrics.distBetweenItems.toFixed(1)}px (>= 10px)`) - console.log(` Bounce duration: ${bounceMetrics.durationMs}ms, Peak height: ${bounceMetrics.peakHeightPx}px`) - console.log(` Parabolic heights: Start=${bounceMetrics.hStart}px, Mid=${bounceMetrics.hMid}px, End=${bounceMetrics.hEnd}px`) - if (bounceMetrics.distBetweenItems < 10) throw new Error('Items stacked without isometric scatter') - if (Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) > 0.5) { - throw new Error(`Parabolic peak height mismatch: expected ${bounceMetrics.peakHeightPx}px, got ${bounceMetrics.hMid}px`) - } - - // Ground sprite flippy rect resolution verification - const groundSpriteValidation = combatResult.items.map((item: any) => { - const sr = resolveGroundItemSpriteRect(item, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile) - return { - id: item.id, - code: item.code, - hasRect: sr !== null, - isFlpbox: sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && item.code !== 'box', - } - }) - const invalidGroundSprites = groundSpriteValidation.filter((v: any) => !v.hasRect || v.isFlpbox) - if (invalidGroundSprites.length > 0) { - throw new Error(`Ground items failed flippy sprite resolution: ${JSON.stringify(invalidGroundSprites)}`) - } - - // ========================================================================= - // STAGE 5: Alt Key Labels & Vertical Ladder Collision Resolution - // ========================================================================= - console.log('\n[STAGE 5] Triggering Alt key floating labels & verifying ladder collisions...') - // Press Alt and wait for render loop to update overlay - await evalJs(`new Promise(resolve => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true })); - requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 200))); - })`) - - const altMetrics = await evalJs(`(() => { - const hud = window.__d2webHud; - const container = document.querySelector('#ground-labels'); - const labelEls = Array.from(container ? container.querySelectorAll('.d2-ground-label') : []) - .filter(el => el.style.display !== 'none' && !el.hidden); - - const boxes = labelEls.map(el => { - const r = el.getBoundingClientRect(); - return { - text: el.textContent, - color: el.style.color, - left: r.left, - top: r.top, - right: r.right, - bottom: r.bottom, - width: r.width, - height: r.height, - }; - }); - - // Check for bounding box collisions - let overlapCount = 0; - for (let i = 0; i < boxes.length; i++) { - for (let j = i + 1; j < boxes.length; j++) { - const a = boxes[i]; - const b = boxes[j]; - const xOverlap = a.left < b.right && a.right > b.left; - const yOverlap = a.top < b.bottom && a.bottom > b.top; - if (xOverlap && yOverlap) overlapCount++; - } - } - - return { - showGroundLabels: hud.showGroundLabels, - labelCount: labelEls.length, - boxes, - overlapCount, - }; - })()`) - - console.log(` Alt pressed: showGroundLabels = ${altMetrics.showGroundLabels}`) - console.log(` Active DOM labels rendered: ${altMetrics.labelCount}`) - console.log(` Labels text & color: ${altMetrics.boxes.map((b: any) => `${b.text} (${b.color})`).join(', ')}`) - console.log(` Ladder collision bounding box overlaps: ${altMetrics.overlapCount} (MUST BE 0)`) - if (altMetrics.labelCount < 5) throw new Error(`Expected at least 5 ground labels rendered for unique drop, got ${altMetrics.labelCount}`) - if (altMetrics.overlapCount !== 0) throw new Error(`Ladder collision resolution failed: ${altMetrics.overlapCount} overlapping labels found!`) - await takeScreenshot('03-alt-labels-ladder-stacked') - - // Release Alt and wait for overlay to hide - await evalJs(`new Promise(resolve => { - window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true })); - requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150))); - })`) - const altReleasedCount = await evalJs(`(() => { - const container = document.querySelector('#ground-labels'); - return Array.from(container ? container.querySelectorAll('.d2-ground-label') : []) - .filter(el => el.style.display !== 'none' && !el.hidden).length; - })()`) - console.log(` Alt released: active visible labels = ${altReleasedCount} (expected 0)`) - if (altReleasedCount !== 0) throw new Error(`Ground labels still visible after Alt release: ${altReleasedCount}`) - - // ========================================================================= - // STAGE 6: Dynamic Ground Entity Collection & Inventory Parity - // ========================================================================= - console.log('\n[STAGE 6] Dynamically collecting all ground entities and verifying inventory parity...') - const pickupMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const mouse = window.__d2webMouseController; - const hud = window.__d2webHudInstance; - const player = engine.world.player; - - const preGold = hud.inventory.gold || 0; - const preBagCount = hud.inventory.gridItems.length; - const prePickups = engine.metrics.pickups || 0; - const initialGroundCount = engine.groundItems.count; - - // Dynamically collect all ground entities - const itemsToPick = [...engine.groundItems.all]; - for (const item of itemsToPick) { - player.x = item.x; - player.y = item.y; - mouse.pickupGroundItem(item); - } - - // Check for invbox fallback in inventory items - let invboxFallbackCount = 0; - const inventoryItems = hud.inventory.gridItems.map(gi => { - const item = gi.item; - if (item.invFile === 'invbox' && item.code !== 'box') { - invboxFallbackCount++; - } - return { - id: item.id, - code: item.code, - name: item.name, - invFile: item.invFile, - }; - }); - - return { - initialGroundCount, - pickedCount: itemsToPick.length, - postGold: hud.inventory.gold, - goldDelta: hud.inventory.gold - preGold, - postBagCount: hud.inventory.gridItems.length, - bagDelta: hud.inventory.gridItems.length - preBagCount, - pickupsTotal: engine.metrics.pickups, - pickupsDelta: engine.metrics.pickups - prePickups, - remainingGroundCount: engine.groundItems.count, - invboxFallbackCount, - inventoryItems, - statusText: document.querySelector('#status')?.textContent ?? '', - }; - })()`) - - console.log(` Dynamic collection: ${pickupMetrics.pickedCount} ground entities processed`) - console.log(` Gold pickup delta: +${pickupMetrics.goldDelta} (Total: ${pickupMetrics.postGold})`) - console.log(` Equipment & potion pickup: +${pickupMetrics.bagDelta} items in bag`) - console.log(` Ground items remaining: ${pickupMetrics.remainingGroundCount} (expected 0)`) - console.log(` Invbox fallback count: ${pickupMetrics.invboxFallbackCount} (MUST BE 0)`) - console.log(` Status text feedback: "${pickupMetrics.statusText}"`) - if (pickupMetrics.remainingGroundCount !== 0) throw new Error('Ground items were not cleared after pickup') - if (pickupMetrics.bagDelta <= 0) throw new Error('No items were placed in bag after picking up drops') - if (pickupMetrics.invboxFallbackCount !== 0) throw new Error(`UI items hit invbox fallback: ${pickupMetrics.invboxFallbackCount}`) - await takeScreenshot('04-items-picked-up-inventory') - - // ========================================================================= - // STAGE 7: Negative Control (Inventory Full Refusal & Secondary Bounce) - // ========================================================================= - console.log('\n[STAGE 7] Negative Control: Inventory full refusal & secondary bounce...') - const refusalMetrics = await evalJs(`(() => { - const engine = window.__d2webEngine; - const mouse = window.__d2webMouseController; - const hud = window.__d2webHudInstance; - - // Fill player inventory completely (40 cells) with 10 2x2 items - hud.inventory.gridItems = []; - for (let c = 0; c < 10; c += 2) { - for (let r = 0; r < 4; r += 2) { - hud.inventory.gridItems.push({ - item: { - id: 'filler-' + c + '-' + r, - code: 'cap', - invFile: 'invcap', - name: 'Cap', - nameZh: '帽子 (Cap)', - baseNameZh: '帽子 (Cap)', - quality: 'normal', - invWidth: 2, - invHeight: 2, - allowedSlots: ['helm'], - stats: [], - }, - col: c, - row: r, - }); - } - } - - // Spawn test item on ground near player - const player = engine.world.player; - const testItem = { - id: 'test_drop_full', - code: 'hax', - name: 'Hand Axe', - nameZh: '手斧 (Hand Axe)', - quality: 'normal', - invWidth: 1, - invHeight: 2, - base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 }, - }; - const groundItem = engine.dropItem(testItem, player.x + 10, player.y + 10); - - const preRefusals = engine.metrics.inventoryRefusals || 0; - - // Attempt pickup with full bag - mouse.pickupGroundItem(groundItem); - - return { - preRefusals, - postRefusals: engine.metrics.inventoryRefusals, - itemStillOnGround: Boolean(engine.groundItems.get(groundItem.id)), - hasSecondaryBounce: Boolean(groundItem.bounceState), - bounceDuration: groundItem.bounceState?.durationMs, - bouncePeak: groundItem.bounceState?.peakHeightPx, - statusText: document.querySelector('#status')?.textContent ?? '', - }; - })()`) - - console.log(` Inventory refusals: ${refusalMetrics.preRefusals} -> ${refusalMetrics.postRefusals}`) - console.log(` Item remains on ground: ${refusalMetrics.itemStillOnGround}`) - console.log(` Secondary bounce triggered: ${refusalMetrics.hasSecondaryBounce} (Peak: ${refusalMetrics.bouncePeak}px)`) - console.log(` Feedback notification: "${refusalMetrics.statusText}"`) - if (refusalMetrics.postRefusals <= refusalMetrics.preRefusals) throw new Error('Inventory refusal counter did not increment') - if (!refusalMetrics.itemStillOnGround) throw new Error('Item disappeared despite bag full refusal') - if (!refusalMetrics.hasSecondaryBounce) throw new Error('Refusal flippy bounce was not triggered') - await takeScreenshot('05-inventory-full-refusal') - - // ========================================================================= - // STAGE 8: Zero Error Invariant Check & 500-Kill Statistical Drop Assertions - // ========================================================================= - console.log('\n[STAGE 8] Asserting zero runtime errors and 500-kill statistical drop invariants...') - const finalGlErr = await evalJs(`document.querySelector('#view')?.getContext('webgl2')?.getError() ?? -1`) - if (finalGlErr !== 0) webGlErrors.push(finalGlErr) - - console.log(` Console errors: ${consoleErrors.length}`) - console.log(` WebGL errors: ${webGlErrors.length}`) - console.log(` MPQ / DLL network requests: ${mpqRequests.length}`) - - // 500-kill drop rate statistical assertions - console.log(' Executing 500-kill drop statistical audit (Act 1 H2H A)...') - const dropTables = getEmbeddedDropTables() - let statTotalDrops = 0 - let statGoldCount = 0 - let statPotionCount = 0 - let statEquipCount = 0 - let statInvboxHits = 0 - let statFlpboxHits = 0 - let statMissingFlippy = 0 - - const N = 500 - for (let i = 0; i < N; i++) { - const drops = executeDropPipeline(dropTables, { - tcName: 'Act 1 H2H A', - nLevel: 2, - monsterType: 1, - difficulty: 'normal', - monsterRng: new D2Rng(i * 997 + 13), - }) - statTotalDrops += drops.length - for (const drop of drops) { - const isGold = drop.code?.trim() === 'gld' - if (isGold) { - statGoldCount++ - } else if (['hp1', 'hp2', 'mp1', 'mp2', 'rvs'].includes(drop.code?.trim() ?? '')) { - statPotionCount++ - } else { - statEquipCount++ - } - - const sr = resolveGroundItemSpriteRect(drop, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile) - if (!sr) statMissingFlippy++ - if (sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && drop.code?.trim() !== 'box') statFlpboxHits++ - - if (!isGold) { - const uiItem = itemToUiInventoryItem(drop, dropTables) - if (uiItem.invFile === 'invbox' && drop.code?.trim() !== 'box') statInvboxHits++ - const ir = resolveItemSpriteRect(uiItem, BAKED_UI_MANIFEST.itemRects) - if (ir === BAKED_UI_MANIFEST.itemRects?.['invbox'] && drop.code?.trim() !== 'box') statInvboxHits++ - } - } - } - - console.log(` 500-kill sample: Total=${statTotalDrops} (3σ: [155, 220]), Gold=${statGoldCount} (3σ: [43, 88]), Potions=${statPotionCount} (3σ: [16, 49]), Equip=${statEquipCount}`) - console.log(` Asset parity: missingFlippy=${statMissingFlippy}, flpboxHits=${statFlpboxHits}, invboxHits=${statInvboxHits}`) - - const statsPass = - statTotalDrops >= 155 && statTotalDrops <= 220 && - statGoldCount >= 43 && statGoldCount <= 88 && - statPotionCount >= 16 && statPotionCount <= 49 && - statMissingFlippy === 0 && - statFlpboxHits === 0 && - statInvboxHits === 0 - - if (!statsPass) { - throw new Error(`500-kill statistical drop audit failed 3-sigma confidence bounds or had sprite fallbacks!`) - } - - const auditPass = - consoleErrors.length === 0 && - webGlErrors.length === 0 && - mpqRequests.length === 0 && - combatResult.killsDelta >= 1 && - combatResult.dropsDelta >= 1 && - combatResult.newGroundCount >= 5 && - bounceMetrics.hMid > 20 && - altMetrics.overlapCount === 0 && - altMetrics.labelCount >= 5 && - pickupMetrics.remainingGroundCount === 0 && - pickupMetrics.bagDelta > 0 && - pickupMetrics.invboxFallbackCount === 0 && - refusalMetrics.postRefusals > refusalMetrics.preRefusals && - statsPass - - return { - success: auditPass, - durationMs: Date.now() - startTime, - stages: { - stage1_sceneReady: ready, - stage2_monsterKill: combatResult.killsDelta >= 1 && combatResult.dropsDelta >= 1 && combatResult.newGroundCount >= 5, - stage3_groundScatter: bounceMetrics.distBetweenItems >= 10, - stage4_flippyBounce: Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) < 1 && bounceMetrics.peakHeightPx >= 20, - stage5_altLabelsAndLadder: altMetrics.overlapCount === 0 && altMetrics.labelCount >= 5, - stage6_itemAndGoldPickup: pickupMetrics.remainingGroundCount === 0 && pickupMetrics.bagDelta > 0 && pickupMetrics.invboxFallbackCount === 0, - stage7_negativeControlFullBag: refusalMetrics.postRefusals > refusalMetrics.preRefusals && refusalMetrics.itemStillOnGround && refusalMetrics.hasSecondaryBounce, - stage8_zeroErrors: consoleErrors.length === 0 && webGlErrors.length === 0 && mpqRequests.length === 0 && statsPass, - }, - metrics: { - kills: combatResult.kills, - drops: combatResult.dropsRolled, - scatterDistance: bounceMetrics.distBetweenItems, - ladderLabelsCount: altMetrics.labelCount, - ladderOverlaps: altMetrics.overlapCount, - goldPickedUp: pickupMetrics.goldDelta, - equipmentPickedUp: pickupMetrics.bagDelta, - refusalsHandled: refusalMetrics.postRefusals, - sampleDrops500: statTotalDrops, - sampleGold500: statGoldCount, - samplePotions500: statPotionCount, - }, - consoleErrors, - webGlErrors, - mpqRequests, - screenshots, - } - } finally { - if (ws) ws.close() - if (chromeProc) chromeProc.kill('SIGTERM') - if (viteServer) await viteServer.close() - } -} - -// Standalone execution entrypoint -if (import.meta.url === `file://${process.argv[1]}`) { - const outputArg = process.argv.find(arg => arg.startsWith('--out='))?.split('=')[1] - const packsArg = process.argv.find(arg => arg.startsWith('--packs-dir='))?.split('=')[1] - runGroundCombatBrowserAudit({ outputDir: outputArg, packsDir: packsArg }) - .then(result => { - console.log('\n======================================================================') - console.log(` AUDIT RESULT: ${result.success ? 'PASSED (ALL STAGES GREEN)' : 'FAILED'}`) - console.log(` Duration: ${(result.durationMs / 1000).toFixed(2)}s`) - console.log(' Stages:', JSON.stringify(result.stages, null, 2)) - console.log('======================================================================\n') - process.exit(result.success ? 0 : 1) - }) - .catch(err => { - console.error('\nHARNESS FATAL ERROR:', err) - process.exit(1) - }) -} diff --git a/tools/verify-issue-503-browser.ts b/tools/verify-issue-503-browser.ts deleted file mode 100644 index e42b3a4..0000000 --- a/tools/verify-issue-503-browser.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #503: - * Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions (`index.html`, `acts-zh.html`, `acts-en.html`, `acts.html`). - * - * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP. - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/worker_503_1' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface CdpSession { - evalJs: (expression: string) => Promise - saveScreenshot: (filename: string) => Promise - waitForGameReady: () => Promise -} - -async function withChromePage( - url: string, - matchSubstr: string, - debugPort: number, - fn: (session: CdpSession) => Promise, -): Promise { - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - url, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr)) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error(`Failed to connect to Chrome CDP for ${url}`) - } - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as R) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - const waitForGameReady = async (): Promise => { - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) { - await sleep(500) - return - } - await sleep(300) - } - throw new Error(`Timed out waiting for game ready at ${url}`) - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const result = await fn({ evalJs, saveScreenshot, waitForGameReady }) - ws.close() - return result - } finally { - chromeProc.kill('SIGKILL') - await sleep(200) - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - try { - // ── Phase A: Verify index.html bilingual entry portal ── - await withChromePage(`${baseUrl}/index.html`, 'index.html', 9241, async ({ evalJs, saveScreenshot }) => { - await sleep(400) - const indexCheck = await evalJs<{ - zhHref: string | null - enHref: string | null - title: string - }>(`({ - zhHref: document.getElementById('enter-zh')?.getAttribute('href') ?? null, - enHref: document.getElementById('enter-en')?.getAttribute('href') ?? null, - title: document.title, - })`) - console.log('2. Verified index.html entry portal:', JSON.stringify(indexCheck)) - if (indexCheck.zhHref !== './acts-zh.html' || indexCheck.enHref !== './acts-en.html') { - throw new Error(`Unexpected index.html links: ${JSON.stringify(indexCheck)}`) - } - await saveScreenshot('index-bilingual.png') - }) - - // ── Phase B: Verify acts-zh.html (Chinese CHI edition) ── - await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9242, async ({ evalJs, saveScreenshot, waitForGameReady }) => { - await waitForGameReady() - const zhCheck = await evalJs<{ - lang: string - title: string - langSwitchHref: string | null - langSwitchText: string - hudText: string - actOpt1: string - heroSor: string - sceneSelectedText: string - charSheetDraws: string[] - }>(`(() => { - const inst = window.__d2webHudInstance; - const draws = []; - const origFill = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { - draws.push(String(text)); - return origFill.call(this, text, ...rest); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.fillText = origFill; - } - const sceneSel = document.getElementById('scene'); - return { - lang: inst.lang, - title: document.title, - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - langSwitchText: document.getElementById('lang-switch')?.textContent?.trim() ?? '', - hudText: document.getElementById('hud')?.textContent ?? '', - actOpt1: document.querySelector('#act option[value="1"]')?.textContent ?? '', - heroSor: document.querySelector('#hero option[value="so"]')?.textContent ?? '', - sceneSelectedText: sceneSel?.selectedOptions?.[0]?.textContent ?? '', - charSheetDraws: draws, - }; - })()`) - - console.log('3. Verified acts-zh.html state:', JSON.stringify({ - lang: zhCheck.lang, - title: zhCheck.title, - langSwitchHref: zhCheck.langSwitchHref, - hudText: zhCheck.hudText, - actOpt1: zhCheck.actOpt1, - heroSor: zhCheck.heroSor, - sceneSelectedText: zhCheck.sceneSelectedText, - sampleDraws: zhCheck.charSheetDraws.slice(0, 14), - })) - if (zhCheck.lang !== 'CHI') { - throw new Error(`Expected acts-zh.html lang === 'CHI', got ${zhCheck.lang}`) - } - if (!zhCheck.langSwitchHref?.endsWith('acts-en.html?act=1')) { - throw new Error(`Expected #lang-switch href ending with 'acts-en.html?act=1', got ${zhCheck.langSwitchHref}`) - } - if (!zhCheck.actOpt1.includes('第一章') || !zhCheck.heroSor.includes('法师')) { - throw new Error(`Expected Chinese act/hero options in acts-zh.html, got ${JSON.stringify(zhCheck)}`) - } - await saveScreenshot('acts-zh-panels.png') - }) - - // ── Phase C: Verify acts-en.html (English ENG edition — strict 0 CJK audit) ── - await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9243, async ({ evalJs, saveScreenshot, waitForGameReady }) => { - await waitForGameReady() - const enCheck = await evalJs<{ - lang: string - title: string - langSwitchHref: string | null - domTexts: Record - cjkDomViolations: string[] - cjkCanvasViolations: string[] - sampleCanvasDraws: string[] - }>(`(() => { - const inst = window.__d2webHudInstance; - const cjkRe = /[\\u4e00-\\u9fff]/; - - // 1. Check all DOM texts except #lang-switch (which shows "Lang: ZH") - const domTexts = { - title: document.title, - headerH1: document.querySelector('header h1')?.textContent || '', - hud: document.getElementById('hud')?.textContent || '', - heroOptions: Array.from(document.querySelectorAll('#hero option')).map(el => el.textContent || '').join(' | '), - actOptions: Array.from(document.querySelectorAll('#act option')).map(el => el.textContent || '').join(' | '), - sceneGroups: Array.from(document.querySelectorAll('#scene optgroup')).map(el => el.getAttribute('label') || '').join(' | '), - sceneOptions: Array.from(document.querySelectorAll('#scene option')).map(el => el.textContent || '').join(' | '), - variantOptions: Array.from(document.querySelectorAll('#variant option')).map(el => el.textContent || '').join(' | '), - lightingOptions: Array.from(document.querySelectorAll('#lighting option')).map(el => el.textContent || '').join(' | '), - densityOptions: Array.from(document.querySelectorAll('#density option')).map(el => el.textContent || '').join(' | '), - eliteOptions: Array.from(document.querySelectorAll('#elite option')).map(el => el.textContent || '').join(' | '), - controlLabels: Array.from(document.querySelectorAll('#controls label')).map(el => el.childNodes[0]?.textContent?.trim() || '').join(' | '), - hint: document.querySelector('#controls .hint')?.textContent || '', - status: document.getElementById('status')?.textContent || '', - dialog: document.getElementById('dialog')?.textContent || '', - labels: document.getElementById('labels')?.textContent || '', - }; - - const cjkDomViolations = []; - for (const [k, v] of Object.entries(domTexts)) { - if (cjkRe.test(v)) { - cjkDomViolations.push(k + ': ' + v); - } - } - - // 2. Intercept all Canvas fillText/strokeText calls across all HUD panels - const canvasDraws = []; - const origFill = CanvasRenderingContext2D.prototype.fillText; - const origStroke = CanvasRenderingContext2D.prototype.strokeText; - CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) { - canvasDraws.push(String(text)); - return origFill.call(this, text, ...rest); - }; - CanvasRenderingContext2D.prototype.strokeText = function(text, ...rest) { - canvasDraws.push(String(text)); - return origStroke.call(this, text, ...rest); - }; - - try { - // Exercise Quest Log, Waypoint, Stash, Cube, Character, Skill Tree, Inventory, NPC Menu, and Vendor Tabs - for (const left of ['quest', 'waypoint', 'stash', 'cube', 'char']) { - if (inst.leftPanel !== left) inst.toggleLeftPanel(left); - inst.render(performance.now(), false); - } - for (const right of ['skill', 'inv']) { - if (inst.rightPanel !== right) inst.toggleRightPanel(right); - inst.render(performance.now(), false); - } - inst.openNpcMenuByName('Akara', 400, 220); - inst.render(performance.now(), false); - inst.worldPanels.npcMenu = null; - inst.openVendorSession('Charsi', 'trade', 0); - for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc']) { - inst.worldPanels.selectVendorTab(tabId); - const placements = inst.worldPanels.vendorTabPlacements[tabId] || []; - for (const p of placements) { - inst.worldPanels.hoveredVendorItem = { item: p.item, x: 200, y: 200, col: p.col, row: p.row }; - inst.render(performance.now(), false); - } - } - inst.worldPanels.hoveredVendorItem = null; - // Leave char + inv open with helm tooltip hovered for screenshot - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.fillText = origFill; - CanvasRenderingContext2D.prototype.strokeText = origStroke; - } - - const cjkCanvasViolations = canvasDraws.filter(t => cjkRe.test(t)); - - return { - lang: inst.lang, - title: document.title, - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - domTexts, - cjkDomViolations, - cjkCanvasViolations, - sampleCanvasDraws: Array.from(new Set(canvasDraws)).slice(0, 25), - }; - })()`) - - console.log('4. Verified acts-en.html state:', JSON.stringify({ - lang: enCheck.lang, - title: enCheck.title, - langSwitchHref: enCheck.langSwitchHref, - hud: enCheck.domTexts.hud, - actOptions: enCheck.domTexts.actOptions, - heroOptions: enCheck.domTexts.heroOptions, - cjkDomViolations: enCheck.cjkDomViolations, - cjkCanvasViolations: enCheck.cjkCanvasViolations, - sampleCanvasDraws: enCheck.sampleCanvasDraws, - })) - - if (enCheck.lang !== 'ENG') { - throw new Error(`Expected acts-en.html lang === 'ENG', got ${enCheck.lang}`) - } - if (!enCheck.langSwitchHref?.endsWith('acts-zh.html?act=1')) { - throw new Error(`Expected #lang-switch href ending with 'acts-zh.html?act=1', got ${enCheck.langSwitchHref}`) - } - if (enCheck.cjkDomViolations.length > 0) { - throw new Error(`Found CJK in acts-en.html DOM: ${JSON.stringify(enCheck.cjkDomViolations)}`) - } - if (enCheck.cjkCanvasViolations.length > 0) { - throw new Error(`Found CJK in acts-en.html Canvas HUD: ${JSON.stringify(enCheck.cjkCanvasViolations)}`) - } - await saveScreenshot('acts-en-panels.png') - }) - - // ── Phase D: Verify acts.html?act=2&lang=en query override ── - await withChromePage(`${baseUrl}/acts.html?act=2&lang=en`, 'acts.html', 9244, async ({ evalJs, waitForGameReady }) => { - await waitForGameReady() - const overrideCheck = await evalJs<{ - lang: string - hud: string - langSwitchHref: string | null - }>(`({ - lang: window.__d2webHudInstance.lang, - hud: document.getElementById('hud')?.textContent || '', - langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null, - })`) - console.log('5. Verified /acts.html?act=2&lang=en override:', JSON.stringify(overrideCheck)) - if (overrideCheck.lang !== 'ENG' || !overrideCheck.hud.includes('Act 2')) { - throw new Error(`Unexpected /acts.html?act=2&lang=en state: ${JSON.stringify(overrideCheck)}`) - } - }) - - console.log('✅ All Issue #503 Headless Browser E2E Checks PASSED!') - } finally { - await server.close() - } -} - -main().catch(err => { - console.error('❌ Issue #503 Browser Verification Failed:', err) - process.exit(1) -}) diff --git a/tools/verify-issue-504-browser.ts b/tools/verify-issue-504-browser.ts deleted file mode 100644 index b2c4fa3..0000000 --- a/tools/verify-issue-504-browser.ts +++ /dev/null @@ -1,594 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #504: - * Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`) - * Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS - * (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`). - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r4' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface CdpSession { - sendCommand: (method: string, params?: Record) => Promise - evalJs: (expression: string) => Promise - saveScreenshot: (filename: string) => Promise - waitForGameReady: () => Promise - externalUrls: string[] -} - -async function withChromePage( - url: string, - matchSubstr: string, - debugPort: number, - fn: (session: CdpSession) => Promise, -): Promise { - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - url, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr)) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error(`Failed to connect to Chrome CDP for ${url}`) - } - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - const externalUrls: string[] = [] - ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Network.requestWillBeSent') { - const reqUrl = String(msg.params?.request?.url ?? '') - if ( - reqUrl && - !reqUrl.startsWith('http://127.0.0.1:') && - !reqUrl.startsWith('data:') && - !reqUrl.startsWith('blob:') - ) { - externalUrls.push(reqUrl) - } - } - } catch { - // Ignore non-JSON CDP events - } - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as R) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - const waitForGameReady = async (): Promise => { - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) { - await sleep(500) - return - } - await sleep(300) - } - throw new Error(`Timed out waiting for game ready at ${url}`) - } - - await sendCommand('Network.enable') - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const result = await fn({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) - ws.close() - return result - } finally { - chromeProc.kill('SIGKILL') - await sleep(200) - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - try { - // ── Phase A: Verify index.html WebFont loading, pixel-level Exocet O cross vs Formal 436 hollow O, & zero external requests ── - await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => { - await sleep(500) - const indexFonts = await evalJs<{ - exocetLoaded: boolean - formalLoaded: boolean - h1FontFamily: string - descFontFamily: string - exocetOCenterAlpha: number - formalOCenterAlpha: number - }>(`(async () => { - await document.fonts.ready; - await Promise.all([ - document.fonts.load('48px D2Exocet'), - document.fonts.load('48px D2Formal436'), - ]); - - const measureGlyphCenterAlpha = (fontSpec) => { - const c = document.createElement('canvas'); - c.width = 96; - c.height = 96; - const ctx = c.getContext('2d'); - ctx.font = fontSpec; - ctx.textBaseline = 'alphabetic'; - ctx.fillStyle = '#ffffff'; - ctx.fillText('O', 16, 68); - const d = ctx.getImageData(0, 0, 96, 96).data; - let minX = 96, maxX = 0, minY = 96, maxY = 0; - for (let y = 0; y < 96; y++) { - for (let x = 0; x < 96; x++) { - if (d[(y * 96 + x) * 4 + 3] > 64) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - } - } - const cx = Math.round((minX + maxX) / 2); - const cy = Math.round((minY + maxY) / 2); - let maxCenterAlpha = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const a = d[((cy + dy) * 96 + (cx + dx)) * 4 + 3]; - if (a > maxCenterAlpha) maxCenterAlpha = a; - } - } - return maxCenterAlpha; - }; - - return { - exocetLoaded: document.fonts.check('16px D2Exocet'), - formalLoaded: document.fonts.check('14px D2Formal436'), - h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily, - descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily, - exocetOCenterAlpha: measureGlyphCenterAlpha('48px "D2Exocet"'), - formalOCenterAlpha: measureGlyphCenterAlpha('48px "D2Formal436"'), - }; - })()`) - console.log('2. Verified index.html WebFonts & O-glyph center pixels:', JSON.stringify(indexFonts)) - if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) { - throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`) - } - if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) { - throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`) - } - if (indexFonts.exocetOCenterAlpha < 128 || indexFonts.formalOCenterAlpha !== 0) { - throw new Error(`Expected D2Exocet 'O' center to have cross pixels (>128) and D2Formal436 'O' center to be hollow (0), got: ${JSON.stringify(indexFonts)}`) - } - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on index.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-index.png') - }) - - // ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting, 12 color codes, Area Banner, Ground Labels, Panels & Formal 436 Prose ── - await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { - await waitForGameReady() - - // 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`) - const groundLabelCheck = await evalJs<{ - groundLabelsCount: number - groundLabelFontFamily: string - groundLabelTexts: string[] - groundLabelPositions1x: Array<{ left: string; top: string }> - }>(`(async () => { - const inst = window.__d2webHudInstance; - inst.leftPanel = 'none'; - inst.rightPanel = 'none'; - inst.worldPanels.npcMenu = null; - inst.worldPanels.areaBanner = { - titleEn: 'Rogue Encampment', - titleZh: '罗格营地', - untilMs: performance.now() + 60000, - }; - if (inst.inventory.equipped?.helm) { - inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.helm, id: 'verify-ground-helm' }); - } - if (inst.inventory.equipped?.weapon1) { - inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.weapon1, id: 'verify-ground-weapon' }); - } - inst.showGroundLabels = true; - await new Promise(r => setTimeout(r, 300)); - const els = Array.from(document.querySelectorAll('.d2-ground-label')); - return { - groundLabelsCount: els.length, - groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', - groundLabelTexts: els.map(el => el.textContent || ''), - groundLabelPositions1x: els.map(el => ({ left: el.style.left, top: el.style.top })), - }; - })()`) - console.log(' Verified Alt ground labels (1x DPR):', JSON.stringify(groundLabelCheck)) - if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) { - throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`) - } - await saveScreenshot('issue-504-acts-en-area-banner.png') - - // 1b. Verify High-DPI (devicePixelRatio = 2) rendering on GroundLabelOverlay and #d2-hud-canvas - await sendCommand('Emulation.setDeviceMetricsOverride', { - width: 1280, - height: 840, - deviceScaleFactor: 2, - mobile: false, - }) - await sleep(300) - const hiDpiCheck = await evalJs<{ - dpr: number - hudCanvasWidth: number - hudCanvasHeight: number - imageSmoothingEnabled: boolean - groundLabelsCount: number - groundLabelFontFamily: string - groundLabelPositions2x: Array<{ left: number; top: number }> - viewCenterCssY: number - }>(`(async () => { - const inst = window.__d2webHudInstance; - inst.render(performance.now(), false); - await new Promise(r => setTimeout(r, 200)); - const hudCanvas = document.getElementById('d2-hud-canvas'); - const viewCanvas = document.getElementById('view'); - const viewRect = viewCanvas.getBoundingClientRect(); - const ctx = hudCanvas.getContext('2d'); - const els = Array.from(document.querySelectorAll('.d2-ground-label')); - return { - dpr: window.devicePixelRatio, - hudCanvasWidth: hudCanvas.width, - hudCanvasHeight: hudCanvas.height, - imageSmoothingEnabled: ctx.imageSmoothingEnabled, - groundLabelsCount: els.length, - groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', - groundLabelPositions2x: els.map(el => ({ - left: parseFloat(el.style.left), - top: parseFloat(el.style.top), - })), - viewCenterCssY: Math.round(viewRect.top + viewRect.height * 0.5), - }; - })()`) - console.log(' Verified High-DPI (2x DPR) GroundLabelOverlay & #d2-hud-canvas:', JSON.stringify(hiDpiCheck)) - if ( - hiDpiCheck.dpr !== 2 || - hiDpiCheck.hudCanvasWidth !== 2560 || - hiDpiCheck.hudCanvasHeight !== 1680 || - hiDpiCheck.imageSmoothingEnabled !== false || - hiDpiCheck.groundLabelsCount !== groundLabelCheck.groundLabelsCount || - !hiDpiCheck.groundLabelFontFamily.includes('D2Exocet') || - hiDpiCheck.groundLabelPositions2x.some( - p => Math.abs(p.left - 640) > 30 || Math.abs(p.top - hiDpiCheck.viewCenterCssY) > 60, - ) - ) { - throw new Error(`High-DPI (2x DPR) verification failed: ${JSON.stringify(hiDpiCheck)}`) - } - await sendCommand('Emulation.clearDeviceMetricsOverride') - await sleep(200) - - // 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', 12 distinct color codes, and multi-color ÿc rare item affix tooltip alignment - const enCheck = await evalJs<{ - exocetLoaded: boolean - formalLoaded: boolean - hudFontFamily: string - controlsFontFamily: string - dialogFontFamily: string - dialogTitleFontFamily: string - dc6FontLoaded: boolean - dc6BlitCount: number - fillTextCount: number - dc6Font30OCenterAlpha: number - dc6Font16OCenterAlpha: number - dc6Formal12OCenterAlpha: number - distinctColorCodesCount: number - multiColorTooltipAligned: boolean - multiColorTooltipBlitCount: number - }>(`(async () => { - await document.fonts.ready; - await Promise.all([ - document.fonts.load('16px D2Exocet'), - document.fonts.load('14px D2Formal436'), - ]); - const inst = window.__d2webHudInstance; - inst.showGroundLabels = false; - - const measureDc6GlyphCenterAlpha = (fontName) => { - const c = document.createElement('canvas'); - c.width = 64; - c.height = 64; - const ctx = c.getContext('2d'); - inst.font.drawText(ctx, 'O', 10, 40, { font: fontName, color: 'white', shadow: false }); - const d = ctx.getImageData(0, 0, 64, 64).data; - let minX = 64, maxX = 0, minY = 64, maxY = 0; - for (let y = 0; y < 64; y++) { - for (let x = 0; x < 64; x++) { - if (d[(y * 64 + x) * 4 + 3] > 128) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - } - } - const cx = Math.round((minX + maxX) / 2); - const cy = Math.round((minY + maxY) / 2); - let maxCenterAlpha = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const a = d[((cy + dy) * 64 + (cx + dx)) * 4 + 3]; - if (a > maxCenterAlpha) maxCenterAlpha = a; - } - } - return maxCenterAlpha; - }; - - const colorCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';']; - const rgbSignatures = new Set(); - for (const cc of colorCodes) { - const c = document.createElement('canvas'); - c.width = 32; - c.height = 32; - const ctx = c.getContext('2d'); - inst.font.drawText(ctx, 'ÿc' + cc + 'I', 4, 24, { font: 'font16', shadow: false }); - const d = ctx.getImageData(0, 0, 32, 32).data; - let brightest = '0,0,0'; - let maxSum = -1; - for (let i = 0; i < d.length; i += 4) { - if (d[i + 3] >= 180) { - const sum = d[i] + d[i + 1] + d[i + 2]; - if (sum > maxSum) { - maxSum = sum; - brightest = d[i] + ',' + d[i + 1] + ',' + d[i + 2]; - } - } - } - rgbSignatures.add(brightest); - } - - // Verify per-segment DC6 bitmap blitting alignment across multi-color ÿc transitions in a long rare item affix tooltip - const multiColorAffix = 'ÿc3+20% Faster Cast Rate ÿc0[Max] ÿc9+40% LRes ÿc1+35% FRes'; - const plainAffix = '+20% Faster Cast Rate [Max] +40% LRes +35% FRes'; - const segCalls = []; - const testCanvas = document.createElement('canvas'); - testCanvas.width = 800; - testCanvas.height = 600; - const testCtx = testCanvas.getContext('2d'); - const origDrawImg = testCtx.drawImage.bind(testCtx); - testCtx.drawImage = function(img, sx, sy, sw, sh, dx, dy, dw, dh) { - if (arguments.length === 9) { - segCalls.push({ dx, dy, dw, dh }); - } - return origDrawImg.apply(this, arguments); - }; - inst.font.drawText(testCtx, multiColorAffix, 400, 200, { - font: 'font8', - color: 'blue', - align: 'center', - shadow: false, - }); - const meta8 = inst.font.getFontMeta('font8'); - let expectedX = Math.round(400 - inst.font.measureText(plainAffix, 'font8') / 2); - let segIdx = 0; - let multiColorTooltipAligned = true; - for (let i = 0; i < plainAffix.length; i++) { - const code = plainAffix.charCodeAt(i); - const g = meta8.glyphs[code]; - const adv = Math.max(4, g.width); - if (code !== 0x20 && g.frameW > 0 && g.frameH > 0) { - if (!segCalls[segIdx] || segCalls[segIdx].dx !== expectedX) { - multiColorTooltipAligned = false; - } - segIdx++; - } - expectedX += adv; - } - if (segIdx !== segCalls.length) multiColorTooltipAligned = false; - - let blitCount = 0; - let fillCount = 0; - const origDrawImage = CanvasRenderingContext2D.prototype.drawImage; - const origFillText = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.drawImage = function(...args) { - if (args.length === 9) blitCount++; - return origDrawImage.apply(this, args); - }; - CanvasRenderingContext2D.prototype.fillText = function(...args) { - fillCount++; - return origFillText.apply(this, args); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.drawImage = origDrawImage; - CanvasRenderingContext2D.prototype.fillText = origFillText; - } - return { - exocetLoaded: document.fonts.check('16px D2Exocet'), - formalLoaded: document.fonts.check('14px D2Formal436'), - hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily, - controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily, - dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily, - dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily, - dc6FontLoaded: Boolean(inst.font?.loaded), - dc6BlitCount: blitCount, - fillTextCount: fillCount, - dc6Font30OCenterAlpha: measureDc6GlyphCenterAlpha('font30'), - dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'), - dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'), - distinctColorCodesCount: rgbSignatures.size, - multiColorTooltipAligned, - multiColorTooltipBlitCount: segCalls.length, - }; - })()`) - console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross, 12 colors & multi-color tooltip alignment:', JSON.stringify(enCheck)) - if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) { - throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`) - } - if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) { - throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`) - } - if (enCheck.dc6Font30OCenterAlpha < 200 || enCheck.dc6Font16OCenterAlpha < 200 || enCheck.dc6Formal12OCenterAlpha !== 0) { - throw new Error(`Expected DC6 font30/font16 'O' center to be solid cross (>200) and fontformal12 'O' center to be hollow (0), got: ${JSON.stringify(enCheck)}`) - } - if (enCheck.distinctColorCodesCount !== 12) { - throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`) - } - if (!enCheck.multiColorTooltipAligned || enCheck.multiColorTooltipBlitCount < 35) { - throw new Error(`Expected multi-color ÿc tooltip DC6 blits to align contiguously across color transitions, got: ${JSON.stringify(enCheck)}`) - } - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-acts-en-panels.png') - - // 3. Open Quest Log + Skill Tree + Canvas NPC Speech Dialog to verify Formal 436 (`fontformal12`) & Exocet in all panels - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.hoveredItem = null; - if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest'); - if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill'); - inst.worldPanels.npcMenu = { - npcId: 1, - npcName: 'Akara', - x: 400, - y: 440, - talking: true, - dialogLines: [ - 'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.', - 'Welcome to our camp, traveler. Please cleanse the Den of Evil.', - ], - }; - inst.render(performance.now(), false); - })()`) - await sleep(300) - await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png') - }) - - // ── Phase C: Verify acts-zh.html hybrid DC6 + CJK bilingual rendering ── - await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { - await waitForGameReady() - const zhCheck = await evalJs<{ - dc6BlitCount: number - fillTextCount: number - }>(`(() => { - const inst = window.__d2webHudInstance; - let blitCount = 0; - let fillCount = 0; - const origDrawImage = CanvasRenderingContext2D.prototype.drawImage; - const origFillText = CanvasRenderingContext2D.prototype.fillText; - CanvasRenderingContext2D.prototype.drawImage = function(...args) { - if (args.length === 9) blitCount++; - return origDrawImage.apply(this, args); - }; - CanvasRenderingContext2D.prototype.fillText = function(...args) { - fillCount++; - return origFillText.apply(this, args); - }; - try { - if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); - if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - } finally { - CanvasRenderingContext2D.prototype.drawImage = origDrawImage; - CanvasRenderingContext2D.prototype.fillText = origFillText; - } - return { - dc6BlitCount: blitCount, - fillTextCount: fillCount, - }; - })()`) - console.log('4. Verified acts-zh.html hybrid DC6 + CJK rendering:', JSON.stringify(zhCheck)) - if (zhCheck.dc6BlitCount < 100 || zhCheck.fillTextCount < 10) { - throw new Error(`Expected acts-zh.html hybrid rendering (>100 DC6 blits and >=10 CJK fillText calls), got: ${JSON.stringify(zhCheck)}`) - } - await sleep(300) - if (externalUrls.length > 0) { - throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`) - } - await saveScreenshot('issue-504-acts-zh-bilingual.png') - }) - - console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!') - } finally { - await server.close() - } -} - -main().catch(err => { - console.error('❌ Issue #504 browser verification failed:', err) - process.exit(1) -}) diff --git a/tools/verify-skill-browser.ts b/tools/verify-skill-browser.ts deleted file mode 100644 index d705118..0000000 --- a/tools/verify-skill-browser.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Headless Chromium Skill Verification & Screenshot Runner (`scripts/verify-skill-browser.ts`). - * - * Usage: - * npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill-47.png] - * - * Launches Vite (if not already running on 127.0.0.1:5173) + headless Chromium - * (`/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome`), - * opens `/acts.html?arena=1&class=&skill=&slvl=&autocast=1`, - * verifies `window.__d2webArena.ready === true && castCount >= 1` and zero runtime errors, - * and saves a PNG screenshot to `--out`. - */ -import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { createServer } from 'vite' -import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts' - -const CHROME_CANDIDATES = [ - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/usr/bin/google-chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) -} - -const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) - -export interface BrowserSkillVerificationResult { - readonly ok: boolean - readonly url: string - readonly screenshotPath: string - readonly screenshotBytes: number - readonly consoleErrors: string[] - readonly arenaState: Record -} - -export async function verifySkillInBrowser(params: { - skillId: number - classCode?: string - slvl?: number - screenshotPath: string - baseUrl?: string -}): Promise { - const registry = await getSharedDataRegistry() - const skillRec = registry.getSkillById(params.skillId) - const classCode = (params.classCode || skillRec?.charClass || 'sor').toLowerCase() - const slvl = params.slvl ?? 20 - - let viteServer: Awaited> | null = null - let baseUrl = params.baseUrl ?? '' - - if (!baseUrl) { - const chosenPort = 5200 + Math.floor(Math.random() * 600) - viteServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { - host: '127.0.0.1', - port: chosenPort, - strictPort: false, - }, - }) - await viteServer.listen() - const addr = viteServer.httpServer?.address() - const port = typeof addr === 'object' && addr ? addr.port : chosenPort - baseUrl = `http://127.0.0.1:${String(port)}` - } - - const targetUrl = `${baseUrl}/acts.html?arena=1&class=${encodeURIComponent(classCode)}&skill=${String(params.skillId)}&slvl=${String(slvl)}&autocast=1` - const chromeBin = resolveChromeBinary() - const debugPort = 9300 + Math.floor(Math.random() * 500) - - const chrome = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1024,680', - `--remote-debugging-port=${String(debugPort)}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - try { - let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null - for (let i = 0; i < 80; i += 1) { - await sleep(150) - try { - const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as { - type: string - url: string - webSocketDebuggerUrl: string - }[] - const pages = list.filter(x => x.type === 'page') - const matched = pages.find(x => x.url.includes('acts.html')) - if (matched && matched.webSocketDebuggerUrl) { - pageTarget = matched - break - } - } catch {} - } - if (!pageTarget) { - throw new Error(`Headless Chromium did not open ${targetUrl}`) - } - - const ws = new WebSocket(pageTarget.webSocketDebuggerUrl) - await new Promise((res, rej) => { - ws.onopen = res - ws.onerror = rej - }) - - let msgId = 0 - const pending = new Map) => void>() - const consoleErrors: string[] = [] - - ws.onmessage = event => { - const msg = JSON.parse(String(event.data)) as Record - if (msg['method'] === 'Runtime.exceptionThrown') { - consoleErrors.push(JSON.stringify(msg['params'])) - } - const id = msg['id'] as number | undefined - if (id && pending.has(id)) { - pending.get(id)!(msg) - pending.delete(id) - } - } - - const send = (method: string, cdpParams: Record = {}) => - new Promise>(res => { - const id = ++msgId - pending.set(id, res) - ws.send(JSON.stringify({ id, method, params: cdpParams })) - }) - - await send('Runtime.enable') - await send('Page.enable') - - // Poll until `window.__d2webArena.ready === true && window.__d2webArena.castCount >= 1` - let arenaState: Record | null = null - for (let i = 0; i < 40; i += 1) { - await sleep(250) - const evalRes = await send('Runtime.evaluate', { - expression: `(async () => { - if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') { - await window.__initBrowserArenaMode(); - } - if (!window.__d2webArena) { - const { initBrowserArenaMode } = await import('/src/scene/act-scene.ts'); - if (typeof initBrowserArenaMode === 'function') await initBrowserArenaMode(); - } - const a = window.__d2webArena; - if (!a || !a.ready) return null; - if (a.castCount < 1 && typeof a.triggerCast === 'function') a.triggerCast(); - return { - ready: a.ready, - classCode: a.classCode, - charToken: a.charToken, - skillId: a.skillId, - skillName: a.skillName, - slvl: a.slvl, - tick: a.tick, - castCount: a.castCount, - actionFrameTriggered: a.actionFrameTriggered, - playerAnimMode: a.playerAnimMode, - playerAnimFrame: a.playerAnimFrame, - activeStates: a.activeStates, - activeOverlays: a.activeOverlays, - missilesSpawned: a.missilesSpawned, - petsActive: a.petsActive, - corpsesRemaining: a.corpsesRemaining, - totalDamageDealt: a.totalDamageDealt, - tooltipStats: a.tooltipStats, - }; - })()`, - awaitPromise: true, - returnByValue: true, - }) - const val = (evalRes['result'] as { result?: { value?: Record | null } } | undefined) - ?.result?.value - if (val && val['ready'] === true && Number(val['castCount']) >= 1) { - arenaState = val - break - } - } - - if (!arenaState) { - throw new Error(`Timed out waiting for window.__d2webArena.ready on ${targetUrl}`) - } - - // Give canvas 250ms to settle paint before screenshot - await sleep(250) - - const shot = await send('Page.captureScreenshot', { format: 'png' }) - const base64Data = (shot['result'] as { data?: string } | undefined)?.data - if (!base64Data) { - throw new Error('CDP Page.captureScreenshot returned empty image data') - } - - const outPath = resolve(params.screenshotPath) - mkdirSync(dirname(outPath), { recursive: true }) - const pngBuf = Buffer.from(base64Data, 'base64') - writeFileSync(outPath, pngBuf) - - ws.close() - return { - ok: consoleErrors.length === 0 && pngBuf.byteLength > 1000, - url: targetUrl, - screenshotPath: outPath, - screenshotBytes: pngBuf.byteLength, - consoleErrors, - arenaState, - } - } finally { - chrome.kill() - if (viteServer) { - await viteServer.close() - } - } -} - -async function main(): Promise { - const args = process.argv.slice(2) - if (args.length === 0) { - console.error('Usage: npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill.png]') - process.exit(1) - } - - const skillId = Number(args[0]) - const classArg = args.find(a => a.startsWith('--class='))?.split('=')[1] - const slvlArg = args.find(a => a.startsWith('--slvl='))?.split('=')[1] - const outArg = args.find(a => a.startsWith('--out='))?.split('=')[1] ?? `/tmp/skill-${String(skillId)}.png` - - const res = await verifySkillInBrowser({ - skillId, - ...(classArg !== undefined ? { classCode: classArg } : {}), - slvl: slvlArg ? Number(slvlArg) : 20, - screenshotPath: outArg, - }) - - console.log(JSON.stringify(res, null, 2)) - if (!res.ok) { - process.exit(1) - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - void main() -} diff --git a/tools/verify-tooltip-browser.ts b/tools/verify-tooltip-browser.ts deleted file mode 100644 index ee83f32..0000000 --- a/tools/verify-tooltip-browser.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Diablo II Tooltip & Color Dye Parity. - * - * Verifies: - * 1. Harlequin Crest in helm slot renders emerald green dyed sprite (`invcap_cgrn`). - * 2. Call to Arms in bag inventory renders full authentic 11-line tooltip without truncation. - * 3. Dynamic viewport vertical flip (hover.y < 280) and screen boundary clamping. - * 4. Zero runtime requests for `*.mpq` and `*.dll` files. - */ - -import { spawn, type ChildProcess } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/d2w-wg1-main/.agents/teamwork_preview_worker_m5_tooltip' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -interface NetworkRequestEvent { - requestId: string - request: { - url: string - method: string - } -} - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - console.log(`Starting headless browser audit, output directory: ${ARTIFACT_DIR}`) - - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - const debugPort = 9244 - const chromeProc: ChildProcess = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - `${baseUrl}/acts.html?act=1`, - ]) - - const networkRequests: string[] = [] - const mpqOrDllRequests: string[] = [] - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error('Failed to connect to Chrome CDP') - } - console.log('2. Connected to Chrome CDP WebSocket:', wsUrl) - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(String(event.data)) - if (msg.method === 'Network.requestWillBeSent') { - const params = msg.params as NetworkRequestEvent - const url = params.request.url - networkRequests.push(url) - if (/\.(mpq|dll)(\?|$)/i.test(url)) { - mpqOrDllRequests.push(url) - } - } - } catch { - // Ignore JSON parse errors on non-event messages - } - }) - - await sendCommand('Network.enable') - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(ARTIFACT_DIR, filename) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename} -> ${filePath}`) - return filePath - } - - // Wait for ActScene and HudManager to become fully ready - console.log('3. Waiting for __d2webAct and __d2webHud ready...') - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.itemsAtlas)`, - ) - if (ready) break - await sleep(300) - } - await sleep(600) - - // Open inventory panel - await evalJs(`(() => { - const inst = window.__d2webHudInstance; - if (inst.rightDockPanel !== 'inv') { - inst.toggleRightPanel('inv'); - } - inst.render(performance.now(), false); - })()`) - await sleep(300) - - // --- Verification 1: Harlequin Crest Dyed Sprite Parity --- - console.log('4. Verifying Harlequin Crest dyed sprite (invcap_cgrn)...') - const helmAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const helm = inst.inventory.equipped.helm; - inst.inventory.handleMouseMove(561, 93); // Hover over helm slot (x=532..590, y=64..122) - inst.render(performance.now(), false); - - const hovered = inst.inventory.hoveredItem; - const hudCanvas = document.getElementById('d2-hud-canvas'); - const ctx = hudCanvas ? hudCanvas.getContext('2d') : null; - // Read pixel in the helm area to verify green color presence - let hasGreen = false; - let sampleR = 0, sampleG = 0, sampleB = 0; - let atlasHasGreen = false; - let atlasSampleR = 0, atlasSampleG = 0, atlasSampleB = 0; - - // Check 1: Sample directly from the itemsAtlas image rect for invcap_cgrn (x: 554, y: 1796, w: 56, h: 56) - const atlasImg = inst.images.get('itemsAtlas'); - if (atlasImg) { - const offCanvas = document.createElement('canvas'); - offCanvas.width = 56; - offCanvas.height = 56; - const offCtx = offCanvas.getContext('2d'); - if (offCtx) { - offCtx.drawImage(atlasImg, 554, 1796, 56, 56, 0, 0, 56, 56); - const imgData = offCtx.getImageData(0, 0, 56, 56).data; - for (let i = 0; i < imgData.length; i += 4) { - const r = imgData[i]; - const g = imgData[i + 1]; - const b = imgData[i + 2]; - const a = imgData[i + 3]; - // Table 104 emerald green check: G significantly higher than R and B (RGB ~ [51, 135, 75]) - if (a > 150 && g > r * 1.3 && g > b * 1.2 && g > 70) { - atlasHasGreen = true; - atlasSampleR = r; atlasSampleG = g; atlasSampleB = b; - break; - } - } - } - } - - // Check 2: Sample physical canvas at paperdoll helm slot - if (ctx && hudCanvas) { - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth); - const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight); - const uiScale = Math.min(cssW / 800, cssH / 600); - const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2); - const offsetY = Math.max(0, cssH - 600 * uiScale); - const physX = Math.round((offsetX + 545 * uiScale) * dpr); - const physY = Math.round((offsetY + 75 * uiScale) * dpr); - const sampleW = Math.round(30 * uiScale * dpr); - const sampleH = Math.round(30 * uiScale * dpr); - - const pixelData = ctx.getImageData(physX, physY, sampleW, sampleH).data; - for (let i = 0; i < pixelData.length; i += 4) { - const r = pixelData[i]; - const g = pixelData[i + 1]; - const b = pixelData[i + 2]; - const a = pixelData[i + 3]; - if (a > 150 && g > r * 1.2 && g > b * 1.1 && g > 50) { - hasGreen = true; - sampleR = r; sampleG = g; sampleB = b; - break; - } - } - } - - return { - helmName: helm?.name, - helmNameZh: helm?.nameZh, - invtransform: helm?.invtransform, - durability: helm?.durability, - defense: helm?.defense, - reqStr: helm?.reqStr, - reqLevel: helm?.reqLevel, - statCount: helm?.stats?.length, - hoveredTitle: hovered?.item?.nameZh, - hoveredY: hovered?.y, - hasEmeraldGreenPixels: hasGreen, - sampleR, sampleG, sampleB, - atlasHasGreen, - atlasSampleR, atlasSampleG, atlasSampleB, - }; - })()`) - console.log(' Helm audit result:', JSON.stringify(helmAudit)) - if (helmAudit.helmName !== 'Harlequin Crest' || helmAudit.invtransform !== 'cgrn') { - throw new Error(`Harlequin Crest audit failed: ${JSON.stringify(helmAudit)}`) - } - const shotHelm = await saveScreenshot('harlequin_crest_helm_hover.png') - - // --- Verification 2: Call to Arms Runeword 11 Canonical Stats & Unoccluded Title --- - console.log('5. Verifying Call to Arms in bag inventory...') - const ctaAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const ctaGrid = inst.inventory.gridItems.find(g => g.item.id === 'bag-cta'); - // Hover Call to Arms in inventory grid (col 8, row 0: cell center x=664, y=330) - inst.inventory.handleMouseMove(664, 330); - inst.render(performance.now(), false); - - const hovered = inst.inventory.hoveredItem; - const cta = ctaGrid?.item; - const wp = inst.worldPanels; - const hudCanvas = document.getElementById('d2-hud-canvas'); - const controls = document.getElementById('controls'); - const hudZIndex = hudCanvas ? parseInt(window.getComputedStyle(hudCanvas).zIndex || '0', 10) : 0; - const controlsZIndex = controls ? parseInt(window.getComputedStyle(controls).zIndex || '0', 10) : 0; - const lastRect = wp ? wp.lastTooltipRect : null; - - // Sample gold title text pixels on canvas to verify full rendering and unoccluded title - let hasGoldTitlePixels = false; - let titleSampleR = 0, titleSampleG = 0, titleSampleB = 0; - const ctx = hudCanvas ? hudCanvas.getContext('2d') : null; - if (ctx && hudCanvas && lastRect) { - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth); - const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight); - const uiScale = Math.min(cssW / 800, cssH / 600); - const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2); - const offsetY = Math.max(0, cssH - 600 * uiScale); - - // Title is centered around (lastRect.bx + lastRect.boxW / 2) at y = lastRect.by + 24 - const physX = Math.round((offsetX + (lastRect.bx + lastRect.boxW / 2) * uiScale) * dpr); - const physY = Math.round((offsetY + (lastRect.by + 24) * uiScale) * dpr); - const sampleW = Math.round(120 * uiScale * dpr); - const sampleH = Math.round(16 * uiScale * dpr); - - const pixelData = ctx.getImageData( - Math.max(0, physX - Math.round(sampleW / 2)), - Math.max(0, physY - Math.round(sampleH / 2)), - sampleW, - sampleH, - ).data; - for (let i = 0; i < pixelData.length; i += 4) { - const r = pixelData[i]; - const g = pixelData[i + 1]; - const b = pixelData[i + 2]; - const a = pixelData[i + 3]; - // D2 Gold font (#c8a15a: R ~ 200, G ~ 161, B ~ 90) - if (a > 150 && r > 150 && g > 120 && b < 130 && r > b * 1.3) { - hasGoldTitlePixels = true; - titleSampleR = r; - titleSampleG = g; - titleSampleB = b; - break; - } - } - } - - return { - id: cta?.id, - name: cta?.name, - nameZh: cta?.nameZh, - baseNameZh: cta?.baseNameZh, - quality: cta?.quality, - runes: cta?.runewordRunes, - damage: cta?.damage, - durability: cta?.durability, - speedText: cta?.speedText, - reqStr: cta?.reqStr, - reqLevel: cta?.reqLevel, - sockets: cta?.sockets, - statCount: cta?.stats?.length, - stats: cta?.stats?.map(s => s.text), - hoveredItemName: hovered?.item?.nameZh, - lastTooltipRect: lastRect, - hudZIndex, - controlsZIndex, - isHudAboveControls: hudZIndex > controlsZIndex, - isTooltipClampedBelowControls: lastRect ? lastRect.by >= 44 : false, - hasGoldTitlePixels, - titleSampleR, - titleSampleG, - titleSampleB, - }; - })()`) - console.log(' Call to Arms audit result:', JSON.stringify(ctaAudit, null, 2)) - if (ctaAudit.name !== 'Call to Arms' || ctaAudit.statCount !== 11) { - throw new Error(`Call to Arms audit failed: expected 11 stats, got ${ctaAudit.statCount}`) - } - if (!ctaAudit.isHudAboveControls) { - throw new Error( - `HUD canvas z-index (${ctaAudit.hudZIndex}) must be higher than controls overlay (${ctaAudit.controlsZIndex})!`, - ) - } - if (!ctaAudit.isTooltipClampedBelowControls) { - throw new Error( - `Call to Arms tooltip must be clamped below controls overlay (by >= 44, got ${ctaAudit.lastTooltipRect?.by})!`, - ) - } - if (!ctaAudit.hasGoldTitlePixels) { - throw new Error('Call to Arms title text gold pixels not detected on canvas!') - } - const shotCta = await saveScreenshot('call_to_arms_inventory_hover.png') - - // --- Verification 3: Dynamic Vertical Flip & Clamping (Live UI Interaction) --- - console.log('6. Verifying Viewport Vertical Flip and Clamping behavior with live interaction...') - const flipAudit = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - const wp = inst.worldPanels; - - // 1. Move to helm slot at (561, 93) near top of screen - inst.inventory.handleMouseMove(561, 93); - inst.render(performance.now(), false); - - const topHelmHover = inst.inventory.hoveredItem; - const topHelmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const helmHoverY = topHelmHover?.y ?? 93; - const isHelmFlippedBelow = topHelmRect ? topHelmRect.by > helmHoverY : false; - - // 2. Move to amulet slot at (618, 107) near top of screen - inst.inventory.handleMouseMove(618, 107); - inst.render(performance.now(), false); - - const topAmuHover = inst.inventory.hoveredItem; - const topAmuRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const amuHoverY = topAmuHover?.y ?? 107; - const isAmuFlippedBelow = topAmuRect ? topAmuRect.by > amuHoverY : false; - - // 3. Move to Call to Arms in bag grid at (664, 330) near bottom - inst.inventory.handleMouseMove(664, 330); - inst.render(performance.now(), false); - - const btmHover = inst.inventory.hoveredItem; - const btmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null; - const btmHoverY = btmHover?.y ?? 330; - const isBtmPlacedAbove = btmRect ? btmRect.by < btmHoverY : false; - const isBtmClamped = btmRect ? (btmRect.by >= 44 && btmRect.by + btmRect.boxH <= 600 - 8) : false; - - // 4. Trigger mouse move to amulet slot (618, 107) and render so screenshot captures genuine flipped tooltip - inst.inventory.handleMouseMove(618, 107); - inst.render(performance.now(), false); - - return { - helmHoverY, - topHelmRect, - isHelmFlippedBelow, - amuHoverY, - topAmuRect, - isAmuFlippedBelow, - btmHoverY, - btmRect, - isBtmPlacedAbove, - isBtmClamped, - liveItemNameZh: inst.inventory.hoveredItem?.item?.nameZh, - activeRect: wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null, - }; - })()`) - console.log(' Vertical flip and clamping audit:', JSON.stringify(flipAudit, null, 2)) - if (!flipAudit.isHelmFlippedBelow || !flipAudit.isAmuFlippedBelow) { - throw new Error( - `Vertical flip below cursor failed: helm=${flipAudit.isHelmFlippedBelow}, amu=${flipAudit.isAmuFlippedBelow}`, - ) - } - if (!flipAudit.isBtmPlacedAbove || !flipAudit.isBtmClamped) { - throw new Error( - `Bottom placement or clamping failed: placedAbove=${flipAudit.isBtmPlacedAbove}, clamped=${flipAudit.isBtmClamped}`, - ) - } - const shotFlip = await saveScreenshot('tooltip_clamping_and_flip.png') - - // Verify distinct screenshot SHA256 checksums - const ctaBuf = readFileSync(shotCta) - const flipBuf = readFileSync(shotFlip) - const helmBuf = readFileSync(shotHelm) - const ctaSha256 = createHash('sha256').update(ctaBuf).digest('hex') - const flipSha256 = createHash('sha256').update(flipBuf).digest('hex') - const helmSha256 = createHash('sha256').update(helmBuf).digest('hex') - - console.log(' Screenshot SHA256 verification:') - console.log(` harlequin_crest_helm_hover.png: ${helmSha256}`) - console.log(` call_to_arms_inventory_hover.png: ${ctaSha256}`) - console.log(` tooltip_clamping_and_flip.png: ${flipSha256}`) - - if (flipSha256 === ctaSha256) { - throw new Error( - `INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and call_to_arms_inventory_hover.png have identical SHA256 (${flipSha256})`, - ) - } - if (flipSha256 === helmSha256) { - throw new Error( - `INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and harlequin_crest_helm_hover.png have identical SHA256 (${flipSha256})`, - ) - } - - // --- Verification 4: Zero Runtime MPQ/DLL Requests --- - console.log('7. Verifying Zero Runtime MPQ/DLL requests...') - console.log(` Total Network Requests observed: ${networkRequests.length}`) - console.log(` MPQ or DLL Requests observed: ${mpqOrDllRequests.length}`) - if (mpqOrDllRequests.length > 0) { - throw new Error( - `VIOLATION: Found ${mpqOrDllRequests.length} runtime MPQ/DLL requests: ${JSON.stringify(mpqOrDllRequests)}`, - ) - } - - // Write full audit report artifact - const auditSummary = { - timestamp: new Date().toISOString(), - status: 'VERIFIED_PASSING', - zeroMpqDllInvariant: { - totalNetworkRequests: networkRequests.length, - mpqDllRequestsCount: mpqOrDllRequests.length, - verifiedZero: true, - }, - harlequinCrest: helmAudit, - callToArms: ctaAudit, - verticalFlipAndClamping: flipAudit, - screenshotChecksums: { - 'harlequin_crest_helm_hover.png': helmSha256, - 'call_to_arms_inventory_hover.png': ctaSha256, - 'tooltip_clamping_and_flip.png': flipSha256, - allDistinct: true, - }, - screenshots: [ - 'harlequin_crest_helm_hover.png', - 'call_to_arms_inventory_hover.png', - 'tooltip_clamping_and_flip.png', - ], - } - - const summaryPath = join(ARTIFACT_DIR, 'audit_summary.json') - writeFileSync(summaryPath, JSON.stringify(auditSummary, null, 2)) - console.log(`8. Wrote audit summary to ${summaryPath}`) - console.log('=== ALL HEADLESS BROWSER CDP AUDITS PASSED 100% ===') - - ws.close() - } finally { - chromeProc.kill() - await server.close() - } -} - -main().catch(err => { - console.error('verify-tooltip-browser failed:', err) - process.exit(1) -}) diff --git a/tools/verify-ui-browser.ts b/tools/verify-ui-browser.ts deleted file mode 100644 index 748fd27..0000000 --- a/tools/verify-ui-browser.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Automated Headless Browser Verification & Visual Audit for Issue #27 (`acts.html`). - * - * Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP (zero external deps). - */ - -import { spawn } from 'node:child_process' -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { createServer } from 'vite' - -const ARTIFACT_DIR = - process.argv[2] || - '/usr/local/google/home/taodao/.gemini/jetski/brain/aa680fb2-b60b-4fb6-86df-d401210969d8' - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -async function main(): Promise { - mkdirSync(ARTIFACT_DIR, { recursive: true }) - const server = await createServer({ - root: process.cwd(), - server: { port: 0, host: '127.0.0.1' }, - }) - await server.listen() - const address = server.httpServer?.address() - const port = typeof address === 'object' && address ? address.port : 5173 - const baseUrl = `http://127.0.0.1:${port}` - console.log(`1. Vite dev server listening at ${baseUrl}`) - - const debugPort = 9237 - const chromeProc = spawn('/usr/bin/google-chrome', [ - '--headless=new', - `--remote-debugging-port=${debugPort}`, - '--no-sandbox', - '--disable-dev-shm-usage', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--window-size=1280,840', - `${baseUrl}/acts.html?act=1`, - ]) - - try { - let wsUrl: string | null = null - for (let i = 0; i < 60; i++) { - await sleep(300) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) - const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl - break - } - } catch { - // Wait for Chrome CDP - } - } - - if (!wsUrl) { - throw new Error('Failed to connect to Chrome CDP') - } - console.log('2. Connected to Chrome CDP WebSocket:', wsUrl) - const ws = new WebSocket(wsUrl) - await new Promise(resolve => { - ws.onopen = () => resolve() - }) - - let idCounter = 1 - const sendCommand = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = idCounter++ - const handler = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id === id) { - ws.removeEventListener('message', handler) - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - } - } - ws.addEventListener('message', handler) - ws.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const saveScreenshot = async (filename: string): Promise => { - const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64')) - console.log(` Saved screenshot: ${filename}`) - } - - await sendCommand('Runtime.enable') - await sendCommand('Page.enable') - - // Wait for both ActScene and HudManager to become ready - for (let i = 0; i < 120; i++) { - const ready = await evalJs( - `Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.ctrlPnl)`, - ) - if (ready) break - await sleep(300) - } - await sleep(600) - - const initialHud = await evalJs(`window.__d2webHud`) - console.log('3. Verified initial __d2webHud state:', JSON.stringify(initialHud)) - - if (!initialHud || initialHud.dc6DecodeFailures !== 0) { - throw new Error(`Expected 0 DC6 decode failures, got: ${JSON.stringify(initialHud)}`) - } - for (const [k, v] of Object.entries(initialHud.assetsLoaded)) { - if (!v) throw new Error(`Asset group failed to load: ${k}`) - } - - // Screenshot 1: Base Town HUD - await saveScreenshot('hud_base_town.png') - - // Screenshot 2: Left Character Sheet (`A`) + Right Inventory (`I`) + Hover Harlequin Crest - const dualState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('char'); - inst.toggleRightPanel('inv'); - inst.inventory.handleMouseMove(561, 93); // Hover Harlequin Crest in Helm slot - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (dualState.leftPanel !== 'char' || dualState.rightPanel !== 'inv') { - throw new Error(`Split panel assertion failed: ${JSON.stringify(dualState)}`) - } - await sleep(200) - await saveScreenshot('hud_char_and_inv.png') - - // Verify Inventory Drag-and-Drop, Equipment Swap, Belt Drink, Stat & Skill Allocation - const interactionCheck = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.inventory.clickEquipSlot('weapon1'); - const pickedCode = inst.inventory.cursorItem?.code; - inst.inventory.clickGridCell(8, 0); - const swappedCode = inst.inventory.cursorItem?.code; - inst.inventory.clickEquipSlot('weapon1'); - const finalWeapon1 = inst.inventory.equipped.weapon1?.code; - inst.hp = 900; - inst.drinkBeltSlot(0); - const afterBeltCount = inst.belt.countTotalPotions(); - const healedHp = inst.hp; - inst.charSheet.allocateStat('vit'); - inst.syncPublishedState(); - return { pickedCode, swappedCode, finalWeapon1, afterBeltCount, healedHp, remainingStatPts: inst.state.statPoints }; - })()`) - console.log('4. Verified HUD interactions via CDP:', JSON.stringify(interactionCheck)) - if ( - interactionCheck.pickedCode !== 'oba' || - interactionCheck.swappedCode !== 'crs' || - interactionCheck.finalWeapon1 !== 'crs' || - interactionCheck.afterBeltCount !== 15 || - interactionCheck.healedHp !== 1080 - ) { - throw new Error(`HUD interaction check failed: ${JSON.stringify(interactionCheck)}`) - } - - // Screenshot 3: Left Quest Log (`Q`) + Right Skill Tree (`T`) + Hover Frozen Orb - const questSkillState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('quest'); - inst.toggleRightPanel('skill'); - inst.skillTree.handleMouseMove(445, 422); // Hover Frozen Orb - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (questSkillState.leftPanel !== 'quest' || questSkillState.rightPanel !== 'skill') { - throw new Error(`Quest/Skill panel assertion failed: ${JSON.stringify(questSkillState)}`) - } - await sleep(200) - await saveScreenshot('hud_quest_and_skills.png') - - // Screenshot 4: Left Waypoint Menu (`V`) + 4x4 Pop-up Belt (`~`) + Right Skill Speedbar Popup + Monster Plaque - const wpBeltState = await evalJs(`(() => { - const inst = window.__d2webHudInstance; - inst.toggleLeftPanel('waypoint'); - inst.toggleRightPanel('skill'); // close right panel - inst.belt.lockedOpen = true; - inst.hotkeys.openPopup = 'right'; - inst.hotkeys.hoveredPopupSkill = { side: 'right', skillId: 64 }; - inst.targetMonster = { - name: 'Corpsefire (尸体发火)', - hp: 740, - maxHp: 980, - rank: 'superunique', - affixes: ['Spectral Hit · 特别命中', 'Undead · 不死族'], - }; - inst.render(performance.now(), false); - return window.__d2webHud; - })()`) - if (wpBeltState.leftPanel !== 'waypoint' || !wpBeltState.beltExpanded) { - throw new Error(`Waypoint/Belt assertion failed: ${JSON.stringify(wpBeltState)}`) - } - await sleep(200) - await saveScreenshot('hud_waypoint_and_belt.png') - - ws.close() - console.log('5. ALL Issue #27 Browser & CDP Assertions Passed 100%!') - } finally { - chromeProc.kill() - await server.close() - } -} - -main().catch((err) => { - console.error('verify-ui-browser failed:', err) - process.exit(1) -}) diff --git a/tools/verify-vendor-ui.ts b/tools/verify-vendor-ui.ts deleted file mode 100644 index 6a25c54..0000000 --- a/tools/verify-vendor-ui.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { createServer, type ViteDevServer } from 'vite' -import { ensurePacksAvailable } from './verify-ground-combat-browser.ts' - -const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) - -const CHROME_CANDIDATES = [ - '/usr/bin/google-chrome', - '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', - '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', -] - -function resolveChromeBinary(): string { - for (const bin of CHROME_CANDIDATES) { - if (existsSync(bin)) return bin - } - throw new Error('No accessible Chromium binary found') -} - -async function main() { - const outputDir = process.env.VENDOR_AUDIT_OUT - if (!outputDir) { - throw new Error('VENDOR_AUDIT_OUT environment variable is required') - } - mkdirSync(outputDir, { recursive: true }) - ensurePacksAvailable() - - const port = 5419 - const viteServer: ViteDevServer = await createServer({ - root: resolve('.'), - logLevel: 'silent', - server: { host: '127.0.0.1', port, strictPort: false }, - }) - await viteServer.listen() - const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port - const targetUrl = `http://127.0.0.1:${actualPort}/acts.html?act=1&level=1-act-1-town-1` - - const chromeBin = resolveChromeBinary() - const debugPort = 9519 - const chromeProc: ChildProcess = spawn( - chromeBin, - [ - '--headless=new', - '--no-sandbox', - '--disable-dev-shm-usage', - '--window-size=1280,840', - '--enable-webgl', - '--ignore-gpu-blocklist', - '--use-gl=angle', - '--use-angle=swiftshader', - '--no-proxy-server', - `--remote-debugging-port=${debugPort}`, - targetUrl, - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - - let ws: WebSocket | null = null - try { - let wsDebuggerUrl: string | null = null - for (let i = 0; i < 80; i++) { - await sleep(150) - try { - const res = await fetch(`http://127.0.0.1:${debugPort}/json`) - const pages = (await res.json()) as any[] - const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) - if (page?.webSocketDebuggerUrl) { - wsDebuggerUrl = page.webSocketDebuggerUrl - break - } - } catch {} - } - if (!wsDebuggerUrl) throw new Error('Failed to attach to Chrome CDP') - - ws = new WebSocket(wsDebuggerUrl) - await new Promise((res, rej) => { - ws!.onopen = () => res(true) - ws!.onerror = rej - }) - - let cmdId = 1 - const pending = new Map void>() - ws.onmessage = (event: MessageEvent) => { - const msg = JSON.parse(String(event.data)) - if (msg.id && pending.has(msg.id)) { - pending.get(msg.id)!(msg) - pending.delete(msg.id) - } - } - - const cdp = (method: string, params: Record = {}): Promise => - new Promise((resolve, reject) => { - const id = cmdId++ - pending.set(id, (msg: any) => { - if (msg.error) reject(new Error(JSON.stringify(msg.error))) - else resolve(msg.result as T) - }) - ws!.send(JSON.stringify({ id, method, params })) - }) - - const evalJs = async (expression: string): Promise => { - const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - }) - if (res.exceptionDetails) { - throw new Error(`Eval error: ${JSON.stringify(res.exceptionDetails)}`) - } - return res.result.value - } - - const takeScreenshot = async (name: string): Promise => { - const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) - const filePath = join(outputDir, `${name}.png`) - writeFileSync(filePath, Buffer.from(shot.data, 'base64')) - console.log(`Saved screenshot: ${filePath}`) - return filePath - } - - await cdp('Runtime.enable') - await cdp('Page.enable') - - for (let i = 0; i < 100; i++) { - const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) - if (ready) break - await sleep(200) - } - - // 1. Exhaustive audit of all 16 trade NPCs and 6 gamble NPCs - const fullAudit = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const wp = hud.worldPanels; - hud.charSheet.attrs.level = 30; - const tradeNpcs = [ - 'Akara', 'Charsi', 'Gheed', - 'Fara', 'Drognan', 'Elzix', 'Lysander', - 'Ormus', 'Hratli', 'Asheara', 'Alkor', - 'Halbu', 'Jamella', - 'Larzuk', 'Malah', 'Anya', - ]; - const gambleNpcs = ['Gheed', 'Elzix', 'Alkor', 'Jamella', 'Anya', 'Nihlathak']; - const tradeResults = []; - for (const name of tradeNpcs) { - const ok = hud.openVendorSession(name, 'trade', 0); - if (!ok) throw new Error('Failed to open trade vendor: ' + name); - hud.render(); - const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); - tradeResults.push({ - name, - hcIdx: wp.activeVendorDescriptor.hcIdx, - canRepair: wp.activeVendorDescriptor.canRepair, - layout: wp.getActiveVendorButtonLayout(), - frames, - activeTab: wp.activeVendorTab, - itemsCount: wp.getActiveVendorPlacements().length, - }); - } - const gambleResults = []; - for (const name of gambleNpcs) { - const ok = hud.openVendorSession(name, 'gamble', 1); - if (!ok) throw new Error('Failed to open gamble vendor: ' + name); - hud.render(); - const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot)); - gambleResults.push({ - name, - hcIdx: wp.activeVendorDescriptor.hcIdx, - layout: wp.getActiveVendorButtonLayout(), - frames, - itemsCount: wp.getActiveVendorPlacements().length, - }); - } - return { tradeCount: tradeResults.length, gambleCount: gambleResults.length, tradeResults, gambleResults }; - })()`) - console.log( - `Audited ${fullAudit.tradeCount} trade NPCs and ${fullAudit.gambleCount} gamble NPCs:`, - JSON.stringify({ - blacksmiths: fullAudit.tradeResults.filter((r: any) => r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), - traders: fullAudit.tradeResults.filter((r: any) => !r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`), - gamblers: fullAudit.gambleResults.map((r: any) => `${r.name}:${r.frames.join(',')}`), - }), - ) - - // 2. Open Charsi NPC Menu (move player next to Charsi in town and trigger interactWithNpc) - const menuInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const engine = window.__d2webEngine; - const mc = window.__d2webMouseController; - hud.closeAllPanels(); - const charsi = engine?.npcEntities?.find(n => n.def?.name?.toLowerCase() === 'charsi'); - if (charsi && mc) { - engine.world.player.x = charsi.x + 28; - engine.world.player.y = charsi.y + 18; - mc.interactWithNpc(charsi); - } else { - hud.openNpcMenuByName('Charsi', 400, 220); - } - hud.render(); - const dialogEl = document.querySelector('#dialog'); - return { - npcName: hud.worldPanels.npcMenu?.npcName, - options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh), - hoveredOptionIdx: hud.worldPanels.npcMenu?.hoveredOptionIdx, - layout: hud.worldPanels.getNpcMenuLayout(hud.font), - domDialogHidden: dialogEl ? dialogEl.hidden : true, - }; - })()`) - console.log('NPC Menu:', JSON.stringify(menuInfo)) - if (!menuInfo.domDialogHidden) { - throw new Error('Legacy #dialog DOM element should be hidden when HudManager is active') - } - await sleep(250) - await takeScreenshot('vendor_npc_menu_charsi') - - // 3. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI (buysell.dc6 + buyselltabs.dc6 + [2,4,6,18] buttons) - const charsiInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.charSheet.attrs.level = 24; - hud.openVendorSession('Charsi', 'trade', 0); - const wp = hud.worldPanels; - wp.selectVendorTab('armor'); - wp.hoveredVendorButtonSlot = null; - const armorItems = wp.vendorTabPlacements.armor; - const docking = hud.getDockingLayout(); - const deltaLeft = -docking.marginW; - if (armorItems.length > 0) { - const first = armorItems[0]; - wp.hoveredVendorItem = { - item: first.item, - x: 80 + 16 + first.col * 29 + 14, - y: 60 + 63 + first.row * 29 + 14, - col: first.col, - row: first.row, - }; - hud.inventory.hoveredItem = { - item: first.item, - x: wp.hoveredVendorItem.x + deltaLeft, - y: wp.hoveredVendorItem.y, - }; - } - hud.render(); - return { - leftPanel: hud.leftPanel, - rightPanel: hud.rightPanel, - buttonLayout: wp.getActiveVendorButtonLayout(), - armorCount: wp.vendorTabPlacements.armor.length, - weapons1Count: wp.vendorTabPlacements.weapons1.length, - weapons2Count: wp.vendorTabPlacements.weapons2.length, - miscCount: wp.vendorTabPlacements.misc.length, - firstItem: armorItems[0] ? { nameZh: armorItems[0].item.nameZh, buyCost: armorItems[0].item.buyCost } : null, - }; - })()`) - console.log('Charsi Trade UI:', JSON.stringify(charsiInfo)) - await sleep(200) - await takeScreenshot('vendor_trade_charsi') - - // 4. Charsi Repair Latched + Repair All Hover Tooltip - const charsiRepairInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - const wp = hud.worldPanels; - wp.hoveredVendorItem = null; - hud.inventory.hoveredItem = null; - // Damage an equipped item to produce a non-zero Repair All cost - const equippedList = Object.values(hud.inventory.equipped).filter(Boolean); - if (equippedList.length > 0 && equippedList[0].durability) { - equippedList[0].durability.current = Math.max(1, Math.floor(equippedList[0].durability.max / 2)); - } - wp.vendorTradeState = 'repair'; - wp.hoveredVendorButtonSlot = 3; - hud.render(); - return { - vendorTradeState: wp.vendorTradeState, - repairFrame: wp.getVendorButtonFrame(2), - repairAllCost: wp.getRepairAllCost(hud.inventory), - }; - })()`) - console.log('Charsi Repair UI:', JSON.stringify(charsiRepairInfo)) - await sleep(200) - await takeScreenshot('vendor_repair_charsi') - - // 5. Akara Trader UI ([Buy, Sell, Blank, Close] + Close hover) - const akaraInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.openVendorSession('Akara', 'trade', 0); - const wp = hud.worldPanels; - wp.hoveredVendorItem = null; - hud.inventory.hoveredItem = null; - wp.hoveredVendorButtonSlot = 3; - hud.render(); - return { - buttonLayout: wp.getActiveVendorButtonLayout(), - activeTab: wp.activeVendorTab, - }; - })()`) - console.log('Akara Trade UI:', JSON.stringify(akaraInfo)) - await sleep(200) - await takeScreenshot('vendor_trade_akara') - - // 6. Open Gheed Gamble UI - const gheedInfo = await evalJs(`(() => { - const hud = window.__d2webHudInstance; - hud.charSheet.attrs.level = 60; - hud.openVendorSession('Gheed', 'gamble', 1); - const wp = hud.worldPanels; - wp.hoveredVendorButtonSlot = null; - const gambleItems = wp.gamblePlacements; - const docking = hud.getDockingLayout(); - const deltaLeft = -docking.marginW; - if (gambleItems.length > 0) { - const first = gambleItems[0]; - wp.hoveredVendorItem = { - item: first.item, - x: 80 + 16 + first.col * 29 + 14, - y: 60 + 63 + first.row * 29 + 14, - col: first.col, - row: first.row, - }; - hud.inventory.hoveredItem = { - item: first.item, - x: wp.hoveredVendorItem.x + deltaLeft, - y: wp.hoveredVendorItem.y, - }; - } - hud.render(); - return { - buttonLayout: wp.getActiveVendorButtonLayout(), - gambleCount: gambleItems.length, - firstGamble: gambleItems[0] ? { nameZh: gambleItems[0].item.nameZh, buyCost: gambleItems[0].item.buyCost } : null, - }; - })()`) - console.log('Gheed Gamble UI:', JSON.stringify(gheedInfo)) - await sleep(200) - await takeScreenshot('vendor_gamble_gheed') - } finally { - ws?.close() - chromeProc.kill('SIGKILL') - await viteServer.close() - } -} - -main().catch(err => { - console.error(err) - process.exit(1) -})