diablo2-web/tests/frontend/flow-state.test.ts

806 lines
32 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
CAMPFIRE_ANIM_RATES,
CAMPFIRE_CLASS_ORDER,
FRONTEND_MAX_CATCHUP_TICKS,
FRONTEND_TICK_MS,
HERO_TN_ANIM_SPECS,
HERO_TOKENS,
canSubmitCreateGame,
cancelConnecting,
charSummaryToRosterCharacter,
clickBattleNetButton,
confirmCharacterSelect,
createInitialFlowState,
enterCharCreate,
getAnimImageLoopFrame,
getCharacterMaxDifficulty,
hoverCampfireClass,
isDifficultyUnlocked,
isValidCharacterName,
returnToLoginAfterSessionLoss,
selectCampfireClass,
stepFlowByDeltaMs,
stepFlowFrame,
stepFlowTick,
submitBattleNetLogin,
submitCharacterCreate,
submitCreateGame,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import { FrontendRenderer, type FrontendManifest } from '../../src/client/frontend/frontend-renderer.ts'
import { parseMcpCharStatString } from '../../src/netproto/mcp/packets.ts'
describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
it('validates character names according to D2Launch.dll rules', () => {
expect(isValidCharacterName('A')).toBe(false)
expect(isValidCharacterName('Isendra')).toBe(true)
expect(isValidCharacterName('Dark_Wanderer')).toBe(true)
expect(isValidCharacterName('-Invalid')).toBe(false)
expect(isValidCharacterName('Invalid-')).toBe(false)
expect(isValidCharacterName('In--valid')).toBe(false)
expect(isValidCharacterName('TooLongCharacterName')).toBe(false)
})
it('walks through all 7 stages from Main Menu to Act 1 Rogue Encampment', () => {
const state = createInitialFlowState()
expect(state.stage).toBe('main_menu')
// Never prefill gameName or gameDescription per D2Multi.dll 0x6f9e4bc0
expect(state.gameName).toBe('')
expect(state.gameDescription).toBe('')
// Stage 1 -> Stage 2 Connecting Modal
clickBattleNetButton(state)
expect(state.stage).toBe('bnet_connecting')
// Can cancel back to main menu
cancelConnecting(state)
expect(state.stage).toBe('main_menu')
// Re-click Battle.net and advance 18 ticks to Login Screen
clickBattleNetButton(state)
for (let i = 0; i < 18; i++) {
stepFlowTick(state)
}
expect(state.stage).toBe('bnet_login')
expect(state.activeInput).toBe('accountName')
// Stage 2: Mock authentication accepts ANY username & password
state.accountName = 'any_user_123'
state.accountPassword = 'any_password_456'
expect(submitBattleNetLogin(state)).toBe(true)
// With empty initial roster, transitions directly to Stage 3 (7-Class Campfire)
expect(state.stage).toBe('char_create')
// Stage 3: Hover Amazon ('am') -> transitions from 'nu1' to 'nu2'
hoverCampfireClass(state, 'am')
expect(state.campfireClasses.am.phase).toBe('nu2')
// Click Sorceress ('so') -> walks forward ('fw' -> 'nu3')
selectCampfireClass(state, 'so')
expect(state.selectedClass).toBe('so')
expect(state.campfireClasses.so.phase).toBe('fw')
expect(state.activeInput).toBe('charName')
const fwCount = state.campfireClasses.so.frameCount.fw
for (let i = 0; i < fwCount; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.so.phase).toBe('nu3')
// Switching to Necromancer ('ne') causes Sorceress to walk backward ('bw' -> 'nu1')
selectCampfireClass(state, 'ne')
expect(state.selectedClass).toBe('ne')
expect(state.campfireClasses.so.phase).toBe('bw')
expect(state.campfireClasses.ne.phase).toBe('fw')
const bwCount = state.campfireClasses.so.frameCount.bw
for (let i = 0; i < bwCount; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.so.phase).toBe('nu1')
// Enter character name and click OK -> transitions to Stage 4 (8-Slot Character Selection)
state.charName = 'XulNecro'
state.isExpansion = true
state.isHardcore = false
const created = submitCharacterCreate(state)
expect(created).not.toBeNull()
expect(created?.name).toBe('XulNecro')
expect(created?.heroToken).toBe('ne')
expect(created?.progression).toBe(0)
expect(state.stage).toBe('char_select')
expect(state.roster).toHaveLength(1)
expect(state.selectedSlotIndex).toBe(0)
// Stage 4 -> Stage 5 (Battle.net Lobby & Create Game Room)
expect(confirmCharacterSelect(state)).toBe(true)
expect(state.stage).toBe('bnet_lobby')
expect(state.lobbySubPanel).toBe('create')
// Confirming character select must NOT prefill gameName or gameDescription
expect(state.gameName).toBe('')
expect(state.gameDescription).toBe('')
expect(canSubmitCreateGame(state)).toBe(false)
// Toggle Chat Gem in Lobby
toggleChatGem(state)
expect(state.gemActivated).toBe(true)
expect(state.chatMessages.at(-1)).toBe('Gem Activated')
toggleChatGem(state)
expect(state.gemActivated).toBe(false)
expect(state.chatMessages.at(-1)).toBe('Gem Deactivated')
// Fresh Level 1 character only has Normal unlocked; Nightmare and Hell are locked
state.gameName = 'Sanctuary1'
state.gamePassword = '123'
state.difficulty = 'nightmare'
expect(canSubmitCreateGame(state)).toBe(false)
expect(submitCreateGame(state)).toBe(false)
// Selecting Normal difficulty succeeds -> transitions to Stage 6 (Door Opening Loading)
state.difficulty = 'normal'
expect(canSubmitCreateGame(state)).toBe(true)
expect(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading')
expect(state.doorFrame).toBe(0)
// Stage 6: Step through all 10 frames (0..9) of Loadingscreen.dc6
for (let i = 0; i < 27; i++) {
stepFlowTick(state)
}
expect(state.doorFrame).toBe(9)
// Does not enter game until sceneReady is true
expect(state.stage).toBe('door_loading')
state.sceneReady = true
for (let i = 0; i < 5; i++) {
stepFlowTick(state)
}
// Stage 7: Act 1 Rogue Encampment active!
expect(state.stage).toBe('in_game')
})
it('unlocks Nightmare and Hell difficulties strictly per 1.13c D2Multi.dll progression thresholds', () => {
// Expansion (5 acts per difficulty): Nightmare >= 5, Hell >= 10
expect(getCharacterMaxDifficulty({ isExpansion: true, progression: 0 })).toBe('normal')
expect(getCharacterMaxDifficulty({ isExpansion: true, progression: 4 })).toBe('normal')
expect(getCharacterMaxDifficulty({ isExpansion: true, progression: 5 })).toBe('nightmare')
expect(getCharacterMaxDifficulty({ isExpansion: true, progression: 9 })).toBe('nightmare')
expect(getCharacterMaxDifficulty({ isExpansion: true, progression: 10 })).toBe('hell')
expect(isDifficultyUnlocked({ isExpansion: true, progression: 5 }, 'nightmare')).toBe(true)
expect(isDifficultyUnlocked({ isExpansion: true, progression: 5 }, 'hell')).toBe(false)
expect(isDifficultyUnlocked({ isExpansion: true, progression: 10 }, 'hell')).toBe(true)
// Classic (4 acts per difficulty): Nightmare >= 4, Hell >= 8
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 3 })).toBe('normal')
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 4 })).toBe('nightmare')
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 7 })).toBe('nightmare')
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 8 })).toBe('hell')
// Verify 33-byte MCP statString progression parsing (statBytes[27] & 0x1f)
const stat = new Uint8Array(33).fill(0xff)
stat[0] = 0x84
stat[1] = 0x80
stat[13] = 0x04 // Paladin (3 + 1)
stat[25] = 65
stat[26] = 0xe0 // EXPANSION | LADDER | 0x80
stat[27] = 0x80 | 5 // progression = 5 (Slayer, Nightmare unlocked)
const summaryNm = parseMcpCharStatString('PalSlayer', 99999, stat)
expect(summaryNm.progression).toBe(5)
expect(summaryNm.difficulty).toBe(1)
const rosterNm = charSummaryToRosterCharacter(summaryNm)
expect(isDifficultyUnlocked(rosterNm, 'normal')).toBe(true)
expect(isDifficultyUnlocked(rosterNm, 'nightmare')).toBe(true)
expect(isDifficultyUnlocked(rosterNm, 'hell')).toBe(false)
stat[27] = 0x80 | 10 // progression = 10 (Champion/Patriarch, Hell unlocked)
const summaryHell = parseMcpCharStatString('PalChamp', 99999, stat)
expect(summaryHell.progression).toBe(10)
expect(summaryHell.difficulty).toBe(2)
const rosterHell = charSummaryToRosterCharacter(summaryHell)
expect(isDifficultyUnlocked(rosterHell, 'hell')).toBe(true)
})
it('validates maxPlayers (1..8) and charDifference (1..99 when enabled) in Create Game', () => {
const state = createInitialFlowState()
state.stage = 'bnet_lobby'
state.gameName = 'TestRoom1'
state.maxPlayers = '0'
expect(canSubmitCreateGame(state)).toBe(false)
expect(submitCreateGame(state)).toBe(false)
state.maxPlayers = '9'
expect(canSubmitCreateGame(state)).toBe(false)
expect(submitCreateGame(state)).toBe(false)
state.maxPlayers = '4'
state.charDifferenceEnabled = true
state.charDifference = ''
expect(canSubmitCreateGame(state)).toBe(false)
expect(submitCreateGame(state)).toBe(false)
state.charDifference = '10'
expect(canSubmitCreateGame(state)).toBe(true)
expect(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading')
expect(state.maxPlayers).toBe('4')
expect(state.charDifference).toBe('10')
})
it('supports mouse and keyboard editing of maxPlayers, charDifference, and progression-gated difficulty radio buttons in FrontendRenderer', () => {
const state = createInitialFlowState()
enterCharCreate(state)
selectCampfireClass(state, 'pa')
state.charName = 'TTO'
submitCharacterCreate(state)
confirmCharacterSelect(state)
const fakeCanvas = {
getContext: () => ({ imageSmoothingEnabled: false }),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const renderer = new FrontendRenderer(fakeCanvas, state)
// 1. Game Name and Description are initially empty
expect(state.gameName).toBe('')
expect(state.gameDescription).toBe('')
// 2. Click maxPlayers editbox (665, 290) and edit via digits & arrow keys
renderer.handleMouseUp(665, 290)
expect(state.activeInput).toBe('maxPlayers')
renderer.handleKeyDown({ key: '4', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.maxPlayers).toBe('4')
renderer.handleKeyDown({ key: 'ArrowUp', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.maxPlayers).toBe('5')
renderer.handleKeyDown({ key: 'ArrowDown', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.maxPlayers).toBe('4')
// 3. Click charDifference checkbox (440, 330) -> enables and focuses charDifference
expect(state.charDifferenceEnabled).toBe(false)
renderer.handleMouseUp(440, 330)
expect(state.charDifferenceEnabled).toBe(true)
expect(state.activeInput).toBe('charDifference')
renderer.handleKeyDown({ key: '1', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.charDifference).toBe('1')
renderer.handleKeyDown({ key: '5', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.charDifference).toBe('15')
renderer.handleKeyDown({ key: 'ArrowUp', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(state.charDifference).toBe('16')
// 4. Level 1 Paladin (progression = 0) cannot select Nightmare (565, 372) or Hell (705, 372)
renderer.handleMouseUp(565, 372)
expect(state.difficulty).toBe('normal')
renderer.handleMouseUp(705, 372)
expect(state.difficulty).toBe('normal')
// 5. Unlock Nightmare (progression = 5) -> Nightmare is selectable, Hell remains locked
state.roster[0]!.progression = 5
state.roster[0]!.maxDifficulty = 'nightmare'
renderer.handleMouseUp(565, 372)
expect(state.difficulty).toBe('nightmare')
renderer.handleMouseUp(705, 372)
expect(state.difficulty).toBe('nightmare')
// 6. Unlock Hell (progression = 10) -> Hell becomes selectable
state.roster[0]!.progression = 10
state.roster[0]!.maxDifficulty = 'hell'
renderer.handleMouseUp(705, 372)
expect(state.difficulty).toBe('hell')
})
it('maps Assassin campfire class id "as" to in-game hero token "ai"', () => {
const state = createInitialFlowState()
enterCharCreate(state)
expect(CAMPFIRE_CLASS_ORDER).toHaveLength(7)
selectCampfireClass(state, 'as')
state.charName = 'Natalya'
const created = submitCharacterCreate(state)
expect(created?.classId).toBe('as')
expect(created?.heroToken).toBe('ai')
expect(created?.className).toBe('Assassin')
})
it('decouples front-end tick progression from display refresh rate (25 ticks/sec across 60Hz, 120Hz, 144Hz) and clamps large frame deltas', () => {
expect(FRONTEND_TICK_MS).toBe(40)
expect(FRONTEND_MAX_CATCHUP_TICKS).toBe(5)
for (const refreshHz of [60, 120, 144]) {
const state = createInitialFlowState()
// First frame anchors lastFrameTimeMs at t = 0ms
expect(stepFlowFrame(state, 0)).toBe(0)
expect(state.globalTick).toBe(0)
for (let i = 1; i <= refreshHz; i++) {
stepFlowFrame(state, (i * 1000) / refreshHz)
}
expect(state.globalTick).toBe(25)
expect(state.stageTicks).toBe(25)
}
// Also verify delta-based stepping across 60Hz, 120Hz, 144Hz, and 240Hz
for (const refreshHz of [60, 120, 144, 240]) {
const state = createInitialFlowState()
const dtMs = 1000 / refreshHz
for (let i = 0; i < refreshHz; i++) {
stepFlowByDeltaMs(state, dtMs)
}
expect(state.globalTick).toBe(25)
}
// Verify bounded catch-up clamp (e.g. 5000ms tab switch clamps to 5 ticks = 200ms)
const pausedState = createInitialFlowState()
expect(stepFlowFrame(pausedState, 100)).toBe(0)
const catchupSteps = stepFlowFrame(pausedState, 5100)
expect(catchupSteps).toBe(FRONTEND_MAX_CATCHUP_TICKS)
expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS)
expect(pausedState.tickAccumulatorMs).toBe(0)
// Non-positive or non-finite timestamps/deltas must be true no-ops that do not rewind lastFrameTimeMs
expect(stepFlowFrame(pausedState, 5050)).toBe(0)
expect(pausedState.lastFrameTimeMs).toBe(5100)
expect(stepFlowFrame(pausedState, 5116)).toBe(0)
expect(pausedState.tickAccumulatorMs).toBe(16)
expect(stepFlowFrame(pausedState, Number.NaN)).toBe(0)
expect(stepFlowByDeltaMs(pausedState, -16)).toBe(0)
expect(stepFlowByDeltaMs(pausedState, Number.NaN)).toBe(0)
expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS)
})
it('wraps AnimImage looping controls (burning logos & campfire flame) at framesPerDir - 1 with rate = 1 per D2Win.dll (0x6f8f0ac6)', () => {
// 30-frame DC6 animations (D2logoBlackLeft/Right, D2logoFireLeft/Right, D2logoBlackBnet, D2logoBnet, fire.dc6)
// loop over frames 0..28 (29 frames) and wrap to 0 at tick 29
for (let t = 0; t <= 28; t++) {
expect(getAnimImageLoopFrame(t, 30, 1)).toBe(t)
}
expect(getAnimImageLoopFrame(29, 30, 1)).toBe(0)
expect(getAnimImageLoopFrame(30, 30, 1)).toBe(1)
expect(getAnimImageLoopFrame(57, 30, 1)).toBe(28)
expect(getAnimImageLoopFrame(58, 30, 1)).toBe(0)
})
it('advances 7-class campfire animations at exact 1.13c D2Launch.dll (0x6fa60c08) per-class, per-phase rates and wraps looping phases at framesPerDir - 1', () => {
// Verify table against 1.13c D2Launch.dll @ 0x6fa60c08
expect(CAMPFIRE_ANIM_RATES).toEqual({
as: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
dz: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
am: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
ne: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
ba: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
so: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
pa: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
})
const state = createInitialFlowState()
enterCharCreate(state)
// 1. Back idle ('nu1'):
// - Amazon ('am'): rate = 3 (120ms/frame), frameCount = 26 -> loops 0..24 (25 frames, wraps at tick 75)
// - Barbarian ('ba'): rate = 2 (80ms/frame), frameCount = 16 -> loops 0..14 (15 frames, wraps at tick 30)
stepFlowTick(state) // tick 1
expect(state.campfireClasses.am.frameIndex).toBe(0)
expect(state.campfireClasses.ba.frameIndex).toBe(0)
stepFlowTick(state) // tick 2
expect(state.campfireClasses.am.frameIndex).toBe(0)
expect(state.campfireClasses.ba.frameIndex).toBe(1)
stepFlowTick(state) // tick 3
expect(state.campfireClasses.am.frameIndex).toBe(1)
expect(state.campfireClasses.ba.frameIndex).toBe(1)
// Step to tick 28 -> Barbarian at frame 14 (last frame before wrap at framesPerDir - 1 = 15)
for (let t = 4; t <= 28; t++) {
stepFlowTick(state)
}
expect(state.campfireClasses.ba.frameIndex).toBe(14)
stepFlowTick(state) // tick 29
expect(state.campfireClasses.ba.frameIndex).toBe(14)
stepFlowTick(state) // tick 30 -> wraps to 0 (never reaches frame 15)
expect(state.campfireClasses.ba.frameIndex).toBe(0)
// Step to tick 72 -> Amazon at frame 24 (last frame before wrap at framesPerDir - 1 = 25)
for (let t = 31; t <= 72; t++) {
stepFlowTick(state)
}
expect(state.campfireClasses.am.frameIndex).toBe(24)
stepFlowTick(state) // tick 73
stepFlowTick(state) // tick 74
expect(state.campfireClasses.am.frameIndex).toBe(24)
stepFlowTick(state) // tick 75 -> wraps to 0 (never reaches frame 25)
expect(state.campfireClasses.am.frameIndex).toBe(0)
// 2. Hover ('nu2') and Select ('fw' -> 'nu3'):
hoverCampfireClass(state, 'as')
expect(state.campfireClasses.as.phase).toBe('nu2')
expect(state.campfireClasses.as.frameIndex).toBe(0)
// Assassin nu2 has rate = 3
stepFlowTick(state)
stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(0)
stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(1)
// Select Paladin ('pa'): fw has rate = 1 (28 frames), then nu3 has rate = 2 (13 frames -> wraps at 12)
selectCampfireClass(state, 'pa')
expect(state.campfireClasses.pa.phase).toBe('fw')
for (let i = 0; i < 28; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.pa.phase).toBe('nu3')
expect(state.campfireClasses.pa.frameIndex).toBe(0)
// Paladin nu3 advances 1 frame every 2 ticks and wraps at 13 - 1 = 12
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(1)
for (let i = 0; i < 20; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.pa.frameIndex).toBe(11)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(11)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0)
})
it('advances Character Selection & Lobby TN HTH hero avatars via 1.13c D2Win.dll (0x6f8ee81b) 8.8 fixed-point COF animRate accumulator', () => {
expect(HERO_TN_ANIM_SPECS).toEqual({
am: { frameCount: 16, animRate: 80 },
so: { frameCount: 16, animRate: 80 },
ne: { frameCount: 16, animRate: 80 },
pa: { frameCount: 16, animRate: 128 },
ba: { frameCount: 16, animRate: 80 },
dz: { frameCount: 10, animRate: 96 },
ai: { frameCount: 12, animRate: 64 },
})
const state = createInitialFlowState()
for (const token of HERO_TOKENS) {
expect(state.avatarAnims[token].acc).toBe(0)
expect(state.avatarAnims[token].frameIndex).toBe(0)
}
// Tick 1:
// am: acc=80 -> frame 0; pa: acc=128 -> frame 0; dz: acc=96 -> frame 0; ai: acc=64 -> frame 0
stepFlowTick(state)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 2:
// pa: acc=256 -> frame 1; others still at frame 0
stepFlowTick(state)
expect(state.avatarAnims.pa.frameIndex).toBe(1)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 3:
// dz: acc=288 -> frame 1; am: acc=240 -> frame 0; ai: acc=192 -> frame 0
stepFlowTick(state)
expect(state.avatarAnims.dz.frameIndex).toBe(1)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 4:
// am: acc=320 -> frame 1; ai: acc=256 -> frame 1; pa: acc=512 -> frame 2
stepFlowTick(state)
expect(state.avatarAnims.am.frameIndex).toBe(1)
expect(state.avatarAnims.so.frameIndex).toBe(1)
expect(state.avatarAnims.ne.frameIndex).toBe(1)
expect(state.avatarAnims.ba.frameIndex).toBe(1)
expect(state.avatarAnims.ai.frameIndex).toBe(1)
expect(state.avatarAnims.pa.frameIndex).toBe(2)
// Advance to tick 24 -> Druid (dz: frameCount=10, wrapFrame=9, animRate=96) reaches 24*96 = 2304 (2304>>8 = 9 >= 9) -> wraps to 0
for (let t = 5; t <= 23; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.dz.frameIndex).toBe(8)
stepFlowTick(state) // tick 24
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.dz.acc).toBe(0)
// Advance to tick 30 -> Paladin (pa: frameCount=16, wrapFrame=15, animRate=128) reaches 30*128 = 3840 (3840>>8 = 15 >= 15) -> wraps to 0
for (let t = 25; t <= 29; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.pa.frameIndex).toBe(14)
stepFlowTick(state) // tick 30
expect(state.avatarAnims.pa.frameIndex).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
// Advance to tick 44 -> Assassin (ai: frameCount=12, wrapFrame=11, animRate=64) reaches 44*64 = 2816 (2816>>8 = 11 >= 11) -> wraps to 0
for (let t = 31; t <= 43; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.ai.frameIndex).toBe(10)
stepFlowTick(state) // tick 44
expect(state.avatarAnims.ai.frameIndex).toBe(0)
expect(state.avatarAnims.ai.acc).toBe(0)
// Advance to tick 48 -> Amazon/Sorceress/Necromancer/Barbarian (frameCount=16, wrapFrame=15, animRate=80) reach 48*80 = 3840 (3840>>8 = 15 >= 15) -> wrap to 0
for (let t = 45; t <= 47; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.am.frameIndex).toBe(14)
stepFlowTick(state) // tick 48
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.so.frameIndex).toBe(0)
expect(state.avatarAnims.ne.frameIndex).toBe(0)
expect(state.avatarAnims.ba.frameIndex).toBe(0)
})
it('keeps public/ui/frontend/manifest.json and FrontendRenderer aligned on 1.13c rates and wrapped frame indices', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest
// 1. Verify manifest animStrips rates
expect(manifest.animStrips.logoMainBlack?.rate).toBe(1)
expect(manifest.animStrips.logoMainFire?.rate).toBe(1)
expect(manifest.animStrips.logoBnetBlack?.rate).toBe(1)
expect(manifest.animStrips.logoBnetFire?.rate).toBe(1)
expect(manifest.animStrips.campfireFlame?.rate).toBe(1)
expect(manifest.animStrips.loadingDoor?.rate).toBe(3)
// 2. Verify manifest campfireClasses rates
for (const clsId of CAMPFIRE_CLASS_ORDER) {
for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) {
expect(manifest.campfireClasses[clsId]?.[phase]?.rate).toBe(
CAMPFIRE_ANIM_RATES[clsId][phase],
)
}
}
// 3. Verify manifest charAvatars animRates & frameCounts
for (const token of HERO_TOKENS) {
expect(manifest.charAvatars[token]?.frameCount).toBe(HERO_TN_ANIM_SPECS[token].frameCount)
expect(manifest.charAvatars[token]?.animRate).toBe(HERO_TN_ANIM_SPECS[token].animRate)
}
// 4. Verify FrontendRenderer draws wrapped logo/flame frames and COF-accumulated avatar frames
const state = createInitialFlowState()
const drawCalls: { img: unknown; sx: number; sy: number; dx: number; dy: number }[] = []
const fakeCtx = {
imageSmoothingEnabled: false,
clearRect: () => {},
save: () => {},
restore: () => {},
fillRect: () => {},
drawImage: (
img: unknown,
sx: number,
sy: number,
_sw?: number,
_sh?: number,
dx?: number,
dy?: number,
) => {
drawCalls.push({ img, sx, sy, dx: dx ?? sx, dy: dy ?? sy })
},
}
const fakeCanvas = {
getContext: () => fakeCtx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
for (const strip of Object.values(manifest.animStrips)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
}
for (const strip of Object.values(manifest.charAvatars)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
}
// At globalTick = 29, 30-frame main menu logo wraps to frame 0 (not frame 29)
state.stage = 'main_menu'
state.globalTick = 29
drawCalls.length = 0
renderer.render(1000)
const logoCall = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'logo-main-black.png',
)
expect(logoCall).toBeDefined()
const logoFrame0 = manifest.animStrips.logoMainBlack!.frames[0]!
expect(logoCall?.sx).toBe(logoFrame0.x)
expect(logoCall?.sy).toBe(logoFrame0.y)
// In char_select after 2 ticks, Sorceress (animRate=80) is still on frame 0 (160>>8 = 0), not frame 2
state.roster = [
{
id: 'so-1',
name: 'Isendra',
classId: 'so',
heroToken: 'so',
className: 'Sorceress',
level: 1,
isExpansion: true,
isHardcore: false,
isLadder: true,
progression: 0,
maxDifficulty: 'normal',
createdAt: 0,
},
]
state.stage = 'char_select'
state.globalTick = 0
state.avatarAnims.so.acc = 0
state.avatarAnims.so.frameIndex = 0
stepFlowTick(state)
stepFlowTick(state)
expect(state.globalTick).toBe(2)
expect(state.avatarAnims.so.frameIndex).toBe(0)
drawCalls.length = 0
renderer.render(1080)
const avatarCallAtTick2 = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png',
)
const soFrame0 = manifest.charAvatars.so!.frames[0]!
expect(avatarCallAtTick2?.sx).toBe(soFrame0.x)
expect(avatarCallAtTick2?.sy).toBe(soFrame0.y)
// After 2 more ticks (tick 4 -> acc = 320 -> 320>>8 = 1), Sorceress advances to frame 1
stepFlowTick(state)
stepFlowTick(state)
expect(state.avatarAnims.so.frameIndex).toBe(1)
drawCalls.length = 0
renderer.render(1160)
const avatarCallAtTick4 = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png',
)
const soFrame1 = manifest.charAvatars.so!.frames[1]!
expect(avatarCallAtTick4?.sx).toBe(soFrame1.x)
expect(avatarCallAtTick4?.sy).toBe(soFrame1.y)
})
it('resets lastFrameTimeMs and tickAccumulatorMs when entering in_game or returning after session loss to prevent catch-up bursts', () => {
const state = createInitialFlowState()
state.stage = 'door_loading'
state.doorFrame = 9
state.stageTicks = 29
state.sceneReady = true
// Anchor at 1000ms and advance 65ms (1 tick + 25ms leftover in tickAccumulatorMs)
expect(stepFlowFrame(state, 1000)).toBe(0)
expect(stepFlowFrame(state, 1065)).toBe(1)
expect(state.stage).toBe('in_game')
expect(state.tickAccumulatorMs).toBe(0)
expect(state.lastFrameTimeMs).toBeNull()
// Simulate a stale timestamp/accumulator right before a background session disconnect
state.lastFrameTimeMs = 2000
state.tickAccumulatorMs = 35
state.avatarAnims.so.acc = 512
state.avatarAnims.so.frameIndex = 2
returnToLoginAfterSessionLoss(state, 'Connection lost')
expect(state.stage).toBe('bnet_login')
expect(state.stageTicks).toBe(0)
expect(state.tickAccumulatorMs).toBe(0)
expect(state.lastFrameTimeMs).toBeNull()
expect(state.avatarAnims.so.acc).toBe(0)
expect(state.avatarAnims.so.frameIndex).toBe(0)
// First front-end frame after 30s background disconnect must anchor (0 ticks), not burst 5 catch-up ticks
const tickBefore = state.globalTick
expect(stepFlowFrame(state, 32000)).toBe(0)
expect(state.globalTick).toBe(tickBefore)
expect(stepFlowFrame(state, 32040)).toBe(1)
expect(state.globalTick).toBe(tickBefore + 1)
// Subsequent game creation after a prior in_game session must reset sceneReady = false
state.stage = 'bnet_lobby'
state.gameName = 'SecondRoom'
state.maxPlayers = '8'
state.sceneReady = true
expect(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading')
expect(state.sceneReady).toBe(false)
for (let i = 0; i < 35; i++) {
stepFlowTick(state)
}
expect(state.doorFrame).toBe(9)
expect(state.stage).toBe('door_loading')
})
it('resets stageTicks and avatarAnims across FrontendRenderer Escape key transitions and offline character deletion', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest
const state = createInitialFlowState()
const fakeCtx = {
imageSmoothingEnabled: false,
clearRect: () => {},
save: () => {},
restore: () => {},
fillRect: () => {},
drawImage: () => {},
}
const fakeCanvas = {
getContext: () => fakeCtx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
const makeEscapeEvent = (): KeyboardEvent =>
({
key: 'Escape',
preventDefault: () => {},
}) as unknown as KeyboardEvent
// 1. Escape on bnet_login -> main_menu resets stageTicks
state.stage = 'bnet_login'
state.stageTicks = 12
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('main_menu')
expect(state.stageTicks).toBe(0)
// 2. Escape on char_create (empty roster) -> bnet_login resets stageTicks
state.roster = []
enterCharCreate(state)
stepFlowTick(state)
stepFlowTick(state)
expect(state.stageTicks).toBe(2)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('bnet_login')
expect(state.stageTicks).toBe(0)
// 3. Escape on char_create (non-empty roster) -> char_select resets stageTicks and avatarAnims
enterCharCreate(state)
selectCampfireClass(state, 'pa')
state.charName = 'Roland'
submitCharacterCreate(state)
enterCharCreate(state)
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.stageTicks).toBe(6)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('char_select')
expect(state.stageTicks).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 4. Escape on bnet_lobby -> char_select resets stageTicks and avatarAnims
confirmCharacterSelect(state)
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.stage).toBe('bnet_lobby')
expect(state.stageTicks).toBe(6)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('char_select')
expect(state.stageTicks).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 5. Offline char_select_delete button resets avatarAnims
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
const deleteBtn = renderer.getActiveButtons().find((b) => b.id === 'char_select_delete')
expect(deleteBtn).toBeDefined()
deleteBtn!.onClick()
expect(state.roster).toHaveLength(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 6. Escape on char_select -> main_menu resets stageTicks
state.stageTicks = 9
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('main_menu')
expect(state.stageTicks).toBe(0)
})
})