From b886e1c3cffe54531a6a7564b4f5126c9eff74ef Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 13:45:53 +0000 Subject: [PATCH] fix(lobby): restore 1.13c Create Game empty fields, progression difficulty lock, and editable maxPlayers/charDifference --- src/client/frontend/flow-state.ts | 112 ++++++++++++++- src/client/frontend/frontend-renderer.ts | 170 +++++++++++++++++++---- src/client/main.ts | 34 ++++- src/netproto/domain/lobby.ts | 1 + src/netproto/mcp/packets.ts | 22 ++- tests/frontend/flow-state.test.ts | 151 +++++++++++++++++++- 6 files changed, 454 insertions(+), 36 deletions(-) diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index 215f226..9b57008 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -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 | 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 | 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 diff --git a/src/client/frontend/frontend-renderer.ts b/src/client/frontend/frontend-renderer.ts index a0c68ea..3651011 100644 --- a/src/client/frontend/frontend-renderer.ts +++ b/src/client/frontend/frontend-renderer.ts @@ -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 - 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, { 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) { - this.state.difficulty = d.id + if (isDifficultyUnlocked(selectedChar, d.id)) { + this.state.difficulty = d.id + this.state.createGameError = null + } return } } @@ -1952,13 +2026,17 @@ export class FrontendRenderer { this.state.activeInput = this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName' } else if (this.state.stage === 'bnet_lobby') { - const order: ActiveInputField[] = [ - 'gameName', - 'gamePassword', - 'gameDescription', - 'maxPlayers', - 'chatInput', - ] + 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) this.state.activeInput = order[(idx + 1) % order.length]! } @@ -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 diff --git a/src/client/main.ts b/src/client/main.ts index 5a13693..4c9ccbc 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -15,6 +15,7 @@ import { applyOnlineCharactersToFlowState, createInitialFlowState, + isDifficultyUnlocked, returnToLoginAfterSessionLoss, stepFlowTick, type BnetFlowState, @@ -562,6 +563,10 @@ async function bootPlayApp(): Promise { 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,7 +592,9 @@ async function bootPlayApp(): Promise { })) if (st.realmGames[0] && !st.selectedGameName) { st.selectedGameName = st.realmGames[0].name - st.gameName = st.realmGames[0].name + if (st.lobbySubPanel === 'join') { + st.gameName = st.realmGames[0].name + } } st.statusText = '' } catch (err) { @@ -605,6 +612,28 @@ async function bootPlayApp(): Promise { 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 { 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 = '' diff --git a/src/netproto/domain/lobby.ts b/src/netproto/domain/lobby.ts index f084653..182575e 100644 --- a/src/netproto/domain/lobby.ts +++ b/src/netproto/domain/lobby.ts @@ -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 diff --git a/src/netproto/mcp/packets.ts b/src/netproto/mcp/packets.ts index f4160f2..b2a1d9d 100644 --- a/src/netproto/mcp/packets.ts +++ b/src/netproto/mcp/packets.ts @@ -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(), diff --git a/tests/frontend/flow-state.test.ts b/tests/frontend/flow-state.test.ts index 9566640..ad654b8 100644 --- a/tests/frontend/flow-state.test.ts +++ b/tests/frontend/flow-state.test.ts @@ -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)