fix(lobby): restore 1.13c Create Game empty fields, progression difficulty lock, and editable maxPlayers/charDifference

This commit is contained in:
troytt 2026-10-01 13:45:53 +00:00
parent 309738cf79
commit b886e1c3cf
6 changed files with 454 additions and 36 deletions

View File

@ -56,6 +56,10 @@ export interface RosterCharacter {
isExpansion: boolean
readonly isHardcore: boolean
readonly isLadder: boolean
/** 5-bit character progression (`0..15`) from MCP 33-byte `statString[27] & 0x1f` (`D2Launch.dll` 0x6fa4c887). */
progression?: number | undefined
/** Optional explicit highest unlocked difficulty override. */
maxDifficulty?: GameDifficulty | undefined
readonly createdAt: number
}
@ -67,6 +71,7 @@ export type ActiveInputField =
| 'gamePassword'
| 'gameDescription'
| 'maxPlayers'
| 'charDifference'
| 'chatInput'
| null
@ -279,9 +284,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
'You have entered chat channel: Diablo II Retail_USA-1',
],
chatInput: '',
gameName: 'RogueCamp1',
gameName: '',
gamePassword: '',
gameDescription: 'Act 1 Rogue Encampment',
gameDescription: '',
maxPlayers: '8',
charDifferenceEnabled: false,
charDifference: '99',
@ -450,6 +455,8 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n
isExpansion: state.isExpansion,
isHardcore: state.isHardcore,
isLadder: state.isLadder,
progression: 0,
maxDifficulty: 'normal',
createdAt: Date.now(),
}
state.roster = [newChar, ...state.roster].slice(0, 8)
@ -460,9 +467,65 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n
return newChar
}
/**
* Determine the highest unlocked difficulty for a character per 1.13c `D2Multi.dll` (`0x6f9e5182..0x6f9e52cd`):
* - Expansion (`isExpansion === true`, 5 acts/difficulty):
* - `progression >= 10` -> `'hell'`
* - `progression >= 5` -> `'nightmare'`
* - otherwise -> `'normal'`
* - Classic (`isExpansion === false`, 4 acts/difficulty):
* - `progression >= 8` -> `'hell'`
* - `progression >= 4` -> `'nightmare'`
* - otherwise -> `'normal'`
*/
export function getCharacterMaxDifficulty(
char: Pick<RosterCharacter, 'isExpansion' | 'progression' | 'maxDifficulty'> | undefined,
): GameDifficulty {
if (!char) return 'normal'
if (char.maxDifficulty) return char.maxDifficulty
const prog = char.progression ?? 0
if (char.isExpansion) {
if (prog >= 10) return 'hell'
if (prog >= 5) return 'nightmare'
return 'normal'
}
if (prog >= 8) return 'hell'
if (prog >= 4) return 'nightmare'
return 'normal'
}
export function isDifficultyUnlocked(
char: Pick<RosterCharacter, 'isExpansion' | 'progression' | 'maxDifficulty'> | undefined,
difficulty: GameDifficulty,
): boolean {
if (difficulty === 'normal') return true
const maxDiff = getCharacterMaxDifficulty(char)
if (difficulty === 'nightmare') return maxDiff === 'nightmare' || maxDiff === 'hell'
return maxDiff === 'hell'
}
/**
* Check whether the `CREATE GAME` button should be enabled per `D2Multi.dll` (`0x6f9e3af0..0x6f9e3c49`).
*/
export function canSubmitCreateGame(state: BnetFlowState): boolean {
const trimmedGame = state.gameName.trim()
if (trimmedGame.length < 1 || trimmedGame.length > 15) return false
const maxP = Number.parseInt(state.maxPlayers.trim(), 10)
if (!Number.isFinite(maxP) || maxP < 1 || maxP > 8) return false
if (state.charDifferenceEnabled) {
const diff = Number.parseInt(state.charDifference.trim(), 10)
if (!Number.isFinite(diff) || diff < 1 || diff > 99) return false
}
const selectedChar = state.roster[state.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, state.difficulty)) return false
return true
}
/**
* Confirm Character Selection (`OK` button or double-click on Stage 4):
* Transitions to Stage 5 (`bnet_lobby`) with the Create Game sub-panel open.
* Per `D2Multi.dll` (`0x6f9e4bc0` & `0x6f9e533f..0x6f9e535c`), `gameName` and `gameDescription`
* are never prefilled, and `difficulty` is clamped to the character's unlocked difficulties.
*/
export function confirmCharacterSelect(state: BnetFlowState): boolean {
if (state.stage !== 'char_select') return false
@ -472,8 +535,8 @@ export function confirmCharacterSelect(state: BnetFlowState): boolean {
state.stageTicks = 0
state.lobbySubPanel = 'create'
state.createGameError = null
if (!state.gameName.trim()) {
state.gameName = `${selected.name}1`.slice(0, 15)
if (!isDifficultyUnlocked(selected, state.difficulty)) {
state.difficulty = 'normal'
}
state.activeInput = 'gameName'
return true
@ -501,7 +564,26 @@ export function submitCreateGame(state: BnetFlowState): boolean {
state.createGameError = 'Please enter a valid Game Name (1-15 characters).'
return false
}
const maxP = Number.parseInt(state.maxPlayers.trim(), 10)
if (!Number.isFinite(maxP) || maxP < 1 || maxP > 8) {
state.createGameError = 'Max Number of Players must be between 1 and 8.'
return false
}
if (state.charDifferenceEnabled) {
const diff = Number.parseInt(state.charDifference.trim(), 10)
if (!Number.isFinite(diff) || diff < 1 || diff > 99) {
state.createGameError = 'Character Difference must be between 1 and 99.'
return false
}
state.charDifference = String(diff)
}
const selectedChar = state.roster[state.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, state.difficulty)) {
state.createGameError = 'Selected difficulty is not unlocked for this character.'
return false
}
state.gameName = trimmedGame
state.maxPlayers = String(maxP)
state.createGameError = null
state.stage = 'door_loading'
state.stageTicks = 0
@ -590,10 +672,28 @@ export function charSummaryToRosterCharacter(summary: {
readonly expansion: boolean
readonly hardcore: boolean
readonly ladder: boolean
readonly progression?: number | undefined
readonly difficulty?: 0 | 1 | 2 | undefined
}): RosterCharacter {
const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass]
if (!classId) throw new Error(`MCP character "${summary.name}" has unknown class id ${summary.charClass}`)
const spec = CAMPFIRE_CLASS_SPECS[classId]
const progression =
summary.progression !== undefined
? summary.progression
: summary.difficulty === 2
? summary.expansion
? 10
: 8
: summary.difficulty === 1
? summary.expansion
? 5
: 4
: 0
const maxDifficulty = getCharacterMaxDifficulty({
isExpansion: summary.expansion,
progression,
})
return {
id: `${spec.heroToken}-${summary.name}`,
name: summary.name,
@ -604,6 +704,8 @@ export function charSummaryToRosterCharacter(summary: {
isExpansion: summary.expansion,
isHardcore: summary.hardcore,
isLadder: summary.ladder,
progression,
maxDifficulty,
createdAt: Date.now(),
}
}
@ -617,6 +719,8 @@ export function applyOnlineCharactersToFlowState(
readonly expansion: boolean
readonly hardcore: boolean
readonly ladder: boolean
readonly progression?: number | undefined
readonly difficulty?: 0 | 1 | 2 | undefined
}[],
): void {
state.onlineConnected = true

View File

@ -15,11 +15,13 @@ import { CursorController } from '../ui/cursor.ts'
import type { FontAtlasMeta } from '../ui/font.ts'
import {
CAMPFIRE_CLASS_ORDER,
canSubmitCreateGame,
cancelConnecting,
clickBattleNetButton,
confirmCharacterSelect,
enterCharCreate,
hoverCampfireClass,
isDifficultyUnlocked,
selectCampfireClass,
submitBattleNetLogin,
submitCharacterCreate,
@ -972,6 +974,16 @@ export class FrontendRenderer {
sprite: 'btn-chatrighttop.png',
label: L.create,
onClick: () => {
if (this.state.lobbySubPanel !== 'create') {
this.state.gameName = ''
this.state.gamePassword = ''
this.state.gameDescription = ''
this.state.createGameError = null
}
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, this.state.difficulty)) {
this.state.difficulty = 'normal'
}
this.state.lobbySubPanel = 'create'
this.state.activeInput = 'gameName'
},
@ -1038,7 +1050,10 @@ export class FrontendRenderer {
onClick: () => {
this.state.gameName = ''
this.state.gamePassword = ''
this.state.gameDescription = ''
this.state.selectedGameName = ''
this.state.createGameError = null
this.state.activeInput = 'gameName'
},
},
{
@ -1049,6 +1064,10 @@ export class FrontendRenderer {
h: 32,
sprite: 'btn-game.png',
label: this.state.lobbySubPanel === 'join' ? L.join : L.createGameBtn,
disabled:
this.state.lobbySubPanel === 'join'
? !(this.state.selectedGameName || this.state.gameName).trim()
: !canSubmitCreateGame(this.state),
onClick: () => {
if (this.state.lobbySubPanel === 'join') {
this.triggerJoinGame()
@ -1380,16 +1399,29 @@ export class FrontendRenderer {
})
}
private drawRadioButton(x: number, y: number, selected: boolean, label: string): void {
private drawRadioButton(
x: number,
y: number,
selected: boolean,
label: string,
disabled = false,
): void {
const rbImg = this.images.get('radiobutton.png')
if (rbImg) {
const w = Math.floor(rbImg.width / 2)
const h = rbImg.height
if (disabled) {
this.ctx.save()
this.ctx.filter = 'brightness(0.5) saturate(0)'
this.ctx.drawImage(rbImg, 0, 0, w, h, x, y, w, h)
this.ctx.restore()
} else {
this.ctx.drawImage(rbImg, selected ? w : 0, 0, w, h, x, y, w, h)
}
}
this.drawBitmapText(label, x + 22, y + 1, {
font: 'font16',
color: 4,
color: disabled ? 5 : 4,
align: 'left',
})
}
@ -1615,27 +1647,47 @@ export class FrontendRenderer {
color: 0,
})
// 5. Character Difference (`x = 655, y = 315, w = 42, h = 29` per create-game-bg.png box & D2Multi.dll 0x6f9eaa50)
// 5. Character Difference (`x = 655, y = 315, w = 42, h = 29` per create-game-bg.png box & D2Multi.dll 0x6f9eaa50..0x6f9eac00)
this.drawCheckbox(
431,
323,
this.state.charDifferenceEnabled,
L.charDifference,
4,
this.state.charDifferenceEnabled ? 4 : 5,
'joingameclickbox.png',
)
this.drawBitmapText(this.state.charDifference, 676, 329, {
if (!this.state.charDifferenceEnabled) {
const glbImg = this.images.get('grey-level-box.png')
if (glbImg) {
this.ctx.drawImage(glbImg, 659, 316)
}
}
this.drawEditBox('charDifference', 655, 315, 42, 29, this.state.charDifference, {
drawFrameImage: false,
font: 'fontformal12',
color: this.state.charDifferenceEnabled ? 0 : 5,
align: 'center',
vAlign: 'middle',
})
// 6. Select Difficulty (`Normal`, `Nightmare`, `Hell` per D2Multi.dll 0x6f9eac30..0x6f9ead20)
// 6. Select Difficulty (`Normal`, `Nightmare`, `Hell` per D2Multi.dll 0x6f9eac30..0x6f9ead20 & 0x6f9e517b..0x6f9e535f)
const selectedCharForDiff = this.state.roster[this.state.selectedSlotIndex]
const nightmareUnlocked = isDifficultyUnlocked(selectedCharForDiff, 'nightmare')
const hellUnlocked = isDifficultyUnlocked(selectedCharForDiff, 'hell')
this.drawBitmapText(L.selectDifficulty, 427, 350, { font: 'font16', color: 4, align: 'left' })
this.drawRadioButton(430, 368, this.state.difficulty === 'normal', L.normal)
this.drawRadioButton(555, 368, this.state.difficulty === 'nightmare', L.nightmare)
this.drawRadioButton(698, 368, this.state.difficulty === 'hell', L.hell)
this.drawRadioButton(430, 368, this.state.difficulty === 'normal', L.normal, false)
this.drawRadioButton(
555,
368,
this.state.difficulty === 'nightmare' && nightmareUnlocked,
L.nightmare,
!nightmareUnlocked,
)
this.drawRadioButton(
698,
368,
this.state.difficulty === 'hell' && hellUnlocked,
L.hell,
!hellUnlocked,
)
}
const lobbyErr = this.state.createGameError || this.state.lastError
@ -1857,16 +1909,35 @@ export class FrontendRenderer {
this.state.activeInput = 'gameDescription'
return
}
if (x >= 647 && x <= 680 && y >= 286 && y <= 310) {
// Max Number of Players label & editbox (`x = 427..705, y = 276..310`)
if (x >= 427 && x <= 705 && y >= 276 && y <= 310) {
this.state.activeInput = 'maxPlayers'
return
}
// Character Difference checkbox
if (x >= 431 && x <= 620 && y >= 322 && y <= 344) {
// Character Difference checkbox (`x = 431..649, y = 315..346`)
if (x >= 431 && x <= 649 && y >= 315 && y <= 346) {
this.state.charDifferenceEnabled = !this.state.charDifferenceEnabled
if (this.state.charDifferenceEnabled) {
if (!this.state.charDifference.trim()) {
this.state.charDifference = '99'
}
this.state.activeInput = 'charDifference'
} else if (this.state.activeInput === 'charDifference') {
this.state.activeInput = 'gameName'
}
return
}
// Difficulty radio buttons
// Character Difference editbox (`x = 650..705, y = 313..346`)
if (x >= 650 && x <= 705 && y >= 313 && y <= 346) {
this.state.charDifferenceEnabled = true
if (!this.state.charDifference.trim()) {
this.state.charDifference = '99'
}
this.state.activeInput = 'charDifference'
return
}
// Difficulty radio buttons (`Normal`, `Nightmare`, `Hell` gated by character progression)
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
const diffs: { id: GameDifficulty; x: number; w: number }[] = [
{ id: 'normal', x: 430, w: 95 },
{ id: 'nightmare', x: 555, w: 125 },
@ -1874,7 +1945,10 @@ export class FrontendRenderer {
]
for (const d of diffs) {
if (x >= d.x && x <= d.x + d.w && y >= 364 && y <= 388) {
if (isDifficultyUnlocked(selectedChar, d.id)) {
this.state.difficulty = d.id
this.state.createGameError = null
}
return
}
}
@ -1952,11 +2026,15 @@ export class FrontendRenderer {
this.state.activeInput =
this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName'
} else if (this.state.stage === 'bnet_lobby') {
const order: ActiveInputField[] = [
const order: ActiveInputField[] =
this.state.lobbySubPanel === 'join'
? ['gameName', 'gamePassword', 'chatInput']
: [
'gameName',
'gamePassword',
'gameDescription',
'maxPlayers',
...(this.state.charDifferenceEnabled ? (['charDifference'] as const) : []),
'chatInput',
]
const idx = order.indexOf(this.state.activeInput)
@ -1968,7 +2046,26 @@ export class FrontendRenderer {
const field = this.state.activeInput
if (!field) return
if (e.key === 'Backspace') {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (field === 'maxPlayers') {
e.preventDefault()
const curNum = Number.parseInt(this.state.maxPlayers, 10)
const base = Number.isFinite(curNum) ? curNum : 8
const next = e.key === 'ArrowUp' ? Math.min(8, base + 1) : Math.max(1, base - 1)
this.setFieldValue('maxPlayers', String(next))
return
}
if (field === 'charDifference' && this.state.charDifferenceEnabled) {
e.preventDefault()
const curNum = Number.parseInt(this.state.charDifference, 10)
const base = Number.isFinite(curNum) ? curNum : 99
const next = e.key === 'ArrowUp' ? Math.min(99, base + 1) : Math.max(1, base - 1)
this.setFieldValue('charDifference', String(next))
return
}
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
this.setFieldValue(field, this.getFieldValue(field).slice(0, -1))
return
@ -1989,7 +2086,21 @@ export class FrontendRenderer {
if (/^[1-8]$/.test(ch)) {
this.setFieldValue(field, ch)
}
} else if (field === 'gameDescription' || field === 'chatInput') {
} else if (field === 'charDifference') {
if (this.state.charDifferenceEnabled && /^[0-9]$/.test(ch)) {
if (cur.length === 0 || cur.length >= 2) {
if (ch !== '0') {
this.setFieldValue(field, ch)
}
} else {
this.setFieldValue(field, cur + ch)
}
}
} else if (field === 'gameDescription') {
if (cur.length < 31) {
this.setFieldValue(field, cur + ch)
}
} else if (field === 'chatInput') {
if (cur.length < 38) {
this.setFieldValue(field, cur + ch)
}
@ -2013,6 +2124,8 @@ export class FrontendRenderer {
return this.state.gameDescription
case 'maxPlayers':
return this.state.maxPlayers
case 'charDifference':
return this.state.charDifference
case 'chatInput':
return this.state.chatInput
}
@ -2042,6 +2155,11 @@ export class FrontendRenderer {
break
case 'maxPlayers':
this.state.maxPlayers = val
this.state.createGameError = null
break
case 'charDifference':
this.state.charDifference = val
this.state.createGameError = null
break
case 'chatInput':
this.state.chatInput = val

View File

@ -15,6 +15,7 @@
import {
applyOnlineCharactersToFlowState,
createInitialFlowState,
isDifficultyUnlocked,
returnToLoginAfterSessionLoss,
stepFlowTick,
type BnetFlowState,
@ -562,6 +563,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
st.statusText = ''
st.stage = 'bnet_lobby'
st.lobbySubPanel = 'create'
st.createGameError = null
if (!isDifficultyUnlocked(selected, st.difficulty)) {
st.difficulty = 'normal'
}
st.activeInput = 'gameName'
} catch (err) {
if (err instanceof SessionLostError) return
@ -587,8 +592,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
}))
if (st.realmGames[0] && !st.selectedGameName) {
st.selectedGameName = st.realmGames[0].name
if (st.lobbySubPanel === 'join') {
st.gameName = st.realmGames[0].name
}
}
st.statusText = ''
} catch (err) {
if (err instanceof SessionLostError) return
@ -605,6 +612,28 @@ async function bootPlayApp(): Promise<PlayAppController> {
frontendRenderer.render(performance.now())
return
}
const maxPlayers = Number.parseInt(st.maxPlayers.trim(), 10)
if (!Number.isFinite(maxPlayers) || maxPlayers < 1 || maxPlayers > 8) {
st.createGameError = 'MAX NUMBER OF PLAYERS MUST BE 1-8.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return
}
let levelDiff = 0xff
if (st.charDifferenceEnabled) {
const parsedDiff = Number.parseInt(st.charDifference.trim(), 10)
if (!Number.isFinite(parsedDiff) || parsedDiff < 1 || parsedDiff > 99) {
st.createGameError = 'CHARACTER DIFFERENCE MUST BE 1-99.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return
}
levelDiff = parsedDiff
}
const selectedChar = st.roster[st.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, st.difficulty)) {
st.difficulty = 'normal'
}
const diffNum: 0 | 1 | 2 =
st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0
st.stage = 'door_loading'
@ -622,7 +651,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
password: st.gamePassword,
description: st.gameDescription,
difficulty: diffNum,
maxPlayers: Math.max(1, Math.min(8, Number.parseInt(st.maxPlayers, 10) || 8)),
maxPlayers,
levelDiff,
},
})
st.statusText = ''

View File

@ -23,6 +23,7 @@ export interface CharSummary {
readonly ladder: boolean
readonly act: number
readonly difficulty: 0 | 1 | 2
readonly progression?: number | undefined
readonly expiresAt: number
readonly equipmentGraphics: Uint8Array // 9 component bytes from 33-byte stat string
readonly rawStatString: Uint8Array

View File

@ -101,10 +101,25 @@ export function parseMcpCharStatString(
const rawLevel = isFull33 ? statBytes[25]! : statBytes.length > 12 ? statBytes[12]! : 1
const level = rawLevel === 0xff || rawLevel === 0 ? 1 : rawLevel
const flags = isFull33 ? statBytes[26]! : statBytes.length > 13 ? statBytes[13]! : McpCharFlag.EXPANSION
const expansion = (flags & McpCharFlag.EXPANSION) !== 0
const actAndDiff = isFull33 && statBytes.length > 27 ? statBytes[27]! : statBytes.length > 26 ? statBytes[26]! : 0
// Per D2Launch.dll (0x6fa4c887..0x6fa4c910) & D2Multi.dll (0x6f9e5182..0x6f9e52cd):
// statBytes[27] & 0x1f is the 5-bit character progression (0..15, acts completed across Normal/Nightmare/Hell).
// Expansion (5 acts/difficulty): >= 10 -> Hell (2), >= 5 -> Nightmare (1), else Normal (0).
// Classic (4 acts/difficulty): >= 8 -> Hell (2), >= 4 -> Nightmare (1), else Normal (0).
const progression = actAndDiff & 0x1f
const act = ((actAndDiff & 0x07) % 5) + 1
const diffRaw = (actAndDiff >>> 3) & 0x03
const difficulty = (diffRaw <= 2 ? diffRaw : 0) as 0 | 1 | 2
const difficulty: 0 | 1 | 2 = expansion
? progression >= 10
? 2
: progression >= 5
? 1
: 0
: progression >= 8
? 2
: progression >= 4
? 1
: 0
return {
name,
@ -112,10 +127,11 @@ export function parseMcpCharStatString(
level,
hardcore: (flags & McpCharFlag.HARDCORE) !== 0,
dead: (flags & McpCharFlag.DEAD) !== 0,
expansion: (flags & McpCharFlag.EXPANSION) !== 0,
expansion,
ladder: (flags & McpCharFlag.LADDER) !== 0,
act,
difficulty,
progression,
expiresAt,
equipmentGraphics,
rawStatString: statBytes.slice(),

View File

@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest'
import {
CAMPFIRE_CLASS_ORDER,
canSubmitCreateGame,
cancelConnecting,
charSummaryToRosterCharacter,
clickBattleNetButton,
confirmCharacterSelect,
createInitialFlowState,
enterCharCreate,
getCharacterMaxDifficulty,
hoverCampfireClass,
isDifficultyUnlocked,
isValidCharacterName,
selectCampfireClass,
stepFlowTick,
@ -15,6 +19,8 @@ import {
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', () => {
@ -30,6 +36,9 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
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)
@ -91,6 +100,7 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
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)
@ -99,6 +109,10 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
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)
@ -108,10 +122,16 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(state.gemActivated).toBe(false)
expect(state.chatMessages.at(-1)).toBe('Gem Deactivated')
// Stage 5: Configure room and click CREATE GAME -> transitions to Stage 6 (Door Opening Loading)
// 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)
@ -132,6 +152,135 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
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)