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 { loadStringTableChain } from '../../common/items/item-names.ts'
import { parseTable } from '../../common/items/tables.ts'
import { decodeTbl } from '../../common/formats/tbl.ts'
import {
ITEM_NAMES_ZH,
MAGIC_PREFIX_ZH,
@ -545,7 +546,8 @@ async function main() {
// (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias
// key `patchstring#<n>`.
// - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095)
for (const idx of [95]) {
// - 1066: "Diablo II was unable to connect to the realm server. ..." (string index 11066)
for (const idx of [95, 1066]) {
const alias = `patchstring#${idx}`
const ev = eng.tables[0]?.[idx]
const cv = chi.tables[0]?.[idx]
@ -554,6 +556,25 @@ async function main() {
chiOut[alias] = cv
}
// Index-only string.tbl (d2data.mpq) Battle.net connection strings, all keyed "x" in the MPQ, emitted
// under `string#<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 fileContent = `/**
* 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
align?: 'left' | 'center'
lineStep?: number
/** Keep at most this many lines; the last kept line ends with '...' when text was cut. */
maxLines?: number
} = {},
): void {
const font = options.font ?? 'font16'
@ -426,6 +428,10 @@ export class FrontendRenderer {
}
}
if (current) lines.push(current)
if (options.maxLines !== undefined && lines.length > options.maxLines) {
lines.length = Math.max(1, options.maxLines)
lines[lines.length - 1] = `${lines[lines.length - 1]!} ...`
}
const meta = this.fontMetas[font]
const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3)
@ -1201,12 +1207,25 @@ export class FrontendRenderer {
this.drawBurningMainLogo()
const s = this.manifest!.strings
this.drawBitmapText(
'Enter your Account Name and Password to log in to Battle.net.',
400,
252,
{ font: 'fontformal12', color: 0, align: 'center' },
)
const loginErr = this.state.loginError || this.state.lastError
if (loginErr) {
// Session-loss reasons (tbl text + technical detail) can be long: wrap them in the space
// between the logo and the Account Name label instead of the instruction line.
this.drawWrappedBitmapText(loginErr, 400, 234, 700, {
font: 'fontformal11',
color: 1,
align: 'center',
lineStep: 14,
maxLines: 4,
})
} else {
this.drawBitmapText(
'Enter your Account Name and Password to log in to Battle.net.',
400,
252,
{ font: 'fontformal12', color: 0, align: 'center' },
)
}
// Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0)
this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' })
@ -1225,14 +1244,7 @@ export class FrontendRenderer {
color: 0,
})
const loginErr = this.state.loginError || this.state.lastError
if (loginErr) {
this.drawBitmapText(loginErr, 400, 274, {
font: 'fontformal11',
color: 1,
align: 'center',
})
} else if (this.state.statusText) {
if (!loginErr && this.state.statusText) {
this.drawBitmapText(this.state.statusText, 400, 274, {
font: 'fontformal11',
color: 4,

View File

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

View File

@ -22,7 +22,6 @@ import {
createWsBridgeResolver,
decodeD2gsS2cPacket,
unitTypeToId,
type ByteStream,
type CharClassId,
type CharCreateSpec,
type CharSummary,
@ -30,6 +29,9 @@ import {
type CloseReason,
type D2OnlineConfig,
type D2OnlineFlow,
type D2OnlineSessionKind,
type D2OnlineSessionLost,
type D2OnlineSessionLostCause,
type D2OnlineState,
type EndpointResolver,
type GameCreateSpec,
@ -40,6 +42,7 @@ import {
type ServerEvent,
type UnitType,
} from '../../netproto/index.ts'
import { requireTbl } from '../i18n/lang.ts'
import { HudManager } from '../ui/hud-manager.ts'
import { SoundService } from '../audio/sound-service.ts'
import { AutomapView, getActForArea } from '../automap/automap-view.ts'
@ -121,6 +124,75 @@ export interface OnlineSessionOptions {
readonly onEscapeMenu?: (() => void) | undefined
}
/** Which connection ended the session (`client` = ended on purpose by this client). */
export type OnlineSessionLostSource = D2OnlineSessionKind | 'client'
export type OnlineSessionLostCause = D2OnlineSessionLostCause | 'local'
export interface OnlineSessionLostEvent {
/** User-facing reason (1.13c tbl text + technical detail), shown on the login screen. */
readonly reason: string
readonly session: OnlineSessionLostSource
readonly cause: OnlineSessionLostCause
/** Technical description (close code/reason, timed-out request, missing session, ...). */
readonly technical: string
}
/**
* Thrown by `OnlineSession` operations once the Battle.net session is gone. The loss itself has
* already been handled centrally (connections disposed, `onSessionLost` notified), so UI callers
* must not show it as an operation error on the current screen.
*/
export class SessionLostError extends Error {
override readonly name = 'SessionLostError'
constructor(readonly event: OnlineSessionLostEvent) {
super(event.reason)
}
}
/**
* 1.13c tbl string for a session loss (ENG: the frontend bitmap fonts are byte glyphs):
* - `string#5352` "Your connection to Battle.net has been lost."
* - `string#5357` "Your connection has been interrupted" (realm / game server)
* - `string#5349` "Timed out waiting for response."
* - `string#5353` "Battle.net is not responding. Please try connecting again in a few minutes."
* - `patchstring#1066` "Diablo II was unable to connect to the realm server. ..."
* - `string#5351` "Lost Connection to battle.net."
* Returns `null` when no 1.13c string matches; the technical reason is then shown alone.
*/
export function sessionLostTblKey(
session: OnlineSessionLostSource,
cause: OnlineSessionLostCause,
): string | null {
if (session === 'client' || cause === 'local') return null
switch (cause) {
case 'timeout':
return 'string#5349'
case 'not_connected':
return 'string#5351'
case 'connect_failed':
return session === 'bncs' ? 'string#5353' : session === 'mcp' ? 'patchstring#1066' : null
case 'closed':
case 'error':
return session === 'bncs' ? 'string#5352' : 'string#5357'
}
}
export function formatSessionLostReason(
session: OnlineSessionLostSource,
cause: OnlineSessionLostCause,
technical: string,
): string {
const key = sessionLostTblKey(session, cause)
if (key === null) return technical
const text = requireTbl(key, 'ENG').replace(/\s+/g, ' ').trim()
return technical ? `${text} (${technical})` : text
}
const LOBBY_SESSION_LABEL: Readonly<Record<'bncs' | 'mcp', string>> = {
bncs: 'BNCS',
mcp: 'MCP realm',
}
/**
* Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap`
* capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry
@ -169,13 +241,17 @@ export class OnlineSession {
} | null = null
private flow: D2OnlineFlow | null = null
private activeBnetStream: ByteStream | null = null
private gameAdapter: GameServerAdapter | null = null
private unsubGameEvent: (() => void) | null = null
private unsubGameClose: (() => void) | null = null
private unsubFlowState: (() => void) | null = null
private unsubFlowError: (() => void) | null = null
private unsubFlowLost: (() => void) | null = null
private unsubSettings: (() => void) | null = null
/** The loss that ended the current session lifecycle (reset by `login` / `attachGameServer`). */
private sessionLostValue: SessionLostError | null = null
/** The flow that was active when `sessionLostValue` was handled. */
private lostFlow: D2OnlineFlow | null = null
private pendingFlowClose: Promise<void> = Promise.resolve()
private phaseValue: OnlineSessionPhase = 'idle'
private usernameValue = ''
@ -194,6 +270,7 @@ export class OnlineSession {
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
private readonly disconnectListeners = new Set<(reason: string) => void>()
private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => void>()
constructor(options: OnlineSessionOptions = {}) {
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
@ -390,6 +467,24 @@ export class OnlineSession {
}
}
/**
* Fires once per session lifecycle when the Battle.net session ends: a BNCS/MCP/D2GS connection
* closed or errored, a request timed out, a command was issued without its connection, or the
* client ended the session itself (`disconnect`). All connections are already disposed when this
* fires; the UI returns to the login screen with `ev.reason`.
*/
onSessionLost(cb: (ev: OnlineSessionLostEvent) => void): () => void {
this.sessionLostListeners.add(cb)
return () => {
this.sessionLostListeners.delete(cb)
}
}
/** The loss that ended the current session lifecycle, or `null`. */
get sessionLost(): OnlineSessionLostEvent | null {
return this.sessionLostValue?.event ?? null
}
// ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
/**
@ -399,48 +494,7 @@ export class OnlineSession {
readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[]
}> {
await this.closeFlowQuietly()
this.disconnectReasonValue = null
this.usernameValue = params.username
this.setPhase('connecting')
const baseResolver = params.wsBridgeBaseUrl
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
: this.resolver
const resolver = this.wrapResolver(baseResolver)
const flow = this.flowFactory({
resolver,
bnetHost: params.host ?? this.defaultHost,
bnetPort: params.bncsPort ?? this.defaultBncsPort,
itemTables: this.itemTables,
tap: this.packetTap,
})
this.bindFlowListeners(flow)
this.flow = flow
try {
await flow.login(params.username, params.password)
const realms = await flow.listRealms()
this.realmsValue = realms
const realmIdx = params.realmIndex ?? 0
const chosenRealm = realms[realmIdx] ?? realms[0] ?? null
this.selectedRealmValue = chosenRealm
const characters = await flow.enterRealm(chosenRealm?.title)
this.charactersValue = characters
if (characters.length > 0 && !this.selectedCharValue) {
this.selectedCharValue = characters[0]!.name
}
this.setPhase('lobby')
return { realms, characters }
} catch (err) {
this.closeBnetStreamQuietly()
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(msg)
throw err
}
return await this.establishSession(params, false)
}
/**
@ -450,15 +504,28 @@ export class OnlineSession {
readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[]
}> {
return await this.establishSession(params, true)
}
private async establishSession(
params: OnlineSessionLoginParams,
createAccount: boolean,
): Promise<{
readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[]
}> {
this.detachGameServerListeners()
this.gameAdapter = null
await this.closeFlowQuietly()
this.sessionLostValue = null
this.lostFlow = null
this.disconnectReasonValue = null
this.usernameValue = params.username
this.setPhase('connecting')
const baseResolver = params.wsBridgeBaseUrl
const resolver = params.wsBridgeBaseUrl
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
: this.resolver
const resolver = this.wrapResolver(baseResolver)
const flow = this.flowFactory({
resolver,
@ -472,13 +539,19 @@ export class OnlineSession {
this.flow = flow
try {
await flow.createAccount(params.username, params.password)
if (createAccount) {
await flow.createAccount(params.username, params.password)
}
await flow.login(params.username, params.password)
const realms = await flow.listRealms()
this.realmsValue = realms
const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null
this.selectedRealmValue = chosenRealm
const characters = await flow.enterRealm(chosenRealm?.title)
if (this.flow !== flow) {
throw this.sessionLostFor(flow) ?? new Error('OnlineSession.login: superseded by a newer login')
}
this.charactersValue = characters
if (characters.length > 0 && !this.selectedCharValue) {
this.selectedCharValue = characters[0]!.name
@ -486,37 +559,38 @@ export class OnlineSession {
this.setPhase('lobby')
return { realms, characters }
} catch (err) {
this.closeBnetStreamQuietly()
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(msg)
const lost = this.sessionLostFor(flow)
if (lost) throw lost
if (this.flow !== flow) {
// A newer login replaced this attempt; its state belongs to the newer attempt.
throw err
}
// Rejected login (bad password or unknown account): the attempt's connections are closed
// and the login screen shows the server's answer.
this.pendingFlowClose = this.closeFlowQuietly()
this.disconnectReasonValue = err instanceof Error ? err.message : String(err)
this.setPhase('disconnected')
throw err
}
}
async refreshCharacters(): Promise<readonly CharSummary[]> {
if (!this.flow) {
throw new Error('OnlineSession.refreshCharacters: not connected to realm')
}
const chars = await this.flow.listCharacters()
this.charactersValue = chars
this.emitState()
return chars
return await this.lobbyOp('refreshCharacters', 'mcp', async (flow) => {
const chars = await flow.listCharacters()
this.charactersValue = chars
this.emitState()
return chars
})
}
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
if (!this.flow) {
throw new Error('OnlineSession.createCharacter: not connected to realm')
}
await this.flow.createCharacter(spec)
await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec))
this.selectedCharValue = spec.name
return await this.refreshCharacters()
}
async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
if (!this.flow) {
throw new Error('OnlineSession.deleteCharacter: not connected to realm')
}
await this.flow.deleteCharacter(name)
await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name))
if (this.selectedCharValue === name) {
this.selectedCharValue = null
}
@ -524,18 +598,12 @@ export class OnlineSession {
}
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
if (!this.flow) {
throw new Error('OnlineSession.convertCharacterToExpansion: not connected to realm')
}
await this.flow.upgradeCharacter(name)
await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name))
return await this.refreshCharacters()
}
async selectCharacter(name: string): Promise<void> {
if (!this.flow) {
throw new Error('OnlineSession.selectCharacter: not connected to realm')
}
await this.flow.selectCharacter(name)
await this.lobbyOp('selectCharacter', 'mcp', (flow) => flow.selectCharacter(name))
this.selectedCharValue = name
const charMeta = this.charactersValue.find((c) => c.name === name)
if (charMeta) {
@ -551,13 +619,7 @@ export class OnlineSession {
async sendLobbyChat(text: string): Promise<void> {
const trimmed = text.trim()
if (!trimmed) return
if (this.flow) {
try {
await this.flow.sendChat(trimmed)
} catch {
// Ignore if BNCS stream was already closed after MCP realm logon
}
}
await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed))
const prevChatCount = this.world.chatLog.length
this.world.apply({
type: 'Chat',
@ -576,13 +638,12 @@ export class OnlineSession {
}
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
if (!this.flow) {
throw new Error('OnlineSession.listGames: not connected to realm')
}
const games = await this.flow.listGames(filter)
this.gamesValue = games
this.emitState()
return games
return await this.lobbyOp('listGames', 'mcp', async (flow) => {
const games = await flow.listGames(filter)
this.gamesValue = games
this.emitState()
return games
})
}
/**
@ -593,29 +654,31 @@ export class OnlineSession {
| { readonly mode: 'create'; readonly spec: GameCreateSpec }
| { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined },
): Promise<GameServerAdapter> {
if (!this.flow) {
throw new Error('OnlineSession.enterGame: not connected to realm')
}
this.setPhase('entering-game')
try {
let adapter: GameServerAdapter
if (request.mode === 'create') {
await this.flow.createGame(request.spec)
adapter = await this.flow.joinGame(request.spec.name, request.spec.password ?? '')
} else {
adapter = await this.flow.joinGame(request.name, request.password ?? '')
}
return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
this.setPhase('entering-game')
try {
let adapter: GameServerAdapter
if (request.mode === 'create') {
await flow.createGame(request.spec)
adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '')
} else {
adapter = await flow.joinGame(request.name, request.password ?? '')
}
this.activeGameNameValue =
request.mode === 'create' ? request.spec.name : request.name
this.attachGameServer(adapter)
return adapter
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.disconnectReasonValue = msg
this.setPhase('lobby')
throw err
}
this.activeGameNameValue =
request.mode === 'create' ? request.spec.name : request.name
this.attachGameServer(adapter)
return adapter
} catch (err) {
if (!this.sessionLostFor(flow)) {
// Game creation/join refused while BNCS/MCP stay up (1.13c stays in the lobby).
const msg = err instanceof Error ? err.message : String(err)
this.disconnectReasonValue = msg
this.setPhase('lobby')
}
throw err
}
})
}
/**
@ -623,6 +686,11 @@ export class OnlineSession {
*/
attachGameServer(adapter: GameServerAdapter): void {
this.detachGameServerListeners()
if (!this.flow) {
// A standalone adapter (replay harness or tests) starts its own session lifecycle.
this.sessionLostValue = null
this.lostFlow = null
}
this.gameAdapter = adapter
this.disconnectReasonValue = null
this.setPhase('ingame')
@ -633,14 +701,19 @@ export class OnlineSession {
if (typeof adapter.onClose === 'function') {
this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
if (this.gameAdapter !== adapter) return
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
const desc =
const technical =
reason.kind === 'error'
? `Connection error: ${reason.error.message}`
? `D2GS game connection error: ${reason.error.message}`
: reason.kind === 'remote'
? `Server closed connection${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
: 'Disconnected from game server'
this.handleUnexpectedDisconnect(desc)
? `D2GS game connection closed by server${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
: 'D2GS game connection closed'
this.handleSessionLost({
session: 'd2gs',
cause: reason.kind === 'error' ? 'error' : 'closed',
technical,
})
}
})
} else {
@ -652,11 +725,11 @@ export class OnlineSession {
* Leave the active D2GS game and return to lobby (or idle if standalone).
*/
async leaveGame(): Promise<void> {
const hadFlow = this.flow !== null
const flow = this.flow
this.detachGameServerListeners()
if (this.flow && this.flow.state === 'ingame') {
if (flow && flow.state === 'ingame') {
try {
await this.flow.leaveToLobby()
await flow.leaveToLobby()
} catch {
// Ignore close errors on leave
}
@ -673,18 +746,97 @@ export class OnlineSession {
}
this.gameAdapter = null
this.activeGameNameValue = null
this.setPhase(hadFlow ? 'lobby' : 'idle')
// `leaveToLobby` reports a BNCS/MCP loss deferred during the game; that already ended the session.
if (flow && this.sessionLostFor(flow)) return
this.setPhase(flow ? 'lobby' : 'idle')
}
/**
* Gracefully close all active connections and transition to `'disconnected'`.
* End the session on purpose: close every connection and transition to `'disconnected'` with
* exactly `reason`. Routed through the same session-loss path so the UI returns to the login screen.
*/
async disconnect(reason = 'Disconnected by user'): Promise<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()
const adapter = this.gameAdapter
this.gameAdapter = null
await this.closeFlowQuietly()
this.disconnectReasonValue = reason
this.activeGameNameValue = null
if (adapter && !this.flow) {
// No flow owns this adapter (standalone attach), so release its connection here.
try {
adapter.leave()
} catch {
// Already closed.
}
}
this.pendingFlowClose = this.closeFlowQuietly()
this.disconnectReasonValue = event.reason
this.setPhase('disconnected')
for (const listener of [...this.sessionLostListeners]) {
listener(event)
}
for (const listener of [...this.disconnectListeners]) {
listener(event.reason)
}
return error
}
// ─── Outbound ClientCommand Dispatch ─────────────────────────
@ -722,7 +874,7 @@ export class OnlineSession {
this.world.noteOutgoingCommand(cmd)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
}
}
}
@ -1189,63 +1341,33 @@ export class OnlineSession {
}
}
private wrapResolver(baseResolver: EndpointResolver): EndpointResolver {
return {
open: async (kind, host, port) => {
const stream = await baseResolver.open(kind, host, port)
if (kind === 'bnet' || port === 6112) {
this.activeBnetStream = stream
}
return stream
},
}
}
private closeBnetStreamQuietly(): void {
if (this.activeBnetStream) {
const stream = this.activeBnetStream
this.activeBnetStream = null
try {
stream.close()
} catch {
// Ignore close errors
}
}
}
private bindFlowListeners(flow: D2OnlineFlow): void {
this.unsubFlowState?.()
this.unsubFlowError?.()
this.unsubFlowLost?.()
this.unsubFlowState = flow.onState(() => {
if (this.flow !== flow) return
this.emitState()
})
const maybeErrorHook = (flow as unknown as { onError?: (cb: (err: unknown) => void) => () => void }).onError
if (typeof maybeErrorHook === 'function') {
this.unsubFlowError = maybeErrorHook.call(flow, (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
this.handleUnexpectedDisconnect(msg)
} else {
this.disconnectReasonValue = msg
this.emitState()
}
})
} else {
this.unsubFlowError = null
}
this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => {
if (this.flow !== flow) return
this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message })
})
}
private handleUnexpectedDisconnect(reason: string): void {
this.detachGameServerListeners()
this.gameAdapter = null
this.closeBnetStreamQuietly()
this.disconnectReasonValue = reason
this.setPhase('disconnected')
for (const listener of this.disconnectListeners) {
listener(reason)
}
/** The game ended for a client-side reason (bad server data, send failure, GameLogout). */
private handleUnexpectedDisconnect(
reason: string,
cause: Extract<OnlineSessionLostCause, 'error' | 'closed'> = 'error',
useTbl = false,
): void {
this.handleSessionLost({
session: 'd2gs',
cause,
technical: reason,
...(useTbl ? {} : { reason }),
})
}
private detachGameServerListeners(): void {
@ -1255,12 +1377,12 @@ export class OnlineSession {
this.unsubGameClose = null
}
/** Detach from and close the current flow (which closes every BNCS/MCP/D2GS connection it owns). */
private async closeFlowQuietly(): Promise<void> {
this.unsubFlowState?.()
this.unsubFlowState = null
this.unsubFlowError?.()
this.unsubFlowError = null
this.closeBnetStreamQuietly()
this.unsubFlowLost?.()
this.unsubFlowLost = null
if (this.flow) {
const current = this.flow
this.flow = null

View File

@ -4016,7 +4016,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"uniquemultishot": "Multiple Shots",
"UniqueAuraEnchanted": "Aura Enchanted",
"strethereal": "Ethereal (Cannot be Repaired)",
"patchstring#95": "Repair all equipment: %d"
"patchstring#95": "Repair all equipment: %d",
"patchstring#1066": "Diablo II was unable to connect to the realm server. The realm you selected may be unavailable or you may be encountering connection issues. Please make sure your internet connection is working properly and/or your firewall has port 6112 opened.",
"string#5349": "Timed out waiting for response.",
"string#5351": "Lost Connection to battle.net.",
"string#5352": "Your connection to Battle.net has been lost.",
"string#5353": "Battle.net is not responding. Please try connecting again in a few minutes.",
"string#5357": "Your connection has been interrupted"
}
export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
@ -7419,7 +7425,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"uniquemultishot": "多重射擊",
"UniqueAuraEnchanted": "靈氣加強",
"strethereal": "无形的 (无法修复)",
"patchstring#95": "修復所有裝備:%d"
"patchstring#95": "修復所有裝備:%d",
"patchstring#1066": "《暗黑破壞神二》無法連上國度伺服器。 你所選擇的國度可能無法使用,也可能是你遭遇了網路連線的問題。 請確認你的網路連線運作正常,以及/或你的防火牆設定有開啟port 6112。",
"string#5349": "等候回應時間超過。",
"string#5351": "與battle.net失去連線。",
"string#5352": "你和battle.net的連線已經中斷了。",
"string#5353": "Battle.net沒有回應,請稍候數分鐘之後再重試。",
"string#5357": "你的連線被中斷了"
}
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = {

View File

@ -18,6 +18,35 @@ export type D2OnlineState =
| 'ingame'
| 'closed'
/** Which live connection of the flow was lost. */
export type D2OnlineSessionKind = 'bncs' | 'mcp' | 'd2gs'
/**
* Why the connection was lost:
* - `closed`: the socket closed without the flow asking for it (server/bridge close).
* - `error`: the transport reported an error (socket error / write on a dead socket).
* - `timeout`: a request/response exchange on that connection timed out.
* - `connect_failed`: opening the connection (or the realm MCP startup) failed.
* - `not_connected`: a command needed the connection but it is not connected.
*/
export type D2OnlineSessionLostCause = 'closed' | 'error' | 'timeout' | 'connect_failed' | 'not_connected'
/**
* Emitted exactly once per `D2OnlineFlow` when a connection it owns is lost. By the time listeners run,
* the flow has already closed every remaining connection and is in state `closed`.
*/
export interface D2OnlineSessionLost {
readonly session: D2OnlineSessionKind
readonly cause: D2OnlineSessionLostCause
/** Concrete technical description (connection, close code/reason, timed-out packet, error text). */
readonly message: string
readonly closeCode?: number | undefined
readonly closeReason?: string | undefined
readonly error?: Error | undefined
/** Flow state at the moment the loss was detected. */
readonly flowState: D2OnlineState
}
export interface D2OnlineConfig {
readonly resolver: EndpointResolver
readonly bnetHost: string

View File

@ -1,12 +1,18 @@
/**
* `D2OnlineFlow` orchestrating BNCS (port 6112) -> MCP (port 6113) -> D2GS (port 4000)
* transitions and keeping BNCS/MCP alive while in-game.
*
* Connection loss is detected centrally here: every BNCS/MCP/D2GS connection the flow opens is
* watched, and any close the flow did not ask for, any request timeout, any connect failure and any
* command issued while its connection is gone produces exactly one `onSessionLost` event per flow.
* Before that event is emitted the flow closes every remaining connection (no leaked sockets, timers or
* reconnects) and enters state `closed`.
*/
import { BncsSession } from '../bncs/session.ts'
import { defaultClock } from '../core/clock.ts'
import { Emitter } from '../core/emitter.ts'
import { ProtocolError } from '../core/errors.ts'
import { ClosedError, ProtocolError, TimeoutError } from '../core/errors.ts'
import { resolveTextCodec } from '../core/text-codec.ts'
import { D2gsAdapter } from '../d2gs/adapter.ts'
import { D2gsSession } from '../d2gs/session.ts'
@ -22,11 +28,22 @@ import type {
RealmInfo,
} from '../domain/lobby.ts'
import { McpSession } from '../mcp/session.ts'
import type { D2OnlineConfig, D2OnlineState } from './config.ts'
import type { CloseReason } from '../transport/byte-stream.ts'
import type {
D2OnlineConfig,
D2OnlineSessionKind,
D2OnlineSessionLost,
D2OnlineSessionLostCause,
D2OnlineState,
} from './config.ts'
export interface D2OnlineFlow {
readonly state: D2OnlineState
/** The loss that ended this flow, or `null` while the flow is healthy / was closed on purpose. */
readonly sessionLost: D2OnlineSessionLost | null
onState(cb: (s: D2OnlineState) => void): () => void
/** Fires once when a BNCS/MCP/D2GS connection is lost (see `D2OnlineSessionLost`). */
onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void
connectBnet(): Promise<void>
connect(): Promise<void>
createAccount(user: string, pass: string): Promise<void>
@ -78,6 +95,59 @@ const FALLBACK_EMPTY_TABLES: ItemDataTables = {
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 {
const bnetHost = config.bnetHost
const bnetPort = config.bnetPort ?? 6112
@ -90,6 +160,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
const stateEmitter = new Emitter<D2OnlineState>()
const chatEmitter = new Emitter<ChatEvent>()
const lostEmitter = new Emitter<D2OnlineSessionLost>()
let currentState: D2OnlineState = 'idle'
let bncs: BncsSession | undefined
@ -99,6 +170,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
let selectedCharName = ''
let selectedCharClass = 0
let knownChars: readonly CharSummary[] = []
let lost: D2OnlineSessionLost | null = null
/** BNCS/MCP loss seen while a D2GS game runs: reported when that game ends instead of aborting it. */
let pendingLost: D2OnlineSessionLost | null = null
/** Bumped by every teardown so connections that finish opening afterwards are disposed, not adopted. */
let epoch = 0
function setState(next: D2OnlineState): void {
if (currentState !== next) {
@ -107,18 +183,108 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}
}
function requireBncs(): BncsSession {
if (!bncs) {
throw new ProtocolError('BNCS session is not connected', { proto: 'bncs' })
/**
* Close every connection the flow owns. References are cleared before closing, so the resulting
* close callbacks fail the identity checks in the watchers and are never reported as losses.
*/
function teardownConnections(): void {
epoch++
const game = d2gsSession
d2gsSession = undefined
d2gsAdapter = undefined
const realm = mcp
mcp = undefined
const bnet = bncs
bncs = undefined
if (game) {
try {
game.leaveAndClose()
} catch {
// The socket is already gone; nothing left to release.
}
}
return bncs
realm?.close()
bnet?.close()
}
function requireMcp(): McpSession {
if (!mcp) {
throw new ProtocolError('MCP realm session is not connected', { proto: 'mcp' })
function declareLost(info: SessionLostInfo): void {
if (lost) return
const ev: D2OnlineSessionLost = { ...info, flowState: currentState }
lost = ev
pendingLost = null
teardownConnections()
setState('closed')
lostEmitter.emit(ev)
}
function onConnectionLost(info: SessionLostInfo): void {
if (lost) return
if (info.session !== 'd2gs' && d2gsSession !== undefined && currentState === 'ingame') {
pendingLost ??= { ...info, flowState: currentState }
if (info.session === 'bncs') {
bncs?.close()
bncs = undefined
} else {
mcp?.close()
mcp = undefined
}
return
}
return mcp
declareLost(info)
}
function watchLobbyConnection(kind: 'bncs' | 'mcp', session: BncsSession | McpSession): void {
session.onClose((reason) => {
const active = kind === 'bncs' ? bncs : mcp
if (lost || active !== session) return
onConnectionLost(describeClose(kind, reason))
})
}
function watchGameConnection(session: D2gsSession): void {
session.onClose((reason) => {
if (lost || d2gsSession !== session) return
const info = describeClose('d2gs', reason)
const earlier = pendingLost
declareLost(earlier ? { ...info, message: `${info.message}; earlier: ${earlier.message}` } : info)
})
}
async function exchange<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 = {
@ -126,20 +292,52 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
return currentState
},
get sessionLost(): D2OnlineSessionLost | null {
return lost
},
onState(cb: (s: D2OnlineState) => void): () => void {
return stateEmitter.on(cb)
},
onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void {
return lostEmitter.on(cb)
},
async connectBnet(): Promise<void> {
// A (re)connect starts a fresh lifecycle for this flow.
teardownConnections()
lost = null
pendingLost = null
const startEpoch = epoch
setState('connecting_bnet')
const stream = await config.resolver.open('bnet', bnetHost, bnetPort)
bncs = new BncsSession(stream, {
let stream
try {
stream = await config.resolver.open('bnet', bnetHost, bnetPort)
} catch (err) {
if (startEpoch === epoch) {
declareLost({
session: 'bncs',
cause: 'connect_failed',
message: `BNCS connect to ${bnetHost}:${bnetPort} failed: ${errorText(err)}`,
...(err instanceof Error ? { error: err } : {}),
})
}
throw err
}
if (startEpoch !== epoch) {
stream.close()
throw staleConnectionError('bncs')
}
const session = new BncsSession(stream, {
clock,
textCodec,
packetTap: config.tap,
})
bncs.onChatEvent((ev) => chatEmitter.emit(ev))
await bncs.handshake()
bncs = session
watchLobbyConnection('bncs', session)
session.onChatEvent((ev) => chatEmitter.emit(ev))
await exchange('bncs', () => session.handshake())
},
async connect(): Promise<void> {
@ -150,8 +348,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
if (!bncs) {
await flow.connectBnet()
}
const session = requireBncs()
const res = await session.createAccount(user, pass)
const session = requireBncs('createAccount')
const res = await exchange('bncs', () => session.createAccount(user, pass))
if (res.status !== 0) {
throw new ProtocolError(
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`,
@ -172,18 +370,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
if (!bncs) {
await flow.connectBnet()
}
const session = requireBncs()
const session = requireBncs('login')
if (opts?.register) {
const firstLogin = await session.login(user, pass)
const firstLogin = await exchange('bncs', () => session.login(user, pass))
if (firstLogin.status !== 0) {
const createRes = await session.createAccount(user, pass)
const createRes = await exchange('bncs', () => session.createAccount(user, pass))
if (createRes.status !== 0) {
throw new ProtocolError(
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`,
{ proto: 'bncs', packetId: 0x3d },
)
}
const secondLogin = await session.login(user, pass)
const secondLogin = await exchange('bncs', () => session.login(user, pass))
if (secondLogin.status !== 0) {
throw new ProtocolError(
`BNCS SID_LOGONRESPONSE2 failed after registration for "${user}" with status=0x${secondLogin.status.toString(16)}`,
@ -192,7 +390,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}
}
} else {
const loginRes = await session.login(user, pass)
const loginRes = await exchange('bncs', () => session.login(user, pass))
if (loginRes.status !== 0) {
throw new ProtocolError(
`BNCS SID_LOGONRESPONSE2 failed for "${user}" with status=0x${loginRes.status.toString(16)}`,
@ -204,28 +402,50 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async listRealms(): Promise<readonly RealmInfo[]> {
const session = requireBncs()
return await session.listRealms()
const session = requireBncs('listRealms')
return await exchange('bncs', () => session.listRealms())
},
async enterRealm(realm?: string): Promise<readonly CharSummary[]> {
const session = requireBncs()
const session = requireBncs('enterRealm')
let targetRealm = realm ?? config.defaultRealm
if (!targetRealm) {
const realms = await session.listRealms()
const realms = await exchange('bncs', () => session.listRealms())
targetRealm = realms[0]?.title ?? 'D2CS'
}
const realmTitle = targetRealm
setState('connecting_realm')
const realmData = await session.logonRealm(targetRealm)
const mcpStream = await config.resolver.open('realm', bnetHost, realmPort || realmData.mcpPort)
mcp = new McpSession(mcpStream, {
const realmData = await exchange('bncs', () => session.logonRealm(realmTitle))
const port = realmPort || realmData.mcpPort
const startEpoch = epoch
let mcpStream
try {
mcpStream = await config.resolver.open('realm', bnetHost, port)
} catch (err) {
if (startEpoch === epoch) {
declareLost({
session: 'mcp',
cause: 'connect_failed',
message: `MCP realm connect to ${bnetHost}:${port} failed: ${errorText(err)}`,
...(err instanceof Error ? { error: err } : {}),
})
}
throw err
}
if (startEpoch !== epoch) {
mcpStream.close()
throw staleConnectionError('mcp')
}
const realmSession = new McpSession(mcpStream, {
clock,
textCodec,
packetTap: config.tap,
})
await mcp.startup(realmData)
knownChars = await mcp.listChars(8)
mcp = realmSession
watchLobbyConnection('mcp', realmSession)
await exchange('mcp', () => realmSession.startup(realmData), 'connect_failed')
knownChars = await exchange('mcp', () => realmSession.listChars(8))
setState('realm_ready')
return knownChars
},
@ -235,8 +455,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async listCharacters(): Promise<readonly CharSummary[]> {
const m = requireMcp()
knownChars = await m.listChars(8)
const m = requireMcp('listCharacters')
knownChars = await exchange('mcp', () => m.listChars(8))
return knownChars
},
@ -245,12 +465,14 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async createCharacter(spec: CharCreateSpec): Promise<void> {
const m = requireMcp()
const status = await m.createChar(spec.name, spec.classId, {
expansion: spec.expansion,
hardcore: spec.hardcore,
ladder: spec.ladder,
})
const m = requireMcp('createCharacter')
const status = await exchange('mcp', () =>
m.createChar(spec.name, spec.classId, {
expansion: spec.expansion,
hardcore: spec.hardcore,
ladder: spec.ladder,
}),
)
if (status !== 0) {
throw new ProtocolError(
`MCP_CHARCREATE failed for "${spec.name}" with status=0x${status.toString(16)}`,
@ -259,7 +481,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}
selectedCharName = spec.name
selectedCharClass = spec.classId
knownChars = await m.listChars(8)
knownChars = await exchange('mcp', () => m.listChars(8))
},
async createChar(
@ -281,9 +503,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async deleteCharacter(name: string): Promise<void> {
const m = requireMcp()
await m.deleteChar(name)
knownChars = await m.listChars(8)
const m = requireMcp('deleteCharacter')
await exchange('mcp', () => m.deleteChar(name))
knownChars = await exchange('mcp', () => m.listChars(8))
},
async deleteChar(name: string): Promise<void> {
@ -291,9 +513,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async upgradeCharacter(name: string): Promise<void> {
const m = requireMcp()
await m.upgradeChar(name)
knownChars = await m.listChars(8)
const m = requireMcp('upgradeCharacter')
await exchange('mcp', () => m.upgradeChar(name))
knownChars = await exchange('mcp', () => m.listChars(8))
},
async upgradeChar(name: string): Promise<void> {
@ -301,8 +523,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async selectCharacter(name: string): Promise<void> {
const m = requireMcp()
const status = await m.charLogon(name)
const m = requireMcp('selectCharacter')
const status = await exchange('mcp', () => m.charLogon(name))
if (status !== 0) {
throw new ProtocolError(
`MCP_CHARLOGON failed for "${name}" with status=0x${status.toString(16)}`,
@ -321,18 +543,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
const m = requireMcp()
return await m.listGames(filter)
const m = requireMcp('listGames')
return await exchange('mcp', () => m.listGames(filter))
},
async getGameInfo(name: string): Promise<GameInfoDetail> {
const m = requireMcp()
return await m.getGameInfo(name)
const m = requireMcp('getGameInfo')
return await exchange('mcp', () => m.getGameInfo(name))
},
async createGame(spec: GameCreateSpec): Promise<void> {
const m = requireMcp()
const res = await m.createGame(spec)
const m = requireMcp('createGame')
const res = await exchange('mcp', () => m.createGame(spec))
if (res.status !== 0 && res.status !== 0x1e) {
throw new ProtocolError(
`MCP_CREATEGAME failed for "${spec.name}" with status=0x${res.status.toString(16)}`,
@ -342,11 +564,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async joinGame(name: string, password = ''): Promise<GameServerAdapter> {
const m = requireMcp()
const m = requireMcp('joinGame')
if (!selectedCharName) {
throw new ProtocolError('Cannot joinGame before selectCharacter()', { proto: 'mcp' })
}
const ticket = await m.joinGame(name, password)
const ticket = await exchange('mcp', () => m.joinGame(name, password))
if (ticket.status !== 0) {
throw new ProtocolError(
`MCP_JOINGAME failed for "${name}" with status=0x${ticket.status.toString(16)}`,
@ -355,8 +577,19 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
}
setState('connecting_game')
const gameStream = await config.resolver.open('game', bnetHost, gamePort)
d2gsSession = new D2gsSession({
const startEpoch = epoch
let gameStream
try {
gameStream = await config.resolver.open('game', bnetHost, gamePort)
} catch (err) {
if (startEpoch === epoch && !lost) setState(mcp ? 'realm_ready' : 'idle')
throw err
}
if (startEpoch !== epoch) {
gameStream.close()
throw new ProtocolError('D2OnlineFlow was closed while D2GS was connecting', { proto: 'd2gs' })
}
const session = new D2gsSession({
stream: gameStream,
logon: {
gameHash: ticket.gameHash,
@ -367,58 +600,88 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
clock,
tap: config.tap,
})
d2gsAdapter = new D2gsAdapter({
session: d2gsSession,
const adapter = new D2gsAdapter({
session,
tables,
encoding,
clock,
})
d2gsSession = session
d2gsAdapter = adapter
await new Promise<void>((resolve, reject) => {
const cancelTimer = clock.setTimeout(() => {
unsubState()
unsubErr()
reject(new ProtocolError(`D2GS handshake timed out (state=${d2gsSession?.state})`, { proto: 'd2gs' }))
}, 12_000)
const unsubState = d2gsSession!.onState((s) => {
if (s === 'ingame') {
try {
await new Promise<void>((resolve, reject) => {
const cancelTimer = clock.setTimeout(() => {
unsubState()
unsubErr()
reject(new ProtocolError(`D2GS handshake timed out (state=${session.state})`, { proto: 'd2gs' }))
}, 12_000)
const unsubState = session.onState((s) => {
if (s === 'ingame') {
cancelTimer()
unsubState()
unsubErr()
setState('ingame')
resolve()
} else if (s === 'closed') {
cancelTimer()
unsubState()
unsubErr()
reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' }))
}
})
const unsubErr = session.onError((err) => {
cancelTimer()
unsubState()
unsubErr()
setState('ingame')
resolve()
} else if (s === 'closed') {
cancelTimer()
unsubState()
unsubErr()
reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' }))
reject(err)
})
})
} catch (err) {
// A failed game join leaves BNCS/MCP usable (1.13c returns to the lobby); only the D2GS
// connection of this attempt is released.
if (d2gsSession === session) {
d2gsSession = undefined
d2gsAdapter = undefined
try {
session.leaveAndClose()
} catch {
// Already closed.
}
})
const unsubErr = d2gsSession!.onError((err) => {
cancelTimer()
unsubState()
unsubErr()
reject(err)
})
})
if (!lost) setState(mcp ? 'realm_ready' : 'idle')
}
throw err
}
return d2gsAdapter
watchGameConnection(session)
return adapter
},
async enterChat(): Promise<void> {
const session = requireBncs()
await session.enterChat(selectedCharName)
const session = requireBncs('enterChat')
await exchange('bncs', () => session.enterChat(selectedCharName))
},
async joinChannel(channel: string): Promise<void> {
const session = requireBncs()
session.joinChannel(channel)
const session = requireBncs('joinChannel')
try {
session.joinChannel(channel)
} catch (err) {
const info = classifyExchangeFailure('bncs', err)
if (info) onConnectionLost(info)
throw err
}
},
async sendChannelChat(text: string): Promise<void> {
const session = requireBncs()
session.sendChat(text)
const session = requireBncs('sendChannelChat')
try {
session.sendChat(text)
} catch (err) {
const info = classifyExchangeFailure('bncs', err)
if (info) onConnectionLost(info)
throw err
}
},
async sendChat(text: string): Promise<void> {
@ -434,28 +697,27 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
},
async leaveToLobby(): Promise<void> {
if (d2gsAdapter) {
await d2gsAdapter.close()
d2gsAdapter = undefined
d2gsSession = undefined
const game = d2gsSession
d2gsSession = undefined
d2gsAdapter = undefined
if (game) {
try {
game.leaveAndClose()
} catch {
// Already closed.
}
}
if (pendingLost) {
const { flowState: _flowState, ...info } = pendingLost
declareLost(info)
return
}
setState(mcp ? 'realm_ready' : 'idle')
},
async close(): Promise<void> {
if (d2gsAdapter) {
await d2gsAdapter.close()
d2gsAdapter = undefined
d2gsSession = undefined
}
if (mcp) {
mcp.close()
mcp = undefined
}
if (bncs) {
bncs.close()
bncs = undefined
}
pendingLost = null
teardownConnections()
setState('closed')
},
}

View File

@ -14,7 +14,14 @@ export type {
TcpResolverOptions,
WsBridgeResolverOptions,
} from './transport/endpoint.ts'
export type { D2OnlineConfig, D2OnlineConfig as OnlineConfig, D2OnlineState } from './flow/config.ts'
export type {
D2OnlineConfig,
D2OnlineConfig as OnlineConfig,
D2OnlineSessionKind,
D2OnlineSessionLost,
D2OnlineSessionLostCause,
D2OnlineState,
} from './flow/config.ts'
export type { D2OnlineFlow } from './flow/online-flow.ts'
export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts'
export type { ServerEvent } from './domain/server-event.ts'

View File

@ -7,7 +7,7 @@ export type EndpointKind = 'bnet' | 'realm' | 'game' | 'ts'
export type CloseReason =
| { readonly kind: 'local'; readonly code?: number | undefined }
| { readonly kind: 'remote'; readonly code?: number | undefined }
| { readonly kind: 'remote'; readonly code?: number | undefined; readonly reason?: string | undefined }
| { readonly kind: 'error'; readonly error: Error }
export interface ByteStream {

View File

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

View File

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

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
* replay with 0 `UnknownS2c` events
* Gate 8. Server host validation (`bare_ip_on_https` rejection) & `localStorage` (`d2web.settings.v1`) persistence across `Page.reload`
* Gate 9. Graceful disconnect recovery to Battle.net lobby overlay with disconnect reason banner, 0 console/page errors.
* Gate 9. Session loss returns to the Battle.net login screen with the reason (loginError + banner), 0 console/page errors.
*/
import { spawn } from 'node:child_process'
@ -1017,12 +1017,14 @@ async function main(): Promise<void> {
throw new Error(`Gate 8b failed: ${JSON.stringify(gate8b)}`)
}
// ─── Gate 9: Graceful Disconnect Recovery to Battle.net Lobby Overlay ──
// ─── Gate 9: Session Loss Returns to the Battle.net Login Screen With Reason ──
const gate9 = await cdp.evaluate<{
phase: string
reason: string | null
overlayVisible: boolean
bannerVisible: boolean
stage: string
loginError: string | null
}>(`(async () => {
const app = window.__d2Play;
await app.simulateDisconnect('Test disconnect: connection reset by peer');
@ -1033,6 +1035,8 @@ async function main(): Promise<void> {
reason: app.session.disconnectReason,
overlayVisible: overlay ? overlay.style.display !== 'none' : false,
bannerVisible: banner ? !banner.hidden : false,
stage: app.flowState.stage,
loginError: app.flowState.loginError,
};
})()`)
console.log('[verify-play] Gate 9 (Graceful disconnect recovery):', gate9)
@ -1040,7 +1044,9 @@ async function main(): Promise<void> {
gate9.phase !== 'disconnected' ||
!gate9.reason?.includes('Test disconnect') ||
!gate9.overlayVisible ||
!gate9.bannerVisible
!gate9.bannerVisible ||
gate9.stage !== 'bnet_login' ||
!gate9.loginError?.includes('Test disconnect')
) {
throw new Error(`Gate 9 failed: ${JSON.stringify(gate9)}`)
}