fix(client): return to the login screen with a 1.13c reason on session loss

OnlineSession duck-typed a flow onError hook that does not exist, so pre-game
losses never propagated; a failed login left a half-dead flow in place, and
main.ts only routed in-game disconnects (to a dead lobby). Concurrent login
attempts (LOG IN, ACCOUNT SETTINGS and Enter all call onLogin) replaced each
other's flow, leaving char_create bound to a flow without MCP.

- OnlineSession.handleSessionLost is the single, idempotent end of a session:
  disposes the game adapter and the flow (all sockets/timers), records the
  reason, enters `disconnected` and notifies onSessionLost/onDisconnect once.
  Flow loss events, D2GS adapter close, in-game client errors and disconnect()
  all route through it.
- Pre-game operations (create/delete/convert/select character, list games,
  enter game, lobby chat) run via lobbyOp: no session -> not_connected loss,
  failure caused by the loss -> SessionLostError; status errors stay on screen.
- Reasons use ENG 1.13c tbl text plus the technical detail: string#5352
  (BNCS lost), string#5357 (realm/game interrupted), string#5349 (timeout),
  string#5353 / patchstring#1066 (connect failed), string#5351 (not
  connected).
- main.ts: one onSessionLost handler -> returnToLoginAfterSessionLoss (keeps
  the typed account name, clears roster/game state); catch blocks ignore
  SessionLostError; onLogin allows one attempt at a time; no bnet_lobby
  routing for dead sessions. The login screen wraps long reasons.

TAG=agy
CONV=109a3012-103a-42f0-8ae1-bbc0857047ef
This commit is contained in:
troytt 2026-10-01 12:01:47 +00:00
parent 5882efc109
commit e623e992dc
5 changed files with 631 additions and 203 deletions

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
this.drawBitmapText( const loginErr = this.state.loginError || this.state.lastError
'Enter your Account Name and Password to log in to Battle.net.', if (loginErr) {
400, // Session-loss reasons (tbl text + technical detail) can be long: wrap them in the space
252, // between the logo and the Account Name label instead of the instruction line.
{ font: 'fontformal12', color: 0, align: 'center' }, 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) // 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,9 +469,14 @@ async function bootPlayApp(): Promise<PlayAppController> {
applyOnlineCharactersToFlowState(st, res.characters) applyOnlineCharactersToFlowState(st, res.characters)
st.statusText = '' st.statusText = ''
} catch (err) { } catch (err) {
st.statusText = '' // A lost session was already routed to the login screen by `onSessionLost`.
st.loginError = err instanceof Error ? err.message : String(err) if (!(err instanceof SessionLostError)) {
st.lastError = st.loginError st.statusText = ''
st.loginError = err instanceof Error ? err.message : String(err)
st.lastError = st.loginError
}
} finally {
loginInFlight = false
} }
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
}, },
@ -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
inGameView = false // connections; show the login screen with the reason (no automatic reconnect).
flowState.stage = 'bnet_lobby' inGameView = false
} 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 {
await flow.createAccount(params.username, params.password) if (createAccount) {
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()
} this.charactersValue = chars
const chars = await this.flow.listCharacters() this.emitState()
this.charactersValue = chars return chars
this.emitState() })
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)
} this.gamesValue = games
const games = await this.flow.listGames(filter) this.emitState()
this.gamesValue = games return games
this.emitState() })
return games
} }
/** /**
@ -593,29 +654,31 @@ 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')
} try {
this.setPhase('entering-game') let adapter: GameServerAdapter
try { if (request.mode === 'create') {
let adapter: GameServerAdapter await flow.createGame(request.spec)
if (request.mode === 'create') { adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '')
await this.flow.createGame(request.spec) } else {
adapter = await this.flow.joinGame(request.spec.name, request.spec.password ?? '') adapter = await flow.joinGame(request.name, request.password ?? '')
} else { }
adapter = await this.flow.joinGame(request.name, request.password ?? '')
}
this.activeGameNameValue = this.activeGameNameValue =
request.mode === 'create' ? request.spec.name : request.name request.mode === 'create' ? request.spec.name : request.name
this.attachGameServer(adapter) this.attachGameServer(adapter)
return adapter return adapter
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) if (!this.sessionLostFor(flow)) {
this.disconnectReasonValue = msg // Game creation/join refused while BNCS/MCP stay up (1.13c stays in the lobby).
this.setPhase('lobby') const msg = err instanceof Error ? err.message : String(err)
throw err this.disconnectReasonValue = msg
} this.setPhase('lobby')
}
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

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