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
|
color?: FrontendColorIndex
|
||||||
align?: 'left' | 'center'
|
align?: 'left' | 'center'
|
||||||
lineStep?: number
|
lineStep?: number
|
||||||
|
/** Keep at most this many lines; the last kept line ends with '...' when text was cut. */
|
||||||
|
maxLines?: number
|
||||||
} = {},
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
const font = options.font ?? 'font16'
|
const font = options.font ?? 'font16'
|
||||||
|
|
@ -426,6 +428,10 @@ export class FrontendRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (current) lines.push(current)
|
if (current) lines.push(current)
|
||||||
|
if (options.maxLines !== undefined && lines.length > options.maxLines) {
|
||||||
|
lines.length = Math.max(1, options.maxLines)
|
||||||
|
lines[lines.length - 1] = `${lines[lines.length - 1]!} ...`
|
||||||
|
}
|
||||||
|
|
||||||
const meta = this.fontMetas[font]
|
const meta = this.fontMetas[font]
|
||||||
const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3)
|
const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3)
|
||||||
|
|
@ -1201,12 +1207,25 @@ export class FrontendRenderer {
|
||||||
this.drawBurningMainLogo()
|
this.drawBurningMainLogo()
|
||||||
|
|
||||||
const s = this.manifest!.strings
|
const s = this.manifest!.strings
|
||||||
|
const loginErr = this.state.loginError || this.state.lastError
|
||||||
|
if (loginErr) {
|
||||||
|
// Session-loss reasons (tbl text + technical detail) can be long: wrap them in the space
|
||||||
|
// between the logo and the Account Name label instead of the instruction line.
|
||||||
|
this.drawWrappedBitmapText(loginErr, 400, 234, 700, {
|
||||||
|
font: 'fontformal11',
|
||||||
|
color: 1,
|
||||||
|
align: 'center',
|
||||||
|
lineStep: 14,
|
||||||
|
maxLines: 4,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
this.drawBitmapText(
|
this.drawBitmapText(
|
||||||
'Enter your Account Name and Password to log in to Battle.net.',
|
'Enter your Account Name and Password to log in to Battle.net.',
|
||||||
400,
|
400,
|
||||||
252,
|
252,
|
||||||
{ font: 'fontformal12', color: 0, align: 'center' },
|
{ font: 'fontformal12', color: 0, align: 'center' },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0)
|
// Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0)
|
||||||
this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' })
|
this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' })
|
||||||
|
|
@ -1225,14 +1244,7 @@ export class FrontendRenderer {
|
||||||
color: 0,
|
color: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loginErr = this.state.loginError || this.state.lastError
|
if (!loginErr && this.state.statusText) {
|
||||||
if (loginErr) {
|
|
||||||
this.drawBitmapText(loginErr, 400, 274, {
|
|
||||||
font: 'fontformal11',
|
|
||||||
color: 1,
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
} else if (this.state.statusText) {
|
|
||||||
this.drawBitmapText(this.state.statusText, 400, 274, {
|
this.drawBitmapText(this.state.statusText, 400, 274, {
|
||||||
font: 'fontformal11',
|
font: 'fontformal11',
|
||||||
color: 4,
|
color: 4,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
import {
|
import {
|
||||||
applyOnlineCharactersToFlowState,
|
applyOnlineCharactersToFlowState,
|
||||||
createInitialFlowState,
|
createInitialFlowState,
|
||||||
|
returnToLoginAfterSessionLoss,
|
||||||
stepFlowTick,
|
stepFlowTick,
|
||||||
type BnetFlowState,
|
type BnetFlowState,
|
||||||
} from './frontend/flow-state.ts'
|
} from './frontend/flow-state.ts'
|
||||||
|
|
@ -50,7 +51,7 @@ import { Minimap } from './ui/minimap.ts'
|
||||||
import { CommandMapper } from './input/command-mapper.ts'
|
import { CommandMapper } from './input/command-mapper.ts'
|
||||||
import { PacketInspector } from './inspector/packet-inspector.ts'
|
import { PacketInspector } from './inspector/packet-inspector.ts'
|
||||||
import { MapService } from './map/map-service.ts'
|
import { MapService } from './map/map-service.ts'
|
||||||
import { OnlineSession } from './session/online-session.ts'
|
import { OnlineSession, SessionLostError } from './session/online-session.ts'
|
||||||
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
|
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
|
||||||
import {
|
import {
|
||||||
getAssetCacheStatus,
|
getAssetCacheStatus,
|
||||||
|
|
@ -411,6 +412,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
|
const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
|
||||||
|
|
||||||
let inGameView = false
|
let inGameView = false
|
||||||
|
/** One login attempt at a time: concurrent attempts used to replace each other's live session. */
|
||||||
|
let loginInFlight = false
|
||||||
|
|
||||||
const syncVisibility = (): void => {
|
const syncVisibility = (): void => {
|
||||||
bnetShell.style.display = inGameView ? 'none' : 'flex'
|
bnetShell.style.display = inGameView ? 'none' : 'flex'
|
||||||
|
|
@ -447,6 +450,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
frontendRenderer.render(performance.now())
|
frontendRenderer.render(performance.now())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (loginInFlight) return
|
||||||
|
loginInFlight = true
|
||||||
st.statusText = createNewAccount
|
st.statusText = createNewAccount
|
||||||
? 'CREATING ACCOUNT & LOGGING IN...'
|
? 'CREATING ACCOUNT & LOGGING IN...'
|
||||||
: 'AUTHENTICATING WITH BATTLE.NET & MCP...'
|
: 'AUTHENTICATING WITH BATTLE.NET & MCP...'
|
||||||
|
|
@ -464,10 +469,15 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
applyOnlineCharactersToFlowState(st, res.characters)
|
applyOnlineCharactersToFlowState(st, res.characters)
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// A lost session was already routed to the login screen by `onSessionLost`.
|
||||||
|
if (!(err instanceof SessionLostError)) {
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.loginError = err instanceof Error ? err.message : String(err)
|
st.loginError = err instanceof Error ? err.message : String(err)
|
||||||
st.lastError = st.loginError
|
st.lastError = st.loginError
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
loginInFlight = false
|
||||||
|
}
|
||||||
frontendRenderer.render(performance.now())
|
frontendRenderer.render(performance.now())
|
||||||
},
|
},
|
||||||
onCreateCharacter: async (st) => {
|
onCreateCharacter: async (st) => {
|
||||||
|
|
@ -504,6 +514,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
applyOnlineCharactersToFlowState(st, chars)
|
applyOnlineCharactersToFlowState(st, chars)
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.charCreateError = err instanceof Error ? err.message : String(err)
|
st.charCreateError = err instanceof Error ? err.message : String(err)
|
||||||
st.lastError = st.charCreateError
|
st.lastError = st.charCreateError
|
||||||
|
|
@ -519,6 +530,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
applyOnlineCharactersToFlowState(st, chars)
|
applyOnlineCharactersToFlowState(st, chars)
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.lastError = err instanceof Error ? err.message : String(err)
|
st.lastError = err instanceof Error ? err.message : String(err)
|
||||||
}
|
}
|
||||||
|
|
@ -533,6 +545,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
applyOnlineCharactersToFlowState(st, chars)
|
applyOnlineCharactersToFlowState(st, chars)
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.lastError = err instanceof Error ? err.message : String(err)
|
st.lastError = err instanceof Error ? err.message : String(err)
|
||||||
}
|
}
|
||||||
|
|
@ -551,6 +564,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
st.lobbySubPanel = 'create'
|
st.lobbySubPanel = 'create'
|
||||||
st.activeInput = 'gameName'
|
st.activeInput = 'gameName'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.lastError = err instanceof Error ? err.message : String(err)
|
st.lastError = err instanceof Error ? err.message : String(err)
|
||||||
}
|
}
|
||||||
|
|
@ -577,6 +591,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
}
|
}
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.lastError = err instanceof Error ? err.message : String(err)
|
st.lastError = err instanceof Error ? err.message : String(err)
|
||||||
}
|
}
|
||||||
|
|
@ -616,6 +631,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
inGameView = true
|
inGameView = true
|
||||||
syncVisibility()
|
syncVisibility()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.stage = 'bnet_lobby'
|
st.stage = 'bnet_lobby'
|
||||||
st.createGameError = err instanceof Error ? err.message : String(err)
|
st.createGameError = err instanceof Error ? err.message : String(err)
|
||||||
|
|
@ -643,6 +659,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
inGameView = true
|
inGameView = true
|
||||||
syncVisibility()
|
syncVisibility()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.statusText = ''
|
st.statusText = ''
|
||||||
st.stage = 'bnet_lobby'
|
st.stage = 'bnet_lobby'
|
||||||
st.createGameError = err instanceof Error ? err.message : String(err)
|
st.createGameError = err instanceof Error ? err.message : String(err)
|
||||||
|
|
@ -654,6 +671,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
try {
|
try {
|
||||||
await session.sendLobbyChat(text)
|
await session.sendLobbyChat(text)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionLostError) return
|
||||||
st.lastError = err instanceof Error ? err.message : String(err)
|
st.lastError = err instanceof Error ? err.message : String(err)
|
||||||
}
|
}
|
||||||
frontendRenderer.render(performance.now())
|
frontendRenderer.render(performance.now())
|
||||||
|
|
@ -679,10 +697,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
} else if (snap.phase === 'connecting') {
|
} else if (snap.phase === 'connecting') {
|
||||||
toolbar.setConnectionState({ phase: 'connecting', rttMs: null })
|
toolbar.setConnectionState({ phase: 'connecting', rttMs: null })
|
||||||
} else if (snap.phase === 'disconnected' || snap.phase === 'error') {
|
} else if (snap.phase === 'disconnected' || snap.phase === 'error') {
|
||||||
if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') {
|
|
||||||
inGameView = false
|
|
||||||
flowState.stage = 'bnet_lobby'
|
|
||||||
}
|
|
||||||
toolbar.setConnectionState({
|
toolbar.setConnectionState({
|
||||||
phase: snap.disconnectReason ? 'error' : 'disconnected',
|
phase: snap.disconnectReason ? 'error' : 'disconnected',
|
||||||
rttMs: null,
|
rttMs: null,
|
||||||
|
|
@ -693,18 +707,19 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
syncVisibility()
|
syncVisibility()
|
||||||
})
|
})
|
||||||
|
|
||||||
session.onDisconnect((reason) => {
|
session.onSessionLost((ev) => {
|
||||||
if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') {
|
// Single handler for BNCS/MCP/D2GS loss on every screen: the session already disposed all
|
||||||
|
// connections; show the login screen with the reason (no automatic reconnect).
|
||||||
inGameView = false
|
inGameView = false
|
||||||
flowState.stage = 'bnet_lobby'
|
returnToLoginAfterSessionLoss(flowState, ev.reason)
|
||||||
}
|
|
||||||
toolbar.setConnectionState({
|
toolbar.setConnectionState({
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
rttMs: null,
|
rttMs: null,
|
||||||
diagnosticKind: 'custom',
|
diagnosticKind: 'custom',
|
||||||
diagnosticMessage: reason,
|
diagnosticMessage: ev.reason,
|
||||||
})
|
})
|
||||||
syncVisibility()
|
syncVisibility()
|
||||||
|
frontendRenderer.render(performance.now())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bind mouse & keyboard input
|
// Bind mouse & keyboard input
|
||||||
|
|
@ -747,7 +762,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) {
|
if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) {
|
||||||
if (pt.y >= boxY + 70 && pt.y <= boxY + 108) {
|
if (pt.y >= boxY + 70 && pt.y <= boxY + 108) {
|
||||||
session.hudModel.togglePanel('escapeMenu')
|
session.hudModel.togglePanel('escapeMenu')
|
||||||
void session.disconnect('Returned to Battle.net Lobby')
|
void session.disconnect('Saved and exited the game; Battle.net session closed')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pt.y >= boxY + 115 && pt.y <= boxY + 155) {
|
if (pt.y >= boxY + 115 && pt.y <= boxY + 155) {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
createWsBridgeResolver,
|
createWsBridgeResolver,
|
||||||
decodeD2gsS2cPacket,
|
decodeD2gsS2cPacket,
|
||||||
unitTypeToId,
|
unitTypeToId,
|
||||||
type ByteStream,
|
|
||||||
type CharClassId,
|
type CharClassId,
|
||||||
type CharCreateSpec,
|
type CharCreateSpec,
|
||||||
type CharSummary,
|
type CharSummary,
|
||||||
|
|
@ -30,6 +29,9 @@ import {
|
||||||
type CloseReason,
|
type CloseReason,
|
||||||
type D2OnlineConfig,
|
type D2OnlineConfig,
|
||||||
type D2OnlineFlow,
|
type D2OnlineFlow,
|
||||||
|
type D2OnlineSessionKind,
|
||||||
|
type D2OnlineSessionLost,
|
||||||
|
type D2OnlineSessionLostCause,
|
||||||
type D2OnlineState,
|
type D2OnlineState,
|
||||||
type EndpointResolver,
|
type EndpointResolver,
|
||||||
type GameCreateSpec,
|
type GameCreateSpec,
|
||||||
|
|
@ -40,6 +42,7 @@ import {
|
||||||
type ServerEvent,
|
type ServerEvent,
|
||||||
type UnitType,
|
type UnitType,
|
||||||
} from '../../netproto/index.ts'
|
} from '../../netproto/index.ts'
|
||||||
|
import { requireTbl } from '../i18n/lang.ts'
|
||||||
import { HudManager } from '../ui/hud-manager.ts'
|
import { HudManager } from '../ui/hud-manager.ts'
|
||||||
import { SoundService } from '../audio/sound-service.ts'
|
import { SoundService } from '../audio/sound-service.ts'
|
||||||
import { AutomapView, getActForArea } from '../automap/automap-view.ts'
|
import { AutomapView, getActForArea } from '../automap/automap-view.ts'
|
||||||
|
|
@ -121,6 +124,75 @@ export interface OnlineSessionOptions {
|
||||||
readonly onEscapeMenu?: (() => void) | undefined
|
readonly onEscapeMenu?: (() => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which connection ended the session (`client` = ended on purpose by this client). */
|
||||||
|
export type OnlineSessionLostSource = D2OnlineSessionKind | 'client'
|
||||||
|
export type OnlineSessionLostCause = D2OnlineSessionLostCause | 'local'
|
||||||
|
|
||||||
|
export interface OnlineSessionLostEvent {
|
||||||
|
/** User-facing reason (1.13c tbl text + technical detail), shown on the login screen. */
|
||||||
|
readonly reason: string
|
||||||
|
readonly session: OnlineSessionLostSource
|
||||||
|
readonly cause: OnlineSessionLostCause
|
||||||
|
/** Technical description (close code/reason, timed-out request, missing session, ...). */
|
||||||
|
readonly technical: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by `OnlineSession` operations once the Battle.net session is gone. The loss itself has
|
||||||
|
* already been handled centrally (connections disposed, `onSessionLost` notified), so UI callers
|
||||||
|
* must not show it as an operation error on the current screen.
|
||||||
|
*/
|
||||||
|
export class SessionLostError extends Error {
|
||||||
|
override readonly name = 'SessionLostError'
|
||||||
|
constructor(readonly event: OnlineSessionLostEvent) {
|
||||||
|
super(event.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c tbl string for a session loss (ENG: the frontend bitmap fonts are byte glyphs):
|
||||||
|
* - `string#5352` "Your connection to Battle.net has been lost."
|
||||||
|
* - `string#5357` "Your connection has been interrupted" (realm / game server)
|
||||||
|
* - `string#5349` "Timed out waiting for response."
|
||||||
|
* - `string#5353` "Battle.net is not responding. Please try connecting again in a few minutes."
|
||||||
|
* - `patchstring#1066` "Diablo II was unable to connect to the realm server. ..."
|
||||||
|
* - `string#5351` "Lost Connection to battle.net."
|
||||||
|
* Returns `null` when no 1.13c string matches; the technical reason is then shown alone.
|
||||||
|
*/
|
||||||
|
export function sessionLostTblKey(
|
||||||
|
session: OnlineSessionLostSource,
|
||||||
|
cause: OnlineSessionLostCause,
|
||||||
|
): string | null {
|
||||||
|
if (session === 'client' || cause === 'local') return null
|
||||||
|
switch (cause) {
|
||||||
|
case 'timeout':
|
||||||
|
return 'string#5349'
|
||||||
|
case 'not_connected':
|
||||||
|
return 'string#5351'
|
||||||
|
case 'connect_failed':
|
||||||
|
return session === 'bncs' ? 'string#5353' : session === 'mcp' ? 'patchstring#1066' : null
|
||||||
|
case 'closed':
|
||||||
|
case 'error':
|
||||||
|
return session === 'bncs' ? 'string#5352' : 'string#5357'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSessionLostReason(
|
||||||
|
session: OnlineSessionLostSource,
|
||||||
|
cause: OnlineSessionLostCause,
|
||||||
|
technical: string,
|
||||||
|
): string {
|
||||||
|
const key = sessionLostTblKey(session, cause)
|
||||||
|
if (key === null) return technical
|
||||||
|
const text = requireTbl(key, 'ENG').replace(/\s+/g, ' ').trim()
|
||||||
|
return technical ? `${text} (${technical})` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOBBY_SESSION_LABEL: Readonly<Record<'bncs' | 'mcp', string>> = {
|
||||||
|
bncs: 'BNCS',
|
||||||
|
mcp: 'MCP realm',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap`
|
* Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap`
|
||||||
* capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry
|
* capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry
|
||||||
|
|
@ -169,13 +241,17 @@ export class OnlineSession {
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
private flow: D2OnlineFlow | null = null
|
private flow: D2OnlineFlow | null = null
|
||||||
private activeBnetStream: ByteStream | null = null
|
|
||||||
private gameAdapter: GameServerAdapter | null = null
|
private gameAdapter: GameServerAdapter | null = null
|
||||||
private unsubGameEvent: (() => void) | null = null
|
private unsubGameEvent: (() => void) | null = null
|
||||||
private unsubGameClose: (() => void) | null = null
|
private unsubGameClose: (() => void) | null = null
|
||||||
private unsubFlowState: (() => void) | null = null
|
private unsubFlowState: (() => void) | null = null
|
||||||
private unsubFlowError: (() => void) | null = null
|
private unsubFlowLost: (() => void) | null = null
|
||||||
private unsubSettings: (() => void) | null = null
|
private unsubSettings: (() => void) | null = null
|
||||||
|
/** The loss that ended the current session lifecycle (reset by `login` / `attachGameServer`). */
|
||||||
|
private sessionLostValue: SessionLostError | null = null
|
||||||
|
/** The flow that was active when `sessionLostValue` was handled. */
|
||||||
|
private lostFlow: D2OnlineFlow | null = null
|
||||||
|
private pendingFlowClose: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
private phaseValue: OnlineSessionPhase = 'idle'
|
private phaseValue: OnlineSessionPhase = 'idle'
|
||||||
private usernameValue = ''
|
private usernameValue = ''
|
||||||
|
|
@ -194,6 +270,7 @@ export class OnlineSession {
|
||||||
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
||||||
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
|
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
|
||||||
private readonly disconnectListeners = new Set<(reason: string) => void>()
|
private readonly disconnectListeners = new Set<(reason: string) => void>()
|
||||||
|
private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => void>()
|
||||||
|
|
||||||
constructor(options: OnlineSessionOptions = {}) {
|
constructor(options: OnlineSessionOptions = {}) {
|
||||||
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
||||||
|
|
@ -390,6 +467,24 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires once per session lifecycle when the Battle.net session ends: a BNCS/MCP/D2GS connection
|
||||||
|
* closed or errored, a request timed out, a command was issued without its connection, or the
|
||||||
|
* client ended the session itself (`disconnect`). All connections are already disposed when this
|
||||||
|
* fires; the UI returns to the login screen with `ev.reason`.
|
||||||
|
*/
|
||||||
|
onSessionLost(cb: (ev: OnlineSessionLostEvent) => void): () => void {
|
||||||
|
this.sessionLostListeners.add(cb)
|
||||||
|
return () => {
|
||||||
|
this.sessionLostListeners.delete(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loss that ended the current session lifecycle, or `null`. */
|
||||||
|
get sessionLost(): OnlineSessionLostEvent | null {
|
||||||
|
return this.sessionLostValue?.event ?? null
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
|
// ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -399,48 +494,7 @@ export class OnlineSession {
|
||||||
readonly realms: readonly RealmSummary[]
|
readonly realms: readonly RealmSummary[]
|
||||||
readonly characters: readonly CharSummary[]
|
readonly characters: readonly CharSummary[]
|
||||||
}> {
|
}> {
|
||||||
await this.closeFlowQuietly()
|
return await this.establishSession(params, false)
|
||||||
this.disconnectReasonValue = null
|
|
||||||
this.usernameValue = params.username
|
|
||||||
this.setPhase('connecting')
|
|
||||||
|
|
||||||
const baseResolver = params.wsBridgeBaseUrl
|
|
||||||
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
|
|
||||||
: this.resolver
|
|
||||||
const resolver = this.wrapResolver(baseResolver)
|
|
||||||
|
|
||||||
const flow = this.flowFactory({
|
|
||||||
resolver,
|
|
||||||
bnetHost: params.host ?? this.defaultHost,
|
|
||||||
bnetPort: params.bncsPort ?? this.defaultBncsPort,
|
|
||||||
itemTables: this.itemTables,
|
|
||||||
tap: this.packetTap,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.bindFlowListeners(flow)
|
|
||||||
this.flow = flow
|
|
||||||
|
|
||||||
try {
|
|
||||||
await flow.login(params.username, params.password)
|
|
||||||
const realms = await flow.listRealms()
|
|
||||||
this.realmsValue = realms
|
|
||||||
const realmIdx = params.realmIndex ?? 0
|
|
||||||
const chosenRealm = realms[realmIdx] ?? realms[0] ?? null
|
|
||||||
this.selectedRealmValue = chosenRealm
|
|
||||||
|
|
||||||
const characters = await flow.enterRealm(chosenRealm?.title)
|
|
||||||
this.charactersValue = characters
|
|
||||||
if (characters.length > 0 && !this.selectedCharValue) {
|
|
||||||
this.selectedCharValue = characters[0]!.name
|
|
||||||
}
|
|
||||||
this.setPhase('lobby')
|
|
||||||
return { realms, characters }
|
|
||||||
} catch (err) {
|
|
||||||
this.closeBnetStreamQuietly()
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
this.handleUnexpectedDisconnect(msg)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -450,15 +504,28 @@ export class OnlineSession {
|
||||||
readonly realms: readonly RealmSummary[]
|
readonly realms: readonly RealmSummary[]
|
||||||
readonly characters: readonly CharSummary[]
|
readonly characters: readonly CharSummary[]
|
||||||
}> {
|
}> {
|
||||||
|
return await this.establishSession(params, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async establishSession(
|
||||||
|
params: OnlineSessionLoginParams,
|
||||||
|
createAccount: boolean,
|
||||||
|
): Promise<{
|
||||||
|
readonly realms: readonly RealmSummary[]
|
||||||
|
readonly characters: readonly CharSummary[]
|
||||||
|
}> {
|
||||||
|
this.detachGameServerListeners()
|
||||||
|
this.gameAdapter = null
|
||||||
await this.closeFlowQuietly()
|
await this.closeFlowQuietly()
|
||||||
|
this.sessionLostValue = null
|
||||||
|
this.lostFlow = null
|
||||||
this.disconnectReasonValue = null
|
this.disconnectReasonValue = null
|
||||||
this.usernameValue = params.username
|
this.usernameValue = params.username
|
||||||
this.setPhase('connecting')
|
this.setPhase('connecting')
|
||||||
|
|
||||||
const baseResolver = params.wsBridgeBaseUrl
|
const resolver = params.wsBridgeBaseUrl
|
||||||
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
|
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
|
||||||
: this.resolver
|
: this.resolver
|
||||||
const resolver = this.wrapResolver(baseResolver)
|
|
||||||
|
|
||||||
const flow = this.flowFactory({
|
const flow = this.flowFactory({
|
||||||
resolver,
|
resolver,
|
||||||
|
|
@ -472,13 +539,19 @@ export class OnlineSession {
|
||||||
this.flow = flow
|
this.flow = flow
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (createAccount) {
|
||||||
await flow.createAccount(params.username, params.password)
|
await flow.createAccount(params.username, params.password)
|
||||||
|
}
|
||||||
await flow.login(params.username, params.password)
|
await flow.login(params.username, params.password)
|
||||||
const realms = await flow.listRealms()
|
const realms = await flow.listRealms()
|
||||||
this.realmsValue = realms
|
this.realmsValue = realms
|
||||||
const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null
|
const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null
|
||||||
this.selectedRealmValue = chosenRealm
|
this.selectedRealmValue = chosenRealm
|
||||||
|
|
||||||
const characters = await flow.enterRealm(chosenRealm?.title)
|
const characters = await flow.enterRealm(chosenRealm?.title)
|
||||||
|
if (this.flow !== flow) {
|
||||||
|
throw this.sessionLostFor(flow) ?? new Error('OnlineSession.login: superseded by a newer login')
|
||||||
|
}
|
||||||
this.charactersValue = characters
|
this.charactersValue = characters
|
||||||
if (characters.length > 0 && !this.selectedCharValue) {
|
if (characters.length > 0 && !this.selectedCharValue) {
|
||||||
this.selectedCharValue = characters[0]!.name
|
this.selectedCharValue = characters[0]!.name
|
||||||
|
|
@ -486,37 +559,38 @@ export class OnlineSession {
|
||||||
this.setPhase('lobby')
|
this.setPhase('lobby')
|
||||||
return { realms, characters }
|
return { realms, characters }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.closeBnetStreamQuietly()
|
const lost = this.sessionLostFor(flow)
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
if (lost) throw lost
|
||||||
this.handleUnexpectedDisconnect(msg)
|
if (this.flow !== flow) {
|
||||||
|
// A newer login replaced this attempt; its state belongs to the newer attempt.
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// Rejected login (bad password or unknown account): the attempt's connections are closed
|
||||||
|
// and the login screen shows the server's answer.
|
||||||
|
this.pendingFlowClose = this.closeFlowQuietly()
|
||||||
|
this.disconnectReasonValue = err instanceof Error ? err.message : String(err)
|
||||||
|
this.setPhase('disconnected')
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshCharacters(): Promise<readonly CharSummary[]> {
|
async refreshCharacters(): Promise<readonly CharSummary[]> {
|
||||||
if (!this.flow) {
|
return await this.lobbyOp('refreshCharacters', 'mcp', async (flow) => {
|
||||||
throw new Error('OnlineSession.refreshCharacters: not connected to realm')
|
const chars = await flow.listCharacters()
|
||||||
}
|
|
||||||
const chars = await this.flow.listCharacters()
|
|
||||||
this.charactersValue = chars
|
this.charactersValue = chars
|
||||||
this.emitState()
|
this.emitState()
|
||||||
return chars
|
return chars
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
|
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
|
||||||
if (!this.flow) {
|
await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec))
|
||||||
throw new Error('OnlineSession.createCharacter: not connected to realm')
|
|
||||||
}
|
|
||||||
await this.flow.createCharacter(spec)
|
|
||||||
this.selectedCharValue = spec.name
|
this.selectedCharValue = spec.name
|
||||||
return await this.refreshCharacters()
|
return await this.refreshCharacters()
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
|
async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
|
||||||
if (!this.flow) {
|
await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name))
|
||||||
throw new Error('OnlineSession.deleteCharacter: not connected to realm')
|
|
||||||
}
|
|
||||||
await this.flow.deleteCharacter(name)
|
|
||||||
if (this.selectedCharValue === name) {
|
if (this.selectedCharValue === name) {
|
||||||
this.selectedCharValue = null
|
this.selectedCharValue = null
|
||||||
}
|
}
|
||||||
|
|
@ -524,18 +598,12 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
|
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
|
||||||
if (!this.flow) {
|
await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name))
|
||||||
throw new Error('OnlineSession.convertCharacterToExpansion: not connected to realm')
|
|
||||||
}
|
|
||||||
await this.flow.upgradeCharacter(name)
|
|
||||||
return await this.refreshCharacters()
|
return await this.refreshCharacters()
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectCharacter(name: string): Promise<void> {
|
async selectCharacter(name: string): Promise<void> {
|
||||||
if (!this.flow) {
|
await this.lobbyOp('selectCharacter', 'mcp', (flow) => flow.selectCharacter(name))
|
||||||
throw new Error('OnlineSession.selectCharacter: not connected to realm')
|
|
||||||
}
|
|
||||||
await this.flow.selectCharacter(name)
|
|
||||||
this.selectedCharValue = name
|
this.selectedCharValue = name
|
||||||
const charMeta = this.charactersValue.find((c) => c.name === name)
|
const charMeta = this.charactersValue.find((c) => c.name === name)
|
||||||
if (charMeta) {
|
if (charMeta) {
|
||||||
|
|
@ -551,13 +619,7 @@ export class OnlineSession {
|
||||||
async sendLobbyChat(text: string): Promise<void> {
|
async sendLobbyChat(text: string): Promise<void> {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
if (this.flow) {
|
await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed))
|
||||||
try {
|
|
||||||
await this.flow.sendChat(trimmed)
|
|
||||||
} catch {
|
|
||||||
// Ignore if BNCS stream was already closed after MCP realm logon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const prevChatCount = this.world.chatLog.length
|
const prevChatCount = this.world.chatLog.length
|
||||||
this.world.apply({
|
this.world.apply({
|
||||||
type: 'Chat',
|
type: 'Chat',
|
||||||
|
|
@ -576,13 +638,12 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
|
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
|
||||||
if (!this.flow) {
|
return await this.lobbyOp('listGames', 'mcp', async (flow) => {
|
||||||
throw new Error('OnlineSession.listGames: not connected to realm')
|
const games = await flow.listGames(filter)
|
||||||
}
|
|
||||||
const games = await this.flow.listGames(filter)
|
|
||||||
this.gamesValue = games
|
this.gamesValue = games
|
||||||
this.emitState()
|
this.emitState()
|
||||||
return games
|
return games
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -593,17 +654,15 @@ export class OnlineSession {
|
||||||
| { readonly mode: 'create'; readonly spec: GameCreateSpec }
|
| { readonly mode: 'create'; readonly spec: GameCreateSpec }
|
||||||
| { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined },
|
| { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined },
|
||||||
): Promise<GameServerAdapter> {
|
): Promise<GameServerAdapter> {
|
||||||
if (!this.flow) {
|
return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
|
||||||
throw new Error('OnlineSession.enterGame: not connected to realm')
|
|
||||||
}
|
|
||||||
this.setPhase('entering-game')
|
this.setPhase('entering-game')
|
||||||
try {
|
try {
|
||||||
let adapter: GameServerAdapter
|
let adapter: GameServerAdapter
|
||||||
if (request.mode === 'create') {
|
if (request.mode === 'create') {
|
||||||
await this.flow.createGame(request.spec)
|
await flow.createGame(request.spec)
|
||||||
adapter = await this.flow.joinGame(request.spec.name, request.spec.password ?? '')
|
adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '')
|
||||||
} else {
|
} else {
|
||||||
adapter = await this.flow.joinGame(request.name, request.password ?? '')
|
adapter = await flow.joinGame(request.name, request.password ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeGameNameValue =
|
this.activeGameNameValue =
|
||||||
|
|
@ -611,11 +670,15 @@ export class OnlineSession {
|
||||||
this.attachGameServer(adapter)
|
this.attachGameServer(adapter)
|
||||||
return adapter
|
return adapter
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!this.sessionLostFor(flow)) {
|
||||||
|
// Game creation/join refused while BNCS/MCP stay up (1.13c stays in the lobby).
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
this.disconnectReasonValue = msg
|
this.disconnectReasonValue = msg
|
||||||
this.setPhase('lobby')
|
this.setPhase('lobby')
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -623,6 +686,11 @@ export class OnlineSession {
|
||||||
*/
|
*/
|
||||||
attachGameServer(adapter: GameServerAdapter): void {
|
attachGameServer(adapter: GameServerAdapter): void {
|
||||||
this.detachGameServerListeners()
|
this.detachGameServerListeners()
|
||||||
|
if (!this.flow) {
|
||||||
|
// A standalone adapter (replay harness or tests) starts its own session lifecycle.
|
||||||
|
this.sessionLostValue = null
|
||||||
|
this.lostFlow = null
|
||||||
|
}
|
||||||
this.gameAdapter = adapter
|
this.gameAdapter = adapter
|
||||||
this.disconnectReasonValue = null
|
this.disconnectReasonValue = null
|
||||||
this.setPhase('ingame')
|
this.setPhase('ingame')
|
||||||
|
|
@ -633,14 +701,19 @@ export class OnlineSession {
|
||||||
|
|
||||||
if (typeof adapter.onClose === 'function') {
|
if (typeof adapter.onClose === 'function') {
|
||||||
this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
|
this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
|
||||||
|
if (this.gameAdapter !== adapter) return
|
||||||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
||||||
const desc =
|
const technical =
|
||||||
reason.kind === 'error'
|
reason.kind === 'error'
|
||||||
? `Connection error: ${reason.error.message}`
|
? `D2GS game connection error: ${reason.error.message}`
|
||||||
: reason.kind === 'remote'
|
: reason.kind === 'remote'
|
||||||
? `Server closed connection${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
|
? `D2GS game connection closed by server${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
|
||||||
: 'Disconnected from game server'
|
: 'D2GS game connection closed'
|
||||||
this.handleUnexpectedDisconnect(desc)
|
this.handleSessionLost({
|
||||||
|
session: 'd2gs',
|
||||||
|
cause: reason.kind === 'error' ? 'error' : 'closed',
|
||||||
|
technical,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -652,11 +725,11 @@ export class OnlineSession {
|
||||||
* Leave the active D2GS game and return to lobby (or idle if standalone).
|
* Leave the active D2GS game and return to lobby (or idle if standalone).
|
||||||
*/
|
*/
|
||||||
async leaveGame(): Promise<void> {
|
async leaveGame(): Promise<void> {
|
||||||
const hadFlow = this.flow !== null
|
const flow = this.flow
|
||||||
this.detachGameServerListeners()
|
this.detachGameServerListeners()
|
||||||
if (this.flow && this.flow.state === 'ingame') {
|
if (flow && flow.state === 'ingame') {
|
||||||
try {
|
try {
|
||||||
await this.flow.leaveToLobby()
|
await flow.leaveToLobby()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore close errors on leave
|
// Ignore close errors on leave
|
||||||
}
|
}
|
||||||
|
|
@ -673,18 +746,97 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
this.gameAdapter = null
|
this.gameAdapter = null
|
||||||
this.activeGameNameValue = null
|
this.activeGameNameValue = null
|
||||||
this.setPhase(hadFlow ? 'lobby' : 'idle')
|
// `leaveToLobby` reports a BNCS/MCP loss deferred during the game; that already ended the session.
|
||||||
|
if (flow && this.sessionLostFor(flow)) return
|
||||||
|
this.setPhase(flow ? 'lobby' : 'idle')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gracefully close all active connections and transition to `'disconnected'`.
|
* End the session on purpose: close every connection and transition to `'disconnected'` with
|
||||||
|
* exactly `reason`. Routed through the same session-loss path so the UI returns to the login screen.
|
||||||
*/
|
*/
|
||||||
async disconnect(reason = 'Disconnected by user'): Promise<void> {
|
async disconnect(reason = 'Disconnected by user'): Promise<void> {
|
||||||
|
this.handleSessionLost({ session: 'client', cause: 'local', technical: reason, reason })
|
||||||
|
await this.pendingFlowClose
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a pre-game operation that needs a live BNCS/MCP session. Without a session the loss is
|
||||||
|
* handled centrally (return to login) instead of surfacing "not connected" on the current screen;
|
||||||
|
* an operation that fails because the session was lost throws `SessionLostError`. Other failures
|
||||||
|
* (status codes such as "name already exists") are rethrown as-is for the current screen.
|
||||||
|
*/
|
||||||
|
private async lobbyOp<T>(
|
||||||
|
op: string,
|
||||||
|
required: 'bncs' | 'mcp',
|
||||||
|
fn: (flow: D2OnlineFlow) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const flow = this.flow
|
||||||
|
if (!flow || this.sessionLostValue) {
|
||||||
|
throw this.handleSessionLost({
|
||||||
|
session: required,
|
||||||
|
cause: 'not_connected',
|
||||||
|
technical: `${LOBBY_SESSION_LABEL[required]} session is not connected for ${op}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(flow)
|
||||||
|
} catch (err) {
|
||||||
|
throw this.sessionLostFor(flow) ?? err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The handled loss if it ended `flow`'s lifecycle. */
|
||||||
|
private sessionLostFor(flow: D2OnlineFlow): SessionLostError | null {
|
||||||
|
return this.sessionLostValue && this.lostFlow === flow ? this.sessionLostValue : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single place where a session ends. Idempotent per session lifecycle: the first call disposes the
|
||||||
|
* game adapter and the flow (which closes BNCS/MCP/D2GS), records the reason, enters
|
||||||
|
* `'disconnected'` and notifies `onSessionLost` / `onDisconnect`; later calls (BNCS and MCP both
|
||||||
|
* closing, or a D2GS close seen by both the flow and the adapter) return the same error.
|
||||||
|
*/
|
||||||
|
private handleSessionLost(input: {
|
||||||
|
readonly session: OnlineSessionLostSource
|
||||||
|
readonly cause: OnlineSessionLostCause
|
||||||
|
readonly technical: string
|
||||||
|
readonly reason?: string | undefined
|
||||||
|
}): SessionLostError {
|
||||||
|
if (this.sessionLostValue) return this.sessionLostValue
|
||||||
|
const event: OnlineSessionLostEvent = {
|
||||||
|
reason: input.reason ?? formatSessionLostReason(input.session, input.cause, input.technical),
|
||||||
|
session: input.session,
|
||||||
|
cause: input.cause,
|
||||||
|
technical: input.technical,
|
||||||
|
}
|
||||||
|
const error = new SessionLostError(event)
|
||||||
|
this.sessionLostValue = error
|
||||||
|
this.lostFlow = this.flow
|
||||||
|
|
||||||
this.detachGameServerListeners()
|
this.detachGameServerListeners()
|
||||||
|
const adapter = this.gameAdapter
|
||||||
this.gameAdapter = null
|
this.gameAdapter = null
|
||||||
await this.closeFlowQuietly()
|
this.activeGameNameValue = null
|
||||||
this.disconnectReasonValue = reason
|
if (adapter && !this.flow) {
|
||||||
|
// No flow owns this adapter (standalone attach), so release its connection here.
|
||||||
|
try {
|
||||||
|
adapter.leave()
|
||||||
|
} catch {
|
||||||
|
// Already closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pendingFlowClose = this.closeFlowQuietly()
|
||||||
|
|
||||||
|
this.disconnectReasonValue = event.reason
|
||||||
this.setPhase('disconnected')
|
this.setPhase('disconnected')
|
||||||
|
for (const listener of [...this.sessionLostListeners]) {
|
||||||
|
listener(event)
|
||||||
|
}
|
||||||
|
for (const listener of [...this.disconnectListeners]) {
|
||||||
|
listener(event.reason)
|
||||||
|
}
|
||||||
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
||||||
|
|
@ -722,7 +874,7 @@ export class OnlineSession {
|
||||||
this.world.noteOutgoingCommand(cmd)
|
this.world.noteOutgoingCommand(cmd)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)
|
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1189,63 +1341,33 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private wrapResolver(baseResolver: EndpointResolver): EndpointResolver {
|
|
||||||
return {
|
|
||||||
open: async (kind, host, port) => {
|
|
||||||
const stream = await baseResolver.open(kind, host, port)
|
|
||||||
if (kind === 'bnet' || port === 6112) {
|
|
||||||
this.activeBnetStream = stream
|
|
||||||
}
|
|
||||||
return stream
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private closeBnetStreamQuietly(): void {
|
|
||||||
if (this.activeBnetStream) {
|
|
||||||
const stream = this.activeBnetStream
|
|
||||||
this.activeBnetStream = null
|
|
||||||
try {
|
|
||||||
stream.close()
|
|
||||||
} catch {
|
|
||||||
// Ignore close errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindFlowListeners(flow: D2OnlineFlow): void {
|
private bindFlowListeners(flow: D2OnlineFlow): void {
|
||||||
this.unsubFlowState?.()
|
this.unsubFlowState?.()
|
||||||
this.unsubFlowError?.()
|
this.unsubFlowLost?.()
|
||||||
|
|
||||||
this.unsubFlowState = flow.onState(() => {
|
this.unsubFlowState = flow.onState(() => {
|
||||||
|
if (this.flow !== flow) return
|
||||||
this.emitState()
|
this.emitState()
|
||||||
})
|
})
|
||||||
|
|
||||||
const maybeErrorHook = (flow as unknown as { onError?: (cb: (err: unknown) => void) => () => void }).onError
|
this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => {
|
||||||
if (typeof maybeErrorHook === 'function') {
|
if (this.flow !== flow) return
|
||||||
this.unsubFlowError = maybeErrorHook.call(flow, (err: unknown) => {
|
this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message })
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
|
||||||
this.handleUnexpectedDisconnect(msg)
|
|
||||||
} else {
|
|
||||||
this.disconnectReasonValue = msg
|
|
||||||
this.emitState()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
this.unsubFlowError = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUnexpectedDisconnect(reason: string): void {
|
/** The game ended for a client-side reason (bad server data, send failure, GameLogout). */
|
||||||
this.detachGameServerListeners()
|
private handleUnexpectedDisconnect(
|
||||||
this.gameAdapter = null
|
reason: string,
|
||||||
this.closeBnetStreamQuietly()
|
cause: Extract<OnlineSessionLostCause, 'error' | 'closed'> = 'error',
|
||||||
this.disconnectReasonValue = reason
|
useTbl = false,
|
||||||
this.setPhase('disconnected')
|
): void {
|
||||||
for (const listener of this.disconnectListeners) {
|
this.handleSessionLost({
|
||||||
listener(reason)
|
session: 'd2gs',
|
||||||
}
|
cause,
|
||||||
|
technical: reason,
|
||||||
|
...(useTbl ? {} : { reason }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private detachGameServerListeners(): void {
|
private detachGameServerListeners(): void {
|
||||||
|
|
@ -1255,12 +1377,12 @@ export class OnlineSession {
|
||||||
this.unsubGameClose = null
|
this.unsubGameClose = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detach from and close the current flow (which closes every BNCS/MCP/D2GS connection it owns). */
|
||||||
private async closeFlowQuietly(): Promise<void> {
|
private async closeFlowQuietly(): Promise<void> {
|
||||||
this.unsubFlowState?.()
|
this.unsubFlowState?.()
|
||||||
this.unsubFlowState = null
|
this.unsubFlowState = null
|
||||||
this.unsubFlowError?.()
|
this.unsubFlowLost?.()
|
||||||
this.unsubFlowError = null
|
this.unsubFlowLost = null
|
||||||
this.closeBnetStreamQuietly()
|
|
||||||
if (this.flow) {
|
if (this.flow) {
|
||||||
const current = this.flow
|
const current = this.flow
|
||||||
this.flow = null
|
this.flow = null
|
||||||
|
|
|
||||||
|
|
@ -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