Merge branch 'fix/lobby-disconnect': return to login with reason on session loss

This commit is contained in:
troytt 2026-10-01 12:11:29 +00:00
commit 309738cf79
16 changed files with 1493 additions and 320 deletions

View File

@ -18,6 +18,7 @@ import { fileSource } from '../io/file-source.ts'
import { MountedArchives } from '../../common/mpq/mount.ts' import { MountedArchives } from '../../common/mpq/mount.ts'
import { loadStringTableChain } from '../../common/items/item-names.ts' import { loadStringTableChain } from '../../common/items/item-names.ts'
import { parseTable } from '../../common/items/tables.ts' import { parseTable } from '../../common/items/tables.ts'
import { decodeTbl } from '../../common/formats/tbl.ts'
import { import {
ITEM_NAMES_ZH, ITEM_NAMES_ZH,
MAGIC_PREFIX_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 // (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias
// key `patchstring#<n>`. // key `patchstring#<n>`.
// - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095) // - 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 alias = `patchstring#${idx}`
const ev = eng.tables[0]?.[idx] const ev = eng.tables[0]?.[idx]
const cv = chi.tables[0]?.[idx] const cv = chi.tables[0]?.[idx]
@ -554,6 +556,25 @@ async function main() {
chiOut[alias] = cv chiOut[alias] = cv
} }
// Index-only string.tbl (d2data.mpq) Battle.net connection strings, all keyed "x" in the MPQ, emitted
// under `string#<n>` (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 outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts')
const fileContent = `/** const fileContent = `/**
* AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives: * AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:

View File

@ -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'
}

View File

@ -410,6 +410,8 @@ export class FrontendRenderer {
color?: FrontendColorIndex color?: FrontendColorIndex
align?: 'left' | 'center' align?: 'left' | 'center'
lineStep?: number lineStep?: number
/** Keep at most this many lines; the last kept line ends with '...' when text was cut. */
maxLines?: number
} = {}, } = {},
): void { ): void {
const font = options.font ?? 'font16' const font = options.font ?? 'font16'
@ -426,6 +428,10 @@ export class FrontendRenderer {
} }
} }
if (current) lines.push(current) 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 meta = this.fontMetas[font]
const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3) const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3)
@ -1201,12 +1207,25 @@ export class FrontendRenderer {
this.drawBurningMainLogo() this.drawBurningMainLogo()
const s = this.manifest!.strings const s = this.manifest!.strings
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( this.drawBitmapText(
'Enter your Account Name and Password to log in to Battle.net.', 'Enter your Account Name and Password to log in to Battle.net.',
400, 400,
252, 252,
{ font: 'fontformal12', color: 0, align: 'center' }, { font: 'fontformal12', color: 0, align: 'center' },
) )
}
// Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0) // Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0)
this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' }) this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' })
@ -1225,14 +1244,7 @@ export class FrontendRenderer {
color: 0, color: 0,
}) })
const loginErr = this.state.loginError || this.state.lastError if (!loginErr && this.state.statusText) {
if (loginErr) {
this.drawBitmapText(loginErr, 400, 274, {
font: 'fontformal11',
color: 1,
align: 'center',
})
} else if (this.state.statusText) {
this.drawBitmapText(this.state.statusText, 400, 274, { this.drawBitmapText(this.state.statusText, 400, 274, {
font: 'fontformal11', font: 'fontformal11',
color: 4, color: 4,

View File

@ -15,6 +15,7 @@
import { import {
applyOnlineCharactersToFlowState, applyOnlineCharactersToFlowState,
createInitialFlowState, createInitialFlowState,
returnToLoginAfterSessionLoss,
stepFlowTick, stepFlowTick,
type BnetFlowState, type BnetFlowState,
} from './frontend/flow-state.ts' } from './frontend/flow-state.ts'
@ -50,7 +51,7 @@ import { Minimap } from './ui/minimap.ts'
import { CommandMapper } from './input/command-mapper.ts' import { CommandMapper } from './input/command-mapper.ts'
import { PacketInspector } from './inspector/packet-inspector.ts' import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.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 { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
import { import {
getAssetCacheStatus, getAssetCacheStatus,
@ -411,6 +412,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState) const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
let inGameView = false 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 => { const syncVisibility = (): void => {
bnetShell.style.display = inGameView ? 'none' : 'flex' bnetShell.style.display = inGameView ? 'none' : 'flex'
@ -447,6 +450,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
return return
} }
if (loginInFlight) return
loginInFlight = true
st.statusText = createNewAccount st.statusText = createNewAccount
? 'CREATING ACCOUNT & LOGGING IN...' ? 'CREATING ACCOUNT & LOGGING IN...'
: 'AUTHENTICATING WITH BATTLE.NET & MCP...' : 'AUTHENTICATING WITH BATTLE.NET & MCP...'
@ -464,10 +469,15 @@ async function bootPlayApp(): Promise<PlayAppController> {
applyOnlineCharactersToFlowState(st, res.characters) applyOnlineCharactersToFlowState(st, res.characters)
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
// A lost session was already routed to the login screen by `onSessionLost`.
if (!(err instanceof SessionLostError)) {
st.statusText = '' st.statusText = ''
st.loginError = err instanceof Error ? err.message : String(err) st.loginError = err instanceof Error ? err.message : String(err)
st.lastError = st.loginError st.lastError = st.loginError
} }
} finally {
loginInFlight = false
}
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
}, },
onCreateCharacter: async (st) => { onCreateCharacter: async (st) => {
@ -504,6 +514,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
applyOnlineCharactersToFlowState(st, chars) applyOnlineCharactersToFlowState(st, chars)
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.charCreateError = err instanceof Error ? err.message : String(err) st.charCreateError = err instanceof Error ? err.message : String(err)
st.lastError = st.charCreateError st.lastError = st.charCreateError
@ -519,6 +530,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
applyOnlineCharactersToFlowState(st, chars) applyOnlineCharactersToFlowState(st, chars)
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err) st.lastError = err instanceof Error ? err.message : String(err)
} }
@ -533,6 +545,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
applyOnlineCharactersToFlowState(st, chars) applyOnlineCharactersToFlowState(st, chars)
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err) st.lastError = err instanceof Error ? err.message : String(err)
} }
@ -551,6 +564,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
st.lobbySubPanel = 'create' st.lobbySubPanel = 'create'
st.activeInput = 'gameName' st.activeInput = 'gameName'
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err) st.lastError = err instanceof Error ? err.message : String(err)
} }
@ -577,6 +591,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
} }
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err) st.lastError = err instanceof Error ? err.message : String(err)
} }
@ -616,6 +631,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
inGameView = true inGameView = true
syncVisibility() syncVisibility()
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.stage = 'bnet_lobby' st.stage = 'bnet_lobby'
st.createGameError = err instanceof Error ? err.message : String(err) st.createGameError = err instanceof Error ? err.message : String(err)
@ -643,6 +659,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
inGameView = true inGameView = true
syncVisibility() syncVisibility()
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.statusText = '' st.statusText = ''
st.stage = 'bnet_lobby' st.stage = 'bnet_lobby'
st.createGameError = err instanceof Error ? err.message : String(err) st.createGameError = err instanceof Error ? err.message : String(err)
@ -654,6 +671,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
try { try {
await session.sendLobbyChat(text) await session.sendLobbyChat(text)
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return
st.lastError = err instanceof Error ? err.message : String(err) st.lastError = err instanceof Error ? err.message : String(err)
} }
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
@ -679,10 +697,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
} else if (snap.phase === 'connecting') { } else if (snap.phase === 'connecting') {
toolbar.setConnectionState({ phase: 'connecting', rttMs: null }) toolbar.setConnectionState({ phase: 'connecting', rttMs: null })
} else if (snap.phase === 'disconnected' || snap.phase === 'error') { } 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({ toolbar.setConnectionState({
phase: snap.disconnectReason ? 'error' : 'disconnected', phase: snap.disconnectReason ? 'error' : 'disconnected',
rttMs: null, rttMs: null,
@ -693,18 +707,19 @@ async function bootPlayApp(): Promise<PlayAppController> {
syncVisibility() syncVisibility()
}) })
session.onDisconnect((reason) => { session.onSessionLost((ev) => {
if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') { // 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 inGameView = false
flowState.stage = 'bnet_lobby' returnToLoginAfterSessionLoss(flowState, ev.reason)
}
toolbar.setConnectionState({ toolbar.setConnectionState({
phase: 'error', phase: 'error',
rttMs: null, rttMs: null,
diagnosticKind: 'custom', diagnosticKind: 'custom',
diagnosticMessage: reason, diagnosticMessage: ev.reason,
}) })
syncVisibility() syncVisibility()
frontendRenderer.render(performance.now())
}) })
// Bind mouse & keyboard input // Bind mouse & keyboard input
@ -747,7 +762,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) { if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) {
if (pt.y >= boxY + 70 && pt.y <= boxY + 108) { if (pt.y >= boxY + 70 && pt.y <= boxY + 108) {
session.hudModel.togglePanel('escapeMenu') 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 return
} }
if (pt.y >= boxY + 115 && pt.y <= boxY + 155) { if (pt.y >= boxY + 115 && pt.y <= boxY + 155) {

View File

@ -22,7 +22,6 @@ import {
createWsBridgeResolver, createWsBridgeResolver,
decodeD2gsS2cPacket, decodeD2gsS2cPacket,
unitTypeToId, unitTypeToId,
type ByteStream,
type CharClassId, type CharClassId,
type CharCreateSpec, type CharCreateSpec,
type CharSummary, type CharSummary,
@ -30,6 +29,9 @@ import {
type CloseReason, type CloseReason,
type D2OnlineConfig, type D2OnlineConfig,
type D2OnlineFlow, type D2OnlineFlow,
type D2OnlineSessionKind,
type D2OnlineSessionLost,
type D2OnlineSessionLostCause,
type D2OnlineState, type D2OnlineState,
type EndpointResolver, type EndpointResolver,
type GameCreateSpec, type GameCreateSpec,
@ -40,6 +42,7 @@ import {
type ServerEvent, type ServerEvent,
type UnitType, type UnitType,
} from '../../netproto/index.ts' } from '../../netproto/index.ts'
import { requireTbl } from '../i18n/lang.ts'
import { HudManager } from '../ui/hud-manager.ts' import { HudManager } from '../ui/hud-manager.ts'
import { SoundService } from '../audio/sound-service.ts' import { SoundService } from '../audio/sound-service.ts'
import { AutomapView, getActForArea } from '../automap/automap-view.ts' import { AutomapView, getActForArea } from '../automap/automap-view.ts'
@ -121,6 +124,75 @@ export interface OnlineSessionOptions {
readonly onEscapeMenu?: (() => void) | undefined 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<Record<'bncs' | 'mcp', string>> = {
bncs: 'BNCS',
mcp: 'MCP realm',
}
/** /**
* Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap` * 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 * capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry
@ -169,13 +241,17 @@ export class OnlineSession {
} | null = null } | null = null
private flow: D2OnlineFlow | null = null private flow: D2OnlineFlow | null = null
private activeBnetStream: ByteStream | null = null
private gameAdapter: GameServerAdapter | null = null private gameAdapter: GameServerAdapter | null = null
private unsubGameEvent: (() => void) | null = null private unsubGameEvent: (() => void) | null = null
private unsubGameClose: (() => void) | null = null private unsubGameClose: (() => void) | null = null
private unsubFlowState: (() => void) | null = null private unsubFlowState: (() => void) | null = null
private unsubFlowError: (() => void) | null = null private unsubFlowLost: (() => void) | null = null
private unsubSettings: (() => 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<void> = Promise.resolve()
private phaseValue: OnlineSessionPhase = 'idle' private phaseValue: OnlineSessionPhase = 'idle'
private usernameValue = '' private usernameValue = ''
@ -194,6 +270,7 @@ export class OnlineSession {
private readonly eventListeners = new Set<(ev: ServerEvent) => void>() private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>() private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
private readonly disconnectListeners = new Set<(reason: string) => void>() private readonly disconnectListeners = new Set<(reason: string) => void>()
private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => void>()
constructor(options: OnlineSessionOptions = {}) { constructor(options: OnlineSessionOptions = {}) {
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore() 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) ───────────── // ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
/** /**
@ -399,48 +494,7 @@ export class OnlineSession {
readonly realms: readonly RealmSummary[] readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[] readonly characters: readonly CharSummary[]
}> { }> {
await this.closeFlowQuietly() return await this.establishSession(params, false)
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
}
} }
/** /**
@ -450,15 +504,28 @@ export class OnlineSession {
readonly realms: readonly RealmSummary[] readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[] 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() await this.closeFlowQuietly()
this.sessionLostValue = null
this.lostFlow = null
this.disconnectReasonValue = null this.disconnectReasonValue = null
this.usernameValue = params.username this.usernameValue = params.username
this.setPhase('connecting') this.setPhase('connecting')
const baseResolver = params.wsBridgeBaseUrl const resolver = params.wsBridgeBaseUrl
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl)) ? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
: this.resolver : this.resolver
const resolver = this.wrapResolver(baseResolver)
const flow = this.flowFactory({ const flow = this.flowFactory({
resolver, resolver,
@ -472,13 +539,19 @@ export class OnlineSession {
this.flow = flow this.flow = flow
try { try {
if (createAccount) {
await flow.createAccount(params.username, params.password) await flow.createAccount(params.username, params.password)
}
await flow.login(params.username, params.password) await flow.login(params.username, params.password)
const realms = await flow.listRealms() const realms = await flow.listRealms()
this.realmsValue = realms this.realmsValue = realms
const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null
this.selectedRealmValue = chosenRealm this.selectedRealmValue = chosenRealm
const characters = await flow.enterRealm(chosenRealm?.title) 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 this.charactersValue = characters
if (characters.length > 0 && !this.selectedCharValue) { if (characters.length > 0 && !this.selectedCharValue) {
this.selectedCharValue = characters[0]!.name this.selectedCharValue = characters[0]!.name
@ -486,37 +559,38 @@ export class OnlineSession {
this.setPhase('lobby') this.setPhase('lobby')
return { realms, characters } return { realms, characters }
} catch (err) { } catch (err) {
this.closeBnetStreamQuietly() const lost = this.sessionLostFor(flow)
const msg = err instanceof Error ? err.message : String(err) if (lost) throw lost
this.handleUnexpectedDisconnect(msg) 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 throw err
} }
} }
async refreshCharacters(): Promise<readonly CharSummary[]> { async refreshCharacters(): Promise<readonly CharSummary[]> {
if (!this.flow) { return await this.lobbyOp('refreshCharacters', 'mcp', async (flow) => {
throw new Error('OnlineSession.refreshCharacters: not connected to realm') const chars = await flow.listCharacters()
}
const chars = await this.flow.listCharacters()
this.charactersValue = chars this.charactersValue = chars
this.emitState() this.emitState()
return chars return chars
})
} }
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> { async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
if (!this.flow) { await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec))
throw new Error('OnlineSession.createCharacter: not connected to realm')
}
await this.flow.createCharacter(spec)
this.selectedCharValue = spec.name this.selectedCharValue = spec.name
return await this.refreshCharacters() return await this.refreshCharacters()
} }
async deleteCharacter(name: string): Promise<readonly CharSummary[]> { async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
if (!this.flow) { await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name))
throw new Error('OnlineSession.deleteCharacter: not connected to realm')
}
await this.flow.deleteCharacter(name)
if (this.selectedCharValue === name) { if (this.selectedCharValue === name) {
this.selectedCharValue = null this.selectedCharValue = null
} }
@ -524,18 +598,12 @@ export class OnlineSession {
} }
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> { async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
if (!this.flow) { await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name))
throw new Error('OnlineSession.convertCharacterToExpansion: not connected to realm')
}
await this.flow.upgradeCharacter(name)
return await this.refreshCharacters() return await this.refreshCharacters()
} }
async selectCharacter(name: string): Promise<void> { async selectCharacter(name: string): Promise<void> {
if (!this.flow) { await this.lobbyOp('selectCharacter', 'mcp', (flow) => flow.selectCharacter(name))
throw new Error('OnlineSession.selectCharacter: not connected to realm')
}
await this.flow.selectCharacter(name)
this.selectedCharValue = name this.selectedCharValue = name
const charMeta = this.charactersValue.find((c) => c.name === name) const charMeta = this.charactersValue.find((c) => c.name === name)
if (charMeta) { if (charMeta) {
@ -551,13 +619,7 @@ export class OnlineSession {
async sendLobbyChat(text: string): Promise<void> { async sendLobbyChat(text: string): Promise<void> {
const trimmed = text.trim() const trimmed = text.trim()
if (!trimmed) return if (!trimmed) return
if (this.flow) { await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed))
try {
await this.flow.sendChat(trimmed)
} catch {
// Ignore if BNCS stream was already closed after MCP realm logon
}
}
const prevChatCount = this.world.chatLog.length const prevChatCount = this.world.chatLog.length
this.world.apply({ this.world.apply({
type: 'Chat', type: 'Chat',
@ -576,13 +638,12 @@ export class OnlineSession {
} }
async listGames(filter = ''): Promise<readonly GameListEntry[]> { async listGames(filter = ''): Promise<readonly GameListEntry[]> {
if (!this.flow) { return await this.lobbyOp('listGames', 'mcp', async (flow) => {
throw new Error('OnlineSession.listGames: not connected to realm') const games = await flow.listGames(filter)
}
const games = await this.flow.listGames(filter)
this.gamesValue = games this.gamesValue = games
this.emitState() this.emitState()
return games return games
})
} }
/** /**
@ -593,17 +654,15 @@ export class OnlineSession {
| { readonly mode: 'create'; readonly spec: GameCreateSpec } | { readonly mode: 'create'; readonly spec: GameCreateSpec }
| { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined }, | { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined },
): Promise<GameServerAdapter> { ): Promise<GameServerAdapter> {
if (!this.flow) { return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
throw new Error('OnlineSession.enterGame: not connected to realm')
}
this.setPhase('entering-game') this.setPhase('entering-game')
try { try {
let adapter: GameServerAdapter let adapter: GameServerAdapter
if (request.mode === 'create') { if (request.mode === 'create') {
await this.flow.createGame(request.spec) await flow.createGame(request.spec)
adapter = await this.flow.joinGame(request.spec.name, request.spec.password ?? '') adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '')
} else { } else {
adapter = await this.flow.joinGame(request.name, request.password ?? '') adapter = await flow.joinGame(request.name, request.password ?? '')
} }
this.activeGameNameValue = this.activeGameNameValue =
@ -611,11 +670,15 @@ export class OnlineSession {
this.attachGameServer(adapter) this.attachGameServer(adapter)
return adapter return adapter
} catch (err) { } 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) const msg = err instanceof Error ? err.message : String(err)
this.disconnectReasonValue = msg this.disconnectReasonValue = msg
this.setPhase('lobby') this.setPhase('lobby')
}
throw err throw err
} }
})
} }
/** /**
@ -623,6 +686,11 @@ export class OnlineSession {
*/ */
attachGameServer(adapter: GameServerAdapter): void { attachGameServer(adapter: GameServerAdapter): void {
this.detachGameServerListeners() 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.gameAdapter = adapter
this.disconnectReasonValue = null this.disconnectReasonValue = null
this.setPhase('ingame') this.setPhase('ingame')
@ -633,14 +701,19 @@ export class OnlineSession {
if (typeof adapter.onClose === 'function') { if (typeof adapter.onClose === 'function') {
this.unsubGameClose = adapter.onClose((reason: CloseReason) => { this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
if (this.gameAdapter !== adapter) return
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') { if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
const desc = const technical =
reason.kind === 'error' reason.kind === 'error'
? `Connection error: ${reason.error.message}` ? `D2GS game connection error: ${reason.error.message}`
: reason.kind === 'remote' : reason.kind === 'remote'
? `Server closed connection${reason.code !== undefined ? ` (code ${reason.code})` : ''}` ? `D2GS game connection closed by server${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
: 'Disconnected from game server' : 'D2GS game connection closed'
this.handleUnexpectedDisconnect(desc) this.handleSessionLost({
session: 'd2gs',
cause: reason.kind === 'error' ? 'error' : 'closed',
technical,
})
} }
}) })
} else { } else {
@ -652,11 +725,11 @@ export class OnlineSession {
* Leave the active D2GS game and return to lobby (or idle if standalone). * Leave the active D2GS game and return to lobby (or idle if standalone).
*/ */
async leaveGame(): Promise<void> { async leaveGame(): Promise<void> {
const hadFlow = this.flow !== null const flow = this.flow
this.detachGameServerListeners() this.detachGameServerListeners()
if (this.flow && this.flow.state === 'ingame') { if (flow && flow.state === 'ingame') {
try { try {
await this.flow.leaveToLobby() await flow.leaveToLobby()
} catch { } catch {
// Ignore close errors on leave // Ignore close errors on leave
} }
@ -673,18 +746,97 @@ export class OnlineSession {
} }
this.gameAdapter = null this.gameAdapter = null
this.activeGameNameValue = 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<void> { async disconnect(reason = 'Disconnected by user'): Promise<void> {
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<T>(
op: string,
required: 'bncs' | 'mcp',
fn: (flow: D2OnlineFlow) => Promise<T>,
): Promise<T> {
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() this.detachGameServerListeners()
const adapter = this.gameAdapter
this.gameAdapter = null this.gameAdapter = null
await this.closeFlowQuietly() this.activeGameNameValue = null
this.disconnectReasonValue = reason 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') 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 ───────────────────────── // ─── Outbound ClientCommand Dispatch ─────────────────────────
@ -722,7 +874,7 @@ export class OnlineSession {
this.world.noteOutgoingCommand(cmd) this.world.noteOutgoingCommand(cmd)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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 { private bindFlowListeners(flow: D2OnlineFlow): void {
this.unsubFlowState?.() this.unsubFlowState?.()
this.unsubFlowError?.() this.unsubFlowLost?.()
this.unsubFlowState = flow.onState(() => { this.unsubFlowState = flow.onState(() => {
if (this.flow !== flow) return
this.emitState() this.emitState()
}) })
const maybeErrorHook = (flow as unknown as { onError?: (cb: (err: unknown) => void) => () => void }).onError this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => {
if (typeof maybeErrorHook === 'function') { if (this.flow !== flow) return
this.unsubFlowError = maybeErrorHook.call(flow, (err: unknown) => { this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message })
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
}
} }
private handleUnexpectedDisconnect(reason: string): void { /** The game ended for a client-side reason (bad server data, send failure, GameLogout). */
this.detachGameServerListeners() private handleUnexpectedDisconnect(
this.gameAdapter = null reason: string,
this.closeBnetStreamQuietly() cause: Extract<OnlineSessionLostCause, 'error' | 'closed'> = 'error',
this.disconnectReasonValue = reason useTbl = false,
this.setPhase('disconnected') ): void {
for (const listener of this.disconnectListeners) { this.handleSessionLost({
listener(reason) session: 'd2gs',
} cause,
technical: reason,
...(useTbl ? {} : { reason }),
})
} }
private detachGameServerListeners(): void { private detachGameServerListeners(): void {
@ -1255,12 +1377,12 @@ export class OnlineSession {
this.unsubGameClose = null this.unsubGameClose = null
} }
/** Detach from and close the current flow (which closes every BNCS/MCP/D2GS connection it owns). */
private async closeFlowQuietly(): Promise<void> { private async closeFlowQuietly(): Promise<void> {
this.unsubFlowState?.() this.unsubFlowState?.()
this.unsubFlowState = null this.unsubFlowState = null
this.unsubFlowError?.() this.unsubFlowLost?.()
this.unsubFlowError = null this.unsubFlowLost = null
this.closeBnetStreamQuietly()
if (this.flow) { if (this.flow) {
const current = this.flow const current = this.flow
this.flow = null this.flow = null

View File

@ -4016,7 +4016,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"uniquemultishot": "Multiple Shots", "uniquemultishot": "Multiple Shots",
"UniqueAuraEnchanted": "Aura Enchanted", "UniqueAuraEnchanted": "Aura Enchanted",
"strethereal": "Ethereal (Cannot be Repaired)", "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<Record<string, string>> = { export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
@ -7419,7 +7425,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"uniquemultishot": "多重射擊", "uniquemultishot": "多重射擊",
"UniqueAuraEnchanted": "靈氣加強", "UniqueAuraEnchanted": "靈氣加強",
"strethereal": "无形的 (无法修复)", "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<Record<string, CanonicalIscDescEntry>> = { export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = {

View File

@ -18,6 +18,35 @@ export type D2OnlineState =
| 'ingame' | 'ingame'
| 'closed' | '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 { export interface D2OnlineConfig {
readonly resolver: EndpointResolver readonly resolver: EndpointResolver
readonly bnetHost: string readonly bnetHost: string

View File

@ -1,12 +1,18 @@
/** /**
* `D2OnlineFlow` orchestrating BNCS (port 6112) -> MCP (port 6113) -> D2GS (port 4000) * `D2OnlineFlow` orchestrating BNCS (port 6112) -> MCP (port 6113) -> D2GS (port 4000)
* transitions and keeping BNCS/MCP alive while in-game. * 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 { BncsSession } from '../bncs/session.ts'
import { defaultClock } from '../core/clock.ts' import { defaultClock } from '../core/clock.ts'
import { Emitter } from '../core/emitter.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 { resolveTextCodec } from '../core/text-codec.ts'
import { D2gsAdapter } from '../d2gs/adapter.ts' import { D2gsAdapter } from '../d2gs/adapter.ts'
import { D2gsSession } from '../d2gs/session.ts' import { D2gsSession } from '../d2gs/session.ts'
@ -22,11 +28,22 @@ import type {
RealmInfo, RealmInfo,
} from '../domain/lobby.ts' } from '../domain/lobby.ts'
import { McpSession } from '../mcp/session.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 { export interface D2OnlineFlow {
readonly state: D2OnlineState 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 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<void> connectBnet(): Promise<void>
connect(): Promise<void> connect(): Promise<void>
createAccount(user: string, pass: string): Promise<void> createAccount(user: string, pass: string): Promise<void>
@ -78,6 +95,59 @@ const FALLBACK_EMPTY_TABLES: ItemDataTables = {
getItemMeta: () => undefined, getItemMeta: () => undefined,
} }
const SESSION_LABEL: Readonly<Record<D2OnlineSessionKind, string>> = {
bncs: 'BNCS',
mcp: 'MCP realm',
d2gs: 'D2GS game',
}
type SessionLostInfo = Omit<D2OnlineSessionLost, 'flowState'>
/** 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 { export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
const bnetHost = config.bnetHost const bnetHost = config.bnetHost
const bnetPort = config.bnetPort ?? 6112 const bnetPort = config.bnetPort ?? 6112
@ -90,6 +160,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
const stateEmitter = new Emitter<D2OnlineState>() const stateEmitter = new Emitter<D2OnlineState>()
const chatEmitter = new Emitter<ChatEvent>() const chatEmitter = new Emitter<ChatEvent>()
const lostEmitter = new Emitter<D2OnlineSessionLost>()
let currentState: D2OnlineState = 'idle' let currentState: D2OnlineState = 'idle'
let bncs: BncsSession | undefined let bncs: BncsSession | undefined
@ -99,6 +170,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
let selectedCharName = '' let selectedCharName = ''
let selectedCharClass = 0 let selectedCharClass = 0
let knownChars: readonly CharSummary[] = [] 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 { function setState(next: D2OnlineState): void {
if (currentState !== next) { if (currentState !== next) {
@ -107,18 +183,108 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
} }
} }
function requireBncs(): BncsSession { /**
if (!bncs) { * Close every connection the flow owns. References are cleared before closing, so the resulting
throw new ProtocolError('BNCS session is not connected', { proto: 'bncs' }) * 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 { function declareLost(info: SessionLostInfo): void {
if (!mcp) { if (lost) return
throw new ProtocolError('MCP realm session is not connected', { proto: 'mcp' }) const ev: D2OnlineSessionLost = { ...info, flowState: currentState }
lost = ev
pendingLost = null
teardownConnections()
setState('closed')
lostEmitter.emit(ev)
} }
return mcp
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
}
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<T>(
kind: 'bncs' | 'mcp',
op: () => Promise<T>,
otherFailureCause?: D2OnlineSessionLostCause,
): Promise<T> {
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 = { const flow: D2OnlineFlow = {
@ -126,20 +292,52 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
return currentState return currentState
}, },
get sessionLost(): D2OnlineSessionLost | null {
return lost
},
onState(cb: (s: D2OnlineState) => void): () => void { onState(cb: (s: D2OnlineState) => void): () => void {
return stateEmitter.on(cb) return stateEmitter.on(cb)
}, },
onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void {
return lostEmitter.on(cb)
},
async connectBnet(): Promise<void> { async connectBnet(): Promise<void> {
// A (re)connect starts a fresh lifecycle for this flow.
teardownConnections()
lost = null
pendingLost = null
const startEpoch = epoch
setState('connecting_bnet') setState('connecting_bnet')
const stream = await config.resolver.open('bnet', bnetHost, bnetPort) let stream
bncs = new BncsSession(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, clock,
textCodec, textCodec,
packetTap: config.tap, packetTap: config.tap,
}) })
bncs.onChatEvent((ev) => chatEmitter.emit(ev)) bncs = session
await bncs.handshake() watchLobbyConnection('bncs', session)
session.onChatEvent((ev) => chatEmitter.emit(ev))
await exchange('bncs', () => session.handshake())
}, },
async connect(): Promise<void> { async connect(): Promise<void> {
@ -150,8 +348,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
if (!bncs) { if (!bncs) {
await flow.connectBnet() await flow.connectBnet()
} }
const session = requireBncs() const session = requireBncs('createAccount')
const res = await session.createAccount(user, pass) const res = await exchange('bncs', () => session.createAccount(user, pass))
if (res.status !== 0) { if (res.status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`, `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) { if (!bncs) {
await flow.connectBnet() await flow.connectBnet()
} }
const session = requireBncs() const session = requireBncs('login')
if (opts?.register) { if (opts?.register) {
const firstLogin = await session.login(user, pass) const firstLogin = await exchange('bncs', () => session.login(user, pass))
if (firstLogin.status !== 0) { if (firstLogin.status !== 0) {
const createRes = await session.createAccount(user, pass) const createRes = await exchange('bncs', () => session.createAccount(user, pass))
if (createRes.status !== 0) { if (createRes.status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`, `BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`,
{ proto: 'bncs', packetId: 0x3d }, { proto: 'bncs', packetId: 0x3d },
) )
} }
const secondLogin = await session.login(user, pass) const secondLogin = await exchange('bncs', () => session.login(user, pass))
if (secondLogin.status !== 0) { if (secondLogin.status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`BNCS SID_LOGONRESPONSE2 failed after registration for "${user}" with status=0x${secondLogin.status.toString(16)}`, `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 { } else {
const loginRes = await session.login(user, pass) const loginRes = await exchange('bncs', () => session.login(user, pass))
if (loginRes.status !== 0) { if (loginRes.status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`BNCS SID_LOGONRESPONSE2 failed for "${user}" with status=0x${loginRes.status.toString(16)}`, `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<readonly RealmInfo[]> { async listRealms(): Promise<readonly RealmInfo[]> {
const session = requireBncs() const session = requireBncs('listRealms')
return await session.listRealms() return await exchange('bncs', () => session.listRealms())
}, },
async enterRealm(realm?: string): Promise<readonly CharSummary[]> { async enterRealm(realm?: string): Promise<readonly CharSummary[]> {
const session = requireBncs() const session = requireBncs('enterRealm')
let targetRealm = realm ?? config.defaultRealm let targetRealm = realm ?? config.defaultRealm
if (!targetRealm) { if (!targetRealm) {
const realms = await session.listRealms() const realms = await exchange('bncs', () => session.listRealms())
targetRealm = realms[0]?.title ?? 'D2CS' targetRealm = realms[0]?.title ?? 'D2CS'
} }
const realmTitle = targetRealm
setState('connecting_realm') setState('connecting_realm')
const realmData = await session.logonRealm(targetRealm) const realmData = await exchange('bncs', () => session.logonRealm(realmTitle))
const mcpStream = await config.resolver.open('realm', bnetHost, realmPort || realmData.mcpPort) const port = realmPort || realmData.mcpPort
mcp = new McpSession(mcpStream, { 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, clock,
textCodec, textCodec,
packetTap: config.tap, packetTap: config.tap,
}) })
await mcp.startup(realmData) mcp = realmSession
knownChars = await mcp.listChars(8) watchLobbyConnection('mcp', realmSession)
await exchange('mcp', () => realmSession.startup(realmData), 'connect_failed')
knownChars = await exchange('mcp', () => realmSession.listChars(8))
setState('realm_ready') setState('realm_ready')
return knownChars return knownChars
}, },
@ -235,8 +455,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async listCharacters(): Promise<readonly CharSummary[]> { async listCharacters(): Promise<readonly CharSummary[]> {
const m = requireMcp() const m = requireMcp('listCharacters')
knownChars = await m.listChars(8) knownChars = await exchange('mcp', () => m.listChars(8))
return knownChars return knownChars
}, },
@ -245,12 +465,14 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async createCharacter(spec: CharCreateSpec): Promise<void> { async createCharacter(spec: CharCreateSpec): Promise<void> {
const m = requireMcp() const m = requireMcp('createCharacter')
const status = await m.createChar(spec.name, spec.classId, { const status = await exchange('mcp', () =>
m.createChar(spec.name, spec.classId, {
expansion: spec.expansion, expansion: spec.expansion,
hardcore: spec.hardcore, hardcore: spec.hardcore,
ladder: spec.ladder, ladder: spec.ladder,
}) }),
)
if (status !== 0) { if (status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`MCP_CHARCREATE failed for "${spec.name}" with status=0x${status.toString(16)}`, `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 selectedCharName = spec.name
selectedCharClass = spec.classId selectedCharClass = spec.classId
knownChars = await m.listChars(8) knownChars = await exchange('mcp', () => m.listChars(8))
}, },
async createChar( async createChar(
@ -281,9 +503,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async deleteCharacter(name: string): Promise<void> { async deleteCharacter(name: string): Promise<void> {
const m = requireMcp() const m = requireMcp('deleteCharacter')
await m.deleteChar(name) await exchange('mcp', () => m.deleteChar(name))
knownChars = await m.listChars(8) knownChars = await exchange('mcp', () => m.listChars(8))
}, },
async deleteChar(name: string): Promise<void> { async deleteChar(name: string): Promise<void> {
@ -291,9 +513,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async upgradeCharacter(name: string): Promise<void> { async upgradeCharacter(name: string): Promise<void> {
const m = requireMcp() const m = requireMcp('upgradeCharacter')
await m.upgradeChar(name) await exchange('mcp', () => m.upgradeChar(name))
knownChars = await m.listChars(8) knownChars = await exchange('mcp', () => m.listChars(8))
}, },
async upgradeChar(name: string): Promise<void> { async upgradeChar(name: string): Promise<void> {
@ -301,8 +523,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async selectCharacter(name: string): Promise<void> { async selectCharacter(name: string): Promise<void> {
const m = requireMcp() const m = requireMcp('selectCharacter')
const status = await m.charLogon(name) const status = await exchange('mcp', () => m.charLogon(name))
if (status !== 0) { if (status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`MCP_CHARLOGON failed for "${name}" with status=0x${status.toString(16)}`, `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<readonly GameListEntry[]> { async listGames(filter = ''): Promise<readonly GameListEntry[]> {
const m = requireMcp() const m = requireMcp('listGames')
return await m.listGames(filter) return await exchange('mcp', () => m.listGames(filter))
}, },
async getGameInfo(name: string): Promise<GameInfoDetail> { async getGameInfo(name: string): Promise<GameInfoDetail> {
const m = requireMcp() const m = requireMcp('getGameInfo')
return await m.getGameInfo(name) return await exchange('mcp', () => m.getGameInfo(name))
}, },
async createGame(spec: GameCreateSpec): Promise<void> { async createGame(spec: GameCreateSpec): Promise<void> {
const m = requireMcp() const m = requireMcp('createGame')
const res = await m.createGame(spec) const res = await exchange('mcp', () => m.createGame(spec))
if (res.status !== 0 && res.status !== 0x1e) { if (res.status !== 0 && res.status !== 0x1e) {
throw new ProtocolError( throw new ProtocolError(
`MCP_CREATEGAME failed for "${spec.name}" with status=0x${res.status.toString(16)}`, `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<GameServerAdapter> { async joinGame(name: string, password = ''): Promise<GameServerAdapter> {
const m = requireMcp() const m = requireMcp('joinGame')
if (!selectedCharName) { if (!selectedCharName) {
throw new ProtocolError('Cannot joinGame before selectCharacter()', { proto: 'mcp' }) 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) { if (ticket.status !== 0) {
throw new ProtocolError( throw new ProtocolError(
`MCP_JOINGAME failed for "${name}" with status=0x${ticket.status.toString(16)}`, `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') setState('connecting_game')
const gameStream = await config.resolver.open('game', bnetHost, gamePort) const startEpoch = epoch
d2gsSession = new D2gsSession({ 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, stream: gameStream,
logon: { logon: {
gameHash: ticket.gameHash, gameHash: ticket.gameHash,
@ -367,21 +600,23 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
clock, clock,
tap: config.tap, tap: config.tap,
}) })
const adapter = new D2gsAdapter({
d2gsAdapter = new D2gsAdapter({ session,
session: d2gsSession,
tables, tables,
encoding, encoding,
clock, clock,
}) })
d2gsSession = session
d2gsAdapter = adapter
try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const cancelTimer = clock.setTimeout(() => { const cancelTimer = clock.setTimeout(() => {
unsubState() unsubState()
unsubErr() unsubErr()
reject(new ProtocolError(`D2GS handshake timed out (state=${d2gsSession?.state})`, { proto: 'd2gs' })) reject(new ProtocolError(`D2GS handshake timed out (state=${session.state})`, { proto: 'd2gs' }))
}, 12_000) }, 12_000)
const unsubState = d2gsSession!.onState((s) => { const unsubState = session.onState((s) => {
if (s === 'ingame') { if (s === 'ingame') {
cancelTimer() cancelTimer()
unsubState() unsubState()
@ -395,30 +630,58 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' })) reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' }))
} }
}) })
const unsubErr = d2gsSession!.onError((err) => { const unsubErr = session.onError((err) => {
cancelTimer() cancelTimer()
unsubState() unsubState()
unsubErr() unsubErr()
reject(err) 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.
}
if (!lost) setState(mcp ? 'realm_ready' : 'idle')
}
throw err
}
return d2gsAdapter watchGameConnection(session)
return adapter
}, },
async enterChat(): Promise<void> { async enterChat(): Promise<void> {
const session = requireBncs() const session = requireBncs('enterChat')
await session.enterChat(selectedCharName) await exchange('bncs', () => session.enterChat(selectedCharName))
}, },
async joinChannel(channel: string): Promise<void> { async joinChannel(channel: string): Promise<void> {
const session = requireBncs() const session = requireBncs('joinChannel')
try {
session.joinChannel(channel) session.joinChannel(channel)
} catch (err) {
const info = classifyExchangeFailure('bncs', err)
if (info) onConnectionLost(info)
throw err
}
}, },
async sendChannelChat(text: string): Promise<void> { async sendChannelChat(text: string): Promise<void> {
const session = requireBncs() const session = requireBncs('sendChannelChat')
try {
session.sendChat(text) session.sendChat(text)
} catch (err) {
const info = classifyExchangeFailure('bncs', err)
if (info) onConnectionLost(info)
throw err
}
}, },
async sendChat(text: string): Promise<void> { async sendChat(text: string): Promise<void> {
@ -434,28 +697,27 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}, },
async leaveToLobby(): Promise<void> { async leaveToLobby(): Promise<void> {
if (d2gsAdapter) { const game = d2gsSession
await d2gsAdapter.close()
d2gsAdapter = undefined
d2gsSession = undefined 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') setState(mcp ? 'realm_ready' : 'idle')
}, },
async close(): Promise<void> { async close(): Promise<void> {
if (d2gsAdapter) { pendingLost = null
await d2gsAdapter.close() teardownConnections()
d2gsAdapter = undefined
d2gsSession = undefined
}
if (mcp) {
mcp.close()
mcp = undefined
}
if (bncs) {
bncs.close()
bncs = undefined
}
setState('closed') setState('closed')
}, },
} }

View File

@ -14,7 +14,14 @@ export type {
TcpResolverOptions, TcpResolverOptions,
WsBridgeResolverOptions, WsBridgeResolverOptions,
} from './transport/endpoint.ts' } 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 { D2OnlineFlow } from './flow/online-flow.ts'
export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts' export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts'
export type { ServerEvent } from './domain/server-event.ts' export type { ServerEvent } from './domain/server-event.ts'

View File

@ -7,7 +7,7 @@ export type EndpointKind = 'bnet' | 'realm' | 'game' | 'ts'
export type CloseReason = export type CloseReason =
| { readonly kind: 'local'; readonly code?: number | undefined } | { 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 } | { readonly kind: 'error'; readonly error: Error }
export interface ByteStream { export interface ByteStream {

View File

@ -77,8 +77,11 @@ export class WsStream implements ByteStream {
this.ws.onclose = (ev: { readonly code?: number; readonly reason?: string }) => { this.ws.onclose = (ev: { readonly code?: number; readonly reason?: string }) => {
if (this.closed) return if (this.closed) return
this.closed = true this.closed = true
const reason: CloseReason = const reason: CloseReason = {
ev.code !== undefined ? { kind: 'remote', code: ev.code } : { kind: 'remote' } kind: 'remote',
...(ev.code !== undefined ? { code: ev.code } : {}),
...(ev.reason ? { reason: ev.reason } : {}),
}
this.closeEmitter.emit(reason) this.closeEmitter.emit(reason)
this.dataEmitter.clear() this.dataEmitter.clear()
this.closeEmitter.clear() this.closeEmitter.clear()

View File

@ -560,7 +560,9 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
get state() { get state() {
return flowState return flowState
}, },
sessionLost: null,
onState: vi.fn(() => () => {}), onState: vi.fn(() => () => {}),
onSessionLost: vi.fn(() => () => {}),
connectBnet: vi.fn(async () => {}), connectBnet: vi.fn(async () => {}),
connect: vi.fn(async () => {}), connect: vi.fn(async () => {}),
login: vi.fn(async () => { login: vi.fn(async () => {

View File

@ -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<void> {
const res = await h.session.login({ username: h.ui.accountName, password: h.ui.accountPassword })
applyOnlineCharactersToFlowState(h.ui, res.characters)
}
async function flushMicrotasks(): Promise<void> {
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')
})
})

View File

@ -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<number>): 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<number>, 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<ByteStream> => {
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)
},
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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)
})
})

View File

@ -17,7 +17,7 @@
* Gate 7. Dockable Packet Inspector live packet capture, filtering, row selection, and all 4 `.d2cap` fixtures * Gate 7. Dockable Packet Inspector live packet capture, filtering, row selection, and all 4 `.d2cap` fixtures
* replay with 0 `UnknownS2c` events * replay with 0 `UnknownS2c` events
* Gate 8. Server host validation (`bare_ip_on_https` rejection) & `localStorage` (`d2web.settings.v1`) persistence across `Page.reload` * 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' import { spawn } from 'node:child_process'
@ -1017,12 +1017,14 @@ async function main(): Promise<void> {
throw new Error(`Gate 8b failed: ${JSON.stringify(gate8b)}`) 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<{ const gate9 = await cdp.evaluate<{
phase: string phase: string
reason: string | null reason: string | null
overlayVisible: boolean overlayVisible: boolean
bannerVisible: boolean bannerVisible: boolean
stage: string
loginError: string | null
}>(`(async () => { }>(`(async () => {
const app = window.__d2Play; const app = window.__d2Play;
await app.simulateDisconnect('Test disconnect: connection reset by peer'); await app.simulateDisconnect('Test disconnect: connection reset by peer');
@ -1033,6 +1035,8 @@ async function main(): Promise<void> {
reason: app.session.disconnectReason, reason: app.session.disconnectReason,
overlayVisible: overlay ? overlay.style.display !== 'none' : false, overlayVisible: overlay ? overlay.style.display !== 'none' : false,
bannerVisible: banner ? !banner.hidden : false, bannerVisible: banner ? !banner.hidden : false,
stage: app.flowState.stage,
loginError: app.flowState.loginError,
}; };
})()`) })()`)
console.log('[verify-play] Gate 9 (Graceful disconnect recovery):', gate9) console.log('[verify-play] Gate 9 (Graceful disconnect recovery):', gate9)
@ -1040,7 +1044,9 @@ async function main(): Promise<void> {
gate9.phase !== 'disconnected' || gate9.phase !== 'disconnected' ||
!gate9.reason?.includes('Test disconnect') || !gate9.reason?.includes('Test disconnect') ||
!gate9.overlayVisible || !gate9.overlayVisible ||
!gate9.bannerVisible !gate9.bannerVisible ||
gate9.stage !== 'bnet_login' ||
!gate9.loginError?.includes('Test disconnect')
) { ) {
throw new Error(`Gate 9 failed: ${JSON.stringify(gate9)}`) throw new Error(`Gate 9 failed: ${JSON.stringify(gate9)}`)
} }