Merge branch 'fix/lobby-disconnect': return to login with reason on session loss
This commit is contained in:
commit
309738cf79
|
|
@ -18,6 +18,7 @@ import { fileSource } from '../io/file-source.ts'
|
|||
import { MountedArchives } from '../../common/mpq/mount.ts'
|
||||
import { loadStringTableChain } from '../../common/items/item-names.ts'
|
||||
import { parseTable } from '../../common/items/tables.ts'
|
||||
import { decodeTbl } from '../../common/formats/tbl.ts'
|
||||
import {
|
||||
ITEM_NAMES_ZH,
|
||||
MAGIC_PREFIX_ZH,
|
||||
|
|
@ -545,7 +546,8 @@ async function main() {
|
|||
// (patchstring index n = global tbl index 10000 + n). They are emitted from the MPQ under the alias
|
||||
// key `patchstring#<n>`.
|
||||
// - 95: Repair All button hover text "Repair all equipment: %d" (D2Client string index 10095)
|
||||
for (const idx of [95]) {
|
||||
// - 1066: "Diablo II was unable to connect to the realm server. ..." (string index 11066)
|
||||
for (const idx of [95, 1066]) {
|
||||
const alias = `patchstring#${idx}`
|
||||
const ev = eng.tables[0]?.[idx]
|
||||
const cv = chi.tables[0]?.[idx]
|
||||
|
|
@ -554,6 +556,25 @@ async function main() {
|
|||
chiOut[alias] = cv
|
||||
}
|
||||
|
||||
// Index-only string.tbl (d2data.mpq) Battle.net connection strings, all keyed "x" in the MPQ, emitted
|
||||
// under `string#<n>` (string.tbl index n = global tbl index n). Read straight from string.tbl so the
|
||||
// index cannot shift with the patch/expansion chain order.
|
||||
// - 5349: "Timed out waiting for response."
|
||||
// - 5351: "Lost Connection to battle.net."
|
||||
// - 5352: "Your connection to Battle.net has been lost."
|
||||
// - 5353: "Battle.net is not responding. Please try connecting again in a few minutes."
|
||||
// - 5357: "Your connection has been interrupted"
|
||||
const engStringTbl = decodeTbl(await archives.read('data\\local\\lng\\eng\\string.tbl'), 'windows-1252')
|
||||
const chiStringTbl = decodeTbl(await archives.read('data\\local\\lng\\chi\\string.tbl'), 'utf-8')
|
||||
for (const idx of [5349, 5351, 5352, 5353, 5357]) {
|
||||
const alias = `string#${idx}`
|
||||
const ev = engStringTbl[idx]
|
||||
const cv = chiStringTbl[idx]
|
||||
if (ev === undefined || cv === undefined) throw new Error(`string.tbl index ${idx} missing (ENG or CHI)`)
|
||||
engOut[alias] = ev
|
||||
chiOut[alias] = cv
|
||||
}
|
||||
|
||||
const outPath = path.join(process.cwd(), 'src/common/data/canonical-tbl-strings.ts')
|
||||
const fileContent = `/**
|
||||
* AUTO-GENERATED by \`src/baker/packers/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4016,7 +4016,13 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"uniquemultishot": "Multiple Shots",
|
||||
"UniqueAuraEnchanted": "Aura Enchanted",
|
||||
"strethereal": "Ethereal (Cannot be Repaired)",
|
||||
"patchstring#95": "Repair all equipment: %d"
|
||||
"patchstring#95": "Repair all equipment: %d",
|
||||
"patchstring#1066": "Diablo II was unable to connect to the realm server. The realm you selected may be unavailable or you may be encountering connection issues. Please make sure your internet connection is working properly and/or your firewall has port 6112 opened.",
|
||||
"string#5349": "Timed out waiting for response.",
|
||||
"string#5351": "Lost Connection to battle.net.",
|
||||
"string#5352": "Your connection to Battle.net has been lost.",
|
||||
"string#5353": "Battle.net is not responding. Please try connecting again in a few minutes.",
|
||||
"string#5357": "Your connection has been interrupted"
|
||||
}
|
||||
|
||||
export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
||||
|
|
@ -7419,7 +7425,13 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"uniquemultishot": "多重射擊",
|
||||
"UniqueAuraEnchanted": "靈氣加強",
|
||||
"strethereal": "无形的 (无法修复)",
|
||||
"patchstring#95": "修復所有裝備:%d"
|
||||
"patchstring#95": "修復所有裝備:%d",
|
||||
"patchstring#1066": "《暗黑破壞神二》無法連上國度伺服器。 你所選擇的國度可能無法使用,也可能是你遭遇了網路連線的問題。 請確認你的網路連線運作正常,以及/或你的防火牆設定有開啟port 6112。",
|
||||
"string#5349": "等候回應時間超過。",
|
||||
"string#5351": "與battle.net失去連線。",
|
||||
"string#5352": "你和battle.net的連線已經中斷了。",
|
||||
"string#5353": "Battle.net沒有回應,請稍候數分鐘之後再重試。",
|
||||
"string#5357": "你的連線被中斷了"
|
||||
}
|
||||
|
||||
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,35 @@ export type D2OnlineState =
|
|||
| 'ingame'
|
||||
| 'closed'
|
||||
|
||||
/** Which live connection of the flow was lost. */
|
||||
export type D2OnlineSessionKind = 'bncs' | 'mcp' | 'd2gs'
|
||||
|
||||
/**
|
||||
* Why the connection was lost:
|
||||
* - `closed`: the socket closed without the flow asking for it (server/bridge close).
|
||||
* - `error`: the transport reported an error (socket error / write on a dead socket).
|
||||
* - `timeout`: a request/response exchange on that connection timed out.
|
||||
* - `connect_failed`: opening the connection (or the realm MCP startup) failed.
|
||||
* - `not_connected`: a command needed the connection but it is not connected.
|
||||
*/
|
||||
export type D2OnlineSessionLostCause = 'closed' | 'error' | 'timeout' | 'connect_failed' | 'not_connected'
|
||||
|
||||
/**
|
||||
* Emitted exactly once per `D2OnlineFlow` when a connection it owns is lost. By the time listeners run,
|
||||
* the flow has already closed every remaining connection and is in state `closed`.
|
||||
*/
|
||||
export interface D2OnlineSessionLost {
|
||||
readonly session: D2OnlineSessionKind
|
||||
readonly cause: D2OnlineSessionLostCause
|
||||
/** Concrete technical description (connection, close code/reason, timed-out packet, error text). */
|
||||
readonly message: string
|
||||
readonly closeCode?: number | undefined
|
||||
readonly closeReason?: string | undefined
|
||||
readonly error?: Error | undefined
|
||||
/** Flow state at the moment the loss was detected. */
|
||||
readonly flowState: D2OnlineState
|
||||
}
|
||||
|
||||
export interface D2OnlineConfig {
|
||||
readonly resolver: EndpointResolver
|
||||
readonly bnetHost: string
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
/**
|
||||
* `D2OnlineFlow` orchestrating BNCS (port 6112) -> MCP (port 6113) -> D2GS (port 4000)
|
||||
* transitions and keeping BNCS/MCP alive while in-game.
|
||||
*
|
||||
* Connection loss is detected centrally here: every BNCS/MCP/D2GS connection the flow opens is
|
||||
* watched, and any close the flow did not ask for, any request timeout, any connect failure and any
|
||||
* command issued while its connection is gone produces exactly one `onSessionLost` event per flow.
|
||||
* Before that event is emitted the flow closes every remaining connection (no leaked sockets, timers or
|
||||
* reconnects) and enters state `closed`.
|
||||
*/
|
||||
|
||||
import { BncsSession } from '../bncs/session.ts'
|
||||
import { defaultClock } from '../core/clock.ts'
|
||||
import { Emitter } from '../core/emitter.ts'
|
||||
import { ProtocolError } from '../core/errors.ts'
|
||||
import { ClosedError, ProtocolError, TimeoutError } from '../core/errors.ts'
|
||||
import { resolveTextCodec } from '../core/text-codec.ts'
|
||||
import { D2gsAdapter } from '../d2gs/adapter.ts'
|
||||
import { D2gsSession } from '../d2gs/session.ts'
|
||||
|
|
@ -22,11 +28,22 @@ import type {
|
|||
RealmInfo,
|
||||
} from '../domain/lobby.ts'
|
||||
import { McpSession } from '../mcp/session.ts'
|
||||
import type { D2OnlineConfig, D2OnlineState } from './config.ts'
|
||||
import type { CloseReason } from '../transport/byte-stream.ts'
|
||||
import type {
|
||||
D2OnlineConfig,
|
||||
D2OnlineSessionKind,
|
||||
D2OnlineSessionLost,
|
||||
D2OnlineSessionLostCause,
|
||||
D2OnlineState,
|
||||
} from './config.ts'
|
||||
|
||||
export interface D2OnlineFlow {
|
||||
readonly state: D2OnlineState
|
||||
/** The loss that ended this flow, or `null` while the flow is healthy / was closed on purpose. */
|
||||
readonly sessionLost: D2OnlineSessionLost | null
|
||||
onState(cb: (s: D2OnlineState) => void): () => void
|
||||
/** Fires once when a BNCS/MCP/D2GS connection is lost (see `D2OnlineSessionLost`). */
|
||||
onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void
|
||||
connectBnet(): Promise<void>
|
||||
connect(): Promise<void>
|
||||
createAccount(user: string, pass: string): Promise<void>
|
||||
|
|
@ -78,6 +95,59 @@ const FALLBACK_EMPTY_TABLES: ItemDataTables = {
|
|||
getItemMeta: () => undefined,
|
||||
}
|
||||
|
||||
const SESSION_LABEL: Readonly<Record<D2OnlineSessionKind, string>> = {
|
||||
bncs: 'BNCS',
|
||||
mcp: 'MCP realm',
|
||||
d2gs: 'D2GS game',
|
||||
}
|
||||
|
||||
type SessionLostInfo = Omit<D2OnlineSessionLost, 'flowState'>
|
||||
|
||||
/** Drop the `[ProtocolError conn=.. packet=.. offset=..]` prefix so user-facing reasons stay readable. */
|
||||
function stripProtocolPrefix(message: string): string {
|
||||
return message.replace(/^\[ProtocolError [^\]]*\]\s*/, '')
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return stripProtocolPrefix(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
function describeClose(kind: D2OnlineSessionKind, reason: CloseReason): SessionLostInfo {
|
||||
const label = SESSION_LABEL[kind]
|
||||
if (reason.kind === 'error') {
|
||||
return {
|
||||
session: kind,
|
||||
cause: 'error',
|
||||
message: `${label} connection error: ${errorText(reason.error)}`,
|
||||
error: reason.error,
|
||||
}
|
||||
}
|
||||
const remoteReason = reason.kind === 'remote' && reason.reason ? reason.reason : undefined
|
||||
const details: string[] = []
|
||||
if (reason.code !== undefined) details.push(`code ${reason.code}`)
|
||||
if (remoteReason !== undefined) details.push(`reason "${remoteReason}"`)
|
||||
return {
|
||||
session: kind,
|
||||
cause: 'closed',
|
||||
message: `${label} connection closed${reason.kind === 'remote' ? ' by server' : ''}${
|
||||
details.length > 0 ? ` (${details.join(', ')})` : ''
|
||||
}`,
|
||||
...(reason.code !== undefined ? { closeCode: reason.code } : {}),
|
||||
...(remoteReason !== undefined ? { closeReason: remoteReason } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport-level failures of a request/response exchange mean the connection is unusable. */
|
||||
function classifyExchangeFailure(kind: D2OnlineSessionKind, err: unknown): SessionLostInfo | null {
|
||||
if (err instanceof TimeoutError) {
|
||||
return { session: kind, cause: 'timeout', message: `${SESSION_LABEL[kind]} ${errorText(err)}`, error: err }
|
||||
}
|
||||
if (err instanceof ClosedError) {
|
||||
return { session: kind, cause: 'error', message: `${SESSION_LABEL[kind]} ${errorText(err)}`, error: err }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
||||
const bnetHost = config.bnetHost
|
||||
const bnetPort = config.bnetPort ?? 6112
|
||||
|
|
@ -90,6 +160,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
|
||||
const stateEmitter = new Emitter<D2OnlineState>()
|
||||
const chatEmitter = new Emitter<ChatEvent>()
|
||||
const lostEmitter = new Emitter<D2OnlineSessionLost>()
|
||||
|
||||
let currentState: D2OnlineState = 'idle'
|
||||
let bncs: BncsSession | undefined
|
||||
|
|
@ -99,6 +170,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
let selectedCharName = ''
|
||||
let selectedCharClass = 0
|
||||
let knownChars: readonly CharSummary[] = []
|
||||
let lost: D2OnlineSessionLost | null = null
|
||||
/** BNCS/MCP loss seen while a D2GS game runs: reported when that game ends instead of aborting it. */
|
||||
let pendingLost: D2OnlineSessionLost | null = null
|
||||
/** Bumped by every teardown so connections that finish opening afterwards are disposed, not adopted. */
|
||||
let epoch = 0
|
||||
|
||||
function setState(next: D2OnlineState): void {
|
||||
if (currentState !== next) {
|
||||
|
|
@ -107,18 +183,108 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
}
|
||||
}
|
||||
|
||||
function requireBncs(): BncsSession {
|
||||
if (!bncs) {
|
||||
throw new ProtocolError('BNCS session is not connected', { proto: 'bncs' })
|
||||
/**
|
||||
* Close every connection the flow owns. References are cleared before closing, so the resulting
|
||||
* close callbacks fail the identity checks in the watchers and are never reported as losses.
|
||||
*/
|
||||
function teardownConnections(): void {
|
||||
epoch++
|
||||
const game = d2gsSession
|
||||
d2gsSession = undefined
|
||||
d2gsAdapter = undefined
|
||||
const realm = mcp
|
||||
mcp = undefined
|
||||
const bnet = bncs
|
||||
bncs = undefined
|
||||
if (game) {
|
||||
try {
|
||||
game.leaveAndClose()
|
||||
} catch {
|
||||
// The socket is already gone; nothing left to release.
|
||||
}
|
||||
}
|
||||
return bncs
|
||||
realm?.close()
|
||||
bnet?.close()
|
||||
}
|
||||
|
||||
function requireMcp(): McpSession {
|
||||
if (!mcp) {
|
||||
throw new ProtocolError('MCP realm session is not connected', { proto: 'mcp' })
|
||||
function declareLost(info: SessionLostInfo): void {
|
||||
if (lost) return
|
||||
const ev: D2OnlineSessionLost = { ...info, flowState: currentState }
|
||||
lost = ev
|
||||
pendingLost = null
|
||||
teardownConnections()
|
||||
setState('closed')
|
||||
lostEmitter.emit(ev)
|
||||
}
|
||||
|
||||
function onConnectionLost(info: SessionLostInfo): void {
|
||||
if (lost) return
|
||||
if (info.session !== 'd2gs' && d2gsSession !== undefined && currentState === 'ingame') {
|
||||
pendingLost ??= { ...info, flowState: currentState }
|
||||
if (info.session === 'bncs') {
|
||||
bncs?.close()
|
||||
bncs = undefined
|
||||
} else {
|
||||
mcp?.close()
|
||||
mcp = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
return mcp
|
||||
declareLost(info)
|
||||
}
|
||||
|
||||
function watchLobbyConnection(kind: 'bncs' | 'mcp', session: BncsSession | McpSession): void {
|
||||
session.onClose((reason) => {
|
||||
const active = kind === 'bncs' ? bncs : mcp
|
||||
if (lost || active !== session) return
|
||||
onConnectionLost(describeClose(kind, reason))
|
||||
})
|
||||
}
|
||||
|
||||
function watchGameConnection(session: D2gsSession): void {
|
||||
session.onClose((reason) => {
|
||||
if (lost || d2gsSession !== session) return
|
||||
const info = describeClose('d2gs', reason)
|
||||
const earlier = pendingLost
|
||||
declareLost(earlier ? { ...info, message: `${info.message}; earlier: ${earlier.message}` } : info)
|
||||
})
|
||||
}
|
||||
|
||||
async function exchange<T>(
|
||||
kind: 'bncs' | 'mcp',
|
||||
op: () => Promise<T>,
|
||||
otherFailureCause?: D2OnlineSessionLostCause,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await op()
|
||||
} catch (err) {
|
||||
const info =
|
||||
classifyExchangeFailure(kind, err) ??
|
||||
(otherFailureCause !== undefined
|
||||
? { session: kind, cause: otherFailureCause, message: `${SESSION_LABEL[kind]} ${errorText(err)}`, ...(err instanceof Error ? { error: err } : {}) }
|
||||
: null)
|
||||
if (info) onConnectionLost(info)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function notConnected(kind: 'bncs' | 'mcp', op: string): never {
|
||||
const message = `${SESSION_LABEL[kind]} session is not connected for ${op}`
|
||||
const err = new ProtocolError(message, { proto: kind })
|
||||
onConnectionLost({ session: kind, cause: 'not_connected', message, error: err })
|
||||
throw err
|
||||
}
|
||||
|
||||
function requireBncs(op: string): BncsSession {
|
||||
return bncs ?? notConnected('bncs', op)
|
||||
}
|
||||
|
||||
function requireMcp(op: string): McpSession {
|
||||
return mcp ?? notConnected('mcp', op)
|
||||
}
|
||||
|
||||
function staleConnectionError(kind: 'bncs' | 'mcp'): ProtocolError {
|
||||
return new ProtocolError(`D2OnlineFlow was closed while ${SESSION_LABEL[kind]} was connecting`, { proto: kind })
|
||||
}
|
||||
|
||||
const flow: D2OnlineFlow = {
|
||||
|
|
@ -126,20 +292,52 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
return currentState
|
||||
},
|
||||
|
||||
get sessionLost(): D2OnlineSessionLost | null {
|
||||
return lost
|
||||
},
|
||||
|
||||
onState(cb: (s: D2OnlineState) => void): () => void {
|
||||
return stateEmitter.on(cb)
|
||||
},
|
||||
|
||||
onSessionLost(cb: (ev: D2OnlineSessionLost) => void): () => void {
|
||||
return lostEmitter.on(cb)
|
||||
},
|
||||
|
||||
async connectBnet(): Promise<void> {
|
||||
// A (re)connect starts a fresh lifecycle for this flow.
|
||||
teardownConnections()
|
||||
lost = null
|
||||
pendingLost = null
|
||||
const startEpoch = epoch
|
||||
setState('connecting_bnet')
|
||||
const stream = await config.resolver.open('bnet', bnetHost, bnetPort)
|
||||
bncs = new BncsSession(stream, {
|
||||
let stream
|
||||
try {
|
||||
stream = await config.resolver.open('bnet', bnetHost, bnetPort)
|
||||
} catch (err) {
|
||||
if (startEpoch === epoch) {
|
||||
declareLost({
|
||||
session: 'bncs',
|
||||
cause: 'connect_failed',
|
||||
message: `BNCS connect to ${bnetHost}:${bnetPort} failed: ${errorText(err)}`,
|
||||
...(err instanceof Error ? { error: err } : {}),
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (startEpoch !== epoch) {
|
||||
stream.close()
|
||||
throw staleConnectionError('bncs')
|
||||
}
|
||||
const session = new BncsSession(stream, {
|
||||
clock,
|
||||
textCodec,
|
||||
packetTap: config.tap,
|
||||
})
|
||||
bncs.onChatEvent((ev) => chatEmitter.emit(ev))
|
||||
await bncs.handshake()
|
||||
bncs = session
|
||||
watchLobbyConnection('bncs', session)
|
||||
session.onChatEvent((ev) => chatEmitter.emit(ev))
|
||||
await exchange('bncs', () => session.handshake())
|
||||
},
|
||||
|
||||
async connect(): Promise<void> {
|
||||
|
|
@ -150,8 +348,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
if (!bncs) {
|
||||
await flow.connectBnet()
|
||||
}
|
||||
const session = requireBncs()
|
||||
const res = await session.createAccount(user, pass)
|
||||
const session = requireBncs('createAccount')
|
||||
const res = await exchange('bncs', () => session.createAccount(user, pass))
|
||||
if (res.status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${res.status.toString(16)}`,
|
||||
|
|
@ -172,18 +370,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
if (!bncs) {
|
||||
await flow.connectBnet()
|
||||
}
|
||||
const session = requireBncs()
|
||||
const session = requireBncs('login')
|
||||
if (opts?.register) {
|
||||
const firstLogin = await session.login(user, pass)
|
||||
const firstLogin = await exchange('bncs', () => session.login(user, pass))
|
||||
if (firstLogin.status !== 0) {
|
||||
const createRes = await session.createAccount(user, pass)
|
||||
const createRes = await exchange('bncs', () => session.createAccount(user, pass))
|
||||
if (createRes.status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`BNCS SID_CREATEACCOUNT2 failed for "${user}" with status=0x${createRes.status.toString(16)}`,
|
||||
{ proto: 'bncs', packetId: 0x3d },
|
||||
)
|
||||
}
|
||||
const secondLogin = await session.login(user, pass)
|
||||
const secondLogin = await exchange('bncs', () => session.login(user, pass))
|
||||
if (secondLogin.status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`BNCS SID_LOGONRESPONSE2 failed after registration for "${user}" with status=0x${secondLogin.status.toString(16)}`,
|
||||
|
|
@ -192,7 +390,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
const loginRes = await session.login(user, pass)
|
||||
const loginRes = await exchange('bncs', () => session.login(user, pass))
|
||||
if (loginRes.status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`BNCS SID_LOGONRESPONSE2 failed for "${user}" with status=0x${loginRes.status.toString(16)}`,
|
||||
|
|
@ -204,28 +402,50 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async listRealms(): Promise<readonly RealmInfo[]> {
|
||||
const session = requireBncs()
|
||||
return await session.listRealms()
|
||||
const session = requireBncs('listRealms')
|
||||
return await exchange('bncs', () => session.listRealms())
|
||||
},
|
||||
|
||||
async enterRealm(realm?: string): Promise<readonly CharSummary[]> {
|
||||
const session = requireBncs()
|
||||
const session = requireBncs('enterRealm')
|
||||
let targetRealm = realm ?? config.defaultRealm
|
||||
if (!targetRealm) {
|
||||
const realms = await session.listRealms()
|
||||
const realms = await exchange('bncs', () => session.listRealms())
|
||||
targetRealm = realms[0]?.title ?? 'D2CS'
|
||||
}
|
||||
const realmTitle = targetRealm
|
||||
|
||||
setState('connecting_realm')
|
||||
const realmData = await session.logonRealm(targetRealm)
|
||||
const mcpStream = await config.resolver.open('realm', bnetHost, realmPort || realmData.mcpPort)
|
||||
mcp = new McpSession(mcpStream, {
|
||||
const realmData = await exchange('bncs', () => session.logonRealm(realmTitle))
|
||||
const port = realmPort || realmData.mcpPort
|
||||
const startEpoch = epoch
|
||||
let mcpStream
|
||||
try {
|
||||
mcpStream = await config.resolver.open('realm', bnetHost, port)
|
||||
} catch (err) {
|
||||
if (startEpoch === epoch) {
|
||||
declareLost({
|
||||
session: 'mcp',
|
||||
cause: 'connect_failed',
|
||||
message: `MCP realm connect to ${bnetHost}:${port} failed: ${errorText(err)}`,
|
||||
...(err instanceof Error ? { error: err } : {}),
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (startEpoch !== epoch) {
|
||||
mcpStream.close()
|
||||
throw staleConnectionError('mcp')
|
||||
}
|
||||
const realmSession = new McpSession(mcpStream, {
|
||||
clock,
|
||||
textCodec,
|
||||
packetTap: config.tap,
|
||||
})
|
||||
await mcp.startup(realmData)
|
||||
knownChars = await mcp.listChars(8)
|
||||
mcp = realmSession
|
||||
watchLobbyConnection('mcp', realmSession)
|
||||
await exchange('mcp', () => realmSession.startup(realmData), 'connect_failed')
|
||||
knownChars = await exchange('mcp', () => realmSession.listChars(8))
|
||||
setState('realm_ready')
|
||||
return knownChars
|
||||
},
|
||||
|
|
@ -235,8 +455,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async listCharacters(): Promise<readonly CharSummary[]> {
|
||||
const m = requireMcp()
|
||||
knownChars = await m.listChars(8)
|
||||
const m = requireMcp('listCharacters')
|
||||
knownChars = await exchange('mcp', () => m.listChars(8))
|
||||
return knownChars
|
||||
},
|
||||
|
||||
|
|
@ -245,12 +465,14 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async createCharacter(spec: CharCreateSpec): Promise<void> {
|
||||
const m = requireMcp()
|
||||
const status = await m.createChar(spec.name, spec.classId, {
|
||||
expansion: spec.expansion,
|
||||
hardcore: spec.hardcore,
|
||||
ladder: spec.ladder,
|
||||
})
|
||||
const m = requireMcp('createCharacter')
|
||||
const status = await exchange('mcp', () =>
|
||||
m.createChar(spec.name, spec.classId, {
|
||||
expansion: spec.expansion,
|
||||
hardcore: spec.hardcore,
|
||||
ladder: spec.ladder,
|
||||
}),
|
||||
)
|
||||
if (status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`MCP_CHARCREATE failed for "${spec.name}" with status=0x${status.toString(16)}`,
|
||||
|
|
@ -259,7 +481,7 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
}
|
||||
selectedCharName = spec.name
|
||||
selectedCharClass = spec.classId
|
||||
knownChars = await m.listChars(8)
|
||||
knownChars = await exchange('mcp', () => m.listChars(8))
|
||||
},
|
||||
|
||||
async createChar(
|
||||
|
|
@ -281,9 +503,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async deleteCharacter(name: string): Promise<void> {
|
||||
const m = requireMcp()
|
||||
await m.deleteChar(name)
|
||||
knownChars = await m.listChars(8)
|
||||
const m = requireMcp('deleteCharacter')
|
||||
await exchange('mcp', () => m.deleteChar(name))
|
||||
knownChars = await exchange('mcp', () => m.listChars(8))
|
||||
},
|
||||
|
||||
async deleteChar(name: string): Promise<void> {
|
||||
|
|
@ -291,9 +513,9 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async upgradeCharacter(name: string): Promise<void> {
|
||||
const m = requireMcp()
|
||||
await m.upgradeChar(name)
|
||||
knownChars = await m.listChars(8)
|
||||
const m = requireMcp('upgradeCharacter')
|
||||
await exchange('mcp', () => m.upgradeChar(name))
|
||||
knownChars = await exchange('mcp', () => m.listChars(8))
|
||||
},
|
||||
|
||||
async upgradeChar(name: string): Promise<void> {
|
||||
|
|
@ -301,8 +523,8 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async selectCharacter(name: string): Promise<void> {
|
||||
const m = requireMcp()
|
||||
const status = await m.charLogon(name)
|
||||
const m = requireMcp('selectCharacter')
|
||||
const status = await exchange('mcp', () => m.charLogon(name))
|
||||
if (status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`MCP_CHARLOGON failed for "${name}" with status=0x${status.toString(16)}`,
|
||||
|
|
@ -321,18 +543,18 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
|
||||
const m = requireMcp()
|
||||
return await m.listGames(filter)
|
||||
const m = requireMcp('listGames')
|
||||
return await exchange('mcp', () => m.listGames(filter))
|
||||
},
|
||||
|
||||
async getGameInfo(name: string): Promise<GameInfoDetail> {
|
||||
const m = requireMcp()
|
||||
return await m.getGameInfo(name)
|
||||
const m = requireMcp('getGameInfo')
|
||||
return await exchange('mcp', () => m.getGameInfo(name))
|
||||
},
|
||||
|
||||
async createGame(spec: GameCreateSpec): Promise<void> {
|
||||
const m = requireMcp()
|
||||
const res = await m.createGame(spec)
|
||||
const m = requireMcp('createGame')
|
||||
const res = await exchange('mcp', () => m.createGame(spec))
|
||||
if (res.status !== 0 && res.status !== 0x1e) {
|
||||
throw new ProtocolError(
|
||||
`MCP_CREATEGAME failed for "${spec.name}" with status=0x${res.status.toString(16)}`,
|
||||
|
|
@ -342,11 +564,11 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async joinGame(name: string, password = ''): Promise<GameServerAdapter> {
|
||||
const m = requireMcp()
|
||||
const m = requireMcp('joinGame')
|
||||
if (!selectedCharName) {
|
||||
throw new ProtocolError('Cannot joinGame before selectCharacter()', { proto: 'mcp' })
|
||||
}
|
||||
const ticket = await m.joinGame(name, password)
|
||||
const ticket = await exchange('mcp', () => m.joinGame(name, password))
|
||||
if (ticket.status !== 0) {
|
||||
throw new ProtocolError(
|
||||
`MCP_JOINGAME failed for "${name}" with status=0x${ticket.status.toString(16)}`,
|
||||
|
|
@ -355,8 +577,19 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
}
|
||||
setState('connecting_game')
|
||||
|
||||
const gameStream = await config.resolver.open('game', bnetHost, gamePort)
|
||||
d2gsSession = new D2gsSession({
|
||||
const startEpoch = epoch
|
||||
let gameStream
|
||||
try {
|
||||
gameStream = await config.resolver.open('game', bnetHost, gamePort)
|
||||
} catch (err) {
|
||||
if (startEpoch === epoch && !lost) setState(mcp ? 'realm_ready' : 'idle')
|
||||
throw err
|
||||
}
|
||||
if (startEpoch !== epoch) {
|
||||
gameStream.close()
|
||||
throw new ProtocolError('D2OnlineFlow was closed while D2GS was connecting', { proto: 'd2gs' })
|
||||
}
|
||||
const session = new D2gsSession({
|
||||
stream: gameStream,
|
||||
logon: {
|
||||
gameHash: ticket.gameHash,
|
||||
|
|
@ -367,58 +600,88 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
clock,
|
||||
tap: config.tap,
|
||||
})
|
||||
|
||||
d2gsAdapter = new D2gsAdapter({
|
||||
session: d2gsSession,
|
||||
const adapter = new D2gsAdapter({
|
||||
session,
|
||||
tables,
|
||||
encoding,
|
||||
clock,
|
||||
})
|
||||
d2gsSession = session
|
||||
d2gsAdapter = adapter
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cancelTimer = clock.setTimeout(() => {
|
||||
unsubState()
|
||||
unsubErr()
|
||||
reject(new ProtocolError(`D2GS handshake timed out (state=${d2gsSession?.state})`, { proto: 'd2gs' }))
|
||||
}, 12_000)
|
||||
const unsubState = d2gsSession!.onState((s) => {
|
||||
if (s === 'ingame') {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cancelTimer = clock.setTimeout(() => {
|
||||
unsubState()
|
||||
unsubErr()
|
||||
reject(new ProtocolError(`D2GS handshake timed out (state=${session.state})`, { proto: 'd2gs' }))
|
||||
}, 12_000)
|
||||
const unsubState = session.onState((s) => {
|
||||
if (s === 'ingame') {
|
||||
cancelTimer()
|
||||
unsubState()
|
||||
unsubErr()
|
||||
setState('ingame')
|
||||
resolve()
|
||||
} else if (s === 'closed') {
|
||||
cancelTimer()
|
||||
unsubState()
|
||||
unsubErr()
|
||||
reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' }))
|
||||
}
|
||||
})
|
||||
const unsubErr = session.onError((err) => {
|
||||
cancelTimer()
|
||||
unsubState()
|
||||
unsubErr()
|
||||
setState('ingame')
|
||||
resolve()
|
||||
} else if (s === 'closed') {
|
||||
cancelTimer()
|
||||
unsubState()
|
||||
unsubErr()
|
||||
reject(new ProtocolError('D2GS connection closed during handshake', { proto: 'd2gs' }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
// A failed game join leaves BNCS/MCP usable (1.13c returns to the lobby); only the D2GS
|
||||
// connection of this attempt is released.
|
||||
if (d2gsSession === session) {
|
||||
d2gsSession = undefined
|
||||
d2gsAdapter = undefined
|
||||
try {
|
||||
session.leaveAndClose()
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
})
|
||||
const unsubErr = d2gsSession!.onError((err) => {
|
||||
cancelTimer()
|
||||
unsubState()
|
||||
unsubErr()
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
if (!lost) setState(mcp ? 'realm_ready' : 'idle')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return d2gsAdapter
|
||||
watchGameConnection(session)
|
||||
return adapter
|
||||
},
|
||||
|
||||
async enterChat(): Promise<void> {
|
||||
const session = requireBncs()
|
||||
await session.enterChat(selectedCharName)
|
||||
const session = requireBncs('enterChat')
|
||||
await exchange('bncs', () => session.enterChat(selectedCharName))
|
||||
},
|
||||
|
||||
async joinChannel(channel: string): Promise<void> {
|
||||
const session = requireBncs()
|
||||
session.joinChannel(channel)
|
||||
const session = requireBncs('joinChannel')
|
||||
try {
|
||||
session.joinChannel(channel)
|
||||
} catch (err) {
|
||||
const info = classifyExchangeFailure('bncs', err)
|
||||
if (info) onConnectionLost(info)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async sendChannelChat(text: string): Promise<void> {
|
||||
const session = requireBncs()
|
||||
session.sendChat(text)
|
||||
const session = requireBncs('sendChannelChat')
|
||||
try {
|
||||
session.sendChat(text)
|
||||
} catch (err) {
|
||||
const info = classifyExchangeFailure('bncs', err)
|
||||
if (info) onConnectionLost(info)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async sendChat(text: string): Promise<void> {
|
||||
|
|
@ -434,28 +697,27 @@ export function createD2OnlineFlow(config: D2OnlineConfig): D2OnlineFlow {
|
|||
},
|
||||
|
||||
async leaveToLobby(): Promise<void> {
|
||||
if (d2gsAdapter) {
|
||||
await d2gsAdapter.close()
|
||||
d2gsAdapter = undefined
|
||||
d2gsSession = undefined
|
||||
const game = d2gsSession
|
||||
d2gsSession = undefined
|
||||
d2gsAdapter = undefined
|
||||
if (game) {
|
||||
try {
|
||||
game.leaveAndClose()
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
if (pendingLost) {
|
||||
const { flowState: _flowState, ...info } = pendingLost
|
||||
declareLost(info)
|
||||
return
|
||||
}
|
||||
setState(mcp ? 'realm_ready' : 'idle')
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (d2gsAdapter) {
|
||||
await d2gsAdapter.close()
|
||||
d2gsAdapter = undefined
|
||||
d2gsSession = undefined
|
||||
}
|
||||
if (mcp) {
|
||||
mcp.close()
|
||||
mcp = undefined
|
||||
}
|
||||
if (bncs) {
|
||||
bncs.close()
|
||||
bncs = undefined
|
||||
}
|
||||
pendingLost = null
|
||||
teardownConnections()
|
||||
setState('closed')
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,14 @@ export type {
|
|||
TcpResolverOptions,
|
||||
WsBridgeResolverOptions,
|
||||
} from './transport/endpoint.ts'
|
||||
export type { D2OnlineConfig, D2OnlineConfig as OnlineConfig, D2OnlineState } from './flow/config.ts'
|
||||
export type {
|
||||
D2OnlineConfig,
|
||||
D2OnlineConfig as OnlineConfig,
|
||||
D2OnlineSessionKind,
|
||||
D2OnlineSessionLost,
|
||||
D2OnlineSessionLostCause,
|
||||
D2OnlineState,
|
||||
} from './flow/config.ts'
|
||||
export type { D2OnlineFlow } from './flow/online-flow.ts'
|
||||
export type { GameServerAdapter, GameServerStats } from './domain/game-server-adapter.ts'
|
||||
export type { ServerEvent } from './domain/server-event.ts'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export type EndpointKind = 'bnet' | 'realm' | 'game' | 'ts'
|
|||
|
||||
export type CloseReason =
|
||||
| { readonly kind: 'local'; readonly code?: number | undefined }
|
||||
| { readonly kind: 'remote'; readonly code?: number | undefined }
|
||||
| { readonly kind: 'remote'; readonly code?: number | undefined; readonly reason?: string | undefined }
|
||||
| { readonly kind: 'error'; readonly error: Error }
|
||||
|
||||
export interface ByteStream {
|
||||
|
|
|
|||
|
|
@ -77,8 +77,11 @@ export class WsStream implements ByteStream {
|
|||
this.ws.onclose = (ev: { readonly code?: number; readonly reason?: string }) => {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
const reason: CloseReason =
|
||||
ev.code !== undefined ? { kind: 'remote', code: ev.code } : { kind: 'remote' }
|
||||
const reason: CloseReason = {
|
||||
kind: 'remote',
|
||||
...(ev.code !== undefined ? { code: ev.code } : {}),
|
||||
...(ev.reason ? { reason: ev.reason } : {}),
|
||||
}
|
||||
this.closeEmitter.emit(reason)
|
||||
this.dataEmitter.clear()
|
||||
this.closeEmitter.clear()
|
||||
|
|
|
|||
|
|
@ -560,7 +560,9 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
|||
get state() {
|
||||
return flowState
|
||||
},
|
||||
sessionLost: null,
|
||||
onState: vi.fn(() => () => {}),
|
||||
onSessionLost: vi.fn(() => () => {}),
|
||||
connectBnet: vi.fn(async () => {}),
|
||||
connect: vi.fn(async () => {}),
|
||||
login: vi.fn(async () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* In-memory BNCS / MCP / D2GS fake servers for `createD2OnlineFlow` session-loss tests.
|
||||
*
|
||||
* Every `resolver.open()` creates a fresh `MemoryStream` pair (so a second login gets new
|
||||
* connections) and records both ends, letting tests force-close the server side of a connection and
|
||||
* assert that the flow disposed its client side. Responders follow the tier4 S6 harness.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMemoryStreamPair,
|
||||
type ByteStream,
|
||||
type EndpointResolver,
|
||||
type EndpointRole,
|
||||
} from '../../src/netproto/index.ts'
|
||||
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
|
||||
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||||
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
|
||||
import { BncsOpcode, encodeBncsPing } from '../../src/netproto/bncs/packets.ts'
|
||||
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
|
||||
import { McpOpcode } from '../../src/netproto/mcp/packets.ts'
|
||||
import {
|
||||
defaultD2gsHuffmanCodec,
|
||||
wrapD2gsCompressedBlock,
|
||||
} from '../../src/netproto/d2gs/compression.ts'
|
||||
import type { MemoryStream } from '../../src/netproto/transport/memory-stream.ts'
|
||||
|
||||
export interface FakeConnection {
|
||||
readonly role: EndpointRole
|
||||
readonly client: MemoryStream
|
||||
readonly server: MemoryStream
|
||||
}
|
||||
|
||||
export interface FakeRealmOptions {
|
||||
/** BNCS request opcodes the fake server never answers (to provoke request timeouts). */
|
||||
readonly silentBncs?: readonly number[] | undefined
|
||||
/** MCP request opcodes the fake server never answers. */
|
||||
readonly silentMcp?: readonly number[] | undefined
|
||||
/** Characters in MCP_CHARLIST2 (0 = empty roster, i.e. the char_create screen). */
|
||||
readonly characters?: number | undefined
|
||||
}
|
||||
|
||||
export interface FakeRealm {
|
||||
readonly resolver: EndpointResolver
|
||||
readonly connections: FakeConnection[]
|
||||
/** Most recent connection for `role`. */
|
||||
latest(role: EndpointRole): FakeConnection
|
||||
/** Every client stream the resolver handed out is closed (nothing leaked). */
|
||||
allClientStreamsClosed(): boolean
|
||||
}
|
||||
|
||||
function charList(count: number): Uint8Array {
|
||||
const w = new ByteWriter()
|
||||
w.u16LE(8)
|
||||
w.u32LE(count)
|
||||
w.u16LE(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
w.u32LE(0x7fffffff)
|
||||
w.cstring(`d2webbot_sorc${i === 0 ? '' : String(i)}`)
|
||||
const stat = new Uint8Array(33).fill(0xff)
|
||||
stat[0] = 0x84
|
||||
stat[1] = 0x80
|
||||
stat[13] = 0x02
|
||||
stat[25] = 80
|
||||
stat[26] = 0xa0
|
||||
stat[27] = 0x80
|
||||
w.bytes(stat)
|
||||
w.u8(0)
|
||||
}
|
||||
return w.toUint8Array()
|
||||
}
|
||||
|
||||
function wireBncs(server: MemoryStream, silent: ReadonlySet<number>): void {
|
||||
const framer = new BncsFramer()
|
||||
let initSeen = false
|
||||
server.onData((chunk) => {
|
||||
let data = chunk
|
||||
if (!initSeen && data[0] === 0x01) {
|
||||
initSeen = true
|
||||
data = data.subarray(1)
|
||||
}
|
||||
for (const pkt of framer.push(data)) {
|
||||
if (silent.has(pkt.id) || server.isClosed) continue
|
||||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||||
server.write(encodeBncsPing(0x11223344))
|
||||
const w = new ByteWriter()
|
||||
w.u32LE(0)
|
||||
w.u32LE(0xabcdef01)
|
||||
w.u32LE(0)
|
||||
w.u32LE(0)
|
||||
w.u32LE(0)
|
||||
w.cstring('ver-IX86-1.mpq')
|
||||
w.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B')
|
||||
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
|
||||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
|
||||
server.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
|
||||
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||||
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
|
||||
} else if (pkt.id === BncsOpcode.SID_QUERYREALMS2) {
|
||||
const w = new ByteWriter()
|
||||
w.u32LE(0)
|
||||
w.u32LE(1)
|
||||
w.u32LE(1)
|
||||
w.cstring('D2CS')
|
||||
w.cstring('Local Test Realm')
|
||||
server.write(encodeBncsFrame(BncsOpcode.SID_QUERYREALMS2, w.toUint8Array()))
|
||||
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
|
||||
const w = new ByteWriter()
|
||||
w.u32LE(0x11223344)
|
||||
w.u32LE(0)
|
||||
w.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))
|
||||
w.bytes(new Uint8Array([127, 0, 0, 1]))
|
||||
w.u16BE(6113)
|
||||
w.u16LE(0)
|
||||
w.bytes(new Uint8Array(48).fill(0x55))
|
||||
w.cstring('d2webbot_sorc')
|
||||
server.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function wireMcp(server: MemoryStream, silent: ReadonlySet<number>, characters: number): void {
|
||||
const framer = new McpFramer()
|
||||
let initSeen = false
|
||||
server.onData((chunk) => {
|
||||
let data = chunk
|
||||
if (!initSeen && data[0] === 0x01) {
|
||||
initSeen = true
|
||||
data = data.subarray(1)
|
||||
}
|
||||
for (const pkt of framer.push(data)) {
|
||||
if (silent.has(pkt.id) || server.isClosed) continue
|
||||
if (pkt.id === McpOpcode.MCP_STARTUP) {
|
||||
server.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
|
||||
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
|
||||
server.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, charList(characters)))
|
||||
} else if (pkt.id === McpOpcode.MCP_CHARLOGON) {
|
||||
server.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
|
||||
} else if (pkt.id === McpOpcode.MCP_CREATEGAME) {
|
||||
const reqId = new ByteReader(pkt.payload).u16LE()
|
||||
const w = new ByteWriter()
|
||||
w.u16LE(reqId)
|
||||
w.u16LE(1)
|
||||
w.u16LE(0)
|
||||
w.u32LE(0)
|
||||
server.write(encodeMcpFrame(McpOpcode.MCP_CREATEGAME, w.toUint8Array()))
|
||||
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
|
||||
const reqId = new ByteReader(pkt.payload).u16LE()
|
||||
const w = new ByteWriter()
|
||||
w.u16LE(reqId)
|
||||
w.u16LE(0x0042)
|
||||
w.u16LE(0)
|
||||
w.bytes(new Uint8Array([127, 0, 0, 1]))
|
||||
w.u32LE(0xdeadbeef)
|
||||
w.u32LE(0)
|
||||
server.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function wireGame(server: MemoryStream): void {
|
||||
server.onData((chunk) => {
|
||||
if (chunk[0] === 0x68 && !server.isClosed) {
|
||||
server.write(
|
||||
wrapD2gsCompressedBlock(
|
||||
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createFakeRealm(options: FakeRealmOptions = {}): FakeRealm {
|
||||
const silentBncs = new Set(options.silentBncs ?? [])
|
||||
const silentMcp = new Set(options.silentMcp ?? [])
|
||||
const characters = options.characters ?? 1
|
||||
const connections: FakeConnection[] = []
|
||||
|
||||
const resolver: EndpointResolver = {
|
||||
open: async (role: EndpointRole): Promise<ByteStream> => {
|
||||
const [client, server] = createMemoryStreamPair(role)
|
||||
connections.push({ role, client, server })
|
||||
if (role === 'bnet') wireBncs(server, silentBncs)
|
||||
else if (role === 'realm') wireMcp(server, silentMcp, characters)
|
||||
else {
|
||||
wireGame(server)
|
||||
setTimeout(() => {
|
||||
if (!server.isClosed) server.write(new Uint8Array([0xaf, 0x01]))
|
||||
}, 5)
|
||||
}
|
||||
return client
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
resolver,
|
||||
connections,
|
||||
latest(role: EndpointRole): FakeConnection {
|
||||
const found = [...connections].reverse().find((c) => c.role === role)
|
||||
if (!found) throw new Error(`no ${role} connection was opened`)
|
||||
return found
|
||||
},
|
||||
allClientStreamsClosed(): boolean {
|
||||
return connections.every((c) => c.client.isClosed)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* `createD2OnlineFlow` connection-loss detection: every BNCS/MCP/D2GS loss (socket close, request
|
||||
* timeout, command without a connection) produces exactly one `onSessionLost` event, the flow ends
|
||||
* in state `closed`, and every connection it opened is closed.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createD2OnlineFlow,
|
||||
type D2OnlineFlow,
|
||||
type D2OnlineSessionLost,
|
||||
} from '../../src/netproto/index.ts'
|
||||
import { FakeClock } from '../../src/netproto/core/clock.ts'
|
||||
import { TimeoutError } from '../../src/netproto/core/errors.ts'
|
||||
import { BncsOpcode } from '../../src/netproto/bncs/packets.ts'
|
||||
import { McpOpcode } from '../../src/netproto/mcp/packets.ts'
|
||||
import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts'
|
||||
import { createFakeRealm, type FakeRealm, type FakeRealmOptions } from './fake-realm-servers.ts'
|
||||
|
||||
interface Harness {
|
||||
readonly realm: FakeRealm
|
||||
readonly clock: FakeClock
|
||||
readonly flow: D2OnlineFlow
|
||||
readonly lost: D2OnlineSessionLost[]
|
||||
}
|
||||
|
||||
function createHarness(options: FakeRealmOptions = {}): Harness {
|
||||
const realm = createFakeRealm(options)
|
||||
const clock = new FakeClock(1000)
|
||||
const flow = createD2OnlineFlow({
|
||||
bnetHost: '127.0.0.1',
|
||||
resolver: realm.resolver,
|
||||
clock,
|
||||
itemTables: getCanonicalItemDataTables(),
|
||||
})
|
||||
const lost: D2OnlineSessionLost[] = []
|
||||
flow.onSessionLost((ev) => lost.push(ev))
|
||||
return { realm, clock, flow, lost }
|
||||
}
|
||||
|
||||
/** Log in and enter the realm: the char_select (1 char) / char_create (0 chars) screen. */
|
||||
async function toCharacterScreen(h: Harness): Promise<void> {
|
||||
await h.flow.connectBnet()
|
||||
await h.flow.login('d2webbot2', 'secret')
|
||||
await h.flow.enterRealm('D2CS')
|
||||
expect(h.flow.state).toBe('realm_ready')
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve()
|
||||
}
|
||||
|
||||
/** Advance the fake clock in steps so promise continuations can arm their timers in between. */
|
||||
async function advance(clock: FakeClock, ms: number): Promise<void> {
|
||||
const step = 500
|
||||
for (let t = 0; t < ms; t += step) {
|
||||
await flushMicrotasks()
|
||||
clock.advance(Math.min(step, ms - t))
|
||||
}
|
||||
await flushMicrotasks()
|
||||
}
|
||||
|
||||
describe('D2OnlineFlow session loss', () => {
|
||||
it('BNCS server close on char_select: one loss event, flow closed, BNCS + MCP sockets disposed', async () => {
|
||||
const h = createHarness({ characters: 1 })
|
||||
await toCharacterScreen(h)
|
||||
|
||||
h.realm.latest('bnet').server.close()
|
||||
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'closed', flowState: 'realm_ready' })
|
||||
expect(h.lost[0]!.message).toContain('BNCS connection closed')
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.flow.sessionLost).toBe(h.lost[0])
|
||||
expect(h.realm.latest('realm').client.isClosed).toBe(true)
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('MCP server close on char_create: one loss event; a later createCharacter fails without a second event', async () => {
|
||||
const h = createHarness({ characters: 0 })
|
||||
await toCharacterScreen(h)
|
||||
|
||||
h.realm.latest('realm').server.close({ kind: 'error', error: new Error('ECONNRESET') })
|
||||
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'error', flowState: 'realm_ready' })
|
||||
expect(h.lost[0]!.message).toContain('ECONNRESET')
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
|
||||
await expect(
|
||||
h.flow.createCharacter({ name: 'NewSorc', classId: 1, expansion: true }),
|
||||
).rejects.toThrow(/MCP realm session is not connected for createCharacter/)
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.flow.state).toBe('closed')
|
||||
})
|
||||
|
||||
it('realm logon timeout (SID_LOGONREALMEX unanswered): timeout loss, no reconnect, sockets disposed', async () => {
|
||||
const h = createHarness({ silentBncs: [BncsOpcode.SID_LOGONREALMEX] })
|
||||
await h.flow.connectBnet()
|
||||
await h.flow.login('d2webbot2', 'secret')
|
||||
|
||||
const entering = h.flow.enterRealm('D2CS')
|
||||
const outcome = entering.then(
|
||||
() => null,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
await advance(h.clock, 10_500)
|
||||
|
||||
expect(await outcome).toBeInstanceOf(TimeoutError)
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'timeout', flowState: 'connecting_realm' })
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.realm.connections.filter((c) => c.role === 'realm')).toHaveLength(0)
|
||||
expect(h.realm.connections.filter((c) => c.role === 'bnet')).toHaveLength(1)
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('MCP_STARTUP timeout: realm timeout loss closes both the MCP and BNCS sockets', async () => {
|
||||
const h = createHarness({ silentMcp: [McpOpcode.MCP_STARTUP] })
|
||||
await h.flow.connectBnet()
|
||||
await h.flow.login('d2webbot2', 'secret')
|
||||
|
||||
const outcome = h.flow.enterRealm('D2CS').then(
|
||||
() => null,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
await advance(h.clock, 10_500)
|
||||
|
||||
expect(await outcome).toBeInstanceOf(TimeoutError)
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'mcp', cause: 'timeout' })
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('BNCS and MCP closing at the same time are reported exactly once', async () => {
|
||||
const h = createHarness({ characters: 1 })
|
||||
await toCharacterScreen(h)
|
||||
|
||||
h.realm.latest('realm').server.close()
|
||||
h.realm.latest('bnet').server.close()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]!.session).toBe('mcp')
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('D2GS drop in game: one d2gs loss event and BNCS/MCP/D2GS are all disposed', async () => {
|
||||
const h = createHarness({ characters: 1 })
|
||||
await toCharacterScreen(h)
|
||||
await h.flow.selectCharacter('d2webbot_sorc')
|
||||
await h.flow.createGame({ name: 'loss-game', password: '', difficulty: 0 })
|
||||
await h.flow.joinGame('loss-game', '')
|
||||
expect(h.flow.state).toBe('ingame')
|
||||
|
||||
h.realm.latest('game').server.close()
|
||||
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'd2gs', cause: 'closed', flowState: 'ingame' })
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('BNCS loss during a game is reported when the game ends, not by aborting the game', async () => {
|
||||
const h = createHarness({ characters: 1 })
|
||||
await toCharacterScreen(h)
|
||||
await h.flow.selectCharacter('d2webbot_sorc')
|
||||
await h.flow.joinGame('loss-game', '')
|
||||
|
||||
h.realm.latest('bnet').server.close()
|
||||
expect(h.lost).toHaveLength(0)
|
||||
expect(h.flow.state).toBe('ingame')
|
||||
expect(h.realm.latest('game').client.isClosed).toBe(false)
|
||||
|
||||
await h.flow.leaveToLobby()
|
||||
expect(h.lost).toHaveLength(1)
|
||||
expect(h.lost[0]).toMatchObject({ session: 'bncs', cause: 'closed', flowState: 'ingame' })
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('close() on purpose emits no loss event and disposes every socket', async () => {
|
||||
const h = createHarness({ characters: 1 })
|
||||
await toCharacterScreen(h)
|
||||
|
||||
await h.flow.close()
|
||||
|
||||
expect(h.lost).toHaveLength(0)
|
||||
expect(h.flow.sessionLost).toBeNull()
|
||||
expect(h.flow.state).toBe('closed')
|
||||
expect(h.realm.allClientStreamsClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
* Gate 7. Dockable Packet Inspector live packet capture, filtering, row selection, and all 4 `.d2cap` fixtures
|
||||
* replay with 0 `UnknownS2c` events
|
||||
* Gate 8. Server host validation (`bare_ip_on_https` rejection) & `localStorage` (`d2web.settings.v1`) persistence across `Page.reload`
|
||||
* Gate 9. Graceful disconnect recovery to Battle.net lobby overlay with disconnect reason banner, 0 console/page errors.
|
||||
* Gate 9. Session loss returns to the Battle.net login screen with the reason (loginError + banner), 0 console/page errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
|
@ -1017,12 +1017,14 @@ async function main(): Promise<void> {
|
|||
throw new Error(`Gate 8b failed: ${JSON.stringify(gate8b)}`)
|
||||
}
|
||||
|
||||
// ─── Gate 9: Graceful Disconnect Recovery to Battle.net Lobby Overlay ──
|
||||
// ─── Gate 9: Session Loss Returns to the Battle.net Login Screen With Reason ──
|
||||
const gate9 = await cdp.evaluate<{
|
||||
phase: string
|
||||
reason: string | null
|
||||
overlayVisible: boolean
|
||||
bannerVisible: boolean
|
||||
stage: string
|
||||
loginError: string | null
|
||||
}>(`(async () => {
|
||||
const app = window.__d2Play;
|
||||
await app.simulateDisconnect('Test disconnect: connection reset by peer');
|
||||
|
|
@ -1033,6 +1035,8 @@ async function main(): Promise<void> {
|
|||
reason: app.session.disconnectReason,
|
||||
overlayVisible: overlay ? overlay.style.display !== 'none' : false,
|
||||
bannerVisible: banner ? !banner.hidden : false,
|
||||
stage: app.flowState.stage,
|
||||
loginError: app.flowState.loginError,
|
||||
};
|
||||
})()`)
|
||||
console.log('[verify-play] Gate 9 (Graceful disconnect recovery):', gate9)
|
||||
|
|
@ -1040,7 +1044,9 @@ async function main(): Promise<void> {
|
|||
gate9.phase !== 'disconnected' ||
|
||||
!gate9.reason?.includes('Test disconnect') ||
|
||||
!gate9.overlayVisible ||
|
||||
!gate9.bannerVisible
|
||||
!gate9.bannerVisible ||
|
||||
gate9.stage !== 'bnet_login' ||
|
||||
!gate9.loginError?.includes('Test disconnect')
|
||||
) {
|
||||
throw new Error(`Gate 9 failed: ${JSON.stringify(gate9)}`)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue