diablo2-web/scripts/verify-char-appearance.ts

692 lines
25 KiB
TypeScript

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<void> => 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<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
return {
quality: 'normal',
identified: true,
...overrides,
}
}
interface CdpSession {
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
evalJs: <T = unknown>(expression: string) => Promise<T>
close: () => void
}
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
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<void>((res, rej) => {
ws.onopen = () => res()
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
})
let idCounter = 1
const send = <T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 20000,
): Promise<T> =>
new Promise<T>((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<string, any>
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<string, any>)
})
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
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<void> {
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<ChildProcess> {
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<void> {
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>(`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<void> {
await runUnitAndCompositorChecks()
await runBrowserE2ECheck()
console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!')
}
main().catch(err => {
console.error(err)
process.exit(1)
})