diablo2-web/src/client/session/online-session.ts

1663 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 CharClassId,
type CharCreateSpec,
type CharSummary,
type ClientCommand,
type CloseReason,
type D2OnlineConfig,
type D2OnlineFlow,
type D2OnlineSessionKind,
type D2OnlineSessionLost,
type D2OnlineSessionLostCause,
type D2OnlineState,
type EndpointResolver,
type GameCreateSpec,
type GameListEntry,
type GameServerAdapter,
type ItemDataTables,
type RealmSummary,
type ServerEvent,
type UnitType,
} from '../../netproto/index.ts'
import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts'
import { resolveGroundItemMetadata } from '../../common/items/ground-items.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'
import {
CommandMapper,
ViewportCamera,
type CameraLike,
} from '../input/command-mapper.ts'
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
import type { LevelView } from '../map/level-view.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 { vendorPrice113c, vendorRepairAll113c } from '../ui-model/vendor-price.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
}
/** 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
* (`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
levelView?: LevelView | null | undefined
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 gameAdapter: GameServerAdapter | null = null
private unsubGameEvent: (() => void) | null = null
private unsubGameClose: (() => void) | null = null
private unsubFlowState: (() => 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 = ''
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>()
private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => 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)
},
vendorPrice: vendorPrice113c,
vendorRepairAll: vendorRepairAll113c,
playUiSound: (soundId) => {
this.soundService.playSoundById(soundId)
},
})
if (!this.hudManager.onEscapeMenuAction) {
this.hudManager.onEscapeMenuAction = (action) => {
if (action === 'saveAndExit') {
void this.leaveGame()
}
}
}
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(
() => {
// #539: the scene is anchored only on server-provided state (0x03 LoadAct area + 0x07
// revealed rooms + server player position). No `|| 1` Rogue Encampment default and no
// "last LevelView MapService happened to build" (AutomapView builds neighbouring levels
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
// level exists the scene reports not-ready.
const predSnap = this.predictor.getSnapshot()
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const levelView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null)
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
return {
tick: this.tickCounter,
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
playerSubX,
playerSubY,
units: this.buildRenderableUnits(),
levelView,
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)
}
}
/**
* 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) ─────────────
/**
* 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[]
}> {
return await this.establishSession(params, false)
}
/**
* 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[]
}> {
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 resolver = params.wsBridgeBaseUrl
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
: this.resolver
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 {
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
}
this.setPhase('lobby')
return { realms, characters }
} catch (err) {
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[]> {
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[]> {
await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec))
this.selectedCharValue = spec.name
return await this.refreshCharacters()
}
async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name))
if (this.selectedCharValue === name) {
this.selectedCharValue = null
}
return await this.refreshCharacters()
}
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name))
return await this.refreshCharacters()
}
async selectCharacter(name: string): Promise<void> {
await this.lobbyOp('selectCharacter', 'mcp', (flow) => 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
await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed))
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[]> {
return await this.lobbyOp('listGames', 'mcp', async (flow) => {
const games = await 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> {
return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
this.setPhase('entering-game')
const savedName = this.world.self.name
const savedClass = this.world.self.charClass
this.world.reset()
this.world.self.name = savedName
this.world.self.charClass = savedClass
this.predictor.setPosition(0, 0)
this.interpolator.clear()
this.hudModel.syncFromWorld()
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) {
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
}
})
}
/**
* Attach a live or mock `GameServerAdapter` directly to this session.
*/
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')
this.unsubGameEvent = adapter.onEvent((ev) => {
this.handleServerEvent(ev)
})
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 technical =
reason.kind === 'error'
? `D2GS game connection error: ${reason.error.message}`
: reason.kind === 'remote'
? `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 {
this.unsubGameClose = null
}
}
/**
* Leave the active D2GS game and return to lobby (or idle if standalone).
*/
async leaveGame(): Promise<void> {
const flow = this.flow
this.detachGameServerListeners()
this.hudModel.closeAllPanels()
this.hudManager.closeAllPanels()
if (flow && flow.state === 'ingame') {
try {
await flow.leaveToLobby()
} catch {
// Ignore close errors on leave
}
} else if (this.gameAdapter) {
try {
if (typeof this.gameAdapter.leave === 'function') {
this.gameAdapter.leave()
} else if (typeof (this.gameAdapter as any).disconnect === 'function') {
;(this.gameAdapter as any).disconnect()
}
} catch {
// Ignore close errors on leave
}
}
this.gameAdapter = null
this.activeGameNameValue = null
// `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')
}
/**
* 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
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 ─────────────────────────
/**
* Dispatch a `ClientCommand` through `OutboundRateLimiter` (used by `HudModel` and direct API callers).
*/
dispatchCommand(cmd: ClientCommand | Record<string, any>, nowMs = performance.now()): boolean {
if (!cmd || typeof cmd !== 'object') return false
const rawCmd = cmd as Record<string, any>
const normalized: ClientCommand =
rawCmd.type === 'PlayerMove'
? {
type: 'RunToLocation',
x: Number(rawCmd.targetX ?? rawCmd.x ?? 0),
y: Number(rawCmd.targetY ?? rawCmd.y ?? 0),
}
: (cmd as ClientCommand)
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) {
this.forwardCommandToAdapter(normalized)
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)
this.world.noteOutgoingCommand(cmd)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
}
}
}
// ─── Authoritative ServerEvent Ingestion ─────────────────────
/**
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
*/
handleServerEvent(rawEv: ServerEvent): void {
const prevChatCount = this.world.chatLog.length
const evAny = rawEv as Record<string, any>
let ev: ServerEvent = rawEv
if (evAny.type === 'PortalOwner' || evAny.type === 'PortalInfo') {
ev = {
type: 'Portal',
ownerId: evAny.ownerId,
ownerName: evAny.ownerName,
localId: Number(evAny.localId ?? evAny.unitId ?? 0),
remoteId: evAny.remoteId,
areaId: Number(evAny.areaId ?? evAny.levelId ?? 0),
flags: evAny.flags,
}
} else if (evAny.type === 'MercAssign') {
ev = {
type: 'Merc',
kind: 'assign',
ownerId: evAny.ownerId,
mercId: evAny.mercId,
classId: evAny.classId,
nameId: evAny.nameId,
}
} else if (evAny.type === 'PlayerKill') {
ev = {
type: 'CorpseAssign',
ownerId: Number(evAny.victimId ?? evAny.ownerId ?? this.world.selfId),
corpseId: Number(evAny.corpseId ?? 0),
assign: 1,
} as unknown as ServerEvent
} else if (
evAny.type === 'SkillCast' &&
(!evAny.caster || (evAny.target === undefined && evAny.targetX !== undefined))
) {
ev = {
...evAny,
type: 'SkillCast',
caster: evAny.caster ?? evAny.unit ?? { unitType: evAny.unitType ?? 0, id: evAny.unitId ?? 1 },
skill: Number(evAny.skill ?? evAny.skillId ?? 0),
level: Number(evAny.level ?? evAny.skillLevel ?? 1),
target:
evAny.target ??
(evAny.targetX !== undefined && evAny.targetY !== undefined
? { x: Number(evAny.targetX), y: Number(evAny.targetY) }
: { x: 0, y: 0 }),
} as ServerEvent
}
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': {
// 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
// loaded into. Without it (or without a buildable LevelView) the client cannot place the
// player on the server's map, so fail fast instead of guessing a town (#539).
if (ev.areaId <= 0) {
this.handleUnexpectedDisconnect(
`0x03 LoadAct carried no areaId (act=${String(ev.act)}): cannot build the level the server placed the player in`,
)
break
}
if (this.mapService) {
try {
this.mapService.onLoadActPacket(
{
act: ev.act,
mapSeed: ev.mapSeed,
areaId: ev.areaId,
},
{ difficulty: this.world.difficulty },
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(
`MapService failed to build area ${String(ev.areaId)} (act=${String(ev.act)}, seed=0x${(ev.mapSeed >>> 0).toString(16)}): ${msg}`,
)
break
}
}
const activeView = this.mapService?.getActiveLevelView() ?? undefined
this.automapView.setContext(
ev.mapSeed,
this.world.difficulty,
ev.areaId,
activeView,
)
void this.soundService.onEnterArea(ev.areaId)
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 evAny = ev as any
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
const isLocalPlayer =
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
if (isLocalPlayer) {
if (ev.mode === 'stop') {
this.predictor.reconcileServerPosition({
x: toX,
y: toY,
teleport: false,
})
}
} else if (ev.mode === 'stop') {
this.interpolator.stopEntity({
unitType: evTypeId,
unitId: evUnitId,
x: toX,
y: toY,
})
} else {
this.interpolator.startMove({
unitType: evTypeId,
unitId: evUnitId,
...(fromX !== undefined ? { currentX: Number(fromX) } : {}),
...(fromY !== undefined ? { currentY: Number(fromY) } : {}),
targetX: toX,
targetY: toY,
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
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
const selfClass = this.world.self.charClass
if (this.world.selfId !== 0 && selfClass !== null) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
const isSelfDead = this.world.isSelfDead || this.world.isDead
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
const selfMode = isSelfDead
? 12
: selfCastState
? selfCastState.mode || 10
: predSnap.moving
? predSnap.running
? 3
: 2
: selfUnit?.mode && selfUnit.mode > 1
? selfUnit.mode
: 1
const selfDirection =
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16
out.push({
id: this.world.selfId,
kind: 'player',
classId: selfClass,
name: this.world.self.name,
x: selfX,
y: selfY,
...(selfCastState?.targetX !== undefined ? { targetX: selfCastState.targetX } : {}),
...(selfCastState?.targetY !== undefined ? { targetY: selfCastState.targetY } : {}),
mode: selfMode,
direction: selfDirection,
hpRatio:
this.world.self.maxLife > 0
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
: 1,
isLocalPlayer: true,
isCorpse: isSelfDead,
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
})
}
const activeLevelView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ??
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView()
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,
)
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
const ux = interp ? interp.x : u.x
const uy = interp ? interp.y : u.y
if (u.unitType === 'tile') {
const warpId = u.warpId ?? u.classId
let matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
if (!matchedWarp && activeLevelView && activeLevelView.warps.length > 0) {
let bestDistSq = 30 * 30
for (const w of activeLevelView.warps) {
const wAny = w as Record<string, any>
const room =
typeof w.room === 'number' ? activeLevelView.rooms[w.room] : undefined
const subX = typeof wAny.subX === 'number' ? wAny.subX : (room?.subX ?? 0)
const subY = typeof wAny.subY === 'number' ? wAny.subY : (room?.subY ?? 0)
const wx = activeLevelView.subOriginX + subX
const wy = activeLevelView.subOriginY + subY
const dSq = (wx - ux) * (wx - ux) + (wy - uy) * (wy - uy)
if (dSq <= bestDistSq) {
bestDistSq = dSq
matchedWarp = w
}
}
}
const toLevelId = matchedWarp?.toLevelId
const toAreaName =
toLevelId !== undefined ? CANONICAL_LEVEL_LIGHTING[toLevelId]?.levelName : undefined
const warpName =
u.name && u.name.trim() !== ''
? u.name
: toAreaName
? `To ${toAreaName}`
: `Entrance (${warpId})`
out.push({
id: u.id,
kind: 'warp',
classId: u.classId,
warpId,
...(toLevelId !== undefined ? { toLevelId } : {}),
name: warpName,
x: ux,
y: uy,
mode: u.mode,
direction: interp ? interp.dir16 : u.direction,
radiusX: 28,
radiusY: 20,
})
continue
}
const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)
const castState = !isCorpse ? u.castState : undefined
const mode = castState ? castState.mode || u.mode : u.mode
const targetX = castState?.targetX ?? u.targetX
const targetY = castState?.targetY ?? u.targetY
if (u.unitType === 'object' && (u.classId === 59 || u.classId === 60)) {
const portal =
this.world.portals.get(u.id) ??
Array.from(this.world.portals.values()).find(
(p) => p.localId === u.id || p.remoteId === u.id,
)
const destAreaName = portal?.areaId
? CANONICAL_LEVEL_LIGHTING[portal.areaId]?.levelName
: undefined
const basePortalName = portal?.ownerName
? `${portal.ownerName}'s Town Portal`
: u.name && u.name.trim() !== ''
? u.name
: 'Town Portal'
const portalName =
destAreaName && !basePortalName.includes(destAreaName)
? `${basePortalName} (${destAreaName})`
: basePortalName
out.push({
id: u.id,
kind: 'object',
classId: u.classId,
name: portalName,
x: ux,
y: uy,
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse: false,
states: Array.from(u.states.list()),
...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}),
...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}),
})
continue
}
if (u.unitType === 'item') {
const itemCode = u.itemCode ?? groundItem?.code ?? u.itemData?.code ?? (u.name && /^[a-z0-9]{3,4}$/i.test(u.name.trim()) ? u.name.trim() : undefined)
const itemQuality = u.itemQuality ?? u.quality ?? groundItem?.quality ?? u.itemData?.quality
const meta = u.itemData
? resolveGroundItemMetadata(u.itemData, u.goldAmount ?? groundItem?.goldAmount)
: itemCode
? resolveGroundItemMetadata(
{
code: itemCode,
...(itemQuality !== undefined ? { quality: itemQuality } : {}),
...((u.goldAmount ?? groundItem?.goldAmount) !== undefined ? { goldAmount: u.goldAmount ?? groundItem?.goldAmount } : {}),
...(u.name !== undefined ? { name: u.name } : {}),
},
u.goldAmount ?? groundItem?.goldAmount,
)
: undefined
const goldAmount = u.goldAmount ?? groundItem?.goldAmount ?? (meta?.isGold ? meta.amount : undefined)
const itemName =
meta?.isGold && goldAmount !== undefined
? `${goldAmount} Gold`
: u.name && u.name.trim() !== ''
? u.name
: meta?.name
out.push({
id: u.id,
kind: 'item',
classId: u.classId,
...(itemName !== undefined ? { name: itemName } : {}),
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
x: ux,
y: uy,
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: 1,
isCorpse: false,
...(goldAmount !== undefined ? { goldAmount } : {}),
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
})
continue
}
const partyMember = u.unitType === 'player' ? this.world.party.get(u.id) : undefined
const isPartyMember = Boolean(
partyMember &&
(partyMember.partyState === 1 ||
(partyMember.partyId !== undefined && partyMember.partyId !== 0xffff)),
)
out.push({
id: u.id,
kind: u.unitType,
classId: u.classId,
name: u.name,
x: ux,
y: uy,
...(targetX !== undefined ? { targetX } : {}),
...(targetY !== undefined ? { targetY } : {}),
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse,
isChampion: u.isChampion,
isUnique: u.isUnique,
isSuperUnique: u.isSuperUnique,
states: Array.from(u.states.list()),
...(castState !== undefined ? { castState } : {}),
...(u.unitType === 'player' ? { isPartyMember } : {}),
})
}
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 bindFlowListeners(flow: D2OnlineFlow): void {
this.unsubFlowState?.()
this.unsubFlowLost?.()
this.unsubFlowState = flow.onState(() => {
if (this.flow !== flow) return
this.emitState()
})
this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => {
if (this.flow !== flow) return
this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message })
})
}
/** 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 {
this.unsubGameEvent?.()
this.unsubGameEvent = null
this.unsubGameClose?.()
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.unsubFlowLost?.()
this.unsubFlowLost = null
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()
}
}