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

1241 lines
40 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 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()
}
}