fix(client): exit Escape menu Save and Exit Game to Battle.net Lobby instead of login

This commit is contained in:
troytt 2026-10-02 02:57:47 +00:00
parent c9ddd1d2e3
commit 33b1b0afce
3 changed files with 101 additions and 2 deletions

View File

@ -222,7 +222,12 @@ async function bootPlayApp(): Promise<PlayAppController> {
onPanAutomap: (dx, dy) => minimap.pan(dx, dy), onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
onEscapeMenuAction: (action) => { onEscapeMenuAction: (action) => {
if (action === 'saveAndExit') { if (action === 'saveAndExit') {
void sessionRef?.disconnect('Saved and exited the game; Battle.net session closed') if (!chatInputEl.hidden) {
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
}
void sessionRef?.leaveGame()
} }
}, },
}) })
@ -442,9 +447,14 @@ async function bootPlayApp(): Promise<PlayAppController> {
const returnToLobby = (): void => { const returnToLobby = (): void => {
inGameView = false inGameView = false
flowState.sceneReady = false
flowState.stage = 'bnet_lobby' flowState.stage = 'bnet_lobby'
flowState.lobbySubPanel = 'create'
flowState.createGameError = null
flowState.activeInput = 'gameName'
toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs }) toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs })
syncVisibility() syncVisibility()
frontendRenderer.render(performance.now())
} }
frontendRenderer.setAsyncHandlers({ frontendRenderer.setAsyncHandlers({
@ -717,6 +727,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
flowState.stage = 'in_game' flowState.stage = 'in_game'
toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs }) toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs })
} else if (snap.phase === 'lobby') { } else if (snap.phase === 'lobby') {
if (inGameView || flowState.stage === 'in_game') {
returnToLobby()
return
}
toolbar.setConnectionState({ phase: 'mcp', rttMs: snap.pingMs }) toolbar.setConnectionState({ phase: 'mcp', rttMs: snap.pingMs })
} else if (snap.phase === 'connecting') { } else if (snap.phase === 'connecting') {
toolbar.setConnectionState({ phase: 'connecting', rttMs: null }) toolbar.setConnectionState({ phase: 'connecting', rttMs: null })

View File

@ -337,7 +337,7 @@ export class OnlineSession {
if (!this.hudManager.onEscapeMenuAction) { if (!this.hudManager.onEscapeMenuAction) {
this.hudManager.onEscapeMenuAction = (action) => { this.hudManager.onEscapeMenuAction = (action) => {
if (action === 'saveAndExit') { if (action === 'saveAndExit') {
void this.disconnect('Saved and exited the game; Battle.net session closed') void this.leaveGame()
} }
} }
} }
@ -664,6 +664,14 @@ export class OnlineSession {
): Promise<GameServerAdapter> { ): Promise<GameServerAdapter> {
return await this.lobbyOp('enterGame', 'mcp', async (flow) => { return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
this.setPhase('entering-game') this.setPhase('entering-game')
const savedName = this.world.self.name
const savedClass = this.world.self.charClass
this.world.reset()
this.world.self.name = savedName
this.world.self.charClass = savedClass
this.predictor.setPosition(0, 0)
this.interpolator.clear()
this.hudModel.syncFromWorld()
try { try {
let adapter: GameServerAdapter let adapter: GameServerAdapter
if (request.mode === 'create') { if (request.mode === 'create') {
@ -735,6 +743,8 @@ export class OnlineSession {
async leaveGame(): Promise<void> { async leaveGame(): Promise<void> {
const flow = this.flow const flow = this.flow
this.detachGameServerListeners() this.detachGameServerListeners()
this.hudModel.closeAllPanels()
this.hudManager.closeAllPanels()
if (flow && flow.state === 'ingame') { if (flow && flow.state === 'ingame') {
try { try {
await flow.leaveToLobby() await flow.leaveToLobby()

View File

@ -282,4 +282,79 @@ describe('Diablo II v1.13c In-Game Escape Menu (D2Client.dll gmenu.cpp 0x6fb13b0
expect(actions).toEqual(['options', 'returnToGame', 'saveAndExit']) expect(actions).toEqual(['options', 'returnToGame', 'saveAndExit'])
expect(hudManager.escapeMenuOpen).toBe(false) expect(hudManager.escapeMenuOpen).toBe(false)
}) })
it('Save and Exit Game leaves D2GS to Battle.net Lobby (phase="lobby") without tearing down BNCS/MCP session', async () => {
const { OnlineSession } = await import('../../src/client/session/online-session.ts')
let flowState: 'idle' | 'realm_ready' | 'ingame' | 'closed' = 'idle'
let leaveToLobbyCount = 0
let closeCount = 0
const session = new OnlineSession({
flowFactory: () =>
({
get state() {
return flowState
},
get sessionLost() {
return null
},
onState: () => () => {},
onSessionLost: () => () => {},
login: async () => {},
listRealms: async () => [{ index: 0, title: 'D2CS', description: 'Realm' }],
enterRealm: async () => [
{
name: 'HeroSorc',
charClass: 1,
level: 1,
flags: 0x20,
act: 1,
expiresAt: 0,
rawAppearance: new Uint8Array(33),
},
],
selectCharacter: async () => {},
createGame: async () => {},
joinGame: async () => {
flowState = 'ingame'
return {
state: 'ingame',
stats: { packetsIn: 0, packetsOut: 0, unknownPackets: 0, opaquePackets: 0, protocolErrors: 0, rttMs: 10 },
send: () => {},
leave: () => {},
onEvent: () => () => {},
onClose: () => () => {},
} as any
},
leaveToLobby: async () => {
leaveToLobbyCount += 1
flowState = 'realm_ready'
},
close: async () => {
closeCount += 1
flowState = 'closed'
},
}) as any,
})
const sessionLostEvents: unknown[] = []
session.onSessionLost((ev) => sessionLostEvents.push(ev))
await session.login({ username: 'acc', password: 'pw' })
await session.selectCharacter('HeroSorc')
await session.enterGame({ mode: 'create', spec: { name: 'g1', difficulty: 0 } })
expect(session.phase).toBe('ingame')
// Open Escape menu and activate row 1 (Save and Exit Game)
session.hudModel.togglePanel('escapeMenu')
expect(session.hudManager.escapeMenuOpen).toBe(true)
session.hudManager.activateEscapeMenuIndex(1)
await new Promise((r) => setTimeout(r, 0))
expect(session.hudManager.escapeMenuOpen).toBe(false)
expect(leaveToLobbyCount).toBe(1)
expect(closeCount).toBe(0)
expect(sessionLostEvents).toHaveLength(0)
expect(session.phase).toBe('lobby')
})
}) })