diablo2-web/tests/client/frontend-batch7-batch8.test.ts

666 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
AVATAR_COMPONENT_NAMES,
CAMPFIRE_CLASS_SPECS,
CAMPFIRE_FLAME_CONTROL_SPEC,
D2LAUNCH_CAMPFIRE_DESCRIPTORS,
D2LAUNCH_CONTROL_RECORDS,
D2MULTI_CONTROL_RECORDS,
D2_COLORS_TXT_PALETTE,
canAcceptCharNameChar,
charSummaryToRosterCharacter,
clickBattleNetButton,
completeBnetConnect,
computeCharacterExpirationDays,
countHyphenUnderscore,
createInitialFlowState,
enterCharCreate,
filterCharNameInput,
formatCharacterExpirationText,
formatCharacterLevelAndClass,
getBnetConnectStageTblId,
getCampfireSlotHitBox,
hoverCampfireClass,
isValidCharacterName,
parseMcpAvatarStatstring,
resolveClassNameByTbl,
resolveStringByTblId,
selectCampfireClass,
setBnetConnectStage,
stepFlowTick,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import {
CAMPFIRE_OVERLAY_SPECS,
FrontendRenderer,
type FrontendManifest,
} from '../../src/client/frontend/frontend-renderer.ts'
const ROOT_DIR = resolve(import.meta.dirname, '../..')
const FRONTEND_DIR = resolve(ROOT_DIR, 'public/ui/frontend')
const MANIFEST_PATH = resolve(FRONTEND_DIR, 'manifest.json')
interface DrawImageCall {
readonly img: unknown
readonly args: readonly number[]
readonly compositeOp: string
readonly alpha: number
readonly filter: string
}
interface FillRectCall {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly fillStyle: string
}
function createRecordingRenderer(manifest: FrontendManifest) {
const drawCalls: DrawImageCall[] = []
const fillCalls: FillRectCall[] = []
const stateStack: Array<{ compositeOp: string; alpha: number; filter: string; fillStyle: string }> = []
const ctx = {
imageSmoothingEnabled: false,
globalCompositeOperation: 'source-over',
globalAlpha: 1,
filter: 'none',
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
save() {
stateStack.push({
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
fillStyle: this.fillStyle,
})
},
restore() {
const prev = stateStack.pop()
if (prev) {
this.globalCompositeOperation = prev.compositeOp
this.globalAlpha = prev.alpha
this.filter = prev.filter
this.fillStyle = prev.fillStyle
}
},
clearRect() {},
fillRect(x: number, y: number, w: number, h: number) {
fillCalls.push({ x, y, w, h, fillStyle: String(this.fillStyle) })
},
strokeRect() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
rect() {},
clip() {},
drawImage(img: unknown, ...args: number[]) {
drawCalls.push({
img,
args,
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
})
},
}
const fakeCanvas = {
width: 800,
height: 600,
style: { cursor: 'none' },
getContext: () => ctx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const state = createInitialFlowState()
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
const stubImg = (id: string) => ({ __id: id, width: 800, height: 600 }) as unknown as HTMLImageElement
for (const key of [
'bg-main-menu.png',
'bg-char-create.png',
'bg-char-select.png',
'bg-waiting-room.png',
'popup-340x224.png',
'popup-okcancel.png',
'terms-of-use-bg.png',
'scrollbar.png',
'btn-popup-small.png',
'create-game-bg.png',
'charselectbox.png',
'realmselect.png',
'btn-realm-thin.png',
'btn-wide.png',
'btn-3wide.png',
'btn-wide02.png',
'btn-medium.png',
'btn-short.png',
'btn-tall.png',
'textbox.png',
'clickbox.png',
'joingameclickbox.png',
'radiobutton.png',
'gembutton.png',
'btn-chatleft.png',
'btn-chatrighttop.png',
'btn-chatright.png',
'btn-game.png',
'btn-create-cancel.png',
'btn-create-game.png',
'grey-level-box.png',
'cursor-hand.png',
]) {
renderer.images.set(key, stubImg(key))
}
for (const strip of Object.values(manifest.animStrips)) {
renderer.images.set(strip.file, stubImg(strip.file))
}
for (const classStrips of Object.values(manifest.campfireClasses)) {
for (const strip of Object.values(classStrips)) {
if (strip) renderer.images.set(strip.file, stubImg(strip.file))
}
}
for (const strip of Object.values(manifest.charAvatars)) {
renderer.images.set(strip.file, stubImg(strip.file))
}
const glyphs = Array.from({ length: 256 }, (_, code) => ({
code,
width: 8,
height: 14,
x: (code % 16) * 16,
y: Math.floor(code / 16) * 16,
frameW: 8,
frameH: 14,
}))
for (const fontName of [
'font6',
'font8',
'font16',
'font24',
'font30',
'font42',
'fontformal10',
'fontformal11',
'fontformal12',
'fontexocet8',
'fontexocet10',
'fontridiculous',
]) {
renderer.fontMetas[fontName] = {
name: fontName,
lineHeight: 14,
capHeight: 12,
atlasWidth: 256,
atlasHeight: 256,
glyphs,
}
for (const colorIdx of [0, 1, 2, 3, 4, 5, 7, 8, 9]) {
renderer.images.set(`fonts/${fontName}-c${colorIdx}.png`, stubImg(`fonts/${fontName}-c${colorIdx}.png`))
}
}
return { renderer, state, ctx, drawCalls, fillCalls }
}
describe('Batch 7 & 8 Phase 3 — Frontend Renderer & Flow State Parity (#696–#701, #703–#711)', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as FrontendManifest
const stringsRecord = manifest.strings as unknown as Record<string, unknown>
it('#696: parses MCP 0x19 statstring 11 equipment components + Colors.txt dye bytes and composites equipped TN avatar on CharSelect & Lobby', () => {
expect(AVATAR_COMPONENT_NAMES).toHaveLength(11)
expect(Object.keys(D2_COLORS_TXT_PALETTE)).toHaveLength(21)
// 1. All 0xFF statstring -> unequipped base TN avatar
const unequippedBytes = new Uint8Array(33).fill(0xff)
unequippedBytes[0] = 0x84
unequippedBytes[1] = 0x80
unequippedBytes[13] = 0x02 // Sorceress
unequippedBytes[25] = 1
unequippedBytes[26] = 0xa0
unequippedBytes[27] = 0x80
const unequippedDesc = parseMcpAvatarStatstring(unequippedBytes, 'so')
expect(unequippedDesc.hasEquippedComponents).toBe(false)
expect(unequippedDesc.equippedLayers).toHaveLength(0)
expect(unequippedDesc.weaponClass).toBe('hth')
// 2. Equipped statstring with heavy helm/torso/legs, 1hs right-hand weapon, shield, and cblu/cred dyes
const equippedBytes = new Uint8Array(33).fill(0xff)
equippedBytes[0] = 0x84
equippedBytes[1] = 0x80
equippedBytes[2] = 3 // HD -> hvy
equippedBytes[3] = 3 // TR -> hvy
equippedBytes[4] = 2 // LG -> med
equippedBytes[5] = 3 // RA -> hvy
equippedBytes[6] = 3 // LA -> hvy
equippedBytes[7] = 22 // RH -> fla (1hs weapon)
equippedBytes[9] = 2 // SH -> shield
equippedBytes[13] = 0x04 // Paladin
equippedBytes[14] = 6 // HD dye -> cblu (Crystal Blue)
equippedBytes[15] = 9 // TR dye -> cred (Crystal Red)
equippedBytes[25] = 85
equippedBytes[26] = 0xe0
equippedBytes[27] = 0x80 | (10 << 1)
const equippedDesc = parseMcpAvatarStatstring(equippedBytes, 'pa')
expect(equippedDesc.hasEquippedComponents).toBe(true)
expect(equippedDesc.components.HD).toBe('hvy')
expect(equippedDesc.components.TR).toBe('hvy')
expect(equippedDesc.components.LG).toBe('med')
expect(equippedDesc.componentColors.HD).toBe(6)
expect(equippedDesc.componentColors.TR).toBe(9)
expect(equippedDesc.weaponClass).toBe('1hs')
const hdLayer = equippedDesc.equippedLayers.find((l) => l.component === 'HD')
const trLayer = equippedDesc.equippedLayers.find((l) => l.component === 'TR')
expect(hdLayer?.colorCode).toBe('cblu')
expect(hdLayer?.rgb).toEqual(D2_COLORS_TXT_PALETTE[6]!.rgb)
expect(trLayer?.colorCode).toBe('cred')
expect(trLayer?.rgb).toEqual(D2_COLORS_TXT_PALETTE[9]!.rgb)
const rosterChar = charSummaryToRosterCharacter({
name: 'Hammerdin',
classId: 3, // Paladin
level: 85,
progression: 10,
expansion: true,
hardcore: false,
statstring: equippedBytes,
})
expect(rosterChar.avatarDesc?.hasEquippedComponents).toBe(true)
expect(rosterChar.components?.HD).toBe('hvy')
expect(rosterChar.componentColors?.HD).toBe(6)
expect(rosterChar.weaponClass).toBe('1hs')
// Verify rendering on char_select draws equipment dye tint layers for equipped character
const { renderer, state, fillCalls } = createRecordingRenderer(manifest)
state.stage = 'char_select'
state.roster = [rosterChar]
state.selectedSlotIndex = 0
renderer.render(100)
const cbluFill = `rgba(${D2_COLORS_TXT_PALETTE[6]!.rgb.join(', ')}, 0.38)`
const credFill = `rgba(${D2_COLORS_TXT_PALETTE[9]!.rgb.join(', ')}, 0.38)`
expect(fillCalls.some((f) => f.fillStyle === cbluFill)).toBe(true)
expect(fillCalls.some((f) => f.fillStyle === credFill)).toBe(true)
// Verify rendering on bnet_lobby bottom bar also composites equipped layers
fillCalls.length = 0
state.stage = 'bnet_lobby'
renderer.render(140)
expect(fillCalls.some((f) => f.fillStyle === cbluFill)).toBe(true)
expect(fillCalls.some((f) => f.fillStyle === credFill)).toBe(true)
})
it('#697: enforces D2Launch 0x6fa52360 charName input filter and 0x6fa54710 OK button enablement without breaking gameName/gamePassword', () => {
// 1. Input filter @ 0x6fa52360
expect(canAcceptCharNameChar('', 'A')).toBe(true)
expect(canAcceptCharNameChar('', 'z')).toBe(true)
expect(canAcceptCharNameChar('', '-')).toBe(false) // '-' rejected at pos 0
expect(canAcceptCharNameChar('', '_')).toBe(false) // '_' rejected at pos 0
expect(canAcceptCharNameChar('', '1')).toBe(false) // digits rejected
expect(canAcceptCharNameChar('Hero', '-')).toBe(true) // first '-' allowed after pos 0
expect(canAcceptCharNameChar('Hero-', '-')).toBe(false) // second '-' rejected
expect(canAcceptCharNameChar('Hero-', '_')).toBe(false) // '_' rejected when '-' already present
expect(canAcceptCharNameChar('Hero_One', '-')).toBe(false)
expect(countHyphenUnderscore('Hero_One')).toBe(1)
expect(filterCharNameInput('-123Hero_One_Two456-')).toBe('Hero_OneTwo')
// 2. OK enablement @ 0x6fa54710
expect(isValidCharacterName('')).toBe(false)
expect(isValidCharacterName('A')).toBe(false)
expect(isValidCharacterName('Ab')).toBe(true)
expect(isValidCharacterName('A-b')).toBe(true)
expect(isValidCharacterName('A_b')).toBe(true)
expect(isValidCharacterName('-Ab')).toBe(false)
expect(isValidCharacterName('Ab-')).toBe(false)
expect(isValidCharacterName('Ab_')).toBe(false)
expect(isValidCharacterName('A-b-c')).toBe(false)
expect(isValidCharacterName('A_b-c')).toBe(false)
expect(isValidCharacterName('Abc1')).toBe(false)
expect(isValidCharacterName('FifteenLettersX')).toBe(true)
expect(isValidCharacterName('SixteenLettersXX')).toBe(false)
// 3. Campfire OK button disabled state in FrontendRenderer
const { renderer, state } = createRecordingRenderer(manifest)
enterCharCreate(state)
selectCampfireClass(state, 'am')
state.charName = 'A' // too short -> OK disabled
const buttonsShort = renderer.getActiveButtons()
expect(buttonsShort.find((b) => b.id === 'char_create_ok')?.disabled).toBe(true)
state.charName = 'Am-' // trailing hyphen -> OK disabled
const buttonsTrailing = renderer.getActiveButtons()
expect(buttonsTrailing.find((b) => b.id === 'char_create_ok')?.disabled).toBe(true)
state.charName = 'Amazon_Queen' // valid -> OK enabled
const buttonsValid = renderer.getActiveButtons()
expect(buttonsValid.find((b) => b.id === 'char_create_ok')?.disabled).toBe(false)
// 4. Typing in charName filters digits, while gameName/gamePassword in bnet_lobby accept digits
state.charName = ''
state.activeInput = 'charName'
for (const k of ['A', '1', '-', 'b', '_', 'c']) {
renderer.handleKeyDown({ key: k, preventDefault() {} } as unknown as KeyboardEvent)
}
expect(state.charName).toBe('A-bc')
state.stage = 'bnet_lobby'
state.lobbySubPanel = 'create'
state.activeInput = 'gameName'
state.gameName = ''
for (const k of ['B', 'a', 'a', 'l', '-', '0', '1']) {
renderer.handleKeyDown({ key: k, preventDefault() {} } as unknown as KeyboardEvent)
}
expect(state.gameName).toBe('Baal-01')
})
it('#698: computes character expiration days per D2Launch 0x6fa4b273 and formats .tbl 11132 / 11133 / 11134 only when days <= 60', () => {
const nowSec = 1_700_000_000
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 90 * 86400 }, nowSec)).toBe(90)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 60 * 86400 }, nowSec)).toBe(60)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec + 86400 }, nowSec)).toBe(1)
expect(computeCharacterExpirationDays({ expiresAtSec: nowSec - 10 }, nowSec)).toBe(0)
// > 60 days or undefined -> null (nothing rendered, never "Expires in 90 days")
expect(formatCharacterExpirationText(undefined, stringsRecord)).toBeNull()
expect(formatCharacterExpirationText(90, stringsRecord)).toBeNull()
expect(formatCharacterExpirationText(61, stringsRecord)).toBeNull()
// 2..60 days -> .tbl 11133 "Expires in %d days"
expect(formatCharacterExpirationText(60, stringsRecord)).toEqual({
text: 'Expires in 60 days',
tblId: 11133,
isUrgent: false,
})
expect(formatCharacterExpirationText(10, stringsRecord)?.text).toBe('Expires in 10 days')
expect(formatCharacterExpirationText(2, stringsRecord)?.text).toBe('Expires in 2 days')
// 1 day -> .tbl 11132 "Expires Today"
expect(formatCharacterExpirationText(1, stringsRecord)).toEqual({
text: 'Expires Today',
tblId: 11132,
isUrgent: true,
})
// <= 0 days -> .tbl 11134 "Character Expired"
expect(formatCharacterExpirationText(0, stringsRecord)).toEqual({
text: 'Character Expired',
tblId: 11134,
isUrgent: true,
})
expect(formatCharacterExpirationText(-5, stringsRecord)?.text).toBe('Character Expired')
})
it('#699: removes fake 18-tick bnet_connecting timer, drives single status text via .tbl 5171..5177, and disables unimplemented Main Menu buttons', () => {
const { renderer, state } = createRecordingRenderer(manifest)
// 1. Main menu disables Single Player, Other Multiplayer, Credits, Cinematics, Exit; only Battle.net & Gateway are enabled
state.stage = 'main_menu'
renderer.render(100)
const menuBtns = renderer.getActiveButtons()
expect(menuBtns.find((b) => b.id === 'single_player')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'other_multiplayer')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'credits')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'cinematics')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'exit_d2')?.disabled).toBe(true)
expect(menuBtns.find((b) => b.id === 'battle_net')?.disabled).toBeFalsy()
// 2. Clicking Battle.net enters bnet_connecting and stays there across >18 ticks until completeBnetConnect
clickBattleNetButton(state, stringsRecord)
expect(state.stage).toBe('bnet_connecting')
expect(state.bnetConnectStage).toBe(0)
expect(getBnetConnectStageTblId(0)).toBe(5171)
for (let i = 0; i < 60; i++) {
stepFlowTick(state)
}
expect(state.stage).toBe('bnet_connecting')
// Verify all 7 BNCS connection stages 0..6 map to .tbl 5171..5177
const expectedIds = [5171, 5172, 5173, 5174, 5175, 5176, 5177] as const
for (let stageIdx = 0; stageIdx <= 6; stageIdx++) {
setBnetConnectStage(state, stageIdx as 0 | 1 | 2 | 3 | 4 | 5 | 6, stringsRecord)
expect(getBnetConnectStageTblId(state.bnetConnectStage)).toBe(expectedIds[stageIdx])
}
completeBnetConnect(state)
expect(state.stage).toBe('bnet_login')
})
it('#700: renders Battle.net Login title (.tbl 5204) and instruction (.tbl 5205) at D2Launch control record 0x6fa61520', () => {
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.va).toBe(0x6fa61520)
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.title.tblId).toBe(5204)
expect(D2LAUNCH_CONTROL_RECORDS.bnetLoginHeader.instruction.tblId).toBe(5205)
expect(resolveStringByTblId(stringsRecord, 5204, 'loginTitle')).toBe('Battle.net Log In')
expect(resolveStringByTblId(stringsRecord, 5205, 'loginInstruction')).toBe(
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
)
})
it('#701: uses Expansion campfire flame control anchor (345, 470) -> draw (379, 338) with drawMode 3', () => {
expect(CAMPFIRE_FLAME_CONTROL_SPEC).toEqual({
va: 0x6fa63080,
x: 345,
y: 470,
w: 110,
h: 127,
speed: 1,
drawMode: 3,
})
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
enterCharCreate(state)
renderer.render(100)
const flameImg = renderer.images.get(manifest.animStrips.campfireFlame!.file)
const flameDraw = drawCalls.find((c) => c.img === flameImg)
expect(flameDraw).toBeDefined()
const flameFrame0 = manifest.animStrips.campfireFlame!.frames[0]!
expect(flameDraw!.args[4]).toBe(379 + flameFrame0.anchorX)
expect(flameDraw!.args[5]).toBe(338 + flameFrame0.anchorY)
expect(flameDraw!.compositeOp).toBe('screen')
})
it('#702: renders campfire overlays using manifest drawMode (5 = source-over for ba/pa fw_ov, 3 = screen for ne/so) and fails fast on missing strip', () => {
expect(CAMPFIRE_OVERLAY_SPECS.ba?.fw?.drawMode).toBe(5)
expect(CAMPFIRE_OVERLAY_SPECS.pa?.fw?.drawMode).toBe(5)
expect(CAMPFIRE_OVERLAY_SPECS.ne?.fw?.drawMode).toBe(3)
expect(CAMPFIRE_OVERLAY_SPECS.so?.fw?.drawMode).toBe(3)
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
enterCharCreate(state)
// 1. Select Barbarian ('ba') -> phase 'fw', overlay 'ba-fw-ov.png' drawn with drawMode 5 ('source-over')
selectCampfireClass(state, 'ba')
drawCalls.length = 0
renderer.render(100)
const baFwOvImg = renderer.images.get(manifest.campfireClasses.ba!.fw_ov!.file)
const baOvDraw = drawCalls.find((c) => c.img === baFwOvImg)
expect(baOvDraw).toBeDefined()
expect(baOvDraw!.compositeOp).toBe('source-over')
// 2. Select Sorceress ('so') -> phase 'fw', overlay 'so-fw-ov.png' drawn with drawMode 3 ('screen')
selectCampfireClass(state, 'so')
drawCalls.length = 0
renderer.render(140)
const soFwOvImg = renderer.images.get(manifest.campfireClasses.so!.fw_ov!.file)
const soOvDraw = drawCalls.find((c) => c.img === soFwOvImg)
expect(soOvDraw).toBeDefined()
expect(soOvDraw!.compositeOp).toBe('screen')
// 3. Missing required strip image throws explicit Error instead of silently skipping (#702, #709)
renderer.images.delete(manifest.campfireClasses.so!.fw_ov!.file)
expect(() => renderer.render(180)).toThrow(/Missing loaded image for animation strip/)
})
it('#703: implements D2WinAnimImage frame-0 cel bounds hit-test (0x6f8f07c0), continuous hover toggle (0x6f8f0966), and fw/bw end at count - 2 (0x6f8f09d7)', () => {
const state = createInitialFlowState()
enterCharCreate(state)
// 1. Hover toggle between nu1 and nu2 preserves frameIndex and phaseTicks
for (let i = 0; i < 15; i++) {
stepFlowTick(state)
}
const beforeHoverFrame = state.campfireClasses.am.frameIndex
const beforeHoverTicks = state.campfireClasses.am.phaseTicks
expect(beforeHoverFrame).toBeGreaterThan(0)
hoverCampfireClass(state, 'am')
expect(state.campfireClasses.am.phase).toBe('nu2')
expect(state.campfireClasses.am.frameIndex).toBe(beforeHoverFrame)
expect(state.campfireClasses.am.phaseTicks).toBe(beforeHoverTicks)
hoverCampfireClass(state, null)
expect(state.campfireClasses.am.phase).toBe('nu1')
expect(state.campfireClasses.am.frameIndex).toBe(beforeHoverFrame)
// 2. Hit-box updates dynamically from active phase's frame-0 cel bounds (nu1 vs nu3)
const amSlot = state.campfireClasses.am
const nu1Box = getCampfireSlotHitBox(amSlot)
expect(nu1Box).toEqual({
x: amSlot.anchorX + D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.anchorX,
y: amSlot.anchorY + D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.anchorY,
w: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.w,
h: D2LAUNCH_CAMPFIRE_DESCRIPTORS.am.frame0Bounds.nu1.h,
})
selectCampfireClass(state, 'am')
const fwFrames = amSlot.frameCount.fw
// Step to fwCount - 3 -> still in 'fw'
for (let i = 0; i < (fwFrames - 2) * amSlot.frameRate.fw - 1; i++) {
stepFlowTick(state)
}
expect(amSlot.phase).toBe('fw')
// One more tick reaches frameIndex === fwCount - 2 -> transitions to 'nu3' (last 2 frames not displayed)
stepFlowTick(state)
expect(amSlot.phase).toBe('nu3')
const nu3Box = getCampfireSlotHitBox(amSlot)
expect(nu3Box.x).not.toBe(nu1Box.x)
expect(nu3Box.y).not.toBe(nu1Box.y)
})
it('#704: removes hardcoded mock lobby chat/channel/gateway strings and uses structured .tbl 5334/5335/5336 gem messages', () => {
const state = createInitialFlowState()
expect(state.chatMessages).toEqual([])
expect(state.chatEntries).toEqual([])
expect(state.channelName).toBe('')
expect(state.gateway).toBe('')
state.stage = 'bnet_lobby'
const actEntry = toggleChatGem(state, stringsRecord)
expect(actEntry).toEqual({ text: 'Gem Activated', color: 3, kind: 'gem', tblId: 5334 })
expect(state.chatMessages).toEqual(['Gem Activated'])
const deactEntry = toggleChatGem(state, stringsRecord)
expect(deactEntry).toEqual({ text: 'Gem Deactivated', color: 3, kind: 'gem', tblId: 5335 })
const perfEntry = toggleChatGem(state, stringsRecord, true)
expect(perfEntry).toEqual({ text: 'Perfect Gem Activated', color: 3, kind: 'gem', tblId: 5336 })
})
it('#705: renders D2WinEditBox underscore "_" cursor at 250ms blink period in edit box text color', () => {
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
state.stage = 'bnet_login'
state.activeInput = 'accountName'
state.accountName = 'Tyrael'
// At nowMs = 0 -> Math.floor(0 / 250) & 1 === 0 (cursor off)
drawCalls.length = 0
renderer.render(0)
const callsOff = drawCalls.length
// At nowMs = 250 -> Math.floor(250 / 250) & 1 === 1 (cursor on -> draws "_" glyph via drawBitmapText, 1 extra drawImage call)
drawCalls.length = 0
renderer.render(250)
const callsOn = drawCalls.length
expect(callsOn).toBe(callsOff + 1)
// Verify "_" glyph (charCode 95) was drawn from the editbox font atlas
const underscoreGlyph = renderer.fontMetas.fontformal12!.glyphs[95]!
expect(
drawCalls.some((c) => c.args[0] === underscoreGlyph.x && c.args[1] === underscoreGlyph.y),
).toBe(true)
})
it('#706: renders disabled buttons and radio buttons with DRAWMODE_TRANS50 (globalAlpha = 0.5) instead of CSS brightness filter', () => {
const { renderer, state, drawCalls } = createRecordingRenderer(manifest)
state.stage = 'main_menu'
renderer.render(100)
// Main menu has 5 disabled buttons -> drawn with alpha = 0.5 and filter = 'none'
const trans50Draws = drawCalls.filter((c) => c.alpha === 0.5)
expect(trans50Draws.length).toBeGreaterThanOrEqual(5)
expect(drawCalls.every((c) => !c.filter.includes('brightness'))).toBe(true)
})
it('#707 & #708 & #711: resolves canonical .tbl strings for Create Account, Please Read, Level/Class, and fails fast when manifest strings are missing', () => {
expect(D2MULTI_CONTROL_RECORDS.lobbyAvatar).toEqual({
va: 0x6f9d2940,
anchorX: 52,
anchorY: 584,
})
expect(resolveClassNameByTbl('ba', stringsRecord)).toBe('Barbarian')
expect(resolveClassNameByTbl('so', stringsRecord)).toBe('Sorceress')
expect(formatCharacterLevelAndClass(42, 'pa', stringsRecord)).toBe('Level 42 Paladin')
// Missing manifest string key must throw explicit Error instead of returning hardcoded English fallback
expect(() => resolveStringByTblId({}, 5223, 'createAccountInstruction')).toThrow(
/Missing required \.tbl string #5223/,
)
expect(() => resolveStringByTblId({}, 5017)).toThrow(/Missing required \.tbl string #5017/)
})
it('#709 & #710: removes CAMPFIRE_CLASS_SPECS defaultFrames guessing, fails fast on missing font/strip, and rejects missing progression/level in charSummaryToRosterCharacter', () => {
// #709: CAMPFIRE_CLASS_SPECS matches manifest/D2Launch descriptors (no stale am.fw=28 / am.nu2=56)
expect(CAMPFIRE_CLASS_SPECS.am.frameCount.fw).toBe(54)
expect(CAMPFIRE_CLASS_SPECS.am.frameCount.nu2).toBe(26)
expect(CAMPFIRE_CLASS_SPECS.so.frameCount.fw).toBe(52)
const { renderer } = createRecordingRenderer(manifest)
delete renderer.fontMetas.font16
expect(() => renderer.measureBitmapText('Hello', 'font16')).toThrow(
/Missing bitmap font metrics for "font16"/,
)
// #710: charSummaryToRosterCharacter throws when progression or valid level is missing
expect(() =>
charSummaryToRosterCharacter({
name: 'NoProgHero',
classId: 0,
level: 10,
}),
).toThrow(/missing required numeric progression/)
expect(() =>
charSummaryToRosterCharacter({
name: 'ZeroLevelHero',
classId: 0,
level: 0,
progression: 0,
}),
).toThrow(/invalid or missing level/)
// Valid character summary succeeds without guessing
const validRoster = charSummaryToRosterCharacter({
name: 'ValidAmz',
classId: 0,
level: 24,
progression: 5,
expansion: true,
})
expect(validRoster.level).toBe(24)
expect(validRoster.progression).toBe(5)
})
})