fix(lobby): restore 1.13c Create Game empty fields, progression difficulty lock, and editable maxPlayers/charDifference
This commit is contained in:
parent
309738cf79
commit
b886e1c3cf
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ''
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue