296 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|