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:
parent
5882efc109
commit
e623e992dc
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue