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/game/gems-data.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { resolveItemColorTransform } from '../src/ui/item-bridge.ts' import { SorceressLayerCompositor, resolveSorceressAppearance, } from '../src/game/char-appearance.ts' import type { ResolvableEquippedItem } from '../src/game/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) })