From 587ed05bf04c58a8dfd40612d7be493d1c317861 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 11:34:59 +0000 Subject: [PATCH 1/4] data(tbl): pack 1.13c index-only Battle.net connection-loss strings string.tbl 5349/5351/5352/5353/5357 and patchstring.tbl 1066 are keyed "x" in the MPQ, so emit them under string# / patchstring# aliases (ENG+CHI) for the online session-lost UI. Only the new alias lines were applied to canonical-tbl-strings.ts (a full regen currently drifts unrelated CHI runeword/skill names). --- src/baker/packers/pack-canonical-tbl.ts | 23 ++++++++++++++++++++++- src/common/data/canonical-tbl-strings.ts | 16 ++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/baker/packers/pack-canonical-tbl.ts b/src/baker/packers/pack-canonical-tbl.ts index a800b45..2434a46 100644 --- a/src/baker/packers/pack-canonical-tbl.ts +++ b/src/baker/packers/pack-canonical-tbl.ts @@ -18,6 +18,7 @@ import { fileSource } from '../io/file-source.ts' import { MountedArchives } from '../../common/mpq/mount.ts' import { loadStringTableChain } from '../../common/items/item-names.ts' import { parseTable } from '../../common/items/tables.ts' +import { decodeTbl } from '../../common/formats/tbl.ts' import { ITEM_NAMES_ZH, MAGIC_PREFIX_ZH, @@ -545,7 +546,8 @@ async function main() { // (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias // key `patchstring#`. // - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095) - for (const idx of [95]) { + // - 1066: "Diablo II was unable to connect to the realm server. ..." (string index 11066) + for (const idx of [95, 1066]) { const alias = `patchstring#${idx}` const ev = eng.tables[0]?.[idx] const cv = chi.tables[0]?.[idx] @@ -554,6 +556,25 @@ async function main() { chiOut[alias] = cv } + // Index-only string.tbl (d2data.mpq) Battle.net connection strings, all keyed "x" in the MPQ, emitted + // under `string#` (string.tbl index n = global tbl index n). Read straight from string.tbl so the + // index cannot shift with the patch/expansion chain order. + // - 5349: "Timed out waiting for response." + // - 5351: "Lost Connection to battle.net." + // - 5352: "Your connection to Battle.net has been lost." + // - 5353: "Battle.net is not responding. Please try connecting again in a few minutes." + // - 5357: "Your connection has been interrupted" + const engStringTbl = decodeTbl(await archives.read('data\\local\\lng\\eng\\string.tbl'), 'windows-1252') + const chiStringTbl = decodeTbl(await archives.read('data\\local\\lng\\chi\\string.tbl'), 'utf-8') + for (const idx of [5349, 5351, 5352, 5353, 5357]) { + const alias = `string#${idx}` + const ev = engStringTbl[idx] + const cv = chiStringTbl[idx] + if (ev === undefined || cv === undefined) throw new Error(`string.tbl index ${idx} missing (ENG or CHI)`) + engOut[alias] = ev + chiOut[alias] = cv + } + const outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts') const fileContent = `/** * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: diff --git a/src/common/data/canonical-tbl-strings.ts b/src/common/data/canonical-tbl-strings.ts index ae159f9..1fce1cf 100644 --- a/src/common/data/canonical-tbl-strings.ts +++ b/src/common/data/canonical-tbl-strings.ts @@ -4016,7 +4016,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "uniquemultishot": "Multiple Shots", "UniqueAuraEnchanted": "Aura Enchanted", "strethereal": "Ethereal (Cannot be Repaired)", - "patchstring#95": "Repair all equipment: %d" + "patchstring#95": "Repair all equipment: %d", + "patchstring#1066": "Diablo II was unable to connect to the realm server. The realm you selected may be unavailable or you may be encountering connection issues. Please make sure your internet connection is working properly and/or your firewall has port 6112 opened.", + "string#5349": "Timed out waiting for response.", + "string#5351": "Lost Connection to battle.net.", + "string#5352": "Your connection to Battle.net has been lost.", + "string#5353": "Battle.net is not responding. Please try connecting again in a few minutes.", + "string#5357": "Your connection has been interrupted" } export const CANONICAL_CHI_TBL_DICT: Readonly> = { @@ -7419,7 +7425,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "uniquemultishot": "多重射擊", "UniqueAuraEnchanted": "靈氣加強", "strethereal": "无形的 (无法修复)", - "patchstring#95": "修復所有裝備:%d" + "patchstring#95": "修復所有裝備:%d", + "patchstring#1066": "《暗黑破壞神二》無法連上國度伺服器。 你所選擇的國度可能無法使用,也可能是你遭遇了網路連線的問題。 請確認你的網路連線運作正常,以及/或你的防火牆設定有開啟port 6112。", + "string#5349": "等候回應時間超過。", + "string#5351": "與battle.net失去連線。", + "string#5352": "你和battle.net的連線已經中斷了。", + "string#5353": "Battle.net沒有回應,請稍候數分鐘之後再重試。", + "string#5357": "你的連線被中斷了" } export const CANONICAL_ISC_DESC_TABLE: Readonly> = { From 5882efc1098406ebd5efd57af76d5047e514dd85 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 12:01:47 +0000 Subject: [PATCH 2/4] fix(netproto): detect BNCS/MCP/D2GS session loss centrally in D2OnlineFlow D2OnlineFlow never subscribed to the BNCS/MCP socket close events and kept stale session references, so a dropped realm connection only surfaced later as "MCP realm session is not connected" on whatever screen issued the next command. The flow now watches every connection it opens and turns any unrequested close/error, request timeout, connect failure or command without its connection into exactly one onSessionLost event. Before emitting it tears down all remaining connections (references cleared first so the resulting close callbacks are not reported again), bumps an epoch so late-opening streams are disposed, and enters `closed`. A BNCS/MCP loss during a game is reported when the game ends. A failed D2GS join releases its D2GS session again. WsStream forwards the WebSocket close reason. Tests: in-memory BNCS/MCP/D2GS fake servers cover BNCS close on char select, MCP close on char create, realm logon / MCP_STARTUP timeouts, both sockets closing (one event), D2GS drop in game, deferred in-game BNCS loss and close() without an event. TAG=agy CONV=109a3012-103a-42f0-8ae1-bbc0857047ef --- src/netproto/flow/config.ts | 29 ++ src/netproto/flow/online-flow.ts | 476 ++++++++++++++---- src/netproto/index.ts | 9 +- src/netproto/transport/byte-stream.ts | 2 +- src/netproto/transport/ws-stream.ts | 7 +- tests/client/hud-session-play.test.ts | 2 + tests/netproto/fake-realm-servers.ts | 208 ++++++++ .../netproto/online-flow-session-lost.test.ts | 195 +++++++ 8 files changed, 817 insertions(+), 111 deletions(-) create mode 100644 tests/netproto/fake-realm-servers.ts create mode 100644 tests/netproto/online-flow-session-lost.test.ts diff --git a/src/netproto/flow/config.ts b/src/netproto/flow/config.ts index b24847c..8bca320 100644 --- a/src/netproto/flow/config.ts +++ b/src/netproto/flow/config.ts @@ -18,6 +18,35 @@ export type D2OnlineState = | 'ingame' | 'closed' +/** Which live connection of the flow was lost. */ +export type D2OnlineSessionKind = 'bncs' | 'mcp' | 'd2gs' + +/** + * Why the connection was lost: + * - `closed`: the socket closed without the flow asking for it (server/bridge close). + * - `error`: the transport reported an error (socket error / write on a dead socket). + * - `timeout`: a request/response exchange on that connection timed out. + * - `connect_failed`: opening the connection (or the realm MCP startup) failed. + * - `not_connected`: a command needed the connection but it is not connected. + */ +export type D2OnlineSessionLostCause = 'closed' | 'error' | 'timeout' | 'connect_failed' | 'not_connected' + +/** + * Emitted exactly once per `D2OnlineFlow` when a connection it owns is lost. By the time listeners run, + * the flow has already closed every remaining connection and is in state `closed`. + */ +export interface D2OnlineSessionLost { + readonly session: D2OnlineSessionKind + readonly cause: D2OnlineSessionLostCause + /** Concrete technical description (connection, close code/reason, timed-out packet, error text). */ + readonly message: string + readonly closeCode?: number | undefined + readonly closeReason?: string | undefined + readonly error?: Error | undefined + /** Flow state at the moment the loss was detected. */ + readonly flowState: D2OnlineState +} + export interface D2OnlineConfig { readonly resolver: EndpointResolver readonly bnetHost: string diff --git a/src/netproto/flow/online-flow.ts b/src/netproto/flow/online-flow.ts index deeb375..e96d1a0 100644 --- a/src/netproto/flow/online-flow.ts +++ b/src/netproto/flow/online-flow.ts @@ -1,12 +1,18 @@ /** * `D2OnlineFlow` orchestrating BNCS (port 6112) -> MCP (port 6113) -> D2GS (port 4000) * transitions and keeping BNCS/MCP alive while in-game. + * + * Connection loss is detected centrally here: every BNCS/MCP/D2GS connection the flow opens is + * watched, and any close the flow did not ask for, any request timeout, any connect failure and any + * command issued while its connection is gone produces exactly one `onSessionLost` event per flow. + * Before that event is emitted the flow closes every remaining connection (no leaked sockets, timers or + * reconnects) and enters state `closed`. */ import { BncsSession } from '../bncs/session.ts' import { defaultClock } from '../core/clock.ts' import { Emitter } from '../core/emitter.ts' -import { ProtocolError } from '../core/errors.ts' +import { ClosedError, ProtocolError, TimeoutError } from '../core/errors.ts' import { resolveTextCodec } from '../core/text-codec.ts' import { D2gsAdapter } from '../d2gs/adapter.ts' import { D2gsSession } from '../d2gs/session.ts' @@ -22,11 +28,22 @@ import type { RealmInfo, } from '../domain/lobby.ts' import { McpSession } from '../mcp/session.ts' -import type { D2OnlineConfig, D2OnlineState } from './config.ts' +import type { CloseReason } from '../transport/byte-stream.ts' +import type { + D2OnlineConfig, + D2OnlineSessionKind, + D2OnlineSessionLost, + D2OnlineSessionLostCause, + D2OnlineState, +} from './config.ts' export interface D2OnlineFlow { readonly state: D2OnlineState + /** The loss that ended this flow, or `null` while the flow is healthy / was closed on purpose. */ + readonly sessionLost: D2OnlineSessionLost | null onState(cb: (s: D2OnlineState) => void): () => void + /** Fires once when a BNCS/MCP/D2GS connection is lost (see `D2OnlineSessionLost`). */ + onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void connectBnet(): Promise connect(): Promise createAccount(user: string, pass: string): Promise @@ -78,6 +95,59 @@ const FALLBACK_EMPTY_TABLES: ItemDataTables = { getItemMeta: () => undefined, } +const SESSION_LABEL: Readonly> = { + bncs: 'BNCS', + mcp: 'MCP realm', + d2gs: 'D2GS game', +} + +type SessionLostInfo = Omit + +/** Drop the `[ProtocolError conn=.. packet=.. offset=..]` prefix so user-facing reasons stay readable. */ +function stripProtocolPrefix(message: string): string { + return message.replace(/^\[ProtocolError [^\]]*\]\s*/, '') +} + +function errorText(err: unknown): string { + return stripProtocolPrefix(err instanceof Error ? err.message : String(err)) +} + +function describeClose(kind: D2OnlineSessionKind, reason: CloseReason): SessionLostInfo { + const label = SESSION_LABEL[kind] + if (reason.kind === 'error') { + return { + session: kind, + cause: 'error', + message: `${label} connection error: ${errorText(reason.error)}`, + error: reason.error, + } + } + const remoteReason = reason.kind === 'remote' && reason.reason ? reason.reason : undefined + const details: string[] = [] + if (reason.code !== undefined) details.push(`code ${reason.code}`) + if (remoteReason !== undefined) details.push(`reason "${remoteReason}"`) + return { + session: kind, + cause: 'closed', + message: `${label} connection closed${reason.kind === 'remote' ? ' by server' : ''}${ + details.length > 0 ? ` (${details.join(', ')})` : '' + }`, + ...(reason.code !== undefined ? { closeCode: reason.code } : {}), + ...(remoteReason !== undefined ? { closeReason: remoteReason } : {}), + } +} + +/** Transport-level failures of a request/response exchange mean the connection is unusable. */ +function classifyExchangeFailure(kind: D2OnlineSessionKind, err: unknown): SessionLostInfo | null { + if (err instanceof TimeoutError) { + return { session: kind, cause: 'timeout', message: `${SESSION_LABEL[kind]} ${errorText(err)}`, error: err } + } + if (err instanceof ClosedError) { + return { session: kind, cause: 'error', message: `${SESSION_LABEL[kind]} ${errorText(err)}`, error: err } + } + return null +} + export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { const bnetHost = config.bnetHost const bnetPort = config.bnetPort ?? 6112 @@ -90,6 +160,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { const stateEmitter = new Emitter() const chatEmitter = new Emitter() + const lostEmitter = new Emitter() let currentState: D2OnlineState = 'idle' let bncs: BncsSession | undefined @@ -99,6 +170,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { let selectedCharName = '' let selectedCharClass = 0 let knownChars: readonly CharSummary[] = [] + let lost: D2OnlineSessionLost | null = null + /** BNCS/MCP loss seen while a D2GS game runs: reported when that game ends instead of aborting it. */ + let pendingLost: D2OnlineSessionLost | null = null + /** Bumped by every teardown so connections that finish opening afterwards are disposed, not adopted. */ + let epoch = 0 function setState(next: D2OnlineState): void { if (currentState !== next) { @@ -107,18 +183,108 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { } } - function requireBncs(): BncsSession { - if (!bncs) { - throw new ProtocolError('BNCS session is not connected', { proto: 'bncs' }) + /** + * Close every connection the flow owns. References are cleared before closing, so the resulting + * close callbacks fail the identity checks in the watchers and are never reported as losses. + */ + function teardownConnections(): void { + epoch++ + const game = d2gsSession + d2gsSession = undefined + d2gsAdapter = undefined + const realm = mcp + mcp = undefined + const bnet = bncs + bncs = undefined + if (game) { + try { + game.leaveAndClose() + } catch { + // The socket is already gone; nothing left to release. + } } - return bncs + realm?.close() + bnet?.close() } - function requireMcp(): McpSession { - if (!mcp) { - throw new ProtocolError('MCP realm session is not connected', { proto: 'mcp' }) + function declareLost(info: SessionLostInfo): void { + if (lost) return + const ev: D2OnlineSessionLost = { ...info, flowState: currentState } + lost = ev + pendingLost = null + teardownConnections() + setState('closed') + lostEmitter.emit(ev) + } + + function onConnectionLost(info: SessionLostInfo): void { + if (lost) return + if (info.session !== 'd2gs' && d2gsSession !== undefined && currentState === 'ingame') { + pendingLost ??= { ...info, flowState: currentState } + if (info.session === 'bncs') { + bncs?.close() + bncs = undefined + } else { + mcp?.close() + mcp = undefined + } + return } - return mcp + declareLost(info) + } + + function watchLobbyConnection(kind: 'bncs' | 'mcp', session: BncsSession | McpSession): void { + session.onClose((reason) => { + const active = kind === 'bncs' ? bncs : mcp + if (lost || active !== session) return + onConnectionLost(describeClose(kind, reason)) + }) + } + + function watchGameConnection(session: D2gsSession): void { + session.onClose((reason) => { + if (lost || d2gsSession !== session) return + const info = describeClose('d2gs', reason) + const earlier = pendingLost + declareLost(earlier ? { ...info, message: `${info.message}; earlier: ${earlier.message}` } : info) + }) + } + + async function exchange( + kind: 'bncs' | 'mcp', + op: () => Promise, + otherFailureCause?: D2OnlineSessionLostCause, + ): Promise { + try { + return await op() + } catch (err) { + const info = + classifyExchangeFailure(kind, err) ?? + (otherFailureCause !== undefined + ? { session: kind, cause: otherFailureCause, message: `${SESSION_LABEL[kind]} ${errorText(err)}`, ...(err instanceof Error ? { error: err } : {}) } + : null) + if (info) onConnectionLost(info) + throw err + } + } + + function notConnected(kind: 'bncs' | 'mcp', op: string): never { + const message = `${SESSION_LABEL[kind]} session is not connected for ${op}` + const err = new ProtocolError(message, { proto: kind }) + onConnectionLost({ session: kind, cause: 'not_connected', message, error: err }) + throw err + } + + function requireBncs(op: string): BncsSession { + return bncs ?? notConnected('bncs', op) + } + + function requireMcp(op: string): McpSession { + return mcp ?? notConnected('mcp', op) + } + + function staleConnectionError(kind: 'bncs' | 'mcp'): ProtocolError { + return new ProtocolError(`D2OnlineFlow was closed while ${SESSION_LABEL[kind]} was connecting`, { proto: kind }) } const flow: D2OnlineFlow = { @@ -126,20 +292,52 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { return currentState }, + get sessionLost(): D2OnlineSessionLost | null { + return lost + }, + onState(cb: (s: D2OnlineState) => void): () => void { return stateEmitter.on(cb) }, + onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void { + return lostEmitter.on(cb) + }, + async connectBnet(): Promise { + // A (re)connect starts a fresh lifecycle for this flow. + teardownConnections() + lost = null + pendingLost = null + const startEpoch = epoch setState('connecting_bnet') - const stream = await config.resolver.open('bnet', bnetHost, bnetPort) - bncs = new BncsSession(stream, { + let stream + try { + stream = await config.resolver.open('bnet', bnetHost, bnetPort) + } catch (err) { + if (startEpoch === epoch) { + declareLost({ + session: 'bncs', + cause: 'connect_failed', + message: `BNCS connect to ${bnetHost}:${bnetPort} failed: ${errorText(err)}`, + ...(err instanceof Error ? { error: err } : {}), + }) + } + throw err + } + if (startEpoch !== epoch) { + stream.close() + throw staleConnectionError('bncs') + } + const session = new BncsSession(stream, { clock, textCodec, packetTap: config.tap, }) - bncs.onChatEvent((ev) => chatEmitter.emit(ev)) - await bncs.handshake() + bncs = session + watchLobbyConnection('bncs', session) + session.onChatEvent((ev) => chatEmitter.emit(ev)) + await exchange('bncs', () => session.handshake()) }, async connect(): Promise { @@ -150,8 +348,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { if (!bncs) { await flow.connectBnet() } - const session = requireBncs() - const res = await session.createAccount(user, pass) + const session = requireBncs('createAccount') + const res = await exchange('bncs', () => session.createAccount(user, pass)) if (res.status !== 0) { throw new ProtocolError( `BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`, @@ -172,18 +370,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { if (!bncs) { await flow.connectBnet() } - const session = requireBncs() + const session = requireBncs('login') if (opts?.register) { - const firstLogin = await session.login(user, pass) + const firstLogin = await exchange('bncs', () => session.login(user, pass)) if (firstLogin.status !== 0) { - const createRes = await session.createAccount(user, pass) + const createRes = await exchange('bncs', () => session.createAccount(user, pass)) if (createRes.status !== 0) { throw new ProtocolError( `BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`, { proto: 'bncs', packetId: 0x3d }, ) } - const secondLogin = await session.login(user, pass) + const secondLogin = await exchange('bncs', () => session.login(user, pass)) if (secondLogin.status !== 0) { throw new ProtocolError( `BNCS SID_LOGONRESPONSE2 failed after registration for "${user}" with status=0x${secondLogin.status.toString(16)}`, @@ -192,7 +390,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { } } } else { - const loginRes = await session.login(user, pass) + const loginRes = await exchange('bncs', () => session.login(user, pass)) if (loginRes.status !== 0) { throw new ProtocolError( `BNCS SID_LOGONRESPONSE2 failed for "${user}" with status=0x${loginRes.status.toString(16)}`, @@ -204,28 +402,50 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async listRealms(): Promise { - const session = requireBncs() - return await session.listRealms() + const session = requireBncs('listRealms') + return await exchange('bncs', () => session.listRealms()) }, async enterRealm(realm?: string): Promise { - const session = requireBncs() + const session = requireBncs('enterRealm') let targetRealm = realm ?? config.defaultRealm if (!targetRealm) { - const realms = await session.listRealms() + const realms = await exchange('bncs', () => session.listRealms()) targetRealm = realms[0]?.title ?? 'D2CS' } + const realmTitle = targetRealm setState('connecting_realm') - const realmData = await session.logonRealm(targetRealm) - const mcpStream = await config.resolver.open('realm', bnetHost, realmPort || realmData.mcpPort) - mcp = new McpSession(mcpStream, { + const realmData = await exchange('bncs', () => session.logonRealm(realmTitle)) + const port = realmPort || realmData.mcpPort + const startEpoch = epoch + let mcpStream + try { + mcpStream = await config.resolver.open('realm', bnetHost, port) + } catch (err) { + if (startEpoch === epoch) { + declareLost({ + session: 'mcp', + cause: 'connect_failed', + message: `MCP realm connect to ${bnetHost}:${port} failed: ${errorText(err)}`, + ...(err instanceof Error ? { error: err } : {}), + }) + } + throw err + } + if (startEpoch !== epoch) { + mcpStream.close() + throw staleConnectionError('mcp') + } + const realmSession = new McpSession(mcpStream, { clock, textCodec, packetTap: config.tap, }) - await mcp.startup(realmData) - knownChars = await mcp.listChars(8) + mcp = realmSession + watchLobbyConnection('mcp', realmSession) + await exchange('mcp', () => realmSession.startup(realmData), 'connect_failed') + knownChars = await exchange('mcp', () => realmSession.listChars(8)) setState('realm_ready') return knownChars }, @@ -235,8 +455,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async listCharacters(): Promise { - const m = requireMcp() - knownChars = await m.listChars(8) + const m = requireMcp('listCharacters') + knownChars = await exchange('mcp', () => m.listChars(8)) return knownChars }, @@ -245,12 +465,14 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async createCharacter(spec: CharCreateSpec): Promise { - const m = requireMcp() - const status = await m.createChar(spec.name, spec.classId, { - expansion: spec.expansion, - hardcore: spec.hardcore, - ladder: spec.ladder, - }) + const m = requireMcp('createCharacter') + const status = await exchange('mcp', () => + m.createChar(spec.name, spec.classId, { + expansion: spec.expansion, + hardcore: spec.hardcore, + ladder: spec.ladder, + }), + ) if (status !== 0) { throw new ProtocolError( `MCP_CHARCREATE failed for "${spec.name}" with status=0x${status.toString(16)}`, @@ -259,7 +481,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { } selectedCharName = spec.name selectedCharClass = spec.classId - knownChars = await m.listChars(8) + knownChars = await exchange('mcp', () => m.listChars(8)) }, async createChar( @@ -281,9 +503,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async deleteCharacter(name: string): Promise { - const m = requireMcp() - await m.deleteChar(name) - knownChars = await m.listChars(8) + const m = requireMcp('deleteCharacter') + await exchange('mcp', () => m.deleteChar(name)) + knownChars = await exchange('mcp', () => m.listChars(8)) }, async deleteChar(name: string): Promise { @@ -291,9 +513,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async upgradeCharacter(name: string): Promise { - const m = requireMcp() - await m.upgradeChar(name) - knownChars = await m.listChars(8) + const m = requireMcp('upgradeCharacter') + await exchange('mcp', () => m.upgradeChar(name)) + knownChars = await exchange('mcp', () => m.listChars(8)) }, async upgradeChar(name: string): Promise { @@ -301,8 +523,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async selectCharacter(name: string): Promise { - const m = requireMcp() - const status = await m.charLogon(name) + const m = requireMcp('selectCharacter') + const status = await exchange('mcp', () => m.charLogon(name)) if (status !== 0) { throw new ProtocolError( `MCP_CHARLOGON failed for "${name}" with status=0x${status.toString(16)}`, @@ -321,18 +543,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async listGames(filter = ''): Promise { - const m = requireMcp() - return await m.listGames(filter) + const m = requireMcp('listGames') + return await exchange('mcp', () => m.listGames(filter)) }, async getGameInfo(name: string): Promise { - const m = requireMcp() - return await m.getGameInfo(name) + const m = requireMcp('getGameInfo') + return await exchange('mcp', () => m.getGameInfo(name)) }, async createGame(spec: GameCreateSpec): Promise { - const m = requireMcp() - const res = await m.createGame(spec) + const m = requireMcp('createGame') + const res = await exchange('mcp', () => m.createGame(spec)) if (res.status !== 0 && res.status !== 0x1e) { throw new ProtocolError( `MCP_CREATEGAME failed for "${spec.name}" with status=0x${res.status.toString(16)}`, @@ -342,11 +564,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async joinGame(name: string, password = ''): Promise { - const m = requireMcp() + const m = requireMcp('joinGame') if (!selectedCharName) { throw new ProtocolError('Cannot joinGame before selectCharacter()', { proto: 'mcp' }) } - const ticket = await m.joinGame(name, password) + const ticket = await exchange('mcp', () => m.joinGame(name, password)) if (ticket.status !== 0) { throw new ProtocolError( `MCP_JOINGAME failed for "${name}" with status=0x${ticket.status.toString(16)}`, @@ -355,8 +577,19 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { } setState('connecting_game') - const gameStream = await config.resolver.open('game', bnetHost, gamePort) - d2gsSession = new D2gsSession({ + const startEpoch = epoch + let gameStream + try { + gameStream = await config.resolver.open('game', bnetHost, gamePort) + } catch (err) { + if (startEpoch === epoch && !lost) setState(mcp ? 'realm_ready' : 'idle') + throw err + } + if (startEpoch !== epoch) { + gameStream.close() + throw new ProtocolError('D2OnlineFlow was closed while D2GS was connecting', { proto: 'd2gs' }) + } + const session = new D2gsSession({ stream: gameStream, logon: { gameHash: ticket.gameHash, @@ -367,58 +600,88 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { clock, tap: config.tap, }) - - d2gsAdapter = new D2gsAdapter({ - session: d2gsSession, + const adapter = new D2gsAdapter({ + session, tables, encoding, clock, }) + d2gsSession = session + d2gsAdapter = adapter - await new Promise((resolve, reject) => { - const cancelTimer = clock.setTimeout(() => { - unsubState() - unsubErr() - reject(new ProtocolError(`D2GS handshake timed out (state=${d2gsSession?.state})`, { proto: 'd2gs' })) - }, 12_000) - const unsubState = d2gsSession!.onState((s) => { - if (s === 'ingame') { + try { + await new Promise((resolve, reject) => { + const cancelTimer = clock.setTimeout(() => { + unsubState() + unsubErr() + reject(new ProtocolError(`D2GS handshake timed out (state=${session.state})`, { proto: 'd2gs' })) + }, 12_000) + const unsubState = session.onState((s) => { + if (s === 'ingame') { + cancelTimer() + unsubState() + unsubErr() + setState('ingame') + resolve() + } else if (s === 'closed') { + cancelTimer() + unsubState() + unsubErr() + reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' })) + } + }) + const unsubErr = session.onError((err) => { cancelTimer() unsubState() unsubErr() - setState('ingame') - resolve() - } else if (s === 'closed') { - cancelTimer() - unsubState() - unsubErr() - reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' })) + reject(err) + }) + }) + } catch (err) { + // A failed game join leaves BNCS/MCP usable (1.13c returns to the lobby); only the D2GS + // connection of this attempt is released. + if (d2gsSession === session) { + d2gsSession = undefined + d2gsAdapter = undefined + try { + session.leaveAndClose() + } catch { + // Already closed. } - }) - const unsubErr = d2gsSession!.onError((err) => { - cancelTimer() - unsubState() - unsubErr() - reject(err) - }) - }) + if (!lost) setState(mcp ? 'realm_ready' : 'idle') + } + throw err + } - return d2gsAdapter + watchGameConnection(session) + return adapter }, async enterChat(): Promise { - const session = requireBncs() - await session.enterChat(selectedCharName) + const session = requireBncs('enterChat') + await exchange('bncs', () => session.enterChat(selectedCharName)) }, async joinChannel(channel: string): Promise { - const session = requireBncs() - session.joinChannel(channel) + const session = requireBncs('joinChannel') + try { + session.joinChannel(channel) + } catch (err) { + const info = classifyExchangeFailure('bncs', err) + if (info) onConnectionLost(info) + throw err + } }, async sendChannelChat(text: string): Promise { - const session = requireBncs() - session.sendChat(text) + const session = requireBncs('sendChannelChat') + try { + session.sendChat(text) + } catch (err) { + const info = classifyExchangeFailure('bncs', err) + if (info) onConnectionLost(info) + throw err + } }, async sendChat(text: string): Promise { @@ -434,28 +697,27 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow { }, async leaveToLobby(): Promise { - if (d2gsAdapter) { - await d2gsAdapter.close() - d2gsAdapter = undefined - d2gsSession = undefined + const game = d2gsSession + d2gsSession = undefined + d2gsAdapter = undefined + if (game) { + try { + game.leaveAndClose() + } catch { + // Already closed. + } + } + if (pendingLost) { + const { flowState: _flowState, ...info } = pendingLost + declareLost(info) + return } setState(mcp ? 'realm_ready' : 'idle') }, async close(): Promise { - if (d2gsAdapter) { - await d2gsAdapter.close() - d2gsAdapter = undefined - d2gsSession = undefined - } - if (mcp) { - mcp.close() - mcp = undefined - } - if (bncs) { - bncs.close() - bncs = undefined - } + pendingLost = null + teardownConnections() setState('closed') }, } diff --git a/src/netproto/index.ts b/src/netproto/index.ts index 1b4a089..c5611cc 100644 --- a/src/netproto/index.ts +++ b/src/netproto/index.ts @@ -14,7 +14,14 @@ export type { TcpResolverOptions, WsBridgeResolverOptions, } from './transport/endpoint.ts' -export type { D2OnlineConfig, D2OnlineConfig as OnlineConfig, D2OnlineState } from './flow/config.ts' +export type { + D2OnlineConfig, + D2OnlineConfig as OnlineConfig, + D2OnlineSessionKind, + D2OnlineSessionLost, + D2OnlineSessionLostCause, + D2OnlineState, +} from './flow/config.ts' export type { D2OnlineFlow } from './flow/online-flow.ts' export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts' export type { ServerEvent } from './domain/server-event.ts' diff --git a/src/netproto/transport/byte-stream.ts b/src/netproto/transport/byte-stream.ts index 40d5aef..2d92b12 100644 --- a/src/netproto/transport/byte-stream.ts +++ b/src/netproto/transport/byte-stream.ts @@ -7,7 +7,7 @@ export type EndpointKind = 'bnet' | 'realm' | 'game' | 'ts' export type CloseReason = | { readonly kind: 'local'; readonly code?: number | undefined } - | { readonly kind: 'remote'; readonly code?: number | undefined } + | { readonly kind: 'remote'; readonly code?: number | undefined; readonly reason?: string | undefined } | { readonly kind: 'error'; readonly error: Error } export interface ByteStream { diff --git a/src/netproto/transport/ws-stream.ts b/src/netproto/transport/ws-stream.ts index 5acf5a9..59d5770 100644 --- a/src/netproto/transport/ws-stream.ts +++ b/src/netproto/transport/ws-stream.ts @@ -77,8 +77,11 @@ export class WsStream implements ByteStream { this.ws.onclose = (ev: { readonly code?: number; readonly reason?: string }) => { if (this.closed) return this.closed = true - const reason: CloseReason = - ev.code !== undefined ? { kind: 'remote', code: ev.code } : { kind: 'remote' } + const reason: CloseReason = { + kind: 'remote', + ...(ev.code !== undefined ? { code: ev.code } : {}), + ...(ev.reason ? { reason: ev.reason } : {}), + } this.closeEmitter.emit(reason) this.dataEmitter.clear() this.closeEmitter.clear() diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 549f383..9b60e39 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -560,7 +560,9 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { get state() { return flowState }, + sessionLost: null, onState: vi.fn(() => () => {}), + onSessionLost: vi.fn(() => () => {}), connectBnet: vi.fn(async () => {}), connect: vi.fn(async () => {}), login: vi.fn(async () => { diff --git a/tests/netproto/fake-realm-servers.ts b/tests/netproto/fake-realm-servers.ts new file mode 100644 index 0000000..e1c8ba5 --- /dev/null +++ b/tests/netproto/fake-realm-servers.ts @@ -0,0 +1,208 @@ +/** + * In-memory BNCS / MCP / D2GS fake servers for `createD2OnlineFlow` session-loss tests. + * + * Every `resolver.open()` creates a fresh `MemoryStream` pair (so a second login gets new + * connections) and records both ends, letting tests force-close the server side of a connection and + * assert that the flow disposed its client side. Responders follow the tier4 S6 harness. + */ + +import { + createMemoryStreamPair, + type ByteStream, + type EndpointResolver, + type EndpointRole, +} from '../../src/netproto/index.ts' +import { ByteReader } from '../../src/netproto/core/byte-reader.ts' +import { ByteWriter } from '../../src/netproto/core/byte-writer.ts' +import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts' +import { BncsOpcode, encodeBncsPing } from '../../src/netproto/bncs/packets.ts' +import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts' +import { McpOpcode } from '../../src/netproto/mcp/packets.ts' +import { + defaultD2gsHuffmanCodec, + wrapD2gsCompressedBlock, +} from '../../src/netproto/d2gs/compression.ts' +import type { MemoryStream } from '../../src/netproto/transport/memory-stream.ts' + +export interface FakeConnection { + readonly role: EndpointRole + readonly client: MemoryStream + readonly server: MemoryStream +} + +export interface FakeRealmOptions { + /** BNCS request opcodes the fake server never answers (to provoke request timeouts). */ + readonly silentBncs?: readonly number[] | undefined + /** MCP request opcodes the fake server never answers. */ + readonly silentMcp?: readonly number[] | undefined + /** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */ + readonly characters?: number | undefined +} + +export interface FakeRealm { + readonly resolver: EndpointResolver + readonly connections: FakeConnection[] + /** Most recent connection for `role`. */ + latest(role: EndpointRole): FakeConnection + /** Every client stream the resolver handed out is closed (nothing leaked). */ + allClientStreamsClosed(): boolean +} + +function charList(count: number): Uint8Array { + const w = new ByteWriter() + w.u16LE(8) + w.u32LE(count) + w.u16LE(count) + for (let i = 0; i < count; i++) { + w.u32LE(0x7fffffff) + w.cstring(`d2webbot_sorc${i === 0 ? '' : String(i)}`) + const stat = new Uint8Array(33).fill(0xff) + stat[0] = 0x84 + stat[1] = 0x80 + stat[13] = 0x02 + stat[25] = 80 + stat[26] = 0xa0 + stat[27] = 0x80 + w.bytes(stat) + w.u8(0) + } + return w.toUint8Array() +} + +function wireBncs(server: MemoryStream, silent: ReadonlySet): void { + const framer = new BncsFramer() + let initSeen = false + server.onData((chunk) => { + let data = chunk + if (!initSeen && data[0] === 0x01) { + initSeen = true + data = data.subarray(1) + } + for (const pkt of framer.push(data)) { + if (silent.has(pkt.id) || server.isClosed) continue + if (pkt.id === BncsOpcode.SID_AUTH_INFO) { + server.write(encodeBncsPing(0x11223344)) + const w = new ByteWriter() + w.u32LE(0) + w.u32LE(0xabcdef01) + w.u32LE(0) + w.u32LE(0) + w.u32LE(0) + w.cstring('ver-IX86-1.mpq') + w.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B') + server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array())) + } else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) { + server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0]))) + } else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) { + server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0]))) + } else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) { + const w = new ByteWriter() + w.u32LE(0) + w.u32LE(1) + w.u32LE(1) + w.cstring('D2CS') + w.cstring('Local Test Realm') + server.write(encodeBncsFrame(BncsOpcode.SID_QUERYREALMS2, w.toUint8Array())) + } else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) { + const w = new ByteWriter() + w.u32LE(0x11223344) + w.u32LE(0) + w.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) + w.bytes(new Uint8Array([127, 0, 0, 1])) + w.u16BE(6113) + w.u16LE(0) + w.bytes(new Uint8Array(48).fill(0x55)) + w.cstring('d2webbot_sorc') + server.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array())) + } + } + }) +} + +function wireMcp(server: MemoryStream, silent: ReadonlySet, characters: number): void { + const framer = new McpFramer() + let initSeen = false + server.onData((chunk) => { + let data = chunk + if (!initSeen && data[0] === 0x01) { + initSeen = true + data = data.subarray(1) + } + for (const pkt of framer.push(data)) { + if (silent.has(pkt.id) || server.isClosed) continue + if (pkt.id === McpOpcode.MCP_STARTUP) { + server.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0]))) + } else if (pkt.id === McpOpcode.MCP_CHARLIST2) { + server.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, charList(characters))) + } else if (pkt.id === McpOpcode.MCP_CHARLOGON) { + server.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0]))) + } else if (pkt.id === McpOpcode.MCP_CREATEGAME) { + const reqId = new ByteReader(pkt.payload).u16LE() + const w = new ByteWriter() + w.u16LE(reqId) + w.u16LE(1) + w.u16LE(0) + w.u32LE(0) + server.write(encodeMcpFrame(McpOpcode.MCP_CREATEGAME, w.toUint8Array())) + } else if (pkt.id === McpOpcode.MCP_JOINGAME) { + const reqId = new ByteReader(pkt.payload).u16LE() + const w = new ByteWriter() + w.u16LE(reqId) + w.u16LE(0x0042) + w.u16LE(0) + w.bytes(new Uint8Array([127, 0, 0, 1])) + w.u32LE(0xdeadbeef) + w.u32LE(0) + server.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array())) + } + } + }) +} + +function wireGame(server: MemoryStream): void { + server.onData((chunk) => { + if (chunk[0] === 0x68 && !server.isClosed) { + server.write( + wrapD2gsCompressedBlock( + defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])), + ), + ) + } + }) +} + +export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm { + const silentBncs = new Set(options.silentBncs ?? []) + const silentMcp = new Set(options.silentMcp ?? []) + const characters = options.characters ?? 1 + const connections: FakeConnection[] = [] + + const resolver: EndpointResolver = { + open: async (role: EndpointRole): Promise => { + const [client, server] = createMemoryStreamPair(role) + connections.push({ role, client, server }) + if (role === 'bnet') wireBncs(server, silentBncs) + else if (role === 'realm') wireMcp(server, silentMcp, characters) + else { + wireGame(server) + setTimeout(() => { + if (!server.isClosed) server.write(new Uint8Array([0xaf, 0x01])) + }, 5) + } + return client + }, + } + + return { + resolver, + connections, + latest(role: EndpointRole): FakeConnection { + const found = [...connections].reverse().find((c) => c.role === role) + if (!found) throw new Error(`no ${role} connection was opened`) + return found + }, + allClientStreamsClosed(): boolean { + return connections.every((c) => c.client.isClosed) + }, + } +} diff --git a/tests/netproto/online-flow-session-lost.test.ts b/tests/netproto/online-flow-session-lost.test.ts new file mode 100644 index 0000000..51d03d7 --- /dev/null +++ b/tests/netproto/online-flow-session-lost.test.ts @@ -0,0 +1,195 @@ +/** + * `createD2OnlineFlow` connection-loss detection: every BNCS/MCP/D2GS loss (socket close, request + * timeout, command without a connection) produces exactly one `onSessionLost` event, the flow ends + * in state `closed`, and every connection it opened is closed. + */ + +import { describe, expect, it } from 'vitest' + +import { + createD2OnlineFlow, + type D2OnlineFlow, + type D2OnlineSessionLost, +} from '../../src/netproto/index.ts' +import { FakeClock } from '../../src/netproto/core/clock.ts' +import { TimeoutError } from '../../src/netproto/core/errors.ts' +import { BncsOpcode } from '../../src/netproto/bncs/packets.ts' +import { McpOpcode } from '../../src/netproto/mcp/packets.ts' +import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts' +import { createFakeRealm, type FakeRealm, type FakeRealmOptions } from './fake-realm-servers.ts' + +interface Harness { + readonly realm: FakeRealm + readonly clock: FakeClock + readonly flow: D2OnlineFlow + readonly lost: D2OnlineSessionLost[] +} + +function createHarness(options: FakeRealmOptions = {}): Harness { + const realm = createFakeRealm(options) + const clock = new FakeClock(1000) + const flow = createD2OnlineFlow({ + bnetHost: '127.0.0.1', + resolver: realm.resolver, + clock, + itemTables: getCanonicalItemDataTables(), + }) + const lost: D2OnlineSessionLost[] = [] + flow.onSessionLost((ev) => lost.push(ev)) + return { realm, clock, flow, lost } +} + +/** Log in and enter the realm: the char_select (1 char) / char_create (0 chars) screen. */ +async function toCharacterScreen(h: Harness): Promise { + await h.flow.connectBnet() + await h.flow.login('d2webbot2', 'secret') + await h.flow.enterRealm('D2CS') + expect(h.flow.state).toBe('realm_ready') +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +/** Advance the fake clock in steps so promise continuations can arm their timers in between. */ +async function advance(clock: FakeClock, ms: number): Promise { + const step = 500 + for (let t = 0; t < ms; t += step) { + await flushMicrotasks() + clock.advance(Math.min(step, ms - t)) + } + await flushMicrotasks() +} + +describe('D2OnlineFlow session loss', () => { + it('BNCS server close on char_select: one loss event, flow closed, BNCS + MCP sockets disposed', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + + h.realm.latest('bnet').server.close() + + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'closed', flowState: 'realm_ready' }) + expect(h.lost[0]!.message).toContain('BNCS connection closed') + expect(h.flow.state).toBe('closed') + expect(h.flow.sessionLost).toBe(h.lost[0]) + expect(h.realm.latest('realm').client.isClosed).toBe(true) + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('MCP server close on char_create: one loss event; a later createCharacter fails without a second event', async () => { + const h = createHarness({ characters: 0 }) + await toCharacterScreen(h) + + h.realm.latest('realm').server.close({ kind: 'error', error: new Error('ECONNRESET') }) + + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'error', flowState: 'realm_ready' }) + expect(h.lost[0]!.message).toContain('ECONNRESET') + expect(h.realm.allClientStreamsClosed()).toBe(true) + + await expect( + h.flow.createCharacter({ name: 'NewSorc', classId: 1, expansion: true }), + ).rejects.toThrow(/MCP realm session is not connected for createCharacter/) + expect(h.lost).toHaveLength(1) + expect(h.flow.state).toBe('closed') + }) + + it('realm logon timeout (SID_LOGONREALMEX unanswered): timeout loss, no reconnect, sockets disposed', async () => { + const h = createHarness({ silentBncs: [BncsOpcode.SID_LOGONREALMEX] }) + await h.flow.connectBnet() + await h.flow.login('d2webbot2', 'secret') + + const entering = h.flow.enterRealm('D2CS') + const outcome = entering.then( + () => null, + (err: unknown) => err, + ) + await advance(h.clock, 10_500) + + expect(await outcome).toBeInstanceOf(TimeoutError) + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'timeout', flowState: 'connecting_realm' }) + expect(h.flow.state).toBe('closed') + expect(h.realm.connections.filter((c) => c.role === 'realm')).toHaveLength(0) + expect(h.realm.connections.filter((c) => c.role === 'bnet')).toHaveLength(1) + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('MCP_STARTUP timeout: realm timeout loss closes both the MCP and BNCS sockets', async () => { + const h = createHarness({ silentMcp: [McpOpcode.MCP_STARTUP] }) + await h.flow.connectBnet() + await h.flow.login('d2webbot2', 'secret') + + const outcome = h.flow.enterRealm('D2CS').then( + () => null, + (err: unknown) => err, + ) + await advance(h.clock, 10_500) + + expect(await outcome).toBeInstanceOf(TimeoutError) + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'timeout' }) + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('BNCS and MCP closing at the same time are reported exactly once', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + + h.realm.latest('realm').server.close() + h.realm.latest('bnet').server.close() + await flushMicrotasks() + + expect(h.lost).toHaveLength(1) + expect(h.lost[0]!.session).toBe('mcp') + expect(h.flow.state).toBe('closed') + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('D2GS drop in game: one d2gs loss event and BNCS/MCP/D2GS are all disposed', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + await h.flow.selectCharacter('d2webbot_sorc') + await h.flow.createGame({ name: 'loss-game', password: '', difficulty: 0 }) + await h.flow.joinGame('loss-game', '') + expect(h.flow.state).toBe('ingame') + + h.realm.latest('game').server.close() + + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'd2gs', cause: 'closed', flowState: 'ingame' }) + expect(h.flow.state).toBe('closed') + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('BNCS loss during a game is reported when the game ends, not by aborting the game', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + await h.flow.selectCharacter('d2webbot_sorc') + await h.flow.joinGame('loss-game', '') + + h.realm.latest('bnet').server.close() + expect(h.lost).toHaveLength(0) + expect(h.flow.state).toBe('ingame') + expect(h.realm.latest('game').client.isClosed).toBe(false) + + await h.flow.leaveToLobby() + expect(h.lost).toHaveLength(1) + expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'closed', flowState: 'ingame' }) + expect(h.flow.state).toBe('closed') + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) + + it('close() on purpose emits no loss event and disposes every socket', async () => { + const h = createHarness({ characters: 1 }) + await toCharacterScreen(h) + + await h.flow.close() + + expect(h.lost).toHaveLength(0) + expect(h.flow.sessionLost).toBeNull() + expect(h.flow.state).toBe('closed') + expect(h.realm.allClientStreamsClosed()).toBe(true) + }) +}) 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 3/4] 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') + }) +}) From aa6d109010ea6b69467f4de230c18f466cda7c0a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 12:11:27 +0000 Subject: [PATCH 4/4] test(verify-play): gate 9 asserts session loss lands on bnet_login with the reason --- tools/verify-play.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/verify-play.ts b/tools/verify-play.ts index 6931c0d..313488d 100644 --- a/tools/verify-play.ts +++ b/tools/verify-play.ts @@ -17,7 +17,7 @@ * Gate 7. Dockable Packet Inspector live packet capture, filtering, row selection, and all 4 `.d2cap` fixtures * replay with 0 `UnknownS2c` events * Gate 8. Server host validation (`bare_ip_on_https` rejection) & `localStorage` (`d2web.settings.v1`) persistence across `Page.reload` - * Gate 9. Graceful disconnect recovery to Battle.net lobby overlay with disconnect reason banner, 0 console/page errors. + * Gate 9. Session loss returns to the Battle.net login screen with the reason (loginError + banner), 0 console/page errors. */ import { spawn } from 'node:child_process' @@ -1017,12 +1017,14 @@ async function main(): Promise { throw new Error(`Gate 8b failed: ${JSON.stringify(gate8b)}`) } - // ─── Gate 9: Graceful Disconnect Recovery to Battle.net Lobby Overlay ── + // ─── Gate 9: Session Loss Returns to the Battle.net Login Screen With Reason ── const gate9 = await cdp.evaluate<{ phase: string reason: string | null overlayVisible: boolean bannerVisible: boolean + stage: string + loginError: string | null }>(`(async () => { const app = window.__d2Play; await app.simulateDisconnect('Test disconnect: connection reset by peer'); @@ -1033,6 +1035,8 @@ async function main(): Promise { reason: app.session.disconnectReason, overlayVisible: overlay ? overlay.style.display !== 'none' : false, bannerVisible: banner ? !banner.hidden : false, + stage: app.flowState.stage, + loginError: app.flowState.loginError, }; })()`) console.log('[verify-play] Gate 9 (Graceful disconnect recovery):', gate9) @@ -1040,7 +1044,9 @@ async function main(): Promise { gate9.phase !== 'disconnected' || !gate9.reason?.includes('Test disconnect') || !gate9.overlayVisible || - !gate9.bannerVisible + !gate9.bannerVisible || + gate9.stage !== 'bnet_login' || + !gate9.loginError?.includes('Test disconnect') ) { throw new Error(`Gate 9 failed: ${JSON.stringify(gate9)}`) }