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 isExpansion: boolean
readonly isHardcore: boolean readonly isHardcore: boolean
readonly isLadder: 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 readonly createdAt: number
} }
@ -67,6 +71,7 @@ export type ActiveInputField =
| 'gamePassword' | 'gamePassword'
| 'gameDescription' | 'gameDescription'
| 'maxPlayers' | 'maxPlayers'
| 'charDifference'
| 'chatInput' | 'chatInput'
| null | null
@ -279,9 +284,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
'You have entered chat channel: Diablo II Retail_USA-1', 'You have entered chat channel: Diablo II Retail_USA-1',
], ],
chatInput: '', chatInput: '',
gameName: 'RogueCamp1', gameName: '',
gamePassword: '', gamePassword: '',
gameDescription: 'Act 1 Rogue Encampment', gameDescription: '',
maxPlayers: '8', maxPlayers: '8',
charDifferenceEnabled: false, charDifferenceEnabled: false,
charDifference: '99', charDifference: '99',
@ -450,6 +455,8 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n
isExpansion: state.isExpansion, isExpansion: state.isExpansion,
isHardcore: state.isHardcore, isHardcore: state.isHardcore,
isLadder: state.isLadder, isLadder: state.isLadder,
progression: 0,
maxDifficulty: 'normal',
createdAt: Date.now(), createdAt: Date.now(),
} }
state.roster = [newChar, ...state.roster].slice(0, 8) state.roster = [newChar, ...state.roster].slice(0, 8)
@ -460,9 +467,65 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n
return newChar 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): * Confirm Character Selection (`OK` button or double-click on Stage 4):
* Transitions to Stage 5 (`bnet_lobby`) with the Create Game sub-panel open. * 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 { export function confirmCharacterSelect(state: BnetFlowState): boolean {
if (state.stage !== 'char_select') return false if (state.stage !== 'char_select') return false
@ -472,8 +535,8 @@ export function confirmCharacterSelect(state: BnetFlowState): boolean {
state.stageTicks = 0 state.stageTicks = 0
state.lobbySubPanel = 'create' state.lobbySubPanel = 'create'
state.createGameError = null state.createGameError = null
if (!state.gameName.trim()) { if (!isDifficultyUnlocked(selected, state.difficulty)) {
state.gameName = `${selected.name}1`.slice(0, 15) state.difficulty = 'normal'
} }
state.activeInput = 'gameName' state.activeInput = 'gameName'
return true return true
@ -501,7 +564,26 @@ export function submitCreateGame(state: BnetFlowState): boolean {
state.createGameError = 'Please enter a valid Game Name (1-15 characters).' state.createGameError = 'Please enter a valid Game Name (1-15 characters).'
return false 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.gameName = trimmedGame
state.maxPlayers = String(maxP)
state.createGameError = null state.createGameError = null
state.stage = 'door_loading' state.stage = 'door_loading'
state.stageTicks = 0 state.stageTicks = 0
@ -590,10 +672,28 @@ export function charSummaryToRosterCharacter(summary: {
readonly expansion: boolean readonly expansion: boolean
readonly hardcore: boolean readonly hardcore: boolean
readonly ladder: boolean readonly ladder: boolean
readonly progression?: number | undefined
readonly difficulty?: 0 | 1 | 2 | undefined
}): RosterCharacter { }): RosterCharacter {
const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass] const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass]
if (!classId) throw new Error(`MCP character "${summary.name}" has unknown class id ${summary.charClass}`) if (!classId) throw new Error(`MCP character "${summary.name}" has unknown class id ${summary.charClass}`)
const spec = CAMPFIRE_CLASS_SPECS[classId] 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 { return {
id: `${spec.heroToken}-${summary.name}`, id: `${spec.heroToken}-${summary.name}`,
name: summary.name, name: summary.name,
@ -604,6 +704,8 @@ export function charSummaryToRosterCharacter(summary: {
isExpansion: summary.expansion, isExpansion: summary.expansion,
isHardcore: summary.hardcore, isHardcore: summary.hardcore,
isLadder: summary.ladder, isLadder: summary.ladder,
progression,
maxDifficulty,
createdAt: Date.now(), createdAt: Date.now(),
} }
} }
@ -617,6 +719,8 @@ export function applyOnlineCharactersToFlowState(
readonly expansion: boolean readonly expansion: boolean
readonly hardcore: boolean readonly hardcore: boolean
readonly ladder: boolean readonly ladder: boolean
readonly progression?: number | undefined
readonly difficulty?: 0 | 1 | 2 | undefined
}[], }[],
): void { ): void {
state.onlineConnected = true state.onlineConnected = true

View File

@ -15,11 +15,13 @@ import { CursorController } from '../ui/cursor.ts'
import type { FontAtlasMeta } from '../ui/font.ts' import type { FontAtlasMeta } from '../ui/font.ts'
import { import {
CAMPFIRE_CLASS_ORDER, CAMPFIRE_CLASS_ORDER,
canSubmitCreateGame,
cancelConnecting, cancelConnecting,
clickBattleNetButton, clickBattleNetButton,
confirmCharacterSelect, confirmCharacterSelect,
enterCharCreate, enterCharCreate,
hoverCampfireClass, hoverCampfireClass,
isDifficultyUnlocked,
selectCampfireClass, selectCampfireClass,
submitBattleNetLogin, submitBattleNetLogin,
submitCharacterCreate, submitCharacterCreate,
@ -972,6 +974,16 @@ export class FrontendRenderer {
sprite: 'btn-chatrighttop.png', sprite: 'btn-chatrighttop.png',
label: L.create, label: L.create,
onClick: () => { 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.lobbySubPanel = 'create'
this.state.activeInput = 'gameName' this.state.activeInput = 'gameName'
}, },
@ -1038,7 +1050,10 @@ export class FrontendRenderer {
onClick: () => { onClick: () => {
this.state.gameName = '' this.state.gameName = ''
this.state.gamePassword = '' this.state.gamePassword = ''
this.state.gameDescription = ''
this.state.selectedGameName = '' this.state.selectedGameName = ''
this.state.createGameError = null
this.state.activeInput = 'gameName'
}, },
}, },
{ {
@ -1049,6 +1064,10 @@ export class FrontendRenderer {
h: 32, h: 32,
sprite: 'btn-game.png', sprite: 'btn-game.png',
label: this.state.lobbySubPanel === 'join' ? L.join : L.createGameBtn, 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: () => { onClick: () => {
if (this.state.lobbySubPanel === 'join') { if (this.state.lobbySubPanel === 'join') {
this.triggerJoinGame() 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') const rbImg = this.images.get('radiobutton.png')
if (rbImg) { if (rbImg) {
const w = Math.floor(rbImg.width / 2) const w = Math.floor(rbImg.width / 2)
const h = rbImg.height const h = rbImg.height
this.ctx.drawImage(rbImg, selected ? w : 0, 0, w, h, x, y, w, h) 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, { this.drawBitmapText(label, x + 22, y + 1, {
font: 'font16', font: 'font16',
color: 4, color: disabled ? 5 : 4,
align: 'left', align: 'left',
}) })
} }
@ -1615,27 +1647,47 @@ export class FrontendRenderer {
color: 0, 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( this.drawCheckbox(
431, 431,
323, 323,
this.state.charDifferenceEnabled, this.state.charDifferenceEnabled,
L.charDifference, L.charDifference,
4, this.state.charDifferenceEnabled ? 4 : 5,
'joingameclickbox.png', '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', font: 'fontformal12',
color: this.state.charDifferenceEnabled ? 0 : 5, 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.drawBitmapText(L.selectDifficulty, 427, 350, { font: 'font16', color: 4, align: 'left' })
this.drawRadioButton(430, 368, this.state.difficulty === 'normal', L.normal) this.drawRadioButton(430, 368, this.state.difficulty === 'normal', L.normal, false)
this.drawRadioButton(555, 368, this.state.difficulty === 'nightmare', L.nightmare) this.drawRadioButton(
this.drawRadioButton(698, 368, this.state.difficulty === 'hell', L.hell) 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 const lobbyErr = this.state.createGameError || this.state.lastError
@ -1857,16 +1909,35 @@ export class FrontendRenderer {
this.state.activeInput = 'gameDescription' this.state.activeInput = 'gameDescription'
return 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' this.state.activeInput = 'maxPlayers'
return return
} }
// Character Difference checkbox // Character Difference checkbox (`x = 431..649, y = 315..346`)
if (x >= 431 && x <= 620 && y >= 322 && y <= 344) { if (x >= 431 && x <= 649 && y >= 315 && y <= 346) {
this.state.charDifferenceEnabled = !this.state.charDifferenceEnabled 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 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 }[] = [ const diffs: { id: GameDifficulty; x: number; w: number }[] = [
{ id: 'normal', x: 430, w: 95 }, { id: 'normal', x: 430, w: 95 },
{ id: 'nightmare', x: 555, w: 125 }, { id: 'nightmare', x: 555, w: 125 },
@ -1874,7 +1945,10 @@ export class FrontendRenderer {
] ]
for (const d of diffs) { for (const d of diffs) {
if (x >= d.x && x <= d.x + d.w && y >= 364 && y <= 388) { if (x >= d.x && x <= d.x + d.w && y >= 364 && y <= 388) {
this.state.difficulty = d.id if (isDifficultyUnlocked(selectedChar, d.id)) {
this.state.difficulty = d.id
this.state.createGameError = null
}
return return
} }
} }
@ -1952,13 +2026,17 @@ export class FrontendRenderer {
this.state.activeInput = this.state.activeInput =
this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName' this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName'
} else if (this.state.stage === 'bnet_lobby') { } else if (this.state.stage === 'bnet_lobby') {
const order: ActiveInputField[] = [ const order: ActiveInputField[] =
'gameName', this.state.lobbySubPanel === 'join'
'gamePassword', ? ['gameName', 'gamePassword', 'chatInput']
'gameDescription', : [
'maxPlayers', 'gameName',
'chatInput', 'gamePassword',
] 'gameDescription',
'maxPlayers',
...(this.state.charDifferenceEnabled ? (['charDifference'] as const) : []),
'chatInput',
]
const idx = order.indexOf(this.state.activeInput) const idx = order.indexOf(this.state.activeInput)
this.state.activeInput = order[(idx + 1) % order.length]! this.state.activeInput = order[(idx + 1) % order.length]!
} }
@ -1968,7 +2046,26 @@ export class FrontendRenderer {
const field = this.state.activeInput const field = this.state.activeInput
if (!field) return 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() e.preventDefault()
this.setFieldValue(field, this.getFieldValue(field).slice(0, -1)) this.setFieldValue(field, this.getFieldValue(field).slice(0, -1))
return return
@ -1989,7 +2086,21 @@ export class FrontendRenderer {
if (/^[1-8]$/.test(ch)) { if (/^[1-8]$/.test(ch)) {
this.setFieldValue(field, 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) { if (cur.length < 38) {
this.setFieldValue(field, cur + ch) this.setFieldValue(field, cur + ch)
} }
@ -2013,6 +2124,8 @@ export class FrontendRenderer {
return this.state.gameDescription return this.state.gameDescription
case 'maxPlayers': case 'maxPlayers':
return this.state.maxPlayers return this.state.maxPlayers
case 'charDifference':
return this.state.charDifference
case 'chatInput': case 'chatInput':
return this.state.chatInput return this.state.chatInput
} }
@ -2042,6 +2155,11 @@ export class FrontendRenderer {
break break
case 'maxPlayers': case 'maxPlayers':
this.state.maxPlayers = val this.state.maxPlayers = val
this.state.createGameError = null
break
case 'charDifference':
this.state.charDifference = val
this.state.createGameError = null
break break
case 'chatInput': case 'chatInput':
this.state.chatInput = val this.state.chatInput = val

View File

@ -15,6 +15,7 @@
import { import {
applyOnlineCharactersToFlowState, applyOnlineCharactersToFlowState,
createInitialFlowState, createInitialFlowState,
isDifficultyUnlocked,
returnToLoginAfterSessionLoss, returnToLoginAfterSessionLoss,
stepFlowTick, stepFlowTick,
type BnetFlowState, type BnetFlowState,
@ -562,6 +563,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
st.statusText = '' st.statusText = ''
st.stage = 'bnet_lobby' st.stage = 'bnet_lobby'
st.lobbySubPanel = 'create' st.lobbySubPanel = 'create'
st.createGameError = null
if (!isDifficultyUnlocked(selected, st.difficulty)) {
st.difficulty = 'normal'
}
st.activeInput = 'gameName' st.activeInput = 'gameName'
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return if (err instanceof SessionLostError) return
@ -587,7 +592,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
})) }))
if (st.realmGames[0] && !st.selectedGameName) { if (st.realmGames[0] && !st.selectedGameName) {
st.selectedGameName = st.realmGames[0].name st.selectedGameName = st.realmGames[0].name
st.gameName = st.realmGames[0].name if (st.lobbySubPanel === 'join') {
st.gameName = st.realmGames[0].name
}
} }
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
@ -605,6 +612,28 @@ async function bootPlayApp(): Promise<PlayAppController> {
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
return 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 = const diffNum: 0 | 1 | 2 =
st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0 st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0
st.stage = 'door_loading' st.stage = 'door_loading'
@ -622,7 +651,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
password: st.gamePassword, password: st.gamePassword,
description: st.gameDescription, description: st.gameDescription,
difficulty: diffNum, difficulty: diffNum,
maxPlayers: Math.max(1, Math.min(8, Number.parseInt(st.maxPlayers, 10) || 8)), maxPlayers,
levelDiff,
}, },
}) })
st.statusText = '' st.statusText = ''

View File

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

View File

@ -101,10 +101,25 @@ export function parseMcpCharStatString(
const rawLevel = isFull33 ? statBytes[25]! : statBytes.length > 12 ? statBytes[12]! : 1 const rawLevel = isFull33 ? statBytes[25]! : statBytes.length > 12 ? statBytes[12]! : 1
const level = rawLevel === 0xff || rawLevel === 0 ? 1 : rawLevel const level = rawLevel === 0xff || rawLevel === 0 ? 1 : rawLevel
const flags = isFull33 ? statBytes[26]! : statBytes.length > 13 ? statBytes[13]! : McpCharFlag.EXPANSION 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 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 act = ((actAndDiff & 0x07) % 5) + 1
const diffRaw = (actAndDiff >>> 3) & 0x03 const difficulty: 0 | 1 | 2 = expansion
const difficulty = (diffRaw <= 2 ? diffRaw : 0) as 0 | 1 | 2 ? progression >= 10
? 2
: progression >= 5
? 1
: 0
: progression >= 8
? 2
: progression >= 4
? 1
: 0
return { return {
name, name,
@ -112,10 +127,11 @@ export function parseMcpCharStatString(
level, level,
hardcore: (flags & McpCharFlag.HARDCORE) !== 0, hardcore: (flags & McpCharFlag.HARDCORE) !== 0,
dead: (flags & McpCharFlag.DEAD) !== 0, dead: (flags & McpCharFlag.DEAD) !== 0,
expansion: (flags & McpCharFlag.EXPANSION) !== 0, expansion,
ladder: (flags & McpCharFlag.LADDER) !== 0, ladder: (flags & McpCharFlag.LADDER) !== 0,
act, act,
difficulty, difficulty,
progression,
expiresAt, expiresAt,
equipmentGraphics, equipmentGraphics,
rawStatString: statBytes.slice(), rawStatString: statBytes.slice(),

View File

@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
CAMPFIRE_CLASS_ORDER, CAMPFIRE_CLASS_ORDER,
canSubmitCreateGame,
cancelConnecting, cancelConnecting,
charSummaryToRosterCharacter,
clickBattleNetButton, clickBattleNetButton,
confirmCharacterSelect, confirmCharacterSelect,
createInitialFlowState, createInitialFlowState,
enterCharCreate, enterCharCreate,
getCharacterMaxDifficulty,
hoverCampfireClass, hoverCampfireClass,
isDifficultyUnlocked,
isValidCharacterName, isValidCharacterName,
selectCampfireClass, selectCampfireClass,
stepFlowTick, stepFlowTick,
@ -15,6 +19,8 @@ import {
submitCreateGame, submitCreateGame,
toggleChatGem, toggleChatGem,
} from '../../src/client/frontend/flow-state.ts' } 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', () => { describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
it('validates character names according to D2Launch.dll rules', () => { 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', () => { it('walks through all 7 stages from Main Menu to Act 1 Rogue Encampment', () => {
const state = createInitialFlowState() const state = createInitialFlowState()
expect(state.stage).toBe('main_menu') 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 // Stage 1 -> Stage 2 Connecting Modal
clickBattleNetButton(state) clickBattleNetButton(state)
@ -91,6 +100,7 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(created).not.toBeNull() expect(created).not.toBeNull()
expect(created?.name).toBe('XulNecro') expect(created?.name).toBe('XulNecro')
expect(created?.heroToken).toBe('ne') expect(created?.heroToken).toBe('ne')
expect(created?.progression).toBe(0)
expect(state.stage).toBe('char_select') expect(state.stage).toBe('char_select')
expect(state.roster).toHaveLength(1) expect(state.roster).toHaveLength(1)
expect(state.selectedSlotIndex).toBe(0) 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(confirmCharacterSelect(state)).toBe(true)
expect(state.stage).toBe('bnet_lobby') expect(state.stage).toBe('bnet_lobby')
expect(state.lobbySubPanel).toBe('create') 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 // Toggle Chat Gem in Lobby
toggleChatGem(state) 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.gemActivated).toBe(false)
expect(state.chatMessages.at(-1)).toBe('Gem Deactivated') 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.gameName = 'Sanctuary1'
state.gamePassword = '123' state.gamePassword = '123'
state.difficulty = 'nightmare' 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(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading') expect(state.stage).toBe('door_loading')
expect(state.doorFrame).toBe(0) 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') 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"', () => { it('maps Assassin campfire class id "as" to in-game hero token "ai"', () => {
const state = createInitialFlowState() const state = createInitialFlowState()
enterCharCreate(state) enterCharCreate(state)