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

296 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CAMPFIRE_CLASS_ORDER,
canSubmitCreateGame,
cancelConnecting,
charSummaryToRosterCharacter,
clickBattleNetButton,
confirmCharacterSelect,
createInitialFlowState,
enterCharCreate,
getCharacterMaxDifficulty,
hoverCampfireClass,
isDifficultyUnlocked,
isValidCharacterName,
selectCampfireClass,
stepFlowTick,
submitBattleNetLogin,
submitCharacterCreate,
submitCreateGame,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import { FrontendRenderer } 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')
})
})