666 lines
26 KiB
TypeScript
666 lines
26 KiB
TypeScript
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
|
||
|
||
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)
|
||
})
|
||
})
|