Merge branch 'fix/lobby-create-game'
This commit is contained in:
commit
40a3a12e67
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import {
|
||||
applyOnlineCharactersToFlowState,
|
||||
createInitialFlowState,
|
||||
isDifficultyUnlocked,
|
||||
returnToLoginAfterSessionLoss,
|
||||
stepFlowTick,
|
||||
type BnetFlowState,
|
||||
|
|
@ -578,6 +579,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
|
||||
|
|
@ -603,8 +608,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
|
||||
|
|
@ -621,6 +628,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'
|
||||
|
|
@ -638,7 +667,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 = ''
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue