1241 lines
40 KiB
TypeScript
1241 lines
40 KiB
TypeScript
/**
|
||
* Online Session Coordinator (`src/client/session/online-session.ts`, Milestone M5 — F16, F18, F20).
|
||
*
|
||
* Orchestrates the end-to-end client runtime across:
|
||
* - `D2OnlineFlow` + `createWsBridgeResolver` (BNCS -> MCP -> D2GS in-game lifecycle)
|
||
* - `ClientWorld` authoritative state mirror
|
||
* - `MapService` + `AutomapView` (`off` | `level` | `act` reveal modes)
|
||
* - `LocalMovementPredictor` (25Hz step + rubber-band / hard-snap server reconciliation)
|
||
* - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion)
|
||
* - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`)
|
||
* - `HudManager` + `HudModel` + `CommandMapper` + `OutboundRateLimiter`
|
||
* - `SettingsStore`, `SoundService`, and `PacketInspector` (with deterministic `.d2cap` replay)
|
||
* - Graceful disconnect / error recovery without uncaught promise rejections or page crashes.
|
||
*
|
||
* Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`).
|
||
*/
|
||
|
||
import {
|
||
D2gsS2cFramer,
|
||
InMemoryPacketTap,
|
||
createD2OnlineFlow,
|
||
createWsBridgeResolver,
|
||
decodeD2gsS2cPacket,
|
||
unitTypeToId,
|
||
type ByteStream,
|
||
type CharClassId,
|
||
type CharCreateSpec,
|
||
type CharSummary,
|
||
type ClientCommand,
|
||
type CloseReason,
|
||
type D2OnlineConfig,
|
||
type D2OnlineFlow,
|
||
type D2OnlineState,
|
||
type EndpointResolver,
|
||
type GameCreateSpec,
|
||
type GameListEntry,
|
||
type GameServerAdapter,
|
||
type ItemDataTables,
|
||
type RealmSummary,
|
||
type ServerEvent,
|
||
type UnitType,
|
||
} from '../../netproto/index.ts'
|
||
import { HudManager } from '../../ui/hud-manager.ts'
|
||
import { SoundService } from '../audio/sound-service.ts'
|
||
import { AutomapView, getActForArea } from '../automap/automap-view.ts'
|
||
import {
|
||
CommandMapper,
|
||
ViewportCamera,
|
||
type CameraLike,
|
||
} from '../input/command-mapper.ts'
|
||
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
|
||
import type { MapService } from '../map/map-service.ts'
|
||
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
||
import { LocalMovementPredictor } from '../motion/predictor.ts'
|
||
import {
|
||
getGlobalSettingsStore,
|
||
type SettingsStore,
|
||
} from '../settings/settings-store.ts'
|
||
import { HudModel } from '../ui-model/hud-model.ts'
|
||
import { ClientWorldSceneSource } from '../view/scene-source.ts'
|
||
import type { RenderableUnitInput } from '../view/unit-drawables.ts'
|
||
import { getViewportProfile } from '../view/viewport-profile.ts'
|
||
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
|
||
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
|
||
import { OutboundRateLimiter } from './rate-limiter.ts'
|
||
|
||
export type OnlineSessionPhase =
|
||
| 'idle'
|
||
| 'connecting'
|
||
| 'lobby'
|
||
| 'entering-game'
|
||
| 'ingame'
|
||
| 'replay'
|
||
| 'disconnected'
|
||
| 'error'
|
||
|
||
export interface OnlineSessionLoginParams {
|
||
readonly host?: string | undefined
|
||
readonly bncsPort?: number | undefined
|
||
readonly wsBridgeBaseUrl?: string | undefined
|
||
readonly username: string
|
||
readonly password: string
|
||
readonly realmIndex?: number | undefined
|
||
}
|
||
|
||
export interface OnlineSessionLobbySnapshot {
|
||
readonly phase: OnlineSessionPhase
|
||
readonly flowState: D2OnlineState
|
||
readonly username: string
|
||
readonly selectedRealm: RealmSummary | null
|
||
readonly realms: readonly RealmSummary[]
|
||
readonly characters: readonly CharSummary[]
|
||
readonly selectedChar: string | null
|
||
readonly games: readonly GameListEntry[]
|
||
readonly activeGameName: string | null
|
||
readonly disconnectReason: string | null
|
||
readonly pingMs: number
|
||
}
|
||
|
||
export interface OnlineSessionOptions {
|
||
readonly world?: ClientWorld | undefined
|
||
readonly hudManager?: HudManager | undefined
|
||
readonly camera?: CameraLike | undefined
|
||
readonly mapService?: MapService | null | undefined
|
||
readonly automapView?: AutomapView | undefined
|
||
readonly predictor?: LocalMovementPredictor | undefined
|
||
readonly interpolator?: RemoteEntityInterpolator | undefined
|
||
readonly rateLimiter?: OutboundRateLimiter | undefined
|
||
readonly settingsStore?: SettingsStore | undefined
|
||
readonly soundService?: SoundService | undefined
|
||
readonly packetInspector?: PacketInspector | undefined
|
||
readonly resolver?: EndpointResolver | undefined
|
||
readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined
|
||
readonly itemTables?: ItemDataTables | undefined
|
||
readonly defaultHost?: string | undefined
|
||
readonly defaultBncsPort?: number | undefined
|
||
readonly wsBridgeBaseUrl?: string | undefined
|
||
readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
|
||
readonly onToggleChatInput?: (() => void) | undefined
|
||
readonly onEscapeMenu?: (() => void) | undefined
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* (`D2GS_S2C_REGISTRY`) and canonical 1.13c item data tables (`ItemStatCost.txt`, `Armor.txt`,
|
||
* `Weapons.txt`, `Misc.txt`).
|
||
*/
|
||
export function decodeCapturedGameS2cPacket(
|
||
pkt: Uint8Array,
|
||
itemTables: ItemDataTables = getCanonicalItemDataTables(),
|
||
): ServerEvent {
|
||
if (pkt.byteLength === 0) {
|
||
return { type: 'Opaque', proto: 'd2gs', id: 0, raw: pkt }
|
||
}
|
||
return decodeD2gsS2cPacket(pkt, {
|
||
tables: itemTables,
|
||
encoding: 'gbk',
|
||
})
|
||
}
|
||
|
||
export class OnlineSession {
|
||
readonly world: ClientWorld
|
||
readonly hudManager: HudManager
|
||
readonly camera: CameraLike
|
||
readonly mapService: MapService | null
|
||
readonly automapView: AutomapView
|
||
readonly predictor: LocalMovementPredictor
|
||
readonly interpolator: RemoteEntityInterpolator
|
||
readonly rateLimiter: OutboundRateLimiter
|
||
readonly settingsStore: SettingsStore
|
||
readonly soundService: SoundService
|
||
readonly packetTap: InMemoryPacketTap
|
||
readonly packetInspector: PacketInspector
|
||
readonly hudModel: HudModel
|
||
readonly commandMapper: CommandMapper
|
||
readonly sceneSource: ClientWorldSceneSource
|
||
|
||
private readonly resolver: EndpointResolver
|
||
private readonly flowFactory: (config: D2OnlineConfig) => D2OnlineFlow
|
||
private readonly itemTables: ItemDataTables
|
||
private readonly defaultHost: string
|
||
private readonly defaultBncsPort: number
|
||
private readonly replayS2cFramer = new D2gsS2cFramer(false)
|
||
private lastDecodedReplayRecord: {
|
||
readonly record: ReplayedD2CapFrame['record']
|
||
readonly events: readonly ServerEvent[]
|
||
} | 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 unsubSettings: (() => void) | null = null
|
||
|
||
private phaseValue: OnlineSessionPhase = 'idle'
|
||
private usernameValue = ''
|
||
private realmsValue: readonly RealmSummary[] = []
|
||
private selectedRealmValue: RealmSummary | null = null
|
||
private charactersValue: readonly CharSummary[] = []
|
||
private selectedCharValue: string | null = null
|
||
private gamesValue: readonly GameListEntry[] = []
|
||
private activeGameNameValue: string | null = null
|
||
private disconnectReasonValue: string | null = null
|
||
private pingMsValue = 0
|
||
private tickCounter = 0
|
||
|
||
private readonly outboundCommandsLog: ClientCommand[] = []
|
||
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
||
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
||
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
|
||
private readonly disconnectListeners = new Set<(reason: string) => void>()
|
||
|
||
constructor(options: OnlineSessionOptions = {}) {
|
||
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
||
const currentSettings = this.settingsStore.get()
|
||
const initialViewport = getViewportProfile(currentSettings.viewport)
|
||
|
||
this.world = options.world ?? new ClientWorld()
|
||
this.camera = options.camera ?? new ViewportCamera(initialViewport)
|
||
this.hudManager = options.hudManager ?? new HudManager()
|
||
this.hudManager.resize(initialViewport.width, initialViewport.height)
|
||
|
||
this.mapService = options.mapService ?? null
|
||
this.automapView =
|
||
options.automapView ??
|
||
new AutomapView({
|
||
mapService: this.mapService,
|
||
initialMode: currentSettings.automapReveal,
|
||
})
|
||
|
||
this.predictor = options.predictor ?? new LocalMovementPredictor()
|
||
this.interpolator = options.interpolator ?? new RemoteEntityInterpolator()
|
||
this.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter()
|
||
this.soundService =
|
||
options.soundService ??
|
||
new SoundService({
|
||
store: this.settingsStore,
|
||
})
|
||
|
||
this.itemTables = options.itemTables ?? getCanonicalItemDataTables()
|
||
|
||
this.packetTap = new InMemoryPacketTap(4000)
|
||
this.packetInspector =
|
||
options.packetInspector ??
|
||
new PacketInspector({
|
||
store: this.settingsStore,
|
||
tap: this.packetTap,
|
||
decodeReplayEntry: (conn, dir, rawBytes, record) => {
|
||
const events = this.extractEventsFromReplayRecord(conn, dir, rawBytes, record)
|
||
this.lastDecodedReplayRecord = { record, events }
|
||
if (events.length === 1) return events[0]
|
||
if (events.length > 1) return { events }
|
||
return undefined
|
||
},
|
||
onReplayRecord: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array) => {
|
||
this.handleReplayedPacketFrame(frame, rawBytes)
|
||
},
|
||
})
|
||
|
||
this.hudModel = new HudModel({
|
||
world: this.world,
|
||
hudManager: this.hudManager,
|
||
predictor: this.predictor,
|
||
automap: this.automapView,
|
||
onCommand: (cmd) => {
|
||
this.dispatchCommand(cmd)
|
||
},
|
||
})
|
||
|
||
this.commandMapper = new CommandMapper({
|
||
world: this.world,
|
||
camera: this.camera,
|
||
hudManager: this.hudManager,
|
||
hudModel: this.hudModel,
|
||
predictor: this.predictor,
|
||
mapService: this.mapService ?? undefined,
|
||
automapView: this.automapView,
|
||
rateLimiter: this.rateLimiter,
|
||
sendCommand: (cmd) => {
|
||
this.forwardCommandToAdapter(cmd)
|
||
},
|
||
isExternalUiIntercepted: options.isExternalUiIntercepted,
|
||
onToggleChatInput: options.onToggleChatInput,
|
||
onEscapeMenu: options.onEscapeMenu,
|
||
})
|
||
|
||
this.sceneSource = new ClientWorldSceneSource(
|
||
() => ({
|
||
tick: this.tickCounter,
|
||
actId: getActForArea(this.world.areaId || 1),
|
||
levelId: this.world.areaId || 1,
|
||
playerSubX: this.predictor.getSnapshot().x,
|
||
playerSubY: this.predictor.getSnapshot().y,
|
||
units: this.buildRenderableUnits(),
|
||
levelView:
|
||
this.mapService?.getActiveLevelView() ??
|
||
this.automapView.getLevelView(this.world.areaId || 1) ??
|
||
null,
|
||
tileAtlas: this.mapService?.getTileAtlas() ?? null,
|
||
}),
|
||
initialViewport,
|
||
)
|
||
|
||
this.defaultHost = options.defaultHost ?? '101.37.117.183'
|
||
this.defaultBncsPort = options.defaultBncsPort ?? 6112
|
||
const wsBase = options.wsBridgeBaseUrl ?? this.resolveWsBridgeBaseUrl(currentSettings.serverHost)
|
||
this.resolver = options.resolver ?? createWsBridgeResolver(this.parseWsBridgeUrl(wsBase))
|
||
this.flowFactory = options.flowFactory ?? createD2OnlineFlow
|
||
|
||
// React to viewport & automap reveal changes in SettingsStore
|
||
this.unsubSettings = this.settingsStore.subscribe((next, prev) => {
|
||
if (next.viewport !== prev.viewport) {
|
||
const vp = getViewportProfile(next.viewport)
|
||
this.camera.resize(vp)
|
||
this.hudManager.resize(vp.width, vp.height)
|
||
this.sceneSource.setViewportProfile(vp)
|
||
}
|
||
if (next.automapReveal !== prev.automapReveal) {
|
||
this.automapView.setMode(next.automapReveal)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── State & Listeners ───────────────────────────────────────
|
||
|
||
get phase(): OnlineSessionPhase {
|
||
return this.phaseValue
|
||
}
|
||
|
||
get selectedChar(): string | null {
|
||
return this.selectedCharValue
|
||
}
|
||
|
||
get disconnectReason(): string | null {
|
||
return this.disconnectReasonValue
|
||
}
|
||
|
||
get pingMs(): number {
|
||
return this.gameAdapter ? this.gameAdapter.stats.rttMs || this.pingMsValue : this.pingMsValue
|
||
}
|
||
|
||
getSentCommands(): readonly ClientCommand[] {
|
||
return this.outboundCommandsLog
|
||
}
|
||
|
||
getSnapshot(): OnlineSessionLobbySnapshot {
|
||
return {
|
||
phase: this.phaseValue,
|
||
flowState: this.flow?.state ?? 'idle',
|
||
username: this.usernameValue,
|
||
selectedRealm: this.selectedRealmValue,
|
||
realms: this.realmsValue,
|
||
characters: this.charactersValue,
|
||
selectedChar: this.selectedCharValue,
|
||
games: this.gamesValue,
|
||
activeGameName: this.activeGameNameValue,
|
||
disconnectReason: this.disconnectReasonValue,
|
||
pingMs: this.pingMs,
|
||
}
|
||
}
|
||
|
||
onStateChange(cb: (snapshot: OnlineSessionLobbySnapshot) => void): () => void {
|
||
this.stateListeners.add(cb)
|
||
return () => {
|
||
this.stateListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onServerEvent(cb: (ev: ServerEvent) => void): () => void {
|
||
this.eventListeners.add(cb)
|
||
return () => {
|
||
this.eventListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onChatMessage(cb: (entry: ChatLogEntry) => void): () => void {
|
||
this.chatListeners.add(cb)
|
||
return () => {
|
||
this.chatListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onDisconnect(cb: (reason: string) => void): () => void {
|
||
this.disconnectListeners.add(cb)
|
||
return () => {
|
||
this.disconnectListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
// ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
|
||
|
||
/**
|
||
* Connect to BNCS, authenticate account, and select Realm (MCP) to list characters.
|
||
*/
|
||
async login(params: OnlineSessionLoginParams): Promise<{
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a new Battle.net account on BNCS, log in, and enter the realm character screen.
|
||
*/
|
||
async createAccountAndLogin(params: OnlineSessionLoginParams): Promise<{
|
||
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.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)
|
||
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
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
|
||
if (!this.flow) {
|
||
throw new Error('OnlineSession.createCharacter: not connected to realm')
|
||
}
|
||
await this.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)
|
||
if (this.selectedCharValue === name) {
|
||
this.selectedCharValue = null
|
||
}
|
||
return await this.refreshCharacters()
|
||
}
|
||
|
||
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
|
||
if (!this.flow) {
|
||
throw new Error('OnlineSession.convertCharacterToExpansion: not connected to realm')
|
||
}
|
||
await this.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)
|
||
this.selectedCharValue = name
|
||
const charMeta = this.charactersValue.find((c) => c.name === name)
|
||
if (charMeta) {
|
||
this.world.self.name = charMeta.name
|
||
this.world.self.charClass = charMeta.charClass
|
||
} else {
|
||
this.world.self.name = name
|
||
}
|
||
this.hudModel.syncFromWorld()
|
||
this.emitState()
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
const prevChatCount = this.world.chatLog.length
|
||
this.world.apply({
|
||
type: 'Chat',
|
||
kind: 'say',
|
||
from: this.selectedCharValue || this.usernameValue || 'Player',
|
||
text: trimmed,
|
||
})
|
||
if (this.world.chatLog.length > prevChatCount) {
|
||
const entry = this.world.chatLog[this.world.chatLog.length - 1]
|
||
if (entry) {
|
||
for (const listener of this.chatListeners) {
|
||
listener(entry)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Create & join or join an existing D2GS game, binding the `GameServerAdapter` to `ClientWorld`.
|
||
*/
|
||
async enterGame(
|
||
request:
|
||
| { 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 ?? '')
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attach a live or mock `GameServerAdapter` directly to this session.
|
||
*/
|
||
attachGameServer(adapter: GameServerAdapter): void {
|
||
this.detachGameServerListeners()
|
||
this.gameAdapter = adapter
|
||
this.disconnectReasonValue = null
|
||
this.setPhase('ingame')
|
||
|
||
this.unsubGameEvent = adapter.onEvent((ev) => {
|
||
this.handleServerEvent(ev)
|
||
})
|
||
|
||
this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
|
||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
||
const desc =
|
||
reason.kind === 'error'
|
||
? `Connection error: ${reason.error.message}`
|
||
: reason.kind === 'remote'
|
||
? `Server closed connection${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
|
||
: 'Disconnected from game server'
|
||
this.handleUnexpectedDisconnect(desc)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Leave the active D2GS game and return to lobby (or idle if standalone).
|
||
*/
|
||
async leaveGame(): Promise<void> {
|
||
const hadFlow = this.flow !== null
|
||
this.detachGameServerListeners()
|
||
if (this.flow && this.flow.state === 'ingame') {
|
||
try {
|
||
await this.flow.leaveToLobby()
|
||
} catch {
|
||
// Ignore close errors on leave
|
||
}
|
||
} else if (this.gameAdapter) {
|
||
try {
|
||
this.gameAdapter.leave()
|
||
} catch {
|
||
// Ignore close errors on leave
|
||
}
|
||
}
|
||
this.gameAdapter = null
|
||
this.activeGameNameValue = null
|
||
this.setPhase(hadFlow ? 'lobby' : 'idle')
|
||
}
|
||
|
||
/**
|
||
* Gracefully close all active connections and transition to `'disconnected'`.
|
||
*/
|
||
async disconnect(reason = 'Disconnected by user'): Promise<void> {
|
||
this.detachGameServerListeners()
|
||
this.gameAdapter = null
|
||
await this.closeFlowQuietly()
|
||
this.disconnectReasonValue = reason
|
||
this.setPhase('disconnected')
|
||
}
|
||
|
||
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
||
|
||
/**
|
||
* Dispatch a `ClientCommand` through `OutboundRateLimiter` (used by `HudModel` and direct API callers).
|
||
*/
|
||
dispatchCommand(cmd: ClientCommand, nowMs = performance.now()): boolean {
|
||
const res = this.rateLimiter.tryAcquire(cmd, nowMs)
|
||
if (res.allowed) {
|
||
this.forwardCommandToAdapter(cmd)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
private forwardCommandToAdapter(cmd: ClientCommand): void {
|
||
this.outboundCommandsLog.push(cmd)
|
||
if (this.outboundCommandsLog.length > 512) {
|
||
this.outboundCommandsLog.shift()
|
||
}
|
||
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
||
try {
|
||
this.gameAdapter.send(cmd)
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Authoritative ServerEvent Ingestion ─────────────────────
|
||
|
||
/**
|
||
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
|
||
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
|
||
*/
|
||
handleServerEvent(ev: ServerEvent): void {
|
||
const prevChatCount = this.world.chatLog.length
|
||
this.world.apply(ev)
|
||
|
||
if (
|
||
this.phaseValue === 'entering-game' &&
|
||
(ev.type === 'LoadAct' ||
|
||
ev.type === 'LoadComplete' ||
|
||
(ev.type === 'UnitAssign' && Boolean(ev.isSelf)))
|
||
) {
|
||
this.setPhase('ingame')
|
||
}
|
||
|
||
switch (ev.type) {
|
||
case 'LoadAct': {
|
||
const defaultTownArea = ([1, 40, 75, 103, 109] as const)[ev.act] ?? 1
|
||
const effectiveAreaId = ev.areaId > 0 ? ev.areaId : defaultTownArea
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onLoadActPacket(
|
||
{
|
||
act: ev.act,
|
||
mapSeed: ev.mapSeed,
|
||
areaId: effectiveAreaId,
|
||
},
|
||
{ difficulty: this.world.difficulty },
|
||
)
|
||
} catch {
|
||
// MapService may be running without packed bundle in lightweight tests
|
||
}
|
||
}
|
||
const activeView = this.mapService?.getActiveLevelView() ?? undefined
|
||
this.automapView.setContext(
|
||
ev.mapSeed,
|
||
this.world.difficulty,
|
||
effectiveAreaId,
|
||
activeView,
|
||
)
|
||
if (effectiveAreaId > 0) {
|
||
void this.soundService.onEnterArea(effectiveAreaId)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'RoomReveal': {
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onAddRoomDataPacket({
|
||
roomX: ev.tileX,
|
||
roomY: ev.tileY,
|
||
areaId: ev.areaId,
|
||
})
|
||
} catch {
|
||
// Ignore if act not yet loaded
|
||
}
|
||
}
|
||
this.automapView.onServerAddRoom(ev.tileX, ev.tileY, ev.areaId)
|
||
if (ev.areaId > 0) {
|
||
void this.soundService.onEnterArea(ev.areaId)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'RoomHide': {
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onRemoveRoomDataPacket({
|
||
roomX: ev.tileX,
|
||
roomY: ev.tileY,
|
||
areaId: ev.areaId,
|
||
})
|
||
} catch {
|
||
// Ignore if act not yet loaded
|
||
}
|
||
}
|
||
this.automapView.onServerRemoveRoom(ev.tileX, ev.tileY, ev.areaId)
|
||
break
|
||
}
|
||
|
||
case 'UnitAssign': {
|
||
const u = ev.unit
|
||
const uTypeId = unitTypeToId(u.unitType)
|
||
const isLocalPlayer =
|
||
Boolean(ev.isSelf) ||
|
||
(uTypeId === 0 && (this.world.selfId === u.id || this.world.selfId === 0))
|
||
|
||
if (isLocalPlayer) {
|
||
if (u.x !== 0 || u.y !== 0) {
|
||
this.predictor.setPosition(u.x, u.y)
|
||
this.camera.snapTo(u.x, u.y)
|
||
}
|
||
} else if (u.x !== 0 || u.y !== 0) {
|
||
this.interpolator.spawnOrSnap({
|
||
unitType: uTypeId,
|
||
unitId: u.id,
|
||
x: u.x,
|
||
y: u.y,
|
||
...(u.direction !== undefined ? { dir16: u.direction } : {}),
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitReassign': {
|
||
const evTypeId = unitTypeToId(ev.unitType)
|
||
const isLocalPlayer =
|
||
evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0)
|
||
if (isLocalPlayer) {
|
||
const prevSnap = this.predictor.getSnapshot()
|
||
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) {
|
||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
} else {
|
||
const res = this.predictor.reconcileServerPosition({
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
teleport: ev.snap,
|
||
})
|
||
if (res.mode === 'hard-snap') {
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
}
|
||
}
|
||
} else {
|
||
this.interpolator.spawnOrSnap({
|
||
unitType: evTypeId,
|
||
unitId: ev.id,
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'LifeMana': {
|
||
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
||
const prevSnap = this.predictor.getSnapshot()
|
||
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) {
|
||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
} else {
|
||
this.predictor.reconcileServerPosition({
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
teleport: false,
|
||
})
|
||
}
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitMove': {
|
||
const evTypeId = unitTypeToId(ev.unitType)
|
||
const isLocalPlayer =
|
||
evTypeId === 0 && ev.id === this.world.selfId && this.world.selfId !== 0
|
||
if (isLocalPlayer) {
|
||
if (ev.mode === 'stop') {
|
||
this.predictor.reconcileServerPosition({
|
||
x: ev.to.x,
|
||
y: ev.to.y,
|
||
teleport: false,
|
||
})
|
||
}
|
||
} else if (ev.mode === 'stop') {
|
||
this.interpolator.stopEntity({
|
||
unitType: evTypeId,
|
||
unitId: ev.id,
|
||
x: ev.to.x,
|
||
y: ev.to.y,
|
||
})
|
||
} else {
|
||
this.interpolator.startMove({
|
||
unitType: evTypeId,
|
||
unitId: ev.id,
|
||
...(ev.from?.x !== undefined ? { currentX: ev.from.x } : {}),
|
||
...(ev.from?.y !== undefined ? { currentY: ev.from.y } : {}),
|
||
targetX: ev.to.x,
|
||
targetY: ev.to.y,
|
||
running: ev.mode === 'run',
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitRemove': {
|
||
this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id)
|
||
break
|
||
}
|
||
|
||
case 'Pong': {
|
||
if (ev.rttMs !== undefined) {
|
||
this.pingMsValue = ev.rttMs
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'GameLogout': {
|
||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
||
this.handleUnexpectedDisconnect('Game session ended (GameLogout)')
|
||
}
|
||
break
|
||
}
|
||
|
||
default:
|
||
break
|
||
}
|
||
|
||
// Drain sound queue into SoundService
|
||
const queuedSounds = this.world.drainSoundQueue()
|
||
const selfSnap = this.predictor.getSnapshot()
|
||
for (const entry of queuedSounds) {
|
||
if (entry.unit) {
|
||
const unit = this.world.getUnit(entry.unit.unitType, entry.unit.id)
|
||
if (unit && (unit.x !== 0 || unit.y !== 0)) {
|
||
this.soundService.playSoundById(entry.sound, {
|
||
sourceSubX: unit.x,
|
||
sourceSubY: unit.y,
|
||
listenerSubX: selfSnap.x || this.world.self.x,
|
||
listenerSubY: selfSnap.y || this.world.self.y,
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
this.soundService.playSoundById(entry.sound)
|
||
}
|
||
|
||
// Emit any newly added chat entries
|
||
if (this.world.chatLog.length > prevChatCount) {
|
||
for (let i = prevChatCount; i < this.world.chatLog.length; i++) {
|
||
const chatEntry = this.world.chatLog[i]
|
||
if (chatEntry) {
|
||
for (const listener of this.chatListeners) {
|
||
listener(chatEntry)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Synchronize HudManager via HudModel
|
||
this.hudModel.syncFromWorld()
|
||
|
||
for (const listener of this.eventListeners) {
|
||
listener(ev)
|
||
}
|
||
}
|
||
|
||
// ─── Frame Tick & Render State ───────────────────────────────
|
||
|
||
/**
|
||
* Step the session by `dtMs` milliseconds:
|
||
* - Flushes rate-limited movement commands in `CommandMapper`
|
||
* - Steps `LocalMovementPredictor` and `Camera`
|
||
* - Steps `RemoteEntityInterpolator` and syncs interpolated positions to `ClientWorld` units
|
||
*/
|
||
tick(dtMs = 40, nowMs = performance.now()): void {
|
||
this.tickCounter++
|
||
this.commandMapper.tick(nowMs)
|
||
|
||
const predSnap = this.predictor.update(dtMs)
|
||
if (predSnap.x !== 0 || predSnap.y !== 0) {
|
||
this.camera.update(predSnap.x, predSnap.y)
|
||
}
|
||
|
||
this.interpolator.update(dtMs)
|
||
for (const state of this.interpolator.getAllStates()) {
|
||
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
|
||
if (unit) {
|
||
unit.x = state.x
|
||
unit.y = state.y
|
||
unit.direction = state.dir16
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build `RenderableUnitInput[]` from `ClientWorld` + `LocalMovementPredictor` + `RemoteEntityInterpolator`.
|
||
*/
|
||
buildRenderableUnits(): RenderableUnitInput[] {
|
||
const out: RenderableUnitInput[] = []
|
||
const predSnap = this.predictor.getSnapshot()
|
||
const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||
const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||
|
||
if (selfX !== 0 || selfY !== 0 || this.world.selfId !== 0) {
|
||
out.push({
|
||
id: this.world.selfId || 1,
|
||
kind: 'player',
|
||
classId: this.world.self.charClass,
|
||
name: this.world.self.name || 'Player',
|
||
x: selfX,
|
||
y: selfY,
|
||
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
|
||
direction: predSnap.dir16,
|
||
hpRatio:
|
||
this.world.self.maxLife > 0
|
||
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
|
||
: 1,
|
||
isLocalPlayer: true,
|
||
})
|
||
}
|
||
|
||
for (const u of this.world.units.values()) {
|
||
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
||
continue
|
||
}
|
||
const interp = this.interpolator.getEntityState(
|
||
u.unitType === 'player'
|
||
? 0
|
||
: u.unitType === 'monster'
|
||
? 1
|
||
: u.unitType === 'object'
|
||
? 2
|
||
: u.unitType === 'missile'
|
||
? 3
|
||
: u.unitType === 'item'
|
||
? 4
|
||
: 5,
|
||
u.id,
|
||
)
|
||
out.push({
|
||
id: u.id,
|
||
kind: u.unitType === 'tile' ? 'object' : u.unitType,
|
||
classId: u.classId,
|
||
name: u.name,
|
||
x: interp ? interp.x : u.x,
|
||
y: interp ? interp.y : u.y,
|
||
mode: u.mode,
|
||
direction: interp ? interp.dir16 : u.direction,
|
||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||
isCorpse: u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0),
|
||
isChampion: u.isChampion,
|
||
isUnique: u.isUnique,
|
||
isSuperUnique: u.isSuperUnique,
|
||
states: Array.from(u.states.list()),
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// ─── Deterministic `.d2cap` Capture Replay (F20) ─────────────
|
||
|
||
/**
|
||
* Load a `.d2cap` JSONL capture into `PacketInspector` and transition to `'replay'` phase.
|
||
*/
|
||
loadD2CapReplay(jsonlText: string): number {
|
||
this.replayS2cFramer.reset(false)
|
||
this.lastDecodedReplayRecord = null
|
||
const records = this.packetInspector.loadD2CapJsonl(jsonlText)
|
||
this.disconnectReasonValue = null
|
||
this.setPhase('replay')
|
||
return records.length
|
||
}
|
||
|
||
/**
|
||
* Step 1 packet from the loaded `.d2cap` capture, decoding `game` `s2c` packets into `ServerEvent`s.
|
||
*/
|
||
stepD2CapReplay(): ReplayedD2CapFrame | null {
|
||
return this.packetInspector.stepReplay()
|
||
}
|
||
|
||
/**
|
||
* Replay all remaining records in the loaded `.d2cap` capture synchronously.
|
||
*/
|
||
replayAllD2Cap(): number {
|
||
this.setPhase('replay')
|
||
return this.packetInspector.replayAll()
|
||
}
|
||
|
||
private extractEventsFromReplayRecord(
|
||
conn: ReplayedD2CapFrame['conn'],
|
||
dir: ReplayedD2CapFrame['dir'],
|
||
rawBytes: Uint8Array,
|
||
record: ReplayedD2CapFrame['record'],
|
||
): readonly ServerEvent[] {
|
||
if (conn !== 'game' || dir !== 's2c') {
|
||
return []
|
||
}
|
||
if (record.stream) {
|
||
const framedPackets = this.replayS2cFramer.push(rawBytes)
|
||
const events: ServerEvent[] = []
|
||
for (const pkt of framedPackets) {
|
||
events.push(decodeCapturedGameS2cPacket(pkt.bytes, this.itemTables))
|
||
}
|
||
return events
|
||
}
|
||
return [decodeCapturedGameS2cPacket(rawBytes, this.itemTables)]
|
||
}
|
||
|
||
private handleReplayedPacketFrame(frame: ReplayedD2CapFrame, rawBytes: Uint8Array): void {
|
||
const events =
|
||
this.lastDecodedReplayRecord?.record === frame.record
|
||
? this.lastDecodedReplayRecord.events
|
||
: this.extractEventsFromReplayRecord(frame.conn, frame.dir, rawBytes, frame.record)
|
||
this.lastDecodedReplayRecord = null
|
||
for (const ev of events) {
|
||
this.handleServerEvent(ev)
|
||
}
|
||
}
|
||
|
||
// ─── Internal Error & Lifecycle Helpers ──────────────────────
|
||
|
||
resolveWsBridgeBaseUrl(serverHost?: string): string {
|
||
const cleanHost = (serverHost || 'www.laiseek.xyz')
|
||
.trim()
|
||
.replace(/^(wss?|https?):\/\//i, '')
|
||
.replace(/\/.*$/, '')
|
||
const isLocalHost = cleanHost.startsWith('localhost') || cleanHost.startsWith('127.0.0.1')
|
||
const scheme =
|
||
!isLocalHost &&
|
||
(cleanHost === 'www.laiseek.xyz' ||
|
||
(typeof window !== 'undefined' && window.location?.protocol === 'https:'))
|
||
? 'wss'
|
||
: 'ws'
|
||
return `${scheme}://${cleanHost}/d2net`
|
||
}
|
||
|
||
private parseWsBridgeUrl(wsBase: string): {
|
||
readonly host: string
|
||
readonly scheme: 'wss' | 'ws'
|
||
readonly basePath: string
|
||
} {
|
||
try {
|
||
const parsed = new URL(wsBase)
|
||
const scheme: 'wss' | 'ws' = parsed.protocol === 'wss:' || parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||
const basePath = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : '/d2net'
|
||
return {
|
||
host: parsed.host || 'www.laiseek.xyz',
|
||
scheme,
|
||
basePath,
|
||
}
|
||
} catch {
|
||
return {
|
||
host: 'www.laiseek.xyz',
|
||
scheme: 'wss',
|
||
basePath: '/d2net',
|
||
}
|
||
}
|
||
}
|
||
|
||
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.unsubFlowState = flow.onState(() => {
|
||
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
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
private detachGameServerListeners(): void {
|
||
this.unsubGameEvent?.()
|
||
this.unsubGameEvent = null
|
||
this.unsubGameClose?.()
|
||
this.unsubGameClose = null
|
||
}
|
||
|
||
private async closeFlowQuietly(): Promise<void> {
|
||
this.unsubFlowState?.()
|
||
this.unsubFlowState = null
|
||
this.unsubFlowError?.()
|
||
this.unsubFlowError = null
|
||
this.closeBnetStreamQuietly()
|
||
if (this.flow) {
|
||
const current = this.flow
|
||
this.flow = null
|
||
try {
|
||
await current.close()
|
||
} catch {
|
||
// Ignore close errors
|
||
}
|
||
}
|
||
}
|
||
|
||
private setPhase(next: OnlineSessionPhase): void {
|
||
this.phaseValue = next
|
||
this.emitState()
|
||
}
|
||
|
||
private emitState(): void {
|
||
const snap = this.getSnapshot()
|
||
for (const listener of this.stateListeners) {
|
||
listener(snap)
|
||
}
|
||
}
|
||
|
||
destroy(): void {
|
||
this.detachGameServerListeners()
|
||
void this.closeFlowQuietly()
|
||
this.unsubSettings?.()
|
||
this.unsubSettings = null
|
||
this.hudModel.dispose()
|
||
this.soundService.destroy()
|
||
}
|
||
}
|