From e623e992dc76dfe473055b4bb107e516fd178a60 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 12:01:47 +0000 Subject: [PATCH] fix(client): return to the login screen with a 1.13c reason on session loss OnlineSession duck-typed a flow onError hook that does not exist, so pre-game losses never propagated; a failed login left a half-dead flow in place, and main.ts only routed in-game disconnects (to a dead lobby). Concurrent login attempts (LOG IN, ACCOUNT SETTINGS and Enter all call onLogin) replaced each other's flow, leaving char_create bound to a flow without MCP. - OnlineSession.handleSessionLost is the single, idempotent end of a session: disposes the game adapter and the flow (all sockets/timers), records the reason, enters `disconnected` and notifies onSessionLost/onDisconnect once. Flow loss events, D2GS adapter close, in-game client errors and disconnect() all route through it. - Pre-game operations (create/delete/convert/select character, list games, enter game, lobby chat) run via lobbyOp: no session -> not_connected loss, failure caused by the loss -> SessionLostError; status errors stay on screen. - Reasons use ENG 1.13c tbl text plus the technical detail: string#5352 (BNCS lost), string#5357 (realm/game interrupted), string#5349 (timeout), string#5353 / patchstring#1066 (connect failed), string#5351 (not connected). - main.ts: one onSessionLost handler -> returnToLoginAfterSessionLoss (keeps the typed account name, clears roster/game state); catch blocks ignore SessionLostError; onLogin allows one attempt at a time; no bnet_lobby routing for dead sessions. The login screen wraps long reasons. TAG=agy CONV=109a3012-103a-42f0-8ae1-bbc0857047ef --- src/client/frontend/flow-state.ts | 25 ++ src/client/frontend/frontend-renderer.ts | 40 +- src/client/main.ts | 45 ++- src/client/session/online-session.ts | 470 ++++++++++++++--------- tests/client/session-lost-login.test.ts | 254 ++++++++++++ 5 files changed, 631 insertions(+), 203 deletions(-) create mode 100644 tests/client/session-lost-login.test.ts diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index 56503de..215f226 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -631,3 +631,28 @@ export function applyOnlineCharactersToFlowState( } } +/** + * Battle.net session loss on any pre-game or in-game screen: return to the Battle.net login screen + * with the reason shown (1.13c D2Launch tears the realm/BNCS connections down and re-shows the + * login panel with the connection-lost message). Everything that belonged to the lost session + * (roster, game list, pending errors) is dropped; the typed account name/password stay so the user + * can log in again explicitly — nothing reconnects on its own. + */ +export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: string): void { + state.stage = 'bnet_login' + state.stageTicks = 0 + state.onlineConnected = false + state.loginError = reason + state.lastError = reason + state.statusText = '' + state.roster = [] + state.selectedSlotIndex = 0 + state.deleteConfirmOpen = false + state.charCreateError = null + state.createGameError = null + state.realmGames = [] + state.selectedGameName = '' + state.doorFrame = 0 + state.sceneReady = false + state.activeInput = 'accountPassword' +} diff --git a/src/client/frontend/frontend-renderer.ts b/src/client/frontend/frontend-renderer.ts index 895af39..a0c68ea 100644 --- a/src/client/frontend/frontend-renderer.ts +++ b/src/client/frontend/frontend-renderer.ts @@ -410,6 +410,8 @@ export class FrontendRenderer { color?: FrontendColorIndex align?: 'left' | 'center' lineStep?: number + /** Keep at most this many lines; the last kept line ends with '...' when text was cut. */ + maxLines?: number } = {}, ): void { const font = options.font ?? 'font16' @@ -426,6 +428,10 @@ export class FrontendRenderer { } } if (current) lines.push(current) + if (options.maxLines !== undefined && lines.length > options.maxLines) { + lines.length = Math.max(1, options.maxLines) + lines[lines.length - 1] = `${lines[lines.length - 1]!} ...` + } const meta = this.fontMetas[font] const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3) @@ -1201,12 +1207,25 @@ export class FrontendRenderer { this.drawBurningMainLogo() const s = this.manifest!.strings - this.drawBitmapText( - 'Enter your Account Name and Password to log in to Battle.net.', - 400, - 252, - { font: 'fontformal12', color: 0, align: 'center' }, - ) + const loginErr = this.state.loginError || this.state.lastError + if (loginErr) { + // Session-loss reasons (tbl text + technical detail) can be long: wrap them in the space + // between the logo and the Account Name label instead of the instruction line. + this.drawWrappedBitmapText(loginErr, 400, 234, 700, { + font: 'fontformal11', + color: 1, + align: 'center', + lineStep: 14, + maxLines: 4, + }) + } else { + this.drawBitmapText( + 'Enter your Account Name and Password to log in to Battle.net.', + 400, + 252, + { font: 'fontformal12', color: 0, align: 'center' }, + ) + } // Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0) this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' }) @@ -1225,14 +1244,7 @@ export class FrontendRenderer { color: 0, }) - const loginErr = this.state.loginError || this.state.lastError - if (loginErr) { - this.drawBitmapText(loginErr, 400, 274, { - font: 'fontformal11', - color: 1, - align: 'center', - }) - } else if (this.state.statusText) { + if (!loginErr && this.state.statusText) { this.drawBitmapText(this.state.statusText, 400, 274, { font: 'fontformal11', color: 4, diff --git a/src/client/main.ts b/src/client/main.ts index 69becb2..5a13693 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -15,6 +15,7 @@ import { applyOnlineCharactersToFlowState, createInitialFlowState, + returnToLoginAfterSessionLoss, stepFlowTick, type BnetFlowState, } from './frontend/flow-state.ts' @@ -50,7 +51,7 @@ import { Minimap } from './ui/minimap.ts' import { CommandMapper } from './input/command-mapper.ts' import { PacketInspector } from './inspector/packet-inspector.ts' import { MapService } from './map/map-service.ts' -import { OnlineSession } from './session/online-session.ts' +import { OnlineSession, SessionLostError } from './session/online-session.ts' import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts' import { getAssetCacheStatus, @@ -411,6 +412,8 @@ async function bootPlayApp(): Promise { const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState) let inGameView = false + /** One login attempt at a time: concurrent attempts used to replace each other's live session. */ + let loginInFlight = false const syncVisibility = (): void => { bnetShell.style.display = inGameView ? 'none' : 'flex' @@ -447,6 +450,8 @@ async function bootPlayApp(): Promise { frontendRenderer.render(performance.now()) return } + if (loginInFlight) return + loginInFlight = true st.statusText = createNewAccount ? 'CREATING ACCOUNT & LOGGING IN...' : 'AUTHENTICATING WITH BATTLE.NET & MCP...' @@ -464,9 +469,14 @@ async function bootPlayApp(): Promise { applyOnlineCharactersToFlowState(st, res.characters) st.statusText = '' } catch (err) { - st.statusText = '' - st.loginError = err instanceof Error ? err.message : String(err) - st.lastError = st.loginError + // A lost session was already routed to the login screen by `onSessionLost`. + if (!(err instanceof SessionLostError)) { + st.statusText = '' + st.loginError = err instanceof Error ? err.message : String(err) + st.lastError = st.loginError + } + } finally { + loginInFlight = false } frontendRenderer.render(performance.now()) }, @@ -504,6 +514,7 @@ async function bootPlayApp(): Promise { applyOnlineCharactersToFlowState(st, chars) st.statusText = '' } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.charCreateError = err instanceof Error ? err.message : String(err) st.lastError = st.charCreateError @@ -519,6 +530,7 @@ async function bootPlayApp(): Promise { applyOnlineCharactersToFlowState(st, chars) st.statusText = '' } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.lastError = err instanceof Error ? err.message : String(err) } @@ -533,6 +545,7 @@ async function bootPlayApp(): Promise { applyOnlineCharactersToFlowState(st, chars) st.statusText = '' } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.lastError = err instanceof Error ? err.message : String(err) } @@ -551,6 +564,7 @@ async function bootPlayApp(): Promise { st.lobbySubPanel = 'create' st.activeInput = 'gameName' } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.lastError = err instanceof Error ? err.message : String(err) } @@ -577,6 +591,7 @@ async function bootPlayApp(): Promise { } st.statusText = '' } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.lastError = err instanceof Error ? err.message : String(err) } @@ -616,6 +631,7 @@ async function bootPlayApp(): Promise { inGameView = true syncVisibility() } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.stage = 'bnet_lobby' st.createGameError = err instanceof Error ? err.message : String(err) @@ -643,6 +659,7 @@ async function bootPlayApp(): Promise { inGameView = true syncVisibility() } catch (err) { + if (err instanceof SessionLostError) return st.statusText = '' st.stage = 'bnet_lobby' st.createGameError = err instanceof Error ? err.message : String(err) @@ -654,6 +671,7 @@ async function bootPlayApp(): Promise { try { await session.sendLobbyChat(text) } catch (err) { + if (err instanceof SessionLostError) return st.lastError = err instanceof Error ? err.message : String(err) } frontendRenderer.render(performance.now()) @@ -679,10 +697,6 @@ async function bootPlayApp(): Promise { } else if (snap.phase === 'connecting') { toolbar.setConnectionState({ phase: 'connecting', rttMs: null }) } else if (snap.phase === 'disconnected' || snap.phase === 'error') { - if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') { - inGameView = false - flowState.stage = 'bnet_lobby' - } toolbar.setConnectionState({ phase: snap.disconnectReason ? 'error' : 'disconnected', rttMs: null, @@ -693,18 +707,19 @@ async function bootPlayApp(): Promise { syncVisibility() }) - session.onDisconnect((reason) => { - if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') { - inGameView = false - flowState.stage = 'bnet_lobby' - } + session.onSessionLost((ev) => { + // Single handler for BNCS/MCP/D2GS loss on every screen: the session already disposed all + // connections; show the login screen with the reason (no automatic reconnect). + inGameView = false + returnToLoginAfterSessionLoss(flowState, ev.reason) toolbar.setConnectionState({ phase: 'error', rttMs: null, diagnosticKind: 'custom', - diagnosticMessage: reason, + diagnosticMessage: ev.reason, }) syncVisibility() + frontendRenderer.render(performance.now()) }) // Bind mouse & keyboard input @@ -747,7 +762,7 @@ async function bootPlayApp(): Promise { if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) { if (pt.y >= boxY + 70 && pt.y <= boxY + 108) { session.hudModel.togglePanel('escapeMenu') - void session.disconnect('Returned to Battle.net Lobby') + void session.disconnect('Saved and exited the game; Battle.net session closed') return } if (pt.y >= boxY + 115 && pt.y <= boxY + 155) { diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index d3f1c2a..b0572f7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -22,7 +22,6 @@ import { createWsBridgeResolver, decodeD2gsS2cPacket, unitTypeToId, - type ByteStream, type CharClassId, type CharCreateSpec, type CharSummary, @@ -30,6 +29,9 @@ import { type CloseReason, type D2OnlineConfig, type D2OnlineFlow, + type D2OnlineSessionKind, + type D2OnlineSessionLost, + type D2OnlineSessionLostCause, type D2OnlineState, type EndpointResolver, type GameCreateSpec, @@ -40,6 +42,7 @@ import { type ServerEvent, type UnitType, } from '../../netproto/index.ts' +import { requireTbl } from '../i18n/lang.ts' import { HudManager } from '../ui/hud-manager.ts' import { SoundService } from '../audio/sound-service.ts' import { AutomapView, getActForArea } from '../automap/automap-view.ts' @@ -121,6 +124,75 @@ export interface OnlineSessionOptions { readonly onEscapeMenu?: (() => void) | undefined } +/** Which connection ended the session (`client` = ended on purpose by this client). */ +export type OnlineSessionLostSource = D2OnlineSessionKind | 'client' +export type OnlineSessionLostCause = D2OnlineSessionLostCause | 'local' + +export interface OnlineSessionLostEvent { + /** User-facing reason (1.13c tbl text + technical detail), shown on the login screen. */ + readonly reason: string + readonly session: OnlineSessionLostSource + readonly cause: OnlineSessionLostCause + /** Technical description (close code/reason, timed-out request, missing session, ...). */ + readonly technical: string +} + +/** + * Thrown by `OnlineSession` operations once the Battle.net session is gone. The loss itself has + * already been handled centrally (connections disposed, `onSessionLost` notified), so UI callers + * must not show it as an operation error on the current screen. + */ +export class SessionLostError extends Error { + override readonly name = 'SessionLostError' + constructor(readonly event: OnlineSessionLostEvent) { + super(event.reason) + } +} + +/** + * 1.13c tbl string for a session loss (ENG: the frontend bitmap fonts are byte glyphs): + * - `string#5352` "Your connection to Battle.net has been lost." + * - `string#5357` "Your connection has been interrupted" (realm / game server) + * - `string#5349` "Timed out waiting for response." + * - `string#5353` "Battle.net is not responding. Please try connecting again in a few minutes." + * - `patchstring#1066` "Diablo II was unable to connect to the realm server. ..." + * - `string#5351` "Lost Connection to battle.net." + * Returns `null` when no 1.13c string matches; the technical reason is then shown alone. + */ +export function sessionLostTblKey( + session: OnlineSessionLostSource, + cause: OnlineSessionLostCause, +): string | null { + if (session === 'client' || cause === 'local') return null + switch (cause) { + case 'timeout': + return 'string#5349' + case 'not_connected': + return 'string#5351' + case 'connect_failed': + return session === 'bncs' ? 'string#5353' : session === 'mcp' ? 'patchstring#1066' : null + case 'closed': + case 'error': + return session === 'bncs' ? 'string#5352' : 'string#5357' + } +} + +export function formatSessionLostReason( + session: OnlineSessionLostSource, + cause: OnlineSessionLostCause, + technical: string, +): string { + const key = sessionLostTblKey(session, cause) + if (key === null) return technical + const text = requireTbl(key, 'ENG').replace(/\s+/g, ' ').trim() + return technical ? `${text} (${technical})` : text +} + +const LOBBY_SESSION_LABEL: Readonly> = { + bncs: 'BNCS', + mcp: 'MCP realm', +} + /** * Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap` * capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry @@ -169,13 +241,17 @@ export class OnlineSession { } | null = null private flow: D2OnlineFlow | null = null - private activeBnetStream: ByteStream | null = null private gameAdapter: GameServerAdapter | null = null private unsubGameEvent: (() => void) | null = null private unsubGameClose: (() => void) | null = null private unsubFlowState: (() => void) | null = null - private unsubFlowError: (() => void) | null = null + private unsubFlowLost: (() => void) | null = null private unsubSettings: (() => void) | null = null + /** The loss that ended the current session lifecycle (reset by `login` / `attachGameServer`). */ + private sessionLostValue: SessionLostError | null = null + /** The flow that was active when `sessionLostValue` was handled. */ + private lostFlow: D2OnlineFlow | null = null + private pendingFlowClose: Promise = Promise.resolve() private phaseValue: OnlineSessionPhase = 'idle' private usernameValue = '' @@ -194,6 +270,7 @@ export class OnlineSession { private readonly eventListeners = new Set<(ev: ServerEvent) => void>() private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>() private readonly disconnectListeners = new Set<(reason: string) => void>() + private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => void>() constructor(options: OnlineSessionOptions = {}) { this.settingsStore = options.settingsStore ?? getGlobalSettingsStore() @@ -390,6 +467,24 @@ export class OnlineSession { } } + /** + * Fires once per session lifecycle when the Battle.net session ends: a BNCS/MCP/D2GS connection + * closed or errored, a request timed out, a command was issued without its connection, or the + * client ended the session itself (`disconnect`). All connections are already disposed when this + * fires; the UI returns to the login screen with `ev.reason`. + */ + onSessionLost(cb: (ev: OnlineSessionLostEvent) => void): () => void { + this.sessionLostListeners.add(cb) + return () => { + this.sessionLostListeners.delete(cb) + } + } + + /** The loss that ended the current session lifecycle, or `null`. */ + get sessionLost(): OnlineSessionLostEvent | null { + return this.sessionLostValue?.event ?? null + } + // ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ───────────── /** @@ -399,48 +494,7 @@ export class OnlineSession { readonly realms: readonly RealmSummary[] readonly characters: readonly CharSummary[] }> { - await this.closeFlowQuietly() - this.disconnectReasonValue = null - this.usernameValue = params.username - this.setPhase('connecting') - - const baseResolver = params.wsBridgeBaseUrl - ? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl)) - : this.resolver - const resolver = this.wrapResolver(baseResolver) - - const flow = this.flowFactory({ - resolver, - bnetHost: params.host ?? this.defaultHost, - bnetPort: params.bncsPort ?? this.defaultBncsPort, - itemTables: this.itemTables, - tap: this.packetTap, - }) - - this.bindFlowListeners(flow) - this.flow = flow - - try { - await flow.login(params.username, params.password) - const realms = await flow.listRealms() - this.realmsValue = realms - const realmIdx = params.realmIndex ?? 0 - const chosenRealm = realms[realmIdx] ?? realms[0] ?? null - this.selectedRealmValue = chosenRealm - - const characters = await flow.enterRealm(chosenRealm?.title) - this.charactersValue = characters - if (characters.length > 0 && !this.selectedCharValue) { - this.selectedCharValue = characters[0]!.name - } - this.setPhase('lobby') - return { realms, characters } - } catch (err) { - this.closeBnetStreamQuietly() - const msg = err instanceof Error ? err.message : String(err) - this.handleUnexpectedDisconnect(msg) - throw err - } + return await this.establishSession(params, false) } /** @@ -450,15 +504,28 @@ export class OnlineSession { readonly realms: readonly RealmSummary[] readonly characters: readonly CharSummary[] }> { + return await this.establishSession(params, true) + } + + private async establishSession( + params: OnlineSessionLoginParams, + createAccount: boolean, + ): Promise<{ + readonly realms: readonly RealmSummary[] + readonly characters: readonly CharSummary[] + }> { + this.detachGameServerListeners() + this.gameAdapter = null await this.closeFlowQuietly() + this.sessionLostValue = null + this.lostFlow = null this.disconnectReasonValue = null this.usernameValue = params.username this.setPhase('connecting') - const baseResolver = params.wsBridgeBaseUrl + const resolver = params.wsBridgeBaseUrl ? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl)) : this.resolver - const resolver = this.wrapResolver(baseResolver) const flow = this.flowFactory({ resolver, @@ -472,13 +539,19 @@ export class OnlineSession { this.flow = flow try { - await flow.createAccount(params.username, params.password) + if (createAccount) { + await flow.createAccount(params.username, params.password) + } await flow.login(params.username, params.password) const realms = await flow.listRealms() this.realmsValue = realms const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null this.selectedRealmValue = chosenRealm + const characters = await flow.enterRealm(chosenRealm?.title) + if (this.flow !== flow) { + throw this.sessionLostFor(flow) ?? new Error('OnlineSession.login: superseded by a newer login') + } this.charactersValue = characters if (characters.length > 0 && !this.selectedCharValue) { this.selectedCharValue = characters[0]!.name @@ -486,37 +559,38 @@ export class OnlineSession { this.setPhase('lobby') return { realms, characters } } catch (err) { - this.closeBnetStreamQuietly() - const msg = err instanceof Error ? err.message : String(err) - this.handleUnexpectedDisconnect(msg) + const lost = this.sessionLostFor(flow) + if (lost) throw lost + if (this.flow !== flow) { + // A newer login replaced this attempt; its state belongs to the newer attempt. + throw err + } + // Rejected login (bad password or unknown account): the attempt's connections are closed + // and the login screen shows the server's answer. + this.pendingFlowClose = this.closeFlowQuietly() + this.disconnectReasonValue = err instanceof Error ? err.message : String(err) + this.setPhase('disconnected') throw err } } async refreshCharacters(): Promise { - if (!this.flow) { - throw new Error('OnlineSession.refreshCharacters: not connected to realm') - } - const chars = await this.flow.listCharacters() - this.charactersValue = chars - this.emitState() - return chars + return await this.lobbyOp('refreshCharacters', 'mcp', async (flow) => { + const chars = await flow.listCharacters() + this.charactersValue = chars + this.emitState() + return chars + }) } async createCharacter(spec: CharCreateSpec): Promise { - if (!this.flow) { - throw new Error('OnlineSession.createCharacter: not connected to realm') - } - await this.flow.createCharacter(spec) + await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec)) this.selectedCharValue = spec.name return await this.refreshCharacters() } async deleteCharacter(name: string): Promise { - if (!this.flow) { - throw new Error('OnlineSession.deleteCharacter: not connected to realm') - } - await this.flow.deleteCharacter(name) + await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name)) if (this.selectedCharValue === name) { this.selectedCharValue = null } @@ -524,18 +598,12 @@ export class OnlineSession { } async convertCharacterToExpansion(name: string): Promise { - if (!this.flow) { - throw new Error('OnlineSession.convertCharacterToExpansion: not connected to realm') - } - await this.flow.upgradeCharacter(name) + await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name)) return await this.refreshCharacters() } async selectCharacter(name: string): Promise { - if (!this.flow) { - throw new Error('OnlineSession.selectCharacter: not connected to realm') - } - await this.flow.selectCharacter(name) + await this.lobbyOp('selectCharacter', 'mcp', (flow) => flow.selectCharacter(name)) this.selectedCharValue = name const charMeta = this.charactersValue.find((c) => c.name === name) if (charMeta) { @@ -551,13 +619,7 @@ export class OnlineSession { async sendLobbyChat(text: string): Promise { const trimmed = text.trim() if (!trimmed) return - if (this.flow) { - try { - await this.flow.sendChat(trimmed) - } catch { - // Ignore if BNCS stream was already closed after MCP realm logon - } - } + await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed)) const prevChatCount = this.world.chatLog.length this.world.apply({ type: 'Chat', @@ -576,13 +638,12 @@ export class OnlineSession { } async listGames(filter = ''): Promise { - if (!this.flow) { - throw new Error('OnlineSession.listGames: not connected to realm') - } - const games = await this.flow.listGames(filter) - this.gamesValue = games - this.emitState() - return games + return await this.lobbyOp('listGames', 'mcp', async (flow) => { + const games = await flow.listGames(filter) + this.gamesValue = games + this.emitState() + return games + }) } /** @@ -593,29 +654,31 @@ export class OnlineSession { | { readonly mode: 'create'; readonly spec: GameCreateSpec } | { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined }, ): Promise { - if (!this.flow) { - throw new Error('OnlineSession.enterGame: not connected to realm') - } - this.setPhase('entering-game') - try { - let adapter: GameServerAdapter - if (request.mode === 'create') { - await this.flow.createGame(request.spec) - adapter = await this.flow.joinGame(request.spec.name, request.spec.password ?? '') - } else { - adapter = await this.flow.joinGame(request.name, request.password ?? '') - } + return await this.lobbyOp('enterGame', 'mcp', async (flow) => { + this.setPhase('entering-game') + try { + let adapter: GameServerAdapter + if (request.mode === 'create') { + await flow.createGame(request.spec) + adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '') + } else { + adapter = await flow.joinGame(request.name, request.password ?? '') + } - this.activeGameNameValue = - request.mode === 'create' ? request.spec.name : request.name - this.attachGameServer(adapter) - return adapter - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - this.disconnectReasonValue = msg - this.setPhase('lobby') - throw err - } + this.activeGameNameValue = + request.mode === 'create' ? request.spec.name : request.name + this.attachGameServer(adapter) + return adapter + } catch (err) { + if (!this.sessionLostFor(flow)) { + // Game creation/join refused while BNCS/MCP stay up (1.13c stays in the lobby). + const msg = err instanceof Error ? err.message : String(err) + this.disconnectReasonValue = msg + this.setPhase('lobby') + } + throw err + } + }) } /** @@ -623,6 +686,11 @@ export class OnlineSession { */ attachGameServer(adapter: GameServerAdapter): void { this.detachGameServerListeners() + if (!this.flow) { + // A standalone adapter (replay harness or tests) starts its own session lifecycle. + this.sessionLostValue = null + this.lostFlow = null + } this.gameAdapter = adapter this.disconnectReasonValue = null this.setPhase('ingame') @@ -633,14 +701,19 @@ export class OnlineSession { if (typeof adapter.onClose === 'function') { this.unsubGameClose = adapter.onClose((reason: CloseReason) => { + if (this.gameAdapter !== adapter) return if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') { - const desc = + const technical = reason.kind === 'error' - ? `Connection error: ${reason.error.message}` + ? `D2GS game connection error: ${reason.error.message}` : reason.kind === 'remote' - ? `Server closed connection${reason.code !== undefined ? ` (code ${reason.code})` : ''}` - : 'Disconnected from game server' - this.handleUnexpectedDisconnect(desc) + ? `D2GS game connection closed by server${reason.code !== undefined ? ` (code ${reason.code})` : ''}` + : 'D2GS game connection closed' + this.handleSessionLost({ + session: 'd2gs', + cause: reason.kind === 'error' ? 'error' : 'closed', + technical, + }) } }) } else { @@ -652,11 +725,11 @@ export class OnlineSession { * Leave the active D2GS game and return to lobby (or idle if standalone). */ async leaveGame(): Promise { - const hadFlow = this.flow !== null + const flow = this.flow this.detachGameServerListeners() - if (this.flow && this.flow.state === 'ingame') { + if (flow && flow.state === 'ingame') { try { - await this.flow.leaveToLobby() + await flow.leaveToLobby() } catch { // Ignore close errors on leave } @@ -673,18 +746,97 @@ export class OnlineSession { } this.gameAdapter = null this.activeGameNameValue = null - this.setPhase(hadFlow ? 'lobby' : 'idle') + // `leaveToLobby` reports a BNCS/MCP loss deferred during the game; that already ended the session. + if (flow && this.sessionLostFor(flow)) return + this.setPhase(flow ? 'lobby' : 'idle') } /** - * Gracefully close all active connections and transition to `'disconnected'`. + * End the session on purpose: close every connection and transition to `'disconnected'` with + * exactly `reason`. Routed through the same session-loss path so the UI returns to the login screen. */ async disconnect(reason = 'Disconnected by user'): Promise { + this.handleSessionLost({ session: 'client', cause: 'local', technical: reason, reason }) + await this.pendingFlowClose + } + + /** + * Run a pre-game operation that needs a live BNCS/MCP session. Without a session the loss is + * handled centrally (return to login) instead of surfacing "not connected" on the current screen; + * an operation that fails because the session was lost throws `SessionLostError`. Other failures + * (status codes such as "name already exists") are rethrown as-is for the current screen. + */ + private async lobbyOp( + op: string, + required: 'bncs' | 'mcp', + fn: (flow: D2OnlineFlow) => Promise, + ): Promise { + const flow = this.flow + if (!flow || this.sessionLostValue) { + throw this.handleSessionLost({ + session: required, + cause: 'not_connected', + technical: `${LOBBY_SESSION_LABEL[required]} session is not connected for ${op}`, + }) + } + try { + return await fn(flow) + } catch (err) { + throw this.sessionLostFor(flow) ?? err + } + } + + /** The handled loss if it ended `flow`'s lifecycle. */ + private sessionLostFor(flow: D2OnlineFlow): SessionLostError | null { + return this.sessionLostValue && this.lostFlow === flow ? this.sessionLostValue : null + } + + /** + * Single place where a session ends. Idempotent per session lifecycle: the first call disposes the + * game adapter and the flow (which closes BNCS/MCP/D2GS), records the reason, enters + * `'disconnected'` and notifies `onSessionLost` / `onDisconnect`; later calls (BNCS and MCP both + * closing, or a D2GS close seen by both the flow and the adapter) return the same error. + */ + private handleSessionLost(input: { + readonly session: OnlineSessionLostSource + readonly cause: OnlineSessionLostCause + readonly technical: string + readonly reason?: string | undefined + }): SessionLostError { + if (this.sessionLostValue) return this.sessionLostValue + const event: OnlineSessionLostEvent = { + reason: input.reason ?? formatSessionLostReason(input.session, input.cause, input.technical), + session: input.session, + cause: input.cause, + technical: input.technical, + } + const error = new SessionLostError(event) + this.sessionLostValue = error + this.lostFlow = this.flow + this.detachGameServerListeners() + const adapter = this.gameAdapter this.gameAdapter = null - await this.closeFlowQuietly() - this.disconnectReasonValue = reason + this.activeGameNameValue = null + if (adapter && !this.flow) { + // No flow owns this adapter (standalone attach), so release its connection here. + try { + adapter.leave() + } catch { + // Already closed. + } + } + this.pendingFlowClose = this.closeFlowQuietly() + + this.disconnectReasonValue = event.reason this.setPhase('disconnected') + for (const listener of [...this.sessionLostListeners]) { + listener(event) + } + for (const listener of [...this.disconnectListeners]) { + listener(event.reason) + } + return error } // ─── Outbound ClientCommand Dispatch ───────────────────────── @@ -722,7 +874,7 @@ export class OnlineSession { this.world.noteOutgoingCommand(cmd) } catch (err) { const msg = err instanceof Error ? err.message : String(err) - this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`) + this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true) } } } @@ -1189,63 +1341,33 @@ export class OnlineSession { } } - private wrapResolver(baseResolver: EndpointResolver): EndpointResolver { - return { - open: async (kind, host, port) => { - const stream = await baseResolver.open(kind, host, port) - if (kind === 'bnet' || port === 6112) { - this.activeBnetStream = stream - } - return stream - }, - } - } - - private closeBnetStreamQuietly(): void { - if (this.activeBnetStream) { - const stream = this.activeBnetStream - this.activeBnetStream = null - try { - stream.close() - } catch { - // Ignore close errors - } - } - } - private bindFlowListeners(flow: D2OnlineFlow): void { this.unsubFlowState?.() - this.unsubFlowError?.() + this.unsubFlowLost?.() this.unsubFlowState = flow.onState(() => { + if (this.flow !== flow) return this.emitState() }) - const maybeErrorHook = (flow as unknown as { onError?: (cb: (err: unknown) => void) => () => void }).onError - if (typeof maybeErrorHook === 'function') { - this.unsubFlowError = maybeErrorHook.call(flow, (err: unknown) => { - const msg = err instanceof Error ? err.message : String(err) - if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') { - this.handleUnexpectedDisconnect(msg) - } else { - this.disconnectReasonValue = msg - this.emitState() - } - }) - } else { - this.unsubFlowError = null - } + this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => { + if (this.flow !== flow) return + this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message }) + }) } - private handleUnexpectedDisconnect(reason: string): void { - this.detachGameServerListeners() - this.gameAdapter = null - this.closeBnetStreamQuietly() - this.disconnectReasonValue = reason - this.setPhase('disconnected') - for (const listener of this.disconnectListeners) { - listener(reason) - } + /** The game ended for a client-side reason (bad server data, send failure, GameLogout). */ + private handleUnexpectedDisconnect( + reason: string, + cause: Extract = 'error', + useTbl = false, + ): void { + this.handleSessionLost({ + session: 'd2gs', + cause, + technical: reason, + ...(useTbl ? {} : { reason }), + }) } private detachGameServerListeners(): void { @@ -1255,12 +1377,12 @@ export class OnlineSession { this.unsubGameClose = null } + /** Detach from and close the current flow (which closes every BNCS/MCP/D2GS connection it owns). */ private async closeFlowQuietly(): Promise { this.unsubFlowState?.() this.unsubFlowState = null - this.unsubFlowError?.() - this.unsubFlowError = null - this.closeBnetStreamQuietly() + this.unsubFlowLost?.() + this.unsubFlowLost = null if (this.flow) { const current = this.flow this.flow = null diff --git a/tests/client/session-lost-login.test.ts b/tests/client/session-lost-login.test.ts new file mode 100644 index 0000000..7cb5cd3 --- /dev/null +++ b/tests/client/session-lost-login.test.ts @@ -0,0 +1,254 @@ +/** + * Session loss on every Battle.net screen returns the UI to the login screen with the reason. + * + * Drives the real `OnlineSession` + `createD2OnlineFlow` over in-memory BNCS/MCP/D2GS fake servers + * and wires `onSessionLost` to `returnToLoginAfterSessionLoss` exactly like `src/client/main.ts`. + * Each case asserts: back on `bnet_login`, the 1.13c tbl reason text, connections disposed, and a + * single loss notification. + */ + +import { describe, expect, it } from 'vitest' + +import { + applyOnlineCharactersToFlowState, + createInitialFlowState, + returnToLoginAfterSessionLoss, + type BnetFlowState, +} from '../../src/client/frontend/flow-state.ts' +import { + OnlineSession, + SessionLostError, + formatSessionLostReason, + type OnlineSessionLostEvent, +} from '../../src/client/session/online-session.ts' +import { createSettingsStore } from '../../src/client/settings/settings-store.ts' +import { createD2OnlineFlow, type D2OnlineFlow } from '../../src/netproto/index.ts' +import { FakeClock } from '../../src/netproto/core/clock.ts' +import { BncsOpcode } from '../../src/netproto/bncs/packets.ts' +import { createFakeRealm, type FakeRealm, type FakeRealmOptions } from '../netproto/fake-realm-servers.ts' + +const TBL_BNCS_LOST = 'Your connection to Battle.net has been lost.' +const TBL_INTERRUPTED = 'Your connection has been interrupted' +const TBL_TIMEOUT = 'Timed out waiting for response.' +const TBL_LOST_CONNECTION = 'Lost Connection to battle.net.' + +interface Harness { + readonly realm: FakeRealm + readonly clock: FakeClock + readonly session: OnlineSession + readonly ui: BnetFlowState + readonly lost: OnlineSessionLostEvent[] + readonly disconnects: string[] +} + +function createHarness( + options: FakeRealmOptions = {}, + wrapFlow: (flow: D2OnlineFlow) => D2OnlineFlow = (flow) => flow, +): Harness { + const realm = createFakeRealm(options) + const clock = new FakeClock(1000) + const session = new OnlineSession({ + settingsStore: createSettingsStore(), + flowFactory: (config) => wrapFlow(createD2OnlineFlow({ ...config, resolver: realm.resolver, clock })), + }) + const ui = createInitialFlowState() + ui.stage = 'bnet_login' + ui.accountName = 'd2webbot2' + ui.accountPassword = 'secret' + const lost: OnlineSessionLostEvent[] = [] + const disconnects: string[] = [] + // Same routing as `bootPlayApp` in src/client/main.ts. + session.onSessionLost((ev) => { + lost.push(ev) + returnToLoginAfterSessionLoss(ui, ev.reason) + }) + session.onDisconnect((reason) => disconnects.push(reason)) + return { realm, clock, session, ui, lost, disconnects } +} + +/** What `onLogin` in main.ts does on success. */ +async function logIn(h: Harness): Promise { + const res = await h.session.login({ username: h.ui.accountName, password: h.ui.accountPassword }) + applyOnlineCharactersToFlowState(h.ui, res.characters) +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +function expectBackOnLogin(h: Harness, reasonPrefix: string, technical: string | RegExp): void { + expect(h.lost).toHaveLength(1) + expect(h.disconnects).toHaveLength(1) + const reason = h.lost[0]!.reason + expect(reason.startsWith(reasonPrefix)).toBe(true) + if (typeof technical === 'string') expect(reason).toContain(technical) + else expect(reason).toMatch(technical) + expect(h.ui.stage).toBe('bnet_login') + expect(h.ui.loginError).toBe(reason) + expect(h.ui.lastError).toBe(reason) + expect(h.ui.roster).toEqual([]) + expect(h.ui.onlineConnected).toBe(false) + expect(h.ui.accountName).toBe('d2webbot2') + expect(h.session.phase).toBe('disconnected') + expect(h.session.disconnectReason).toBe(reason) + expect(h.session.getSnapshot().flowState).toBe('idle') + expect(h.realm.allClientStreamsClosed()).toBe(true) +} + +describe('OnlineSession session loss -> login screen', () => { + it('BNCS close on char_select returns to login with string#5352', async () => { + const h = createHarness({ characters: 1 }) + await logIn(h) + expect(h.ui.stage).toBe('char_select') + + h.realm.latest('bnet').server.close() + + expectBackOnLogin(h, TBL_BNCS_LOST, 'BNCS connection closed') + expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'closed' }) + }) + + it('MCP close on char_create returns to login with string#5357', async () => { + const h = createHarness({ characters: 0 }) + await logIn(h) + expect(h.ui.stage).toBe('char_create') + + h.realm.latest('realm').server.close() + + expectBackOnLogin(h, TBL_INTERRUPTED, 'MCP realm connection closed') + expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'closed' }) + + // A create-character click on the stale screen stays on login and reports nothing new. + await expect(h.session.createCharacter({ name: 'NewSorc', classId: 1 })).rejects.toBeInstanceOf( + SessionLostError, + ) + expect(h.lost).toHaveLength(1) + expect(h.ui.stage).toBe('bnet_login') + expect(h.ui.charCreateError).toBeNull() + }) + + it('realm logon timeout during login returns to login with string#5349', async () => { + const h = createHarness({ silentBncs: [BncsOpcode.SID_LOGONREALMEX] }) + const attempt = logIn(h).then( + () => null, + (err: unknown) => err, + ) + for (let t = 0; t < 10_500; t += 500) { + await flushMicrotasks() + h.clock.advance(500) + } + + const err = await attempt + expect(err).toBeInstanceOf(SessionLostError) + expectBackOnLogin(h, TBL_TIMEOUT, /timed out after 10000ms/) + expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'timeout' }) + expect(h.realm.connections.filter((c) => c.role === 'realm')).toHaveLength(0) + }) + + it('create-character click while the MCP session is gone returns to login with string#5351', async () => { + // A flow whose realm logon left no MCP connection (the original dead-end: the char_create + // screen showed "MCP realm session is not connected" and stayed there). + const h = createHarness({ characters: 0 }, (flow) => ({ + ...flow, + get state() { + return flow.state + }, + get sessionLost() { + return flow.sessionLost + }, + enterRealm: async () => [], + })) + await logIn(h) + expect(h.ui.stage).toBe('char_create') + expect(h.realm.connections.filter((c) => c.role === 'realm')).toHaveLength(0) + + let routedError: unknown = null + try { + await h.session.createCharacter({ name: 'NewSorc', classId: 1 }) + } catch (err) { + routedError = err + } + + expect(routedError).toBeInstanceOf(SessionLostError) + expectBackOnLogin(h, TBL_LOST_CONNECTION, 'MCP realm session is not connected for createCharacter') + expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'not_connected' }) + expect(h.ui.charCreateError).toBeNull() + }) + + it('create-character click without any session returns to login with string#5351', async () => { + const h = createHarness() + h.ui.stage = 'char_create' + + await expect(h.session.createCharacter({ name: 'NewSorc', classId: 1 })).rejects.toBeInstanceOf( + SessionLostError, + ) + + expectBackOnLogin(h, TBL_LOST_CONNECTION, 'MCP realm session is not connected for createCharacter') + }) + + it('BNCS and MCP closing together are handled once', async () => { + const h = createHarness({ characters: 1 }) + await logIn(h) + + h.realm.latest('bnet').server.close() + h.realm.latest('realm').server.close() + await flushMicrotasks() + + expectBackOnLogin(h, TBL_BNCS_LOST, 'BNCS connection closed') + }) + + it('D2GS drop in game returns to login with string#5357 and disposes BNCS/MCP/D2GS', async () => { + const h = createHarness({ characters: 1 }) + await logIn(h) + await h.session.selectCharacter('d2webbot_sorc') + await h.session.enterGame({ mode: 'create', spec: { name: 'loss-game', difficulty: 0 } }) + h.ui.stage = 'in_game' + expect(h.session.phase).toBe('ingame') + + h.realm.latest('game').server.close() + + expectBackOnLogin(h, TBL_INTERRUPTED, 'D2GS game connection closed') + expect(h.lost[0]).toMatchObject({ session: 'd2gs', cause: 'closed' }) + expect(h.ui.sceneReady).toBe(false) + }) + + it('disconnect() ends the session through the same path with the exact reason', async () => { + const h = createHarness({ characters: 1 }) + await logIn(h) + + await h.session.disconnect('Saved and exited the game; Battle.net session closed') + + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'client', cause: 'local' }) + expect(h.ui.stage).toBe('bnet_login') + expect(h.ui.loginError).toBe('Saved and exited the game; Battle.net session closed') + expect(h.session.disconnectReason).toBe('Saved and exited the game; Battle.net session closed') + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('a new login after a loss starts a fresh session', async () => { + const h = createHarness({ characters: 1 }) + await logIn(h) + h.realm.latest('realm').server.close() + expect(h.ui.stage).toBe('bnet_login') + + await logIn(h) + + expect(h.ui.stage).toBe('char_select') + expect(h.session.phase).toBe('lobby') + expect(h.session.sessionLost).toBeNull() + expect(h.realm.latest('bnet').client.isClosed).toBe(false) + expect(h.realm.latest('realm').client.isClosed).toBe(false) + }) +}) + +describe('formatSessionLostReason', () => { + it('uses the ENG 1.13c tbl string plus the technical reason', () => { + expect(formatSessionLostReason('bncs', 'connect_failed', 'BNCS connect failed')).toBe( + 'Battle.net is not responding. Please try connecting again in a few minutes. (BNCS connect failed)', + ) + expect(formatSessionLostReason('mcp', 'connect_failed', 'x')).toMatch( + /^Diablo II was unable to connect to the realm server\..* \(x\)$/, + ) + expect(formatSessionLostReason('client', 'local', 'Disconnected by user')).toBe('Disconnected by user') + }) +})