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

2331 lines
83 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 {
BncsCreateAccountError,
D2gsS2cFramer,
InMemoryPacketTap,
createD2OnlineFlow,
createWsBridgeResolver,
decodeD2gsS2cPacket,
unitTypeFromId,
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 { resolveLevelWarpName, resolveLvlWarpBounds } from '../../common/world/level-warps-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, type ServerPositionSource } 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, classifyClientCommand } from './rate-limiter.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
import { isTownLevel } from '../../common/world/scene-population.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 loginHostValue: string | null = null
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 knownAreaIds = new Set<number>()
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.world.skipSelfTurnStep = true
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.world.setCollisionChecker((subX, subY) => {
const predSnap = this.predictor.getSnapshot()
const px = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const py = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const activeView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (px !== 0 || py !== 0)
? this.mapService.findLevelViewAtWorldSubTile(px, py, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ??
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView()
if (!activeView) return false
if (typeof (activeView as any).isWalkableWorldSubTile === 'function') {
return !(activeView as any).isWalkableWorldSubTile(subX, subY)
}
const localX = Math.floor(subX - (activeView.subOriginX ?? 0))
const localY = Math.floor(subY - (activeView.subOriginY ?? 0))
if (
localX < 0 ||
localY < 0 ||
localX >= activeView.subWidth ||
localY >= activeView.subHeight
) {
return false
}
const idx = localY * activeView.subWidth + localX
if (activeView.blocked && activeView.blocked[idx] === 1) return true
if (activeView.walkable) return activeView.walkable[idx] === 0
return false
})
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, nowMs) => {
this.forwardCommandToAdapter(cmd, nowMs)
},
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.
// #549: resolving the active levelView also keeps `world.areaId` and `automapView` in lockstep.
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.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
return {
tick: this.world.frameCount,
...(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 loginHost(): string | null {
return this.loginHostValue
}
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)
}
/**
* Connect to BNCS, optionally create a new account (`createNewAccount: true`), authenticate, and enter the realm character screen.
*/
async connectAndAuthenticate(
params: OnlineSessionLoginParams & { readonly createNewAccount?: boolean | undefined },
): Promise<{
readonly realms: readonly RealmSummary[]
readonly characters: readonly CharSummary[]
}> {
return await this.establishSession(params, Boolean(params.createNewAccount))
}
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.loginHostValue = (params.host ?? this.defaultHost).trim() || this.defaultHost
this.setPhase('connecting')
const resolver = params.wsBridgeBaseUrl
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
: this.resolver
const flow = this.flowFactory({
resolver,
bnetHost: this.loginHostValue,
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 or account creation: the attempt's connections are closed.
// For BncsCreateAccountError, keep disconnectReasonValue null so the in-canvas
// PopUpOkCancel2.dc6 modal displays without a redundant DOM disconnect banner.
this.pendingFlowClose = this.closeFlowQuietly()
this.disconnectReasonValue =
err instanceof BncsCreateAccountError
? null
: 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.knownAreaIds.clear()
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
}
// ─── Local Player Server Position (D2Client 1.13c) ───────────
/**
* Check an authoritative local-player position with the 1.13c tolerance gate (D2Client
* 0x6FADA1B0, `LocalMovementPredictor.verifyServerPosition`). The player is never moved toward
* it: out of tolerance the client sends C2S `0x5F` with its own sub-tile, and the server
* answers with `0x15` if it disagrees.
*
* `.d2cap` replay is not part of the game: it feeds no local input to the predictor and has no
* server to answer `0x5F`, so there the local player takes the captured position directly.
*/
private verifyLocalPlayerPosition(x: number, y: number, source: ServerPositionSource): void {
if (this.phaseValue === 'replay') {
if (x !== 0 && y !== 0) this.predictor.setPosition(x, y)
return
}
const inTown = this.isLocalPlayerInTown()
// No loaded level contains the player, i.e. it has no room yet. The gate's hard branch
// skips a unit without a room when the server position has none either
// (0x6FAB1400 / 0x6FABC2E0), so there is nothing to verify against.
if (inTown === null) return
const verdict = this.predictor.verifyServerPosition({
x,
y,
source,
rttMs: this.pingMs,
inTown,
})
if (verdict.action === 'request-resync') {
this.forwardCommandToAdapter({
type: 'UpdatePlayerPos',
x: verdict.clientX,
y: verdict.clientY,
})
}
}
private getCandidateAreaIds(): number[] {
const out: number[] = []
if (this.world.areaId > 0) {
out.push(this.world.areaId)
}
for (const id of this.world.getRevealedAreaIds()) {
if (id > 0 && !out.includes(id)) {
out.push(id)
}
}
for (const id of this.knownAreaIds) {
if (id > 0 && !out.includes(id)) {
out.push(id)
}
}
return out
}
/**
* Resolve the `LevelView` containing the local player's current world sub-tile and synchronize
* `world.areaId` and `automapView` whenever the player crosses into a different area (#549).
*/
private syncActiveAreaFromPlayerPosition(options?: {
readonly emitHudSync?: boolean
}): LevelView | null {
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 candidateIds = this.getCandidateAreaIds()
const levelView =
this.levelView ??
(this.mapService && candidateIds.length > 0 && (playerSubX !== 0 || playerSubY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, candidateIds)
: null)
if (levelView && levelView.areaId > 0) {
if (this.world.areaId > 0) {
this.knownAreaIds.add(this.world.areaId)
}
this.knownAreaIds.add(levelView.areaId)
const areaChanged = this.world.areaId !== levelView.areaId
const automapOutOfSync =
this.automapView.getCurrentAreaId() !== levelView.areaId ||
this.automapView.getLevelView(levelView.areaId) === undefined
if (areaChanged) {
this.world.areaId = levelView.areaId
}
if (areaChanged || automapOutOfSync) {
this.automapView.syncActiveLevel(levelView.areaId, levelView)
}
if (areaChanged) {
void this.soundService.onEnterArea(levelView.areaId)
if (options?.emitHudSync) {
this.hudModel.syncFromWorld()
}
}
}
return levelView
}
/**
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
* compares, `Math.round` of the logical position (logical N.0 is the centre of sub-tile N).
*/
private isLocalPlayerInTown(): boolean | null {
if (!this.mapService || this.world.areaId <= 0) return null
const snap = this.predictor.getSnapshot()
const levelView = this.mapService.findLevelViewAtWorldSubTile(
Math.round(snap.logicalX),
Math.round(snap.logicalY),
this.getCandidateAreaIds(),
)
return levelView === null ? null : isTownLevel(levelView.areaId)
}
/**
* Resolve whether `(subX, subY)` is in a town level (`DUNGEON_IsRoomInTown` equivalent):
* prefers the exact `LevelView` containing `(subX, subY)` when `mapService` is loaded,
* and falls back to the active `world.areaId` / `levelView.areaId`.
*/
private isSubTileInTown(subX: number, subY: number): boolean {
if (this.mapService && this.world.areaId > 0 && (subX !== 0 || subY !== 0)) {
const levelView = this.mapService.findLevelViewAtWorldSubTile(
Math.round(subX),
Math.round(subY),
[this.world.areaId, ...this.world.getRevealedAreaIds()],
)
if (levelView !== null) {
return isTownLevel(levelView.areaId)
}
}
const activeAreaId =
this.world.areaId > 0
? this.world.areaId
: (this.levelView?.areaId ??
this.mapService?.getActiveLevelView()?.areaId ??
this.automapView.getLevelView()?.areaId ??
0)
return isTownLevel(activeAreaId)
}
// ─── 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 category = classifyClientCommand(normalized)
// Non-movement commands (interaction, skill, UI) bypass generic rate limiter per 1.13c parity (#575)
// to prevent player clicks and interactions from being silently swallowed.
if (category !== 'movement') {
this.forwardCommandToAdapter(normalized, nowMs)
return true
}
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) {
this.forwardCommandToAdapter(normalized, nowMs)
return true
}
console.warn(`[OnlineSession] Outbound command throttled: ${normalized.type} (${res.reason ?? 'unknown'})`)
return false
}
/**
* Alias for `dispatchCommand` used by input/HUD callers.
*/
sendCommand(cmd: ClientCommand | Record<string, any>, nowMs = performance.now()): boolean {
return this.dispatchCommand(cmd, nowMs)
}
/**
* Casts the active left or right skill at world sub-tile `(x, y)`.
*/
useSkillAt(
x: number,
y: number,
hand: 'left' | 'right' = 'right',
shift = false,
): boolean {
const cmd: ClientCommand = {
type: 'SkillAt',
hand,
at: { x: Math.round(x), y: Math.round(y) },
shift,
}
this.world.noteOutgoingCommand(cmd)
return this.dispatchCommand(cmd)
}
/**
* Casts the active left or right skill on target unit `(unitKind, unitId)`.
*/
useSkillOn(
unitKind: UnitType,
unitId: number,
hand: 'left' | 'right' = 'right',
shift = false,
): boolean {
const cmd: ClientCommand = {
type: 'SkillOn',
hand,
target: { unitType: unitKind, id: unitId },
shift,
}
this.world.noteOutgoingCommand(cmd)
return this.dispatchCommand(cmd)
}
/**
* Attacks target unit `(unitKind, unitId)` using the active left-hand skill.
*/
attackUnit(unitKind: UnitType, unitId: number, shift = false): boolean {
return this.useSkillOn(unitKind, unitId, 'left', shift)
}
private forwardCommandToAdapter(cmd: ClientCommand, nowMs?: number): 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}`, 'error', true)
return
}
}
this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
if (
cmd.type === 'SkillAt' ||
cmd.type === 'SkillOn' ||
cmd.type === 'CastLeftSkillOnLocation' ||
cmd.type === 'CastRightSkillOnLocation' ||
cmd.type === 'CastLeftSkillOnTarget' ||
cmd.type === 'CastRightSkillOnTarget' ||
cmd.type === 'Interact' ||
cmd.type === 'OperateObject' ||
cmd.type === 'NpcInit'
) {
const selfUnit = this.world.getUnit('player', this.world.selfId || 1)
if (selfUnit) {
this.predictor.stop()
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
}
// ─── 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
} else if (evAny.type === 'UnitMove') {
const uType = typeof evAny.unitType === 'number' ? unitTypeFromId(evAny.unitType) : (evAny.unitType ?? 'player')
const uId = Number(evAny.id ?? evAny.unitId ?? 0)
const hasFrom = evAny.from !== undefined || evAny.currentX !== undefined
const from = hasFrom
? {
x: Number(evAny.from?.x ?? evAny.currentX ?? 0),
y: Number(evAny.from?.y ?? evAny.currentY ?? 0),
}
: undefined
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? (from ? from.x : 0))
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? (from ? from.y : 0))
ev = {
...evAny,
type: 'UnitMove',
unitType: uType,
id: uId,
mode: evAny.mode ?? 'walk',
...(from ? { from } : {}),
to: { x: toX, y: toY },
} as ServerEvent
}
const preMoveUnit =
ev.type === 'UnitMove'
? this.world.getUnit(
(evAny.unitType ?? ev.unitType) as UnitType,
Number(evAny.id ?? evAny.unitId ?? 1),
)
: undefined
const preMoveUnitPos =
preMoveUnit !== undefined && (preMoveUnit.x !== 0 || preMoveUnit.y !== 0)
? { x: preMoveUnit.x, y: preMoveUnit.y }
: undefined
this.world.apply(ev)
if (
this.phaseValue === 'entering-game' &&
(ev.type === 'LoadAct' ||
ev.type === 'LoadComplete' ||
(ev.type === 'UnitAssign' && Boolean(ev.isSelf)))
) {
this.setPhase('ingame')
}
if ((ev as any).type === 'Interact') {
const raw = ev as any
const actorType: UnitType = raw.actor?.unitType ?? raw.unit?.unitType ?? raw.unitType ?? 0
const actorTypeId = unitTypeToId(actorType)
const actorId = Number(
raw.actor?.id ?? raw.unit?.id ?? raw.unitId ?? (this.world.selfId || 1),
)
const unit = this.world.getUnit(actorType, actorId)
if (unit) {
const isLocalPlayer =
actorTypeId === 0 && (actorId === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) {
this.predictor.stop()
this.predictor.setTargetDirection(unit.targetDirection, false)
} else {
this.interpolator.setEntityTargetDirection(
actorTypeId,
actorId,
unit.targetDirection,
false,
)
}
}
return
}
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
}
}
this.knownAreaIds.clear()
this.knownAreaIds.add(ev.areaId)
const activeView =
this.mapService?.getActiveLevelView() ??
(this.levelView && this.levelView.areaId === ev.areaId ? this.levelView : undefined)
this.automapView.setContext(
ev.mapSeed,
this.world.difficulty,
ev.areaId,
activeView,
)
void this.soundService.onEnterArea(ev.areaId)
break
}
case 'RoomReveal': {
if (ev.areaId > 0) {
this.knownAreaIds.add(ev.areaId)
}
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)
}
if (u.direction !== undefined) {
this.predictor.setDirection(u.direction)
}
} 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 ? { dir64: 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) {
// 1.13c D2Client 0x6FB5F640 → 0x6FB57E20: `0x15` repositions the player directly, with
// no tolerance check whatever its flag byte, and drops the path.
this.predictor.setPosition(ev.at.x, ev.at.y)
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 {
if (ev.vx === undefined || ev.vy === undefined) {
throw new Error(
'LifeMana has a position but no vx/vy motion vector (0x18/0x95/0x96 always carry both)',
)
}
this.verifyLocalPlayerPosition(ev.at.x, ev.at.y, {
kind: 'life-mana',
vx: ev.vx,
vy: ev.vy,
})
}
}
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 ?? (evTypeId === 0 ? 1 : 0))
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
let toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX
let toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
if (
ev.target &&
((rawToX === 0 && rawToY === 0) ||
(ev.from !== undefined && rawToX === ev.from.x && rawToY === ev.from.y))
) {
const targetTypeId = unitTypeToId(ev.target.unitType)
const isTargetLocalPlayer =
targetTypeId === 0 && (ev.target.id === this.world.selfId || this.world.selfId === 0)
if (isTargetLocalPlayer) {
const predSnap = this.predictor.getSnapshot()
if (predSnap.x !== 0 || predSnap.y !== 0) {
toX = predSnap.x
toY = predSnap.y
}
} else {
const targetInterp = this.interpolator.getEntityState(targetTypeId, ev.target.id)
if (targetInterp && (targetInterp.x !== 0 || targetInterp.y !== 0)) {
toX = targetInterp.x
toY = targetInterp.y
}
}
if (unit && (toX !== 0 || toY !== 0)) {
unit.targetX = toX
unit.targetY = toY
}
}
const explicitFromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
const explicitFromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
const existingInterp = this.interpolator.getEntityState(evTypeId, evUnitId)
const fromX =
explicitFromX !== undefined
? Number(explicitFromX)
: !existingInterp && preMoveUnitPos !== undefined
? preMoveUnitPos.x
: undefined
const fromY =
explicitFromY !== undefined
? Number(explicitFromY)
: !existingInterp && preMoveUnitPos !== undefined
? preMoveUnitPos.y
: undefined
const isLocalPlayer =
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
if (unit && unit.unitType === 'player') {
const posForTownX = ev.mode === 'stop' ? toX : Number(fromX ?? unit.x ?? toX)
const posForTownY = ev.mode === 'stop' ? toY : Number(fromY ?? unit.y ?? toY)
const inTown = this.isSubTileInTown(posForTownX, posForTownY)
if (ev.mode === 'stop') {
unit.mode = inTown ? 5 : 1
} else if (ev.mode === 'run') {
unit.mode = 3
} else if (ev.mode === 'knockback') {
unit.mode = 19
} else {
unit.mode = inTown ? 6 : 2
}
}
if (isLocalPlayer) {
if (ev.mode === 'stop') {
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
}
} else if (ev.mode === 'stop') {
const motionState = this.interpolator.stopEntity({
unitType: evTypeId,
unitId: evUnitId,
x: toX,
y: toY,
})
if (unit) {
unit.x = motionState.x
unit.y = motionState.y
unit.direction = motionState.dir64
}
} else {
const speedSubTilesPerSec =
evTypeId === 1
? computeMonsterMoveSpeedSubTilesPerSec(
unit?.classId ?? 0,
ev.mode === 'run',
ev.velocityPercent,
)
: undefined
const motionState = this.interpolator.startMove({
unitType: evTypeId,
unitId: evUnitId,
...(fromX !== undefined ? { currentX: fromX } : {}),
...(fromY !== undefined ? { currentY: fromY } : {}),
targetX: toX,
targetY: toY,
running: ev.mode === 'run',
...(speedSubTilesPerSec !== undefined ? { speedSubTilesPerSec } : {}),
})
if (unit) {
unit.x = motionState.x
unit.y = motionState.y
unit.syncTurnState(motionState.dir64, motionState.targetDir64, motionState.stepDir)
}
const worldUnit = this.world.getUnit(ev.unitType, evUnitId)
if (worldUnit && ev.target) {
this.interpolator.setEntityTargetDirection(
evTypeId,
evUnitId,
worldUnit.targetDirection,
false,
)
}
}
break
}
case 'SkillCast': {
const raw = ev as any
const actorType: UnitType =
raw.caster?.unitType ??
raw.actor?.unitType ??
raw.unit?.unitType ??
(raw.kind === 'monsterAttack' ? 1 : (raw.unitType ?? raw.attackerType ?? 0))
const actorTypeId = unitTypeToId(actorType)
const actorId = Number(
raw.caster?.id ??
raw.actor?.id ??
raw.unit?.id ??
raw.monsterId ??
raw.unitId ??
raw.attackerId ??
(this.world.selfId || 1),
)
const unit = this.world.getUnit(actorType, actorId)
if (unit) {
const isLocalPlayer =
actorTypeId === 0 && (actorId === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) {
this.predictor.stop()
this.predictor.setTargetDirection(unit.targetDirection, false)
} else {
this.interpolator.setEntityTargetDirection(
actorTypeId,
actorId,
unit.targetDirection,
false,
)
}
}
break
}
case 'UnitMode': {
const uType = unitTypeToId(ev.unitType)
const uId = ev.id
const isLocalPlayer = uType === 0 && (uId === this.world.selfId || this.world.selfId === 0)
if (isLocalPlayer) {
if (ev.mode === 0 || ev.mode === 17) {
this.predictor.stop()
}
} else {
const unit = this.world.getUnit(ev.unitType, uId)
const stopX = ev.at?.x ?? unit?.x
const stopY = ev.at?.y ?? unit?.y
if (
stopX !== undefined &&
stopY !== undefined &&
(ev.mode === undefined ||
(ev.mode !== 2 && ev.mode !== 3 && ev.mode !== 6 && ev.mode !== 15))
) {
this.interpolator.stopEntity({
unitType: uType,
unitId: uId,
x: stopX,
y: stopY,
})
}
}
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 active area and AutomapView from current player position (#549)
this.syncActiveAreaFromPlayerPosition({ emitHudSync: false })
// 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 and 25Hz facings to `ClientWorld` units
* - Expires finished one-shot cast/attack animations in `ClientWorld`
*/
tick(dtMs = 40, nowMs = performance.now()): void {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
if (!Number.isFinite(nowMs)) {
throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`)
}
const safeDt = Math.max(0, dtMs)
const clampedDt = Math.min(200, safeDt)
this.tickCounter++
this.commandMapper.tick(nowMs)
const selfUnit = this.world.getUnit('player', this.world.selfId || 1)
if (selfUnit) {
const curSnap = this.predictor.getSnapshot()
if (selfUnit.castState && curSnap.moving) {
this.predictor.stop()
}
if (
(!curSnap.moving || selfUnit.castState) &&
selfUnit.targetDirection !== curSnap.targetDir64
) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
const predSnap = this.predictor.update(clampedDt)
if (predSnap.x !== 0 || predSnap.y !== 0) {
this.camera.update(predSnap.x, predSnap.y)
}
if (selfUnit) {
selfUnit.syncTurnState(predSnap.dir64, predSnap.targetDir64, predSnap.stepDir)
}
for (const unit of this.world.units.values()) {
if (unit.unitType === 'player' && (this.world.selfId === 0 || unit.id === (this.world.selfId || 1))) {
continue
}
const uTypeId = unitTypeToId(unit.unitType)
const state = this.interpolator.getEntityState(uTypeId, unit.id)
if (state) {
if (!state.moving && unit.targetDirection !== state.targetDir64) {
this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false)
}
} else if (!unit.dead) {
unit.advanceTurn(clampedDt)
}
}
this.world.tick(clampedDt, nowMs)
this.interpolator.update(clampedDt)
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.syncTurnState(state.dir64, state.targetDir64, state.stepDir)
if (unit.unitType === 'player' && unit.id !== this.world.selfId) {
const unitInTown = this.isSubTileInTown(state.x, state.y)
if (state.moving && unit.moveMode !== 'stop') {
if (unit.moveMode === 'knockback') {
unit.mode = 19
} else if (state.running || unit.mode === 3) {
unit.mode = 3
} else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) {
unit.mode = unitInTown ? 6 : 2
}
} else if (
unit.mode === 2 ||
unit.mode === 3 ||
unit.mode === 6 ||
unit.mode === 19 ||
unit.mode === 1 ||
unit.mode === 5
) {
unit.mode = unitInTown ? 5 : 1
unit.moveMode = 'stop'
}
} else if (unit.unitType === 'monster' && unit.lifePct > 0) {
if (state.moving && !unit.castState && unit.moveMode !== 'stop') {
unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 13 : 2
} else if (
!state.moving &&
(unit.mode === 2 || unit.mode === 13 || unit.mode === 15)
) {
unit.mode = 1
unit.moveMode = 'stop'
}
}
}
}
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
}
/**
* Build `RenderableUnitInput[]` from `ClientWorld` + `LocalMovementPredictor` + `RemoteEntityInterpolator`.
*/
buildRenderableUnits(nowMs?: number): RenderableUnitInput[] {
if (nowMs !== undefined) {
this.world.expireCastStates(nowMs)
}
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 selfInTown = this.isSubTileInTown(selfX, selfY)
const neutralMode = selfInTown ? 5 : 1
const walkMode = selfInTown ? 6 : 2
const unitExplicitMode =
selfUnit?.mode !== undefined &&
selfUnit.mode !== 0 &&
selfUnit.mode !== 1 &&
selfUnit.mode !== 2 &&
selfUnit.mode !== 3 &&
selfUnit.mode !== 5 &&
selfUnit.mode !== 6
? selfUnit.mode
: undefined
const selfMode = isSelfDead
? (selfUnit?.mode === 0 ? 0 : 17)
: selfCastState
? selfCastState.mode || 10
: predSnap.moving
? predSnap.running
? 3
: walkMode
: (unitExplicitMode ?? neutralMode)
if (selfUnit !== undefined && !selfCastState) {
selfUnit.mode = selfMode
}
const selfDirection =
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
const selfAnimStartedAtMs = selfCastState?.startedAtMs ?? selfUnit?.modeStartedAtMs
const selfAnimStepSpeed256 = selfCastState?.stepSpeed256 ?? selfUnit?.animStepSpeed256
const selfAnimEffRate = selfCastState?.effRate ?? selfUnit?.animEffRate
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,
states: selfUnit
? Array.from(selfUnit.states.list())
: this.world.self.states.size > 0
? Array.from(this.world.self.states)
: undefined,
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}),
...(selfAnimStartedAtMs !== undefined ? { animStartedAtMs: selfAnimStartedAtMs } : {}),
...(selfAnimStepSpeed256 !== undefined ? { animStepSpeed256: selfAnimStepSpeed256 } : {}),
...(selfAnimEffRate !== undefined ? { animEffRate: selfAnimEffRate } : {}),
})
}
const activeLevelView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds())
: null) ??
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView()
const hoveredUnit = this.commandMapper.currentHoveredUnit
for (const u of this.world.units.values()) {
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
continue
}
if (u.unitType === 'missile') {
out.push({
id: u.id,
kind: 'missile',
classId: u.classId,
...(u.name !== undefined ? { name: u.name } : {}),
...(u.missileKey !== undefined ? { missileKey: u.missileKey } : {}),
x: u.x,
y: u.y,
...(u.targetX !== undefined ? { targetX: u.targetX } : {}),
...(u.targetY !== undefined ? { targetY: u.targetY } : {}),
...(u.vx !== undefined ? { vx: u.vx } : {}),
...(u.vy !== undefined ? { vy: u.vy } : {}),
...(u.frame !== undefined ? { frame: u.frame } : {}),
...(u.totalFrames !== undefined ? { totalFrames: u.totalFrames } : {}),
...(u.loopAnim !== undefined ? { loopAnim: u.loopAnim } : {}),
...(u.altitude !== undefined ? { altitude: u.altitude } : {}),
mode: u.mode,
direction: u.direction,
hpRatio: 1,
isCorpse: false,
})
continue
}
const isHovered = Boolean(
hoveredUnit && hoveredUnit.unitType === u.unitType && hoveredUnit.id === u.id,
)
const interp = this.interpolator.getEntityState(
u.unitType === 'player'
? 0
: u.unitType === 'monster'
? 1
: u.unitType === 'object'
? 2
: 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
const matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
const toLevelId = matchedWarp?.toLevelId
const warpNames = toLevelId !== undefined ? resolveLevelWarpName(toLevelId) : undefined
const bounds = resolveLvlWarpBounds(warpId)
const warpName =
u.name && u.name.trim() !== ''
? u.name
: warpNames
? warpNames.name
: `Entrance (${warpId})`
const warpNameZh = warpNames?.nameZh
out.push({
id: u.id,
kind: 'warp',
classId: u.classId,
warpId,
...(toLevelId !== undefined ? { toLevelId } : {}),
name: warpName,
...(warpNameZh !== undefined ? { nameZh: warpNameZh } : {}),
x: ux,
y: uy,
mode: u.mode,
direction: interp ? interp.dir64 : u.direction,
radiusX: bounds.radiusX,
radiusY: bounds.radiusY,
isHovered,
})
continue
}
const isCorpse =
(u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) ||
(u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0))
if (interp?.moving && u.castState) {
this.world.clearUnitCastState(u)
}
const castState = !isCorpse ? u.castState : undefined
let mode = castState ? castState.mode || u.mode : u.mode
if (u.unitType === 'player' && !isCorpse && !castState) {
const unitInTown = this.isSubTileInTown(ux, uy)
if (interp?.moving && u.moveMode !== 'stop') {
mode =
u.moveMode === 'knockback'
? 19
: interp.running || u.mode === 3
? 3
: unitInTown
? 6
: 2
} else if (
u.mode === 1 ||
u.mode === 5 ||
(interp !== null &&
interp !== undefined &&
!interp.moving &&
(u.mode === 2 || u.mode === 3 || u.mode === 6 || u.mode === 19))
) {
mode = unitInTown ? 5 : 1
} else if (u.mode === 2 || u.mode === 6) {
mode = unitInTown ? 6 : 2
}
u.mode = mode
} else if (u.unitType === 'monster') {
if (isCorpse) {
mode = u.mode === 0 ? 0 : 12
} else if (castState) {
mode = castState.mode || u.mode || 1
} else if (interp?.moving && u.moveMode !== 'stop') {
mode = u.moveMode === 'knockback' ? 13 : interp.running ? 15 : 2
} else {
mode = u.mode || 1
}
}
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.dir64 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse: false,
isHovered,
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
const itemNameZh =
meta?.isGold && goldAmount !== undefined
? `${goldAmount} 金币`
: u.nameZh && u.nameZh.trim() !== ''
? u.nameZh
: meta?.nameZh
const itemColor = u.itemColor ?? meta?.color
out.push({
id: u.id,
kind: 'item',
classId: u.classId,
...(itemName !== undefined ? { name: itemName } : {}),
...(itemNameZh !== undefined ? { nameZh: itemNameZh } : {}),
...(itemColor !== undefined ? { itemColor } : {}),
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
x: ux,
y: uy,
mode,
direction: interp ? interp.dir64 : u.direction,
hpRatio: 1,
isCorpse: false,
isHovered,
...(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)),
)
const unitAnimStartedAtMs = castState?.startedAtMs ?? u.modeStartedAtMs
const unitAnimStepSpeed256 = castState?.stepSpeed256 ?? u.animStepSpeed256
const unitAnimEffRate = castState?.effRate ?? u.animEffRate
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.dir64 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse,
isHovered,
isChampion: u.isChampion,
isUnique: u.isUnique,
isSuperUnique: u.isSuperUnique,
states: Array.from(u.states.list()),
...(castState !== undefined ? { castState } : {}),
...(u.overlays.length > 0 ? { overlays: u.overlays } : {}),
...(unitAnimStartedAtMs !== undefined ? { animStartedAtMs: unitAnimStartedAtMs } : {}),
...(unitAnimStepSpeed256 !== undefined ? { animStepSpeed256: unitAnimStepSpeed256 } : {}),
...(unitAnimEffRate !== undefined ? { animEffRate: unitAnimEffRate } : {}),
...(u.unitType === 'player' ? { isPartyMember, isLocalPlayer: false } : {}),
})
}
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()
}
}