feat(char): pack and render all 7 hero classes with R8 atlases and tests

This commit is contained in:
troytt 2026-09-22 02:38:22 +00:00
parent 6e4d0382ec
commit f24ad292ca
9 changed files with 2048 additions and 28 deletions

View File

@ -27,6 +27,7 @@
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; }
.hint { color: #6d5a34; }
@ -38,6 +39,15 @@
<span id="hud">—</span>
</header>
<div id="controls" class="overlay">
<label>职业 <select id="hero" disabled>
<option value="so">法师 (Sorceress)</option>
<option value="am">亚马逊 (Amazon)</option>
<option value="ne">死灵法师 (Necromancer)</option>
<option value="pa">圣骑士 (Paladin)</option>
<option value="ba">野蛮人 (Barbarian)</option>
<option value="dz">德鲁伊 (Druid)</option>
<option value="ai">刺客 (Assassin)</option>
</select></label>
<label>章节 <select id="act" disabled>
<option value="1">第一章</option>
<option value="2">第二章</option>

186
scripts/pack-characters.ts Normal file
View File

@ -0,0 +1,186 @@
/**
* Bake all 7 Diablo II character classes into web-native indexed PNG and raw R8 atlases (Schema 2).
*
* Classes:
* - so: Sorceress (法师 / 女巫)
* - am: Amazon (亚马逊)
* - ne: Necromancer (死灵法师)
* - pa: Paladin (圣骑士)
* - ba: Barbarian (野蛮人)
* - dz: Druid (德鲁伊)
* - ai: Assassin (刺客)
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts'
import { packSheetToR8, type PackedEntityR8 } from './pack-entity-assets.ts'
import { encodeIndexedPng } from './png.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw'])
export const HERO_SPECS = [
{ token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress' },
{ token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon' },
{ token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer' },
{ token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin' },
{ token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian' },
{ token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid' },
{ token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin' },
] as const
export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
const t0 = performance.now()
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
const archive = await MpqArchive.open(await fileSource(join(archiveDir, name)))
archives.add(name, archive)
} catch (err) {
console.warn(`skip ${name}: ${(err as Error).message}`)
}
}
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
if (!existsSync(animDataJsonPath)) {
throw new Error(`AnimData JSON missing in ${join(outDir, 'anim')}`)
}
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
const globalEntityDir = join(outDir, 'entities')
await mkdir(globalEntityDir, { recursive: true })
// Read palettes for Act 1..5
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
for (let act = 1; act <= 5; act += 1) {
try {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
await mkdir(actEntityDir, { recursive: true })
actPalettes.push({ act, palette: pl2.rgb, dir: actEntityDir })
} catch (err) {
console.warn(`Cannot read palette for act ${act}: ${(err as Error).message}`)
}
}
for (const spec of HERO_SPECS) {
const { token, nameZh, nameEn } = spec
const charT0 = performance.now()
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
const charSheets: CharacterSheet[] = []
const charClips: Record<string, {
group: number
directions: number
frames: number
speed: number
loop: boolean
events: { f: number; c: number }[]
}> = {}
let charGroupOffset = 0
let totalCharMembers = 0
for (const mode of PLAYER_MODES) {
let modeToLoad = mode
let cofName = `${token.toUpperCase()}${mode.toUpperCase()}HTH`
if (!animData.clips[cofName] && mode === 'a2') {
modeToLoad = 'a1'
cofName = `${token.toUpperCase()}A1HTH`
}
const sheet = await loadCharacterSheet(archives, token, modeToLoad, 'hth')
charSheets.push(sheet)
totalCharMembers += sheet.members.length
const animRecord = animData.clips[cofName]
const speed = animRecord?.speed ?? 256
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
charClips[mode] = {
group: charGroupOffset,
directions: sheet.directions,
frames: sheet.framesPerDirection,
speed,
loop: LOOPING_PLAYER_MODES.has(mode),
events,
}
charGroupOffset += sheet.sheet.groups.length
}
const charJoined: SpriteSheet = {
groups: charSheets.flatMap(s => s.sheet.groups),
width: null,
}
const packedCharR8 = packSheetToR8(charJoined)
// Save global R8 atlas
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
await writeFile(
join(globalEntityDir, `char-${token}.json`),
JSON.stringify({
schema: 2,
token,
file: `char-${token}.r8`,
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
walk: charClips.wl?.frames ?? 8,
stand: charClips.nu?.frames ?? 8,
walkOffset: 0,
standOffset: charClips.nu?.group ?? 16,
members: totalCharMembers,
clips: charClips,
groups: packedCharR8.groups,
}, null, 2)
)
// Save per-Act PNGs
for (const { palette, dir } of actPalettes) {
const charPng = encodeIndexedPng({
width: packedCharR8.width,
height: packedCharR8.height,
pixels: packedCharR8.r8,
palette,
transparentIndex: 0,
})
const charPngName = `char-${token}.png`
await writeFile(join(dir, charPngName), charPng)
await writeFile(
join(dir, `char-${token}.json`),
JSON.stringify({
schema: 2,
token,
file: charPngName,
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
clips: charClips,
walk: charClips.wl?.group ?? 0,
stand: charClips.nu?.group ?? 16,
members: totalCharMembers,
groups: packedCharR8.groups,
}, null, 2)
)
}
console.log(` ✓ ${nameZh} (${token}) finished in ${(performance.now() - charT0).toFixed(0)} ms (atlas: ${packedCharR8.width}x${packedCharR8.height})`)
}
console.log(`All 7 characters baked successfully in ${(performance.now() - t0).toFixed(0)} ms!`)
}
if (import.meta.url === `file://${process.argv[1]}`) {
bakeAllCharacters().catch(err => {
console.error('Failed to bake characters:', err)
process.exit(1)
})
}

View File

@ -0,0 +1,296 @@
/**
* 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<void>(resolve => setTimeout(resolve, ms))
async function main(): Promise<void> {
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<void>(resolve => { ws.onopen = () => resolve() })
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
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 <T = any>(expression: string): Promise<T> => {
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>(`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)
})

View File

@ -0,0 +1,205 @@
/**
* Empirical verification harness for Milestone M1 (Issue #146: ControlBar XP bar alignment & 10-segment ticks).
*
* Runs headless verification of:
* 1. Geometry of the 9 divider ticks: strictly at x = 256 + 12*i, height 4.
* 2. Hit-testing boundary edge cases: (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567).
* 3. All XP values from 0.0 to 1.0 (0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, >1.0 overflow).
*/
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
type ControlBarState,
} from '../src/ui/control-bar.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
interface DrawCall {
fillStyle: string
x: number
y: number
w: number
h: number
}
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
const rects: DrawCall[] = []
let currentFillStyle = '#000000'
const ctx = {
get fillStyle() {
return currentFillStyle
},
set fillStyle(val: any) {
currentFillStyle = String(val)
},
fillRect(x: number, y: number, w: number, h: number) {
rects.push({ fillStyle: currentFillStyle, x, y, w, h })
},
createLinearGradient() {
return { addColorStop: () => {} }
},
drawImage() {},
strokeRect() {},
} as unknown as CanvasRenderingContext2D
return { ctx, rects }
}
function createDummyState(xp: number, cur: number, next: number): ControlBarState {
return {
level: 85,
xp,
currentLevelXp: cur,
nextLevelXp: next,
stamina: 465,
maxStamina: 465,
isRunning: true,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
}
async function runEmpiricalVerification() {
console.log('=== [M1 EMPIRICAL VERIFICATION HARNESS: ISSUE #146] ===\n')
let allPassed = true
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(` [PASS] ${msg}`)
} else {
console.error(` [FAIL] ${msg}`)
allPassed = false
}
}
// --- SECTION 1: GEOMETRY & BOUNDS ---
console.log('1. Checking EXP_BAR_BOUNDS & EXP_BAR_HOVER_BOUNDS:')
assert(EXP_BAR_BOUNDS.x === 256, `EXP_BAR_BOUNDS.x === 256 (actual: ${EXP_BAR_BOUNDS.x})`)
assert(EXP_BAR_BOUNDS.y === 560, `EXP_BAR_BOUNDS.y === 560 (actual: ${EXP_BAR_BOUNDS.y})`)
assert(EXP_BAR_BOUNDS.width === 120, `EXP_BAR_BOUNDS.width === 120 (actual: ${EXP_BAR_BOUNDS.width})`)
assert(EXP_BAR_BOUNDS.height === 4, `EXP_BAR_BOUNDS.height === 4 (actual: ${EXP_BAR_BOUNDS.height})`)
assert(EXP_BAR_HOVER_BOUNDS.x === 256, `EXP_BAR_HOVER_BOUNDS.x === 256 (actual: ${EXP_BAR_HOVER_BOUNDS.x})`)
assert(EXP_BAR_HOVER_BOUNDS.y === 558, `EXP_BAR_HOVER_BOUNDS.y === 558 (actual: ${EXP_BAR_HOVER_BOUNDS.y})`)
assert(EXP_BAR_HOVER_BOUNDS.width === 120, `EXP_BAR_HOVER_BOUNDS.width === 120 (actual: ${EXP_BAR_HOVER_BOUNDS.width})`)
assert(EXP_BAR_HOVER_BOUNDS.height === 8, `EXP_BAR_HOVER_BOUNDS.height === 8 (actual: ${EXP_BAR_HOVER_BOUNDS.height})`)
// --- SECTION 2: 9 DIVIDER TICKS GEOMETRY ---
console.log('\n2. Checking 9 Divider Ticks Geometry (strictly at x = 256 + 12*i, height 4):')
const bar = new ControlBarHud()
const { ctx: ctxTicks, rects: rectsTicks } = createMockCtx()
bar.draw(ctxTicks, createDummyState(500, 0, 1000), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const tickRects = rectsTicks.filter(r => r.fillStyle === '#1e1812' && r.y === 560 && r.w === 1 && r.h === 4)
assert(tickRects.length === 9, `Rendered exactly 9 divider ticks (actual: ${tickRects.length})`)
for (let i = 1; i <= 9; i++) {
const expectedX = 256 + 12 * i
const tick = tickRects[i - 1]
const matches = tick && tick.x === expectedX && tick.y === 560 && tick.w === 1 && tick.h === 4
assert(!!matches, `Tick #${i}: x=${expectedX}, y=560, w=1, h=4 (actual: ${JSON.stringify(tick)})`)
}
// --- SECTION 3: HIT-TESTING BOUNDARY EDGE CASES ---
console.log('\n3. Checking Hit-Testing Boundary Edge Cases:')
const hitCases = [
{ x: 256, y: 560, expected: 'exp', desc: '(256, 560) Top-left of inner groove' },
{ x: 375, y: 563, expected: 'exp', desc: '(375, 563) Bottom-right interior of groove' },
{ x: 255, y: 560, expected: null, desc: '(255, 560) 1px left of groove' },
{ x: 376, y: 560, expected: 'exp', desc: '(376, 560) Right edge boundary (x = 256+120)' },
{ x: 256, y: 557, expected: null, desc: '(256, 557) 1px above padded hover bounds' },
{ x: 256, y: 558, expected: 'exp', desc: '(256, 558) Top edge of padded hover bounds' },
{ x: 256, y: 566, expected: 'exp', desc: '(256, 566) Bottom edge of padded hover bounds' },
{ x: 256, y: 567, expected: null, desc: '(256, 567) 1px below padded hover bounds' },
]
for (const hc of hitCases) {
bar.handleMouseMove(hc.x, hc.y)
const actual = bar.hoveredElement
assert(
actual === hc.expected,
`${hc.desc} -> expected: ${hc.expected}, actual: ${actual}`,
)
}
// --- SECTION 4: XP RENDERING & VALUE SWEEPS ---
console.log('\n4. Checking XP Fill Widths for Various XP Values (0.0 to 1.0, negative, overflow):')
const xpCases = [
{ label: '0% (0.0)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
{ label: '5% (0.05)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
{ label: '10% (0.10)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
{ label: '12.5% (0.125)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
{ label: '50% (0.50)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
{ label: '99.9% (0.999)', xp: 999, cur: 0, next: 1000, expectedW: 120 },
{ label: '100% (1.0)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
{ label: 'Negative XP (-500)', xp: -500, cur: 0, next: 1000, expectedW: 0 },
{ label: 'XP below currentLevelXp', xp: 200, cur: 300, next: 1000, expectedW: 0 },
{ label: 'XP overflow (>1.0: 1500)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
{ label: 'Massive XP overflow (10000000)', xp: 10000000, cur: 0, next: 1000, expectedW: 120 },
]
for (const xc of xpCases) {
const { ctx, rects } = createMockCtx()
bar.draw(ctx, createDummyState(xc.xp, xc.cur, xc.next), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
const actualW = fillRect ? fillRect.w : -1
assert(
actualW === xc.expectedW,
`${xc.label}: expected width ${xc.expectedW}, actual width ${actualW}`,
)
}
// --- SECTION 5: DIFFERENTIAL FUZZING ---
console.log('\n5. Differential Fuzzing Across 1,000 Step Fractions:')
let fuzzerFailures = 0
for (let i = 0; i <= 1000; i++) {
const fraction = i / 1000
const xp = fraction * 50000
const { ctx, rects } = createMockCtx()
bar.draw(ctx, createDummyState(xp, 0, 50000), {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}, new D2FontRenderer())
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
const expectedW = Math.round(120 * fraction)
if (!fillRect || fillRect.w !== expectedW || fillRect.w < 0 || fillRect.w > 120) {
fuzzerFailures++
}
}
assert(fuzzerFailures === 0, `1,000 differential fuzz iterations had 0 mismatches`)
console.log(`\n=== FINAL VERDICT: ${allPassed ? 'ALL CHECKS PASSED (APPROVE)' : 'FAILED (REJECT)'} ===\n`)
if (!allPassed) {
process.exit(1)
}
}
runEmpiricalVerification().catch(err => {
console.error('Unhandled verification error:', err)
process.exit(1)
})

View File

@ -128,9 +128,13 @@ export async function loadCharacterSheet(
if (component === undefined) {
return { sprite: null, member: undefined, note: `layer type ${String(layer.type)} has no component directory` }
}
const member = findLayerSprite(names, root, lower, animation, weapon, component)
const layerWeapon = (layer.weaponClass && layer.weaponClass.trim().length > 0) ? layer.weaponClass.trim() : weapon
let member = findLayerSprite(names, root, lower, animation, layerWeapon, component)
if (member === undefined && layerWeapon !== weapon) {
member = findLayerSprite(names, root, lower, animation, weapon, component)
}
if (member === undefined) {
return { sprite: null, member: undefined, note: `no sprite for component ${component} (${animation}${weapon})` }
return { sprite: null, member: undefined, note: `no sprite for component ${component} (${animation}${layerWeapon})` }
}
try {
const dcc = decodeDcc(await archives.read(member))

View File

@ -239,6 +239,10 @@ export interface ActSceneState {
characterFrames: number
/** Last group the character path selected (walk or stand, times 8 directions). */
characterGroup: number
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
heroToken?: string
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
heroName?: string
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */
@ -311,14 +315,114 @@ declare global {
interface Window {
/** Act scene state for automation and debugging. */
__d2webAct?: ActSceneState
__d2webActScene?: {
switchHero: (token: string) => Promise<boolean>
getHero: () => string
}
}
}
export interface HeroClassInfo {
readonly token: string
readonly code: string
readonly nameZh: string
readonly nameEn: string
readonly defaultLeftSkill: number
readonly defaultRightSkill: number
readonly charIdentity: {
readonly name: string
readonly classNameZh: string
readonly classNameEn: string
readonly str: number
readonly dex: number
readonly vit: number
readonly ene: number
readonly baseMaxHp: number
readonly baseMaxMana: number
}
}
export const HERO_CLASSES: Record<string, HeroClassInfo> = {
so: {
token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress',
defaultLeftSkill: 47, defaultRightSkill: 64,
charIdentity: {
name: 'Isendra', classNameZh: '女法师', classNameEn: 'Sorceress',
str: 156, dex: 75, vit: 315, ene: 95, baseMaxHp: 850, baseMaxMana: 620,
},
},
am: {
token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon',
defaultLeftSkill: 6, defaultRightSkill: 7,
charIdentity: {
name: 'Cassia', classNameZh: '亚马逊', classNameEn: 'Amazon',
str: 135, dex: 210, vit: 250, ene: 50, baseMaxHp: 920, baseMaxMana: 320,
},
},
ne: {
token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer',
defaultLeftSkill: 67, defaultRightSkill: 70,
charIdentity: {
name: 'Xul', classNameZh: '死灵法师', classNameEn: 'Necromancer',
str: 120, dex: 80, vit: 290, ene: 120, baseMaxHp: 780, baseMaxMana: 580,
},
},
pa: {
token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin',
defaultLeftSkill: 96, defaultRightSkill: 112,
charIdentity: {
name: 'Cartographus', classNameZh: '圣骑士', classNameEn: 'Paladin',
str: 175, dex: 120, vit: 340, ene: 45, baseMaxHp: 1150, baseMaxMana: 380,
},
},
ba: {
token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian',
defaultLeftSkill: 126, defaultRightSkill: 133,
charIdentity: {
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
str: 220, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
},
},
dz: {
token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid',
defaultLeftSkill: 225, defaultRightSkill: 229,
charIdentity: {
name: 'Loreov', classNameZh: '德鲁伊', classNameEn: 'Druid',
str: 165, dex: 90, vit: 330, ene: 70, baseMaxHp: 1050, baseMaxMana: 420,
},
},
ai: {
token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin',
defaultLeftSkill: 254, defaultRightSkill: 251,
charIdentity: {
name: 'Natalya', classNameZh: '刺客', classNameEn: 'Assassin',
str: 150, dex: 180, vit: 280, ene: 60, baseMaxHp: 960, baseMaxMana: 360,
},
},
}
export function normalizeHeroToken(input: string | null | undefined): string {
if (!input) return 'so'
const lower = input.toLowerCase().trim()
if (HERO_CLASSES[lower]) return lower
const codeMap: Record<string, string> = {
sor: 'so', sorceress: 'so',
ama: 'am', amazon: 'am',
nec: 'ne', necromancer: 'ne',
pal: 'pa', paladin: 'pa',
bar: 'ba', barbarian: 'ba',
dru: 'dz', druid: 'dz', dr: 'dz',
ass: 'ai', assassin: 'ai', as: 'ai',
}
return codeMap[lower] ?? 'so'
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
heroToken: 'so', heroName: 'Sorceress',
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
@ -2083,17 +2187,19 @@ async function loadMonsterArtMap(
async function loadCharacterArt(
_bases: readonly string[],
_palette: Palette,
_palette: Palette | null,
renderer: SpriteRenderer,
packBase?: string,
act?: number,
token = 'so',
): Promise<LoadedCharacter | null> {
const heroToken = normalizeHeroToken(token)
if (packBase !== undefined) {
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
try {
const [jsonResp, r8Resp] = await Promise.all([
fetch(`${packBase}/entities/char-so.json`),
fetch(`${packBase}/entities/char-so.r8`),
fetch(`${packBase}/entities/char-${heroToken}.json`),
fetch(`${packBase}/entities/char-${heroToken}.r8`),
])
if (jsonResp.ok && r8Resp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
@ -2125,8 +2231,8 @@ async function loadCharacterArt(
const entitiesUrl = `${packBase}/act${String(act)}/entities`
try {
const [jsonResp, pngResp] = await Promise.all([
fetch(`${entitiesUrl}/char-so.json`),
fetch(`${entitiesUrl}/char-so.png`),
fetch(`${entitiesUrl}/char-${heroToken}.json`),
fetch(`${entitiesUrl}/char-${heroToken}.png`),
])
if (jsonResp.ok && pngResp.ok) {
const meta = (await jsonResp.json()) as PackedCharJson
@ -2160,7 +2266,12 @@ async function loadCharacterArt(
}
}
console.warn('character art unavailable in pre-baked packs')
// Fallback to 'so' if requested hero failed
if (heroToken !== 'so') {
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
}
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
return null
}
@ -2298,6 +2409,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const hud = document.querySelector<HTMLElement>('#hud')!
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
@ -2305,10 +2417,102 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
input.attach()
let skippedDraws = 0
let currentHeroToken = normalizeHeroToken(param('hero', param('class', 'so')))
state.heroToken = currentHeroToken
state.heroName = HERO_CLASSES[currentHeroToken]?.nameEn ?? 'Sorceress'
let activePalette: Palette | null = runtime.palette
let packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
let packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
let character: LoadedCharacter | null = null
let monsterArtMap = new Map<string, LoadedMonsterArt>()
let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator()
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const nextToken = normalizeHeroToken(newHeroToken)
if (nextToken === currentHeroToken && character !== null) return true
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
status.textContent = `正在切换角色至 ${nextInfo.nameZh} …`
const nextChar = await loadCharacterArt(
runtime.charBases,
activePalette,
renderer,
packEntityBase,
packEntityAct,
nextToken,
)
if (nextChar !== null) {
if (character?.handle) {
renderer.deleteAtlas(character.handle)
}
character = nextChar
currentHeroToken = nextToken
state.heroToken = nextToken
state.heroName = nextInfo.nameEn
state.character = true
state.characterMembers = nextChar.members
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = nextInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = nextInfo.charIdentity.str
hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = nextInfo.charIdentity.baseMaxHp
hudManager.hp = nextInfo.charIdentity.baseMaxHp
hudManager.maxMana = nextInfo.charIdentity.baseMaxMana
hudManager.mana = nextInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill
hudManager.syncPublishedState()
}
if (heroSelect !== null && heroSelect.value !== nextToken) {
heroSelect.value = nextToken
}
try {
const url = new URL(location.href)
url.searchParams.set('hero', nextToken)
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
if (nextChar.clips?.nu) {
playerAnimator.play('nu', nextChar.clips.nu, { restart: true })
}
status.textContent = `已切换角色为 ${nextInfo.nameZh}(${String(nextChar.members)} 层素材)。`
setTimeout(() => {
if (status.textContent.startsWith('已切换角色为')) {
status.textContent = ''
}
}, 3000)
return true
}
status.textContent = `切换角色 ${nextInfo.nameZh} 失败。`
return false
}
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = currentHeroToken
heroSelect.onchange = async () => {
await switchHero(heroSelect.value)
}
}
if (typeof window !== 'undefined') {
window.__d2webActScene = {
switchHero,
getHero: () => currentHeroToken,
}
;(state as any).switchHero = switchHero
}
const monsterAnimators = new Map<number, ActorAnimator>()
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
camera.attach()
@ -2627,16 +2831,16 @@ if (typeof window !== 'undefined') {
if (next.palette !== null) {
renderer.setPalette(next.palette.rgb)
const paletteForEntities = next.palette
const packEntityBase = next.source === 'pack' ? next.base : undefined
const packEntityAct = next.source === 'pack' ? next.act : undefined
activePalette = next.palette
packEntityBase = next.source === 'pack' ? next.base : undefined
packEntityAct = next.source === 'pack' ? next.act : undefined
const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
activePalette,
renderer,
packEntityBase,
packEntityAct,
@ -3378,9 +3582,9 @@ if (typeof window !== 'undefined') {
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
renderer.setPalette(runtime.palette.rgb)
const paletteForEntities = runtime.palette
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
activePalette = runtime.palette
packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
// Collect monster types to load
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
@ -3389,7 +3593,7 @@ if (typeof window !== 'undefined') {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
activePalette,
renderer,
packEntityBase,
packEntityAct,
@ -3400,31 +3604,43 @@ if (typeof window !== 'undefined') {
const loadedChar = await loadCharacterArt(
runtime.charBases,
paletteForEntities,
activePalette,
renderer,
packEntityBase,
packEntityAct,
currentHeroToken,
)
character = loadedChar
state.character = loadedChar !== null
if (loadedChar !== null) state.characterMembers = loadedChar.members
const heroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = heroInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = heroInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = heroInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = heroInfo.charIdentity.str
hudManager.charSheet.attrs.dex = heroInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = heroInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = heroInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = heroInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = heroInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = heroInfo.charIdentity.baseMaxHp
hudManager.hp = heroInfo.charIdentity.baseMaxHp
hudManager.maxMana = heroInfo.charIdentity.baseMaxMana
hudManager.mana = heroInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = heroInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
hudManager.syncPublishedState()
}
}
loop.start()
reportMonsterPlan(runtime)
state.ready = true
state.loadMs = Math.round(performance.now() - started)
void variantSelect
status.textContent = 'WASD / 方向键移动。'
+ (runtime.source === 'pack'
? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。`
: '本次直接读归档并解码(较慢):资源包不可用时会走这条路。')
+ (state.character
? ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(state.characterMembers)} 层)。`
: ' 角色暂为占位棋子:角色素材不可用时会退回这里。')
+ (runtime.objectDrawables.length > 0
? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。`
: ' 可破坏物品的位置与美术成员已进包,静态帧待接。')
status.textContent = ''
window.addEventListener('beforeunload', () => {
loop.stop()
@ -3615,9 +3831,19 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
async function boot(): Promise<void> {
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#status')!
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
heroSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('hero', heroSelect.value)
location.search = params.toString()
}
}
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true
@ -3690,6 +3916,7 @@ async function boot(): Promise<void> {
} catch (err) {
state.error = (err as Error).message
status.textContent = `启动失败:${state.error}`
if (heroSelect !== null) heroSelect.disabled = false
actSelect.disabled = false
sceneSelect.disabled = false
variantSelect.disabled = false

View File

@ -0,0 +1,652 @@
/**
* tests/challenger-m1-controlbar-stress.test.ts
*
* Empirical Challenger Verification Suite for ControlBar (Issue #146 / Milestone 1 Batch 2).
*
* Requirements Tested:
* 1. Offscreen Canvas Rendering & Pixel Audit:
* - Decode authentic `public/ui/ctrlpnl-800.png` background panel.
* - Simulate Canvas 2D rendering of ControlBarHud across all xpRatio states (0%..100%, adversarial values).
* - Inspect pixel data inside the groove area `[256, 560, 120, 4]`:
* - Background pixels: `#070605` (7, 6, 5)
* - Filled progress pixels: `#d8d0a8` (216, 208, 168)
* - 9 tick notches at x = 256 + 12*i (i=1..9): `#1e1812` (30, 24, 18) across all 4 rows (y=560..563)
* - Surrounding Border Bleed Audit:
* - Top border row (y = 559, x in [256..375]): 100% match with base panel (ZERO bleed into gold trim)
* - Bottom border row (y = 564, x in [256..375]): 100% match with base panel (ZERO bleed into gold trim)
* - Left border (x = 255, y in [560..563]): 100% match with base panel (ZERO bleed)
* - Right border (x = 376, y in [560..563]): 100% match with base panel (ZERO bleed)
* - Corner pixels: (255, 559), (376, 559), (255, 564), (376, 564): 100% match with base panel
* 2. Hover Hit-Testing across DPRs and Aspect Ratios:
* - DPRs: 1.0, 1.25, 1.5, 2.0
* - Resolutions: 800x600 (4:3), 1920x1080 (16:9), 1280x720 (16:9), 2560x1440 (16:9), 1280x800 (16:10), 2560x1080 (21:9)
* - Logical boundary testing on all 4 corners and center of `EXP_BAR_HOVER_BOUNDS` (x in [256, 376], y in [558, 566])
* - Client integer pixel projection (physical browser mouse coordinates):
* - 100% of client pixels in [ceil(clientLeft), floor(clientRight)] trigger 'exp'
* - All client pixels outside trigger non-exp
* - Round-trip coordinate invariance: clientToLogical(logicalToClient(x, y)) === (x, y)
* - IEEE 754 precision boundary analysis at sub-pixel scale (3e-14)
* - 10,000-iteration random differential fuzzing harness under each display mode.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { inflateSync } from 'node:zlib'
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
type ControlBarState,
} from '../src/ui/control-bar.ts'
import {
computeHudLayout,
clientToLogical,
logicalToClient,
} from '../src/ui/hud-manager.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
// --- Helpers: Decode ctrlpnl-800.png from disk into RGBA buffer ---
interface DecodedImage {
width: number
height: number
rgba: Uint8ClampedArray
}
function decodeCtrlPnlPng(): DecodedImage {
const buf = readFileSync('public/ui/ctrlpnl-800.png')
let offset = 8
let width = 0
let height = 0
let plte: Uint8Array | null = null
let trns: Uint8Array | null = null
const idats: Buffer[] = []
while (offset < buf.length) {
const len = buf.readUInt32BE(offset)
const type = buf.toString('ascii', offset + 4, offset + 8)
const data = buf.subarray(offset + 8, offset + 8 + len)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
} else if (type === 'PLTE') {
plte = new Uint8Array(data)
} else if (type === 'tRNS') {
trns = new Uint8Array(data)
} else if (type === 'IDAT') {
idats.push(Buffer.from(data))
}
offset += 12 + len
}
if (!plte) throw new Error('Missing PLTE in ctrlpnl-800.png')
const decompressed = inflateSync(Buffer.concat(idats))
const rgba = new Uint8ClampedArray(width * height * 4)
let at = 0
for (let y = 0; y < height; y++) {
at++ // skip filter type (filter 0 in this asset)
for (let x = 0; x < width; x++) {
const idx = decompressed[at++]!
const outIdx = (y * width + x) * 4
rgba[outIdx + 0] = plte[idx * 3 + 0]!
rgba[outIdx + 1] = plte[idx * 3 + 1]!
rgba[outIdx + 2] = plte[idx * 3 + 2]!
rgba[outIdx + 3] = trns && idx < trns.length ? trns[idx]! : 255
}
}
return { width, height, rgba }
}
// --- Software Canvas 2D Simulator ---
class MockOffscreenCanvas2D {
readonly width: number
readonly height: number
readonly pixels: Uint8ClampedArray
fillStyle: string = '#000000'
strokeStyle: string = '#000000'
constructor(width: number, height: number) {
this.width = width
this.height = height
this.pixels = new Uint8ClampedArray(width * height * 4)
}
private parseColor(color: any): [number, number, number, number] {
if (typeof color === 'string') {
if (color.startsWith('#')) {
const hex = color.slice(1)
if (hex.length === 6) {
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
255,
]
}
}
if (color.startsWith('rgba')) {
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
if (m) {
return [
parseInt(m[1]!, 10),
parseInt(m[2]!, 10),
parseInt(m[3]!, 10),
Math.round(parseFloat(m[4] ?? '1') * 255),
]
}
}
}
// CanvasGradient or fallback color (stamina bar gradient)
return [232, 168, 40, 255]
}
fillRect(x: number, y: number, w: number, h: number): void {
const [r, g, b, a] = this.parseColor(this.fillStyle)
const startX = Math.max(0, Math.floor(x))
const startY = Math.max(0, Math.floor(y))
const endX = Math.min(this.width, Math.floor(x + w))
const endY = Math.min(this.height, Math.floor(y + h))
for (let py = startY; py < endY; py++) {
for (let px = startX; px < endX; px++) {
const idx = (py * this.width + px) * 4
this.pixels[idx + 0] = r
this.pixels[idx + 1] = g
this.pixels[idx + 2] = b
this.pixels[idx + 3] = a
}
}
}
drawImage(img: any, dx: number, dy: number, ..._args: any[]): void {
if (!img || !img.rgba) return
const srcW = img.width as number
const srcH = img.height as number
const srcData = img.rgba as Uint8ClampedArray
for (let sy = 0; sy < srcH; sy++) {
const py = dy + sy
if (py < 0 || py >= this.height) continue
for (let sx = 0; sx < srcW; sx++) {
const px = dx + sx
if (px < 0 || px >= this.width) continue
const srcIdx = (sy * srcW + sx) * 4
const dstIdx = (py * this.width + px) * 4
this.pixels[dstIdx + 0] = srcData[srcIdx + 0]!
this.pixels[dstIdx + 1] = srcData[srcIdx + 1]!
this.pixels[dstIdx + 2] = srcData[srcIdx + 2]!
this.pixels[dstIdx + 3] = srcData[srcIdx + 3]!
}
}
}
createLinearGradient(_x0: number, _y0: number, _x1: number, _y1: number) {
return {
addColorStop: (_offset: number, _color: string) => {},
}
}
strokeRect(_x: number, _y: number, _w: number, _h: number): void {}
getPixel(x: number, y: number): { r: number; g: number; b: number; a: number } {
const idx = (y * this.width + x) * 4
return {
r: this.pixels[idx + 0]!,
g: this.pixels[idx + 1]!,
b: this.pixels[idx + 2]!,
a: this.pixels[idx + 3]!,
}
}
}
function createDummyFont(): D2FontRenderer {
const font = new D2FontRenderer()
font.measureText = (_text: string, _font?: any) => 100
font.drawText = (_ctx: any, _text: string, _x: number, _y: number, _opt?: any) => {}
return font
}
function makeState(xpRatio: number): ControlBarState {
const currentLevelXp = 1000
const nextLevelXp = 2000
const span = nextLevelXp - currentLevelXp
const xp = currentLevelXp + Math.round(span * xpRatio)
return {
level: 1,
xp,
currentLevelXp,
nextLevelXp,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
}
describe('Milestone 1 Batch 2 Challenger: ControlBar Empirical Stress Tests', () => {
const bgImg = decodeCtrlPnlPng()
const dummyFont = createDummyFont()
// --- Requirement 1: Canvas Rendering & Pixel Bleed Audit ---
describe('Requirement 1: Offscreen Canvas Rendering & Pixel Bleed Audit', () => {
it('verifies exact dimensions and position of authentic 800CtrlPnl7 panel', () => {
expect(bgImg.width).toBe(800)
expect(bgImg.height).toBe(104)
// Panel is drawn at y=496, covering y=496..600
expect(496 + bgImg.height).toBe(600)
})
it('confirms the authentic panel has gold trim at y=559 and y=564 with a dark groove at y=560..563', () => {
const canvas = new MockOffscreenCanvas2D(800, 600)
canvas.drawImage(bgImg, 0, 496)
// Top trim (y=559) has non-zero gold trim filigree pixels
let topNonZero = 0
for (let x = 256; x <= 375; x++) {
const p = canvas.getPixel(x, 559)
if (p.r + p.g + p.b > 0) topNonZero++
}
expect(topNonZero).toBeGreaterThanOrEqual(40)
// Bottom trim (y=564) has non-zero gold trim filigree pixels
let botNonZero = 0
for (let x = 256; x <= 375; x++) {
const p = canvas.getPixel(x, 564)
if (p.r + p.g + p.b > 0) botNonZero++
}
expect(botNonZero).toBeGreaterThanOrEqual(60)
})
const testXpRatios = [0.0, 0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1.0]
for (const ratio of testXpRatios) {
it(`renders xpRatio = ${(ratio * 100).toFixed(0)}% with ZERO pixel bleed into gold trim at y=559 or y=564`, () => {
// Step 1: Base canvas with only panel art
const baseCanvas = new MockOffscreenCanvas2D(800, 600)
baseCanvas.drawImage(bgImg, 0, 496)
// Snapshot original border pixels before ControlBar draw
const originalTopRow: { r: number; g: number; b: number }[] = []
const originalBottomRow: { r: number; g: number; b: number }[] = []
const originalLeftCol: { r: number; g: number; b: number }[] = []
const originalRightCol: { r: number; g: number; b: number }[] = []
for (let x = 256; x <= 375; x++) {
originalTopRow.push(baseCanvas.getPixel(x, 559))
originalBottomRow.push(baseCanvas.getPixel(x, 564))
}
for (let y = 560; y <= 563; y++) {
originalLeftCol.push(baseCanvas.getPixel(255, y))
originalRightCol.push(baseCanvas.getPixel(376, y))
}
// Step 2: Render ControlBarHud onto fresh canvas
const canvas = new MockOffscreenCanvas2D(800, 600)
const hud = new ControlBarHud()
const state = makeState(ratio)
hud.draw(
canvas as any,
state,
{
ctrlPnlImg: bgImg as any,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
},
dummyFont,
)
// Step 3: Verify top border (y=559) across all 120 pixels (x in [256..375])
for (let i = 0; i < 120; i++) {
const x = 256 + i
const current = canvas.getPixel(x, 559)
const expected = originalTopRow[i]!
expect(
{ x, y: 559, r: current.r, g: current.g, b: current.b },
`Pixel bleed detected at y=559, x=${x}`,
).toEqual({ x, y: 559, r: expected.r, g: expected.g, b: expected.b })
}
// Step 4: Verify bottom border (y=564) across all 120 pixels (x in [256..375])
for (let i = 0; i < 120; i++) {
const x = 256 + i
const current = canvas.getPixel(x, 564)
const expected = originalBottomRow[i]!
expect(
{ x, y: 564, r: current.r, g: current.g, b: current.b },
`Pixel bleed detected at y=564, x=${x}`,
).toEqual({ x, y: 564, r: expected.r, g: expected.g, b: expected.b })
}
// Step 5: Verify left border (x=255) across y=560..563
for (let i = 0; i < 4; i++) {
const y = 560 + i
const current = canvas.getPixel(255, y)
const expected = originalLeftCol[i]!
expect(
{ x: 255, y, r: current.r, g: current.g, b: current.b },
`Pixel bleed detected at x=255, y=${y}`,
).toEqual({ x: 255, y, r: expected.r, g: expected.g, b: expected.b })
}
// Step 6: Verify right border (x=376) across y=560..563
for (let i = 0; i < 4; i++) {
const y = 560 + i
const current = canvas.getPixel(376, y)
const expected = originalRightCol[i]!
expect(
{ x: 376, y, r: current.r, g: current.g, b: current.b },
`Pixel bleed detected at x=376, y=${y}`,
).toEqual({ x: 376, y, r: expected.r, g: expected.g, b: expected.b })
}
// Step 7: Inspect groove area [256, 560, 120, 4] pixel contents
const filledWidth = Math.round(120 * ratio)
const tickXs = new Set([268, 280, 292, 304, 316, 328, 340, 352, 364])
for (let y = 560; y <= 563; y++) {
for (let x = 256; x <= 375; x++) {
const pixel = canvas.getPixel(x, y)
if (tickXs.has(x)) {
// Tick notch color: #1e1812 -> (30, 24, 18)
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
x,
y,
r: 30,
g: 24,
b: 18,
})
} else if (x < 256 + filledWidth) {
// Filled XP bar color: #d8d0a8 -> (216, 208, 168)
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
x,
y,
r: 216,
g: 208,
b: 168,
})
} else {
// Background groove color: #070605 -> (7, 6, 5)
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
x,
y,
r: 7,
g: 6,
b: 5,
})
}
}
}
})
}
it('verifies 101-step sweep of xpRatio from 0% to 100% maintains 100% border integrity', () => {
const baseCanvas = new MockOffscreenCanvas2D(800, 600)
baseCanvas.drawImage(bgImg, 0, 496)
const top559 = Array.from({ length: 120 }, (_, i) => baseCanvas.getPixel(256 + i, 559))
const bot564 = Array.from({ length: 120 }, (_, i) => baseCanvas.getPixel(256 + i, 564))
const hud = new ControlBarHud()
for (let step = 0; step <= 100; step++) {
const ratio = step / 100
const canvas = new MockOffscreenCanvas2D(800, 600)
hud.draw(
canvas as any,
makeState(ratio),
{
ctrlPnlImg: bgImg as any,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
},
dummyFont,
)
// Check top and bottom border unchanged
for (let i = 0; i < 120; i++) {
const x = 256 + i
const pTop = canvas.getPixel(x, 559)
const eTop = top559[i]!
if (pTop.r !== eTop.r || pTop.g !== eTop.g || pTop.b !== eTop.b) {
throw new Error(`Bleed at step ${step} (${ratio}), x=${x}, y=559`)
}
const pBot = canvas.getPixel(x, 564)
const eBot = bot564[i]!
if (pBot.r !== eBot.r || pBot.g !== eBot.g || pBot.b !== eBot.b) {
throw new Error(`Bleed at step ${step} (${ratio}), x=${x}, y=564`)
}
}
}
})
it('handles adversarial xp state inputs without crashing or bleeding', () => {
const hud = new ControlBarHud()
const adversarialStates: ControlBarState[] = [
{ ...makeState(0), xp: -500 }, // Negative XP
{ ...makeState(0), xp: 999999999 }, // Vastly overflowing XP
{ ...makeState(0), currentLevelXp: 2000, nextLevelXp: 2000 }, // 0 xpSpan (division by zero guard)
{ ...makeState(0), currentLevelXp: 5000, nextLevelXp: 1000 }, // Inverted level bounds
]
for (const st of adversarialStates) {
const canvas = new MockOffscreenCanvas2D(800, 600)
canvas.drawImage(bgImg, 0, 496)
expect(() => {
hud.draw(
canvas as any,
st,
{
ctrlPnlImg: bgImg as any,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
},
dummyFont,
)
}).not.toThrow()
// Snapshot base before
const base = new MockOffscreenCanvas2D(800, 600)
base.drawImage(bgImg, 0, 496)
// Check y=559 and y=564 bounds are untouched
for (let x = 256; x <= 375; x++) {
const pt = canvas.getPixel(x, 559)
const orig = base.getPixel(x, 559)
expect({ r: pt.r, g: pt.g, b: pt.b, a: pt.a }).toEqual({ r: orig.r, g: orig.g, b: orig.b, a: orig.a })
}
}
})
})
// --- Requirement 2: Hover Hit-Testing across DPRs and Aspect Ratios ---
describe('Requirement 2: Hover Hit-Testing across DPRs and Aspect Ratios', () => {
const testDisplayConfigs = [
{ name: '800x600 (4:3 canonical)', w: 800, h: 600 },
{ name: '1920x1080 (16:9 Full HD)', w: 1920, h: 1080 },
{ name: '1280x720 (16:9 HD)', w: 1280, h: 720 },
{ name: '2560x1440 (16:9 QHD)', w: 2560, h: 1440 },
{ name: '1280x800 (16:10 Laptop)', w: 1280, h: 800 },
{ name: '2560x1080 (21:9 Ultrawide)', w: 2560, h: 1080 },
]
const testDprs = [1.0, 1.25, 1.5, 2.0]
for (const cfg of testDisplayConfigs) {
for (const dpr of testDprs) {
describe(`${cfg.name} @ DPR ${dpr}`, () => {
const rect = { left: 0, top: 0, width: cfg.w, height: cfg.h }
const hud = new ControlBarHud()
it('ensures round-trip coordinate projection invariance', () => {
const sampleLogicalPoints = [
{ x: 256, y: 558 },
{ x: 376, y: 558 },
{ x: 256, y: 566 },
{ x: 376, y: 566 },
{ x: 316, y: 562 },
{ x: 268, y: 560 },
{ x: 364, y: 563 },
]
for (const pt of sampleLogicalPoints) {
const client = logicalToClient(pt.x, pt.y, rect, dpr)
const roundTrip = clientToLogical(client.x, client.y, rect, dpr)
expect(roundTrip.x).toBeCloseTo(pt.x, 8)
expect(roundTrip.y).toBeCloseTo(pt.y, 8)
}
})
it('hits hoveredElement === "exp" for all integer client pixels on screen', () => {
// Convert EXP_BAR_HOVER_BOUNDS to client coordinates
const topLeftClient = logicalToClient(EXP_BAR_HOVER_BOUNDS.x, EXP_BAR_HOVER_BOUNDS.y, rect, dpr)
const botRightClient = logicalToClient(
EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width,
EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height,
rect,
dpr,
)
// Integer pixel range on client screen
const minClientX = Math.ceil(topLeftClient.x)
const maxClientX = Math.floor(botRightClient.x)
const minClientY = Math.ceil(topLeftClient.y)
const maxClientY = Math.floor(botRightClient.y)
// Test center
const centerClient = logicalToClient(316, 562, rect, dpr)
const mappedCenter = clientToLogical(centerClient.x, centerClient.y, rect, dpr)
hud.handleMouseMove(mappedCenter.x, mappedCenter.y)
expect(hud.hoveredElement).toBe('exp')
// Test horizontal span at center Y
for (let cx = minClientX; cx <= maxClientX; cx++) {
const mapped = clientToLogical(cx, centerClient.y, rect, dpr)
hud.handleMouseMove(mapped.x, mapped.y)
expect(
hud.hoveredElement,
`Client pixel cx=${cx} (logical x=${mapped.x}) failed to hit exp`,
).toBe('exp')
}
// Test vertical span at center X
for (let cy = minClientY; cy <= maxClientY; cy++) {
const mapped = clientToLogical(centerClient.x, cy, rect, dpr)
hud.handleMouseMove(mapped.x, mapped.y)
expect(
hud.hoveredElement,
`Client pixel cy=${cy} (logical y=${mapped.y}) failed to hit exp`,
).toBe('exp')
}
})
it('rejects points outside the client bounds', () => {
const topLeftClient = logicalToClient(EXP_BAR_HOVER_BOUNDS.x, EXP_BAR_HOVER_BOUNDS.y, rect, dpr)
const botRightClient = logicalToClient(
EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width,
EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height,
rect,
dpr,
)
const { uiScale } = computeHudLayout(rect.width, rect.height, dpr)
const outsideClientPoints = [
{ x: topLeftClient.x - uiScale * 2, y: topLeftClient.y, label: '2px left' },
{ x: botRightClient.x + uiScale * 2, y: botRightClient.y, label: '2px right' },
{ x: topLeftClient.x, y: topLeftClient.y - uiScale * 2, label: '2px above' },
{ x: topLeftClient.x, y: botRightClient.y + uiScale * 2, label: '2px below' },
{ x: 0, y: 0, label: 'origin' },
]
for (const pt of outsideClientPoints) {
const mapped = clientToLogical(pt.x, pt.y, rect, dpr)
hud.handleMouseMove(mapped.x, mapped.y)
expect(
hud.hoveredElement,
`Expected non-exp for outside point ${pt.label}`,
).not.toBe('exp')
}
})
})
}
}
it('passes 10,000-iteration PRNG differential fuzzing harness', () => {
let seed = 0x12345678
function nextRandom(): number {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
const hud = new ControlBarHud()
const resolutions = [
{ w: 800, h: 600 },
{ w: 1920, h: 1080 },
{ w: 1280, h: 720 },
]
const dprs = [1.0, 1.25, 1.5, 2.0]
let totalCases = 0
let hitCount = 0
for (let iter = 0; iter < 10000; iter++) {
const res = resolutions[iter % resolutions.length]!
const dpr = dprs[iter % dprs.length]!
const rect = { left: 0, top: 0, width: res.w, height: res.h }
let clientX: number
let clientY: number
if (nextRandom() < 0.5) {
// Inset into the neighborhood of EXP_BAR_HOVER_BOUNDS
const lx = 250 + nextRandom() * 130
const ly = 550 + nextRandom() * 20
const cl = logicalToClient(lx, ly, rect, dpr)
clientX = cl.x
clientY = cl.y
} else {
// Uniform across entire viewport
clientX = nextRandom() * res.w
clientY = nextRandom() * res.h
}
const logical = clientToLogical(clientX, clientY, rect, dpr)
hud.handleMouseMove(logical.x, logical.y)
// Oracle: inside EXP_BAR_HOVER_BOUNDS?
const isExpOracle =
logical.x >= EXP_BAR_HOVER_BOUNDS.x &&
logical.x <= EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width &&
logical.y >= EXP_BAR_HOVER_BOUNDS.y &&
logical.y <= EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height
if (isExpOracle) {
expect(hud.hoveredElement, `Fuzz mismatch at iter ${iter} (clientX=${clientX}, clientY=${clientY})`).toBe('exp')
hitCount++
} else {
expect(hud.hoveredElement, `Fuzz false positive at iter ${iter} (clientX=${clientX}, clientY=${clientY})`).not.toBe('exp')
}
totalCases++
}
expect(totalCases).toBe(10000)
expect(hitCount).toBeGreaterThan(1000)
})
})
})

View File

@ -0,0 +1,297 @@
import { describe, it, expect } from 'vitest'
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
type ControlBarState,
} from '../src/ui/control-bar.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
describe('M1 Empirical Stress Test: ControlBar XP Bar Alignment & 10-Segment Ticks (Issue #146)', () => {
describe('Geometry & Invariants', () => {
it('enforces exact EXP_BAR_BOUNDS dimensions in stone groove (x: 256, y: 560, w: 120, h: 4)', () => {
expect(EXP_BAR_BOUNDS.x).toBe(256)
expect(EXP_BAR_BOUNDS.y).toBe(560)
expect(EXP_BAR_BOUNDS.width).toBe(120)
expect(EXP_BAR_BOUNDS.height).toBe(4)
expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 })
})
it('enforces exact EXP_BAR_HOVER_BOUNDS dimensions (x: 256, y: 558, w: 120, h: 8)', () => {
expect(EXP_BAR_HOVER_BOUNDS.x).toBe(256)
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(558)
expect(EXP_BAR_HOVER_BOUNDS.width).toBe(120)
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(8)
expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 })
// Vertical padding check: expanded by 2px above and 2px below
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(EXP_BAR_BOUNDS.y - 2)
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(EXP_BAR_BOUNDS.height + 4)
})
it('enforces 10 equal 12px segments totalling 120px', () => {
const segmentCount = 10
const segmentWidth = EXP_BAR_BOUNDS.width / segmentCount
expect(segmentWidth).toBe(12)
expect(segmentWidth * segmentCount).toBe(EXP_BAR_BOUNDS.width)
})
it('enforces geometry of the 9 divider ticks strictly at x = 256 + 12*i, height 4', () => {
const expectedTicks = [
{ i: 1, x: 268, y: 560, w: 1, h: 4 },
{ i: 2, x: 280, y: 560, w: 1, h: 4 },
{ i: 3, x: 292, y: 560, w: 1, h: 4 },
{ i: 4, x: 304, y: 560, w: 1, h: 4 },
{ i: 5, x: 316, y: 560, w: 1, h: 4 },
{ i: 6, x: 328, y: 560, w: 1, h: 4 },
{ i: 7, x: 340, y: 560, w: 1, h: 4 },
{ i: 8, x: 352, y: 560, w: 1, h: 4 },
{ i: 9, x: 364, y: 560, w: 1, h: 4 },
]
for (let i = 1; i <= 9; i++) {
const tickX = EXP_BAR_BOUNDS.x + i * 12
const expected = expectedTicks[i - 1]!
expect(tickX).toBe(expected.x)
expect(tickX).toBe(256 + 12 * i)
}
})
})
describe('Hit-Testing Boundary Edge Cases', () => {
const bar = new ControlBarHud()
// Test cases specified directly in mission prompt:
// (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567)
it('handles mandatory prompt test coordinates correctly', () => {
// (256, 560) -> Inside groove top-left: HIT
bar.handleMouseMove(256, 560)
expect(bar.hoveredElement).toBe('exp')
// (375, 563) -> Inside groove bottom-right: HIT
bar.handleMouseMove(375, 563)
expect(bar.hoveredElement).toBe('exp')
// (255, 560) -> 1px left of groove: MISS
bar.handleMouseMove(255, 560)
expect(bar.hoveredElement).toBeNull()
// (376, 560) -> At boundary x = 256 + 120 = 376: HIT
bar.handleMouseMove(376, 560)
expect(bar.hoveredElement).toBe('exp')
// (256, 557) -> 1px above padded hover bounds (557 < 558): MISS
bar.handleMouseMove(256, 557)
expect(bar.hoveredElement).toBeNull()
// (256, 558) -> Top edge of padded hover bounds (y = 558): HIT
bar.handleMouseMove(256, 558)
expect(bar.hoveredElement).toBe('exp')
// (256, 566) -> Bottom edge of padded hover bounds (y = 558 + 8 = 566): HIT
bar.handleMouseMove(256, 566)
expect(bar.hoveredElement).toBe('exp')
// (256, 567) -> 1px below padded hover bounds (567 > 566): MISS
bar.handleMouseMove(256, 567)
expect(bar.hoveredElement).toBeNull()
})
it('comprehensively tests all 4 corners and outer edges of EXP_BAR_HOVER_BOUNDS', () => {
// 4 corners of hover box [256..376] x [558..566]
const corners = [
{ x: 256, y: 558, name: 'top-left' },
{ x: 376, y: 558, name: 'top-right' },
{ x: 256, y: 566, name: 'bottom-left' },
{ x: 376, y: 566, name: 'bottom-right' },
]
for (const pt of corners) {
bar.handleMouseMove(pt.x, pt.y)
expect(bar.hoveredElement, `Corner ${pt.name} (${pt.x}, ${pt.y}) should hit`).toBe('exp')
}
// Outer boundary points (1px outside in each cardinal direction)
const outerPoints = [
{ x: 255, y: 558, name: 'left-of-top-left' },
{ x: 255, y: 566, name: 'left-of-bottom-left' },
{ x: 377, y: 558, name: 'right-of-top-right' },
{ x: 377, y: 566, name: 'right-of-bottom-right' },
{ x: 316, y: 557, name: 'above-center' },
{ x: 316, y: 567, name: 'below-center' },
]
for (const pt of outerPoints) {
bar.handleMouseMove(pt.x, pt.y)
expect(bar.hoveredElement, `Outer point ${pt.name} (${pt.x}, ${pt.y}) should miss`).toBeNull()
}
})
})
describe('Canvas Rendering & XP Value Sweeps', () => {
interface DrawCall {
fillStyle: string | CanvasGradient | CanvasPattern
x: number
y: number
w: number
h: number
}
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
const rects: DrawCall[] = []
let currentFillStyle: any = '#000000'
const ctx = {
get fillStyle() {
return currentFillStyle
},
set fillStyle(val: any) {
currentFillStyle = val
},
fillRect(x: number, y: number, w: number, h: number) {
rects.push({ fillStyle: currentFillStyle, x, y, w, h })
},
createLinearGradient() {
return {
addColorStop: () => {},
}
},
drawImage() {},
strokeRect() {},
} as unknown as CanvasRenderingContext2D
return { ctx, rects }
}
function createDummyState(xp: number, currentLevelXp: number, nextLevelXp: number): ControlBarState {
return {
level: 80,
xp,
currentLevelXp,
nextLevelXp,
stamina: 300,
maxStamina: 300,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
}
}
const dummyAssets = {
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
}
const dummyFont = new D2FontRenderer()
it('renders exactly background, fill, and 9 ticks in correct order', () => {
const bar = new ControlBarHud()
const { ctx, rects } = createMockCtx()
const state = createDummyState(500, 0, 1000) // 50% XP
bar.draw(ctx, state, dummyAssets, dummyFont)
// Filter rects within EXP_BAR_BOUNDS area
const xpRects = rects.filter(r => r.y === EXP_BAR_BOUNDS.y && r.h === EXP_BAR_BOUNDS.height)
// Expected:
// 1. Background bar: fillStyle '#070605', x: 256, y: 560, w: 120, h: 4
// 2. Fill bar: fillStyle '#d8d0a8', x: 256, y: 560, w: 60, h: 4
// 3. 9 divider ticks: fillStyle '#1e1812', x: 256 + 12*i, y: 560, w: 1, h: 4
expect(xpRects.length).toBe(1 + 1 + 9) // 11 rects
// 1. Background
expect(xpRects[0]).toEqual({
fillStyle: '#070605',
x: 256,
y: 560,
w: 120,
h: 4,
})
// 2. Fill
expect(xpRects[1]).toEqual({
fillStyle: '#d8d0a8',
x: 256,
y: 560,
w: 60,
h: 4,
})
// 3. 9 Ticks
for (let i = 1; i <= 9; i++) {
expect(xpRects[1 + i]).toEqual({
fillStyle: '#1e1812',
x: 256 + i * 12,
y: 560,
w: 1,
h: 4,
})
}
})
it('accurately sweeps all milestone XP values: 0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, overflow', () => {
const testCases = [
{ name: '0.0 (0%)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
{ name: '0.05 (5%)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
{ name: '0.10 (10% - 1st tick)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
{ name: '0.125 (12.5%)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
{ name: '0.20 (20% - 2nd tick)', xp: 200, cur: 0, next: 1000, expectedW: 24 },
{ name: '0.50 (50% - 5th tick)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
{ name: '0.90 (90% - 9th tick)', xp: 900, cur: 0, next: 1000, expectedW: 108 },
{ name: '0.999 (99.9%)', xp: 999, cur: 0, next: 1000, expectedW: 120 }, // Math.round(120 * 0.999) = 120
{ name: '0.991 (99.1%)', xp: 991, cur: 0, next: 1000, expectedW: 119 }, // Math.round(120 * 0.991) = 119
{ name: '1.0 (100% full)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
{ name: 'negative XP underflow', xp: -500, cur: 0, next: 1000, expectedW: 0 },
{ name: 'XP below current level', xp: 400, cur: 500, next: 1000, expectedW: 0 },
{ name: 'XP overflow (>1.0)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
{ name: 'extreme massive overflow', xp: 999999999, cur: 0, next: 1000, expectedW: 120 },
{ name: 'degenerate zero span (cur == next)', xp: 100, cur: 100, next: 100, expectedW: 0 },
]
const bar = new ControlBarHud()
for (const tc of testCases) {
const { ctx, rects } = createMockCtx()
const state = createDummyState(tc.xp, tc.cur, tc.next)
bar.draw(ctx, state, dummyAssets, dummyFont)
// Find the fill rect
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)
expect(fillRect, `Fill rect exists for ${tc.name}`).toBeDefined()
expect(fillRect!.w, `Fill width for ${tc.name}`).toBe(tc.expectedW)
expect(fillRect!.x).toBe(256)
expect(fillRect!.y).toBe(560)
expect(fillRect!.h).toBe(4)
}
})
it('performs differential fuzzing across 1,000 fine-grained XP fractions', () => {
const bar = new ControlBarHud()
let prevWidth = 0
for (let i = 0; i <= 1000; i++) {
const fraction = i / 1000
const xp = fraction * 10000
const { ctx, rects } = createMockCtx()
const state = createDummyState(xp, 0, 10000)
bar.draw(ctx, state, dummyAssets, dummyFont)
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)!
expect(fillRect.w).toBeGreaterThanOrEqual(0)
expect(fillRect.w).toBeLessThanOrEqual(120)
expect(fillRect.w).toBeGreaterThanOrEqual(prevWidth) // monotonic
expect(Number.isInteger(fillRect.w)).toBe(true)
// Oracle formula: Math.round(120 * fraction)
const expectedW = Math.round(120 * fraction)
expect(fillRect.w).toBe(expectedW)
prevWidth = fillRect.w
}
})
})
})

View File

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { HERO_SPECS } from '../scripts/pack-characters.ts'
import { HERO_CLASSES, normalizeHeroToken } from '../src/scene/act-scene.ts'
describe('7 Playable Character Classes Artwork & Selection Parity', () => {
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
const globalDir = join(packRoot, 'entities')
const hasGlobalEntities = existsSync(globalDir)
it('verifies all 7 hero classes are defined in HERO_CLASSES with 1.13c ground truth stats', () => {
expect(HERO_SPECS).toHaveLength(7)
for (const spec of HERO_SPECS) {
const hero = HERO_CLASSES[spec.token]
expect(hero).toBeDefined()
expect(hero.token).toBe(spec.token)
expect(hero.code).toBe(spec.code)
expect(hero.nameZh).toBe(spec.nameZh)
expect(hero.nameEn).toBe(spec.nameEn)
expect(hero.charIdentity.str).toBeGreaterThan(0)
expect(hero.charIdentity.dex).toBeGreaterThan(0)
expect(hero.charIdentity.vit).toBeGreaterThan(0)
expect(hero.charIdentity.ene).toBeGreaterThan(0)
expect(hero.charIdentity.baseMaxHp).toBeGreaterThan(0)
expect(hero.charIdentity.baseMaxMana).toBeGreaterThan(0)
expect(hero.defaultLeftSkill).toBeGreaterThanOrEqual(0)
expect(hero.defaultRightSkill).toBeGreaterThanOrEqual(0)
}
})
it('normalizes various class identifiers (token, code, names, aliases) correctly', () => {
// Sorceress
expect(normalizeHeroToken('so')).toBe('so')
expect(normalizeHeroToken('sor')).toBe('so')
expect(normalizeHeroToken('Sorceress')).toBe('so')
expect(normalizeHeroToken('sorceress')).toBe('so')
expect(normalizeHeroToken('SO')).toBe('so')
// Amazon
expect(normalizeHeroToken('am')).toBe('am')
expect(normalizeHeroToken('ama')).toBe('am')
expect(normalizeHeroToken('Amazon')).toBe('am')
// Necromancer
expect(normalizeHeroToken('ne')).toBe('ne')
expect(normalizeHeroToken('nec')).toBe('ne')
expect(normalizeHeroToken('Necromancer')).toBe('ne')
// Paladin
expect(normalizeHeroToken('pa')).toBe('pa')
expect(normalizeHeroToken('pal')).toBe('pa')
expect(normalizeHeroToken('Paladin')).toBe('pa')
// Barbarian
expect(normalizeHeroToken('ba')).toBe('ba')
expect(normalizeHeroToken('bar')).toBe('ba')
expect(normalizeHeroToken('Barbarian')).toBe('ba')
// Druid (both canonical 'dz' and common alias 'dr')
expect(normalizeHeroToken('dz')).toBe('dz')
expect(normalizeHeroToken('dru')).toBe('dz')
expect(normalizeHeroToken('dr')).toBe('dz')
expect(normalizeHeroToken('Druid')).toBe('dz')
// Assassin (both canonical 'ai' and common alias 'as')
expect(normalizeHeroToken('ai')).toBe('ai')
expect(normalizeHeroToken('ass')).toBe('ai')
expect(normalizeHeroToken('as')).toBe('ai')
expect(normalizeHeroToken('Assassin')).toBe('ai')
// Unknown fallback to 'so'
expect(normalizeHeroToken('unknown')).toBe('so')
expect(normalizeHeroToken('')).toBe('so')
})
it.skipIf(!hasGlobalEntities)('validates global Schema 2 raw R8 textures for all 7 hero classes', () => {
for (const spec of HERO_SPECS) {
const jsonPath = join(globalDir, `char-${spec.token}.json`)
const r8Path = join(globalDir, `char-${spec.token}.r8`)
expect(existsSync(jsonPath), `missing json for ${spec.token}`).toBe(true)
expect(existsSync(r8Path), `missing r8 for ${spec.token}`).toBe(true)
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.schema).toBe(2)
expect(meta.file).toBe(`char-${spec.token}.r8`)
expect(meta.format).toBe('r8')
expect(meta.directions).toBe(16)
expect([8, 10]).toContain(meta.walk)
expect(meta.walkOffset).toBe(0)
expect(meta.standOffset).toBe(16)
expect(meta.members).toBeGreaterThanOrEqual(14)
// In 1.13c, Assassin's death/dead animations have 8 directions; all other classes have 16
const expectedGroups = spec.token === 'ai' ? 160 : 176
expect(meta.groups.length).toBe(expectedGroups)
// Standard player modes present
expect(meta.clips).toBeDefined()
expect(meta.clips.nu).toBeDefined()
expect(meta.clips.wl).toBeDefined()
expect(meta.clips.rn).toBeDefined()
expect(meta.clips.a1).toBeDefined()
expect(meta.clips.gh).toBeDefined()
expect(meta.clips.dt).toBeDefined()
const r8 = readFileSync(r8Path)
expect(r8.byteLength).toBe(meta.width * meta.height)
let nonZero = 0
for (let i = 0; i < r8.length; i += 1) {
if (r8[i] !== 0) nonZero += 1
}
expect(nonZero).toBeGreaterThan(0)
}
})
it.skipIf(!hasGlobalEntities)('validates per-Act fallback PNG atlases for all 7 hero classes across Acts 1..5', () => {
for (let act = 1; act <= 5; act += 1) {
const actDir = join(packRoot, `act${act}`, 'entities')
for (const spec of HERO_SPECS) {
const jsonPath = join(actDir, `char-${spec.token}.json`)
const pngPath = join(actDir, `char-${spec.token}.png`)
expect(existsSync(jsonPath), `missing act${act} json for ${spec.token}`).toBe(true)
expect(existsSync(pngPath), `missing act${act} png for ${spec.token}`).toBe(true)
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
expect(meta.schema).toBe(2)
expect(meta.file).toBe(`char-${spec.token}.png`)
expect(meta.directions).toBe(16)
const expectedGroups = spec.token === 'ai' ? 160 : 176
expect(meta.groups.length).toBe(expectedGroups)
const png = readFileSync(pngPath)
expect(png[0]).toBe(137)
expect(png[1]).toBe(80) // P
expect(png[2]).toBe(78) // N
expect(png[3]).toBe(71) // G
}
}
})
})