From 33b1b0afcee9603b023243dfe4d8d68e5ad9f1c3 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 02:57:47 +0000 Subject: [PATCH] fix(client): exit Escape menu Save and Exit Game to Battle.net Lobby instead of login --- src/client/main.ts | 16 +++++- src/client/session/online-session.ts | 12 ++++- tests/client/escape-menu-113c.test.ts | 75 +++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/client/main.ts b/src/client/main.ts index 3b9faba..36e9dde 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -222,7 +222,12 @@ async function bootPlayApp(): Promise { onPanAutomap: (dx, dy) => minimap.pan(dx, dy), onEscapeMenuAction: (action) => { 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 { const returnToLobby = (): void => { inGameView = false + flowState.sceneReady = false flowState.stage = 'bnet_lobby' + flowState.lobbySubPanel = 'create' + flowState.createGameError = null + flowState.activeInput = 'gameName' toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs }) syncVisibility() + frontendRenderer.render(performance.now()) } frontendRenderer.setAsyncHandlers({ @@ -717,6 +727,10 @@ async function bootPlayApp(): Promise { flowState.stage = 'in_game' toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs }) } else if (snap.phase === 'lobby') { + if (inGameView || flowState.stage === 'in_game') { + returnToLobby() + return + } toolbar.setConnectionState({ phase: 'mcp', rttMs: snap.pingMs }) } else if (snap.phase === 'connecting') { toolbar.setConnectionState({ phase: 'connecting', rttMs: null }) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 81ba070..b448633 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -337,7 +337,7 @@ export class OnlineSession { if (!this.hudManager.onEscapeMenuAction) { this.hudManager.onEscapeMenuAction = (action) => { 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 { return await this.lobbyOp('enterGame', 'mcp', async (flow) => { 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 { let adapter: GameServerAdapter if (request.mode === 'create') { @@ -735,6 +743,8 @@ export class OnlineSession { async leaveGame(): Promise { const flow = this.flow this.detachGameServerListeners() + this.hudModel.closeAllPanels() + this.hudManager.closeAllPanels() if (flow && flow.state === 'ingame') { try { await flow.leaveToLobby() diff --git a/tests/client/escape-menu-113c.test.ts b/tests/client/escape-menu-113c.test.ts index c9bfb0a..ad5ade9 100644 --- a/tests/client/escape-menu-113c.test.ts +++ b/tests/client/escape-menu-113c.test.ts @@ -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(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') + }) })