692 lines
25 KiB
TypeScript
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)
|
|
})
|