1663 lines
58 KiB
TypeScript
1663 lines
58 KiB
TypeScript
/**
|
||
* Online Session Coordinator (`src/client/session/online-session.ts`, Milestone M5 — F16, F18, F20).
|
||
*
|
||
* Orchestrates the end-to-end client runtime across:
|
||
* - `D2OnlineFlow` + `createWsBridgeResolver` (BNCS -> MCP -> D2GS in-game lifecycle)
|
||
* - `ClientWorld` authoritative state mirror
|
||
* - `MapService` + `AutomapView` (`off` | `level` | `act` reveal modes)
|
||
* - `LocalMovementPredictor` (25Hz step + rubber-band / hard-snap server reconciliation)
|
||
* - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion)
|
||
* - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`)
|
||
* - `HudManager` + `HudModel` + `CommandMapper` + `OutboundRateLimiter`
|
||
* - `SettingsStore`, `SoundService`, and `PacketInspector` (with deterministic `.d2cap` replay)
|
||
* - Graceful disconnect / error recovery without uncaught promise rejections or page crashes.
|
||
*
|
||
* Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`).
|
||
*/
|
||
|
||
import {
|
||
D2gsS2cFramer,
|
||
InMemoryPacketTap,
|
||
createD2OnlineFlow,
|
||
createWsBridgeResolver,
|
||
decodeD2gsS2cPacket,
|
||
unitTypeToId,
|
||
type CharClassId,
|
||
type CharCreateSpec,
|
||
type CharSummary,
|
||
type ClientCommand,
|
||
type CloseReason,
|
||
type D2OnlineConfig,
|
||
type D2OnlineFlow,
|
||
type D2OnlineSessionKind,
|
||
type D2OnlineSessionLost,
|
||
type D2OnlineSessionLostCause,
|
||
type D2OnlineState,
|
||
type EndpointResolver,
|
||
type GameCreateSpec,
|
||
type GameListEntry,
|
||
type GameServerAdapter,
|
||
type ItemDataTables,
|
||
type RealmSummary,
|
||
type ServerEvent,
|
||
type UnitType,
|
||
} from '../../netproto/index.ts'
|
||
import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts'
|
||
import { resolveGroundItemMetadata } from '../../common/items/ground-items.ts'
|
||
import { requireTbl } from '../i18n/lang.ts'
|
||
import { HudManager } from '../ui/hud-manager.ts'
|
||
import { SoundService } from '../audio/sound-service.ts'
|
||
import { AutomapView, getActForArea } from '../automap/automap-view.ts'
|
||
import {
|
||
CommandMapper,
|
||
ViewportCamera,
|
||
type CameraLike,
|
||
} from '../input/command-mapper.ts'
|
||
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
|
||
import type { LevelView } from '../map/level-view.ts'
|
||
import type { MapService } from '../map/map-service.ts'
|
||
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
||
import { LocalMovementPredictor } from '../motion/predictor.ts'
|
||
import {
|
||
getGlobalSettingsStore,
|
||
type SettingsStore,
|
||
} from '../settings/settings-store.ts'
|
||
import { HudModel } from '../ui-model/hud-model.ts'
|
||
import { vendorPrice113c, vendorRepairAll113c } from '../ui-model/vendor-price.ts'
|
||
import { ClientWorldSceneSource } from '../view/scene-source.ts'
|
||
import type { RenderableUnitInput } from '../view/unit-drawables.ts'
|
||
import { getViewportProfile } from '../view/viewport-profile.ts'
|
||
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
|
||
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
|
||
import { OutboundRateLimiter } from './rate-limiter.ts'
|
||
|
||
export type OnlineSessionPhase =
|
||
| 'idle'
|
||
| 'connecting'
|
||
| 'lobby'
|
||
| 'entering-game'
|
||
| 'ingame'
|
||
| 'replay'
|
||
| 'disconnected'
|
||
| 'error'
|
||
|
||
export interface OnlineSessionLoginParams {
|
||
readonly host?: string | undefined
|
||
readonly bncsPort?: number | undefined
|
||
readonly wsBridgeBaseUrl?: string | undefined
|
||
readonly username: string
|
||
readonly password: string
|
||
readonly realmIndex?: number | undefined
|
||
}
|
||
|
||
export interface OnlineSessionLobbySnapshot {
|
||
readonly phase: OnlineSessionPhase
|
||
readonly flowState: D2OnlineState
|
||
readonly username: string
|
||
readonly selectedRealm: RealmSummary | null
|
||
readonly realms: readonly RealmSummary[]
|
||
readonly characters: readonly CharSummary[]
|
||
readonly selectedChar: string | null
|
||
readonly games: readonly GameListEntry[]
|
||
readonly activeGameName: string | null
|
||
readonly disconnectReason: string | null
|
||
readonly pingMs: number
|
||
}
|
||
|
||
export interface OnlineSessionOptions {
|
||
readonly world?: ClientWorld | undefined
|
||
readonly hudManager?: HudManager | undefined
|
||
readonly camera?: CameraLike | undefined
|
||
readonly mapService?: MapService | null | undefined
|
||
readonly automapView?: AutomapView | undefined
|
||
readonly predictor?: LocalMovementPredictor | undefined
|
||
readonly interpolator?: RemoteEntityInterpolator | undefined
|
||
readonly rateLimiter?: OutboundRateLimiter | undefined
|
||
readonly settingsStore?: SettingsStore | undefined
|
||
readonly soundService?: SoundService | undefined
|
||
readonly packetInspector?: PacketInspector | undefined
|
||
readonly resolver?: EndpointResolver | undefined
|
||
readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined
|
||
readonly itemTables?: ItemDataTables | undefined
|
||
readonly defaultHost?: string | undefined
|
||
readonly defaultBncsPort?: number | undefined
|
||
readonly wsBridgeBaseUrl?: string | undefined
|
||
readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
|
||
readonly onToggleChatInput?: (() => void) | undefined
|
||
readonly onEscapeMenu?: (() => void) | undefined
|
||
}
|
||
|
||
/** Which connection ended the session (`client` = ended on purpose by this client). */
|
||
export type OnlineSessionLostSource = D2OnlineSessionKind | 'client'
|
||
export type OnlineSessionLostCause = D2OnlineSessionLostCause | 'local'
|
||
|
||
export interface OnlineSessionLostEvent {
|
||
/** User-facing reason (1.13c tbl text + technical detail), shown on the login screen. */
|
||
readonly reason: string
|
||
readonly session: OnlineSessionLostSource
|
||
readonly cause: OnlineSessionLostCause
|
||
/** Technical description (close code/reason, timed-out request, missing session, ...). */
|
||
readonly technical: string
|
||
}
|
||
|
||
/**
|
||
* Thrown by `OnlineSession` operations once the Battle.net session is gone. The loss itself has
|
||
* already been handled centrally (connections disposed, `onSessionLost` notified), so UI callers
|
||
* must not show it as an operation error on the current screen.
|
||
*/
|
||
export class SessionLostError extends Error {
|
||
override readonly name = 'SessionLostError'
|
||
constructor(readonly event: OnlineSessionLostEvent) {
|
||
super(event.reason)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 1.13c tbl string for a session loss (ENG: the frontend bitmap fonts are byte glyphs):
|
||
* - `string#5352` "Your connection to Battle.net has been lost."
|
||
* - `string#5357` "Your connection has been interrupted" (realm / game server)
|
||
* - `string#5349` "Timed out waiting for response."
|
||
* - `string#5353` "Battle.net is not responding. Please try connecting again in a few minutes."
|
||
* - `patchstring#1066` "Diablo II was unable to connect to the realm server. ..."
|
||
* - `string#5351` "Lost Connection to battle.net."
|
||
* Returns `null` when no 1.13c string matches; the technical reason is then shown alone.
|
||
*/
|
||
export function sessionLostTblKey(
|
||
session: OnlineSessionLostSource,
|
||
cause: OnlineSessionLostCause,
|
||
): string | null {
|
||
if (session === 'client' || cause === 'local') return null
|
||
switch (cause) {
|
||
case 'timeout':
|
||
return 'string#5349'
|
||
case 'not_connected':
|
||
return 'string#5351'
|
||
case 'connect_failed':
|
||
return session === 'bncs' ? 'string#5353' : session === 'mcp' ? 'patchstring#1066' : null
|
||
case 'closed':
|
||
case 'error':
|
||
return session === 'bncs' ? 'string#5352' : 'string#5357'
|
||
}
|
||
}
|
||
|
||
export function formatSessionLostReason(
|
||
session: OnlineSessionLostSource,
|
||
cause: OnlineSessionLostCause,
|
||
technical: string,
|
||
): string {
|
||
const key = sessionLostTblKey(session, cause)
|
||
if (key === null) return technical
|
||
const text = requireTbl(key, 'ENG').replace(/\s+/g, ' ').trim()
|
||
return technical ? `${text} (${technical})` : text
|
||
}
|
||
|
||
const LOBBY_SESSION_LABEL: Readonly<Record<'bncs' | 'mcp', string>> = {
|
||
bncs: 'BNCS',
|
||
mcp: 'MCP realm',
|
||
}
|
||
|
||
/**
|
||
* Decode a framed D2GS S->C packet (`conn === 'game'`, `dir === 's2c'`) from a `.d2cap`
|
||
* capture into a protocol-neutral `ServerEvent` using the full 167-opcode 1.13c registry
|
||
* (`D2GS_S2C_REGISTRY`) and canonical 1.13c item data tables (`ItemStatCost.txt`, `Armor.txt`,
|
||
* `Weapons.txt`, `Misc.txt`).
|
||
*/
|
||
export function decodeCapturedGameS2cPacket(
|
||
pkt: Uint8Array,
|
||
itemTables: ItemDataTables = getCanonicalItemDataTables(),
|
||
): ServerEvent {
|
||
if (pkt.byteLength === 0) {
|
||
return { type: 'Opaque', proto: 'd2gs', id: 0, raw: pkt }
|
||
}
|
||
return decodeD2gsS2cPacket(pkt, {
|
||
tables: itemTables,
|
||
encoding: 'gbk',
|
||
})
|
||
}
|
||
|
||
export class OnlineSession {
|
||
readonly world: ClientWorld
|
||
readonly hudManager: HudManager
|
||
readonly camera: CameraLike
|
||
readonly mapService: MapService | null
|
||
readonly automapView: AutomapView
|
||
readonly predictor: LocalMovementPredictor
|
||
readonly interpolator: RemoteEntityInterpolator
|
||
readonly rateLimiter: OutboundRateLimiter
|
||
readonly settingsStore: SettingsStore
|
||
readonly soundService: SoundService
|
||
readonly packetTap: InMemoryPacketTap
|
||
readonly packetInspector: PacketInspector
|
||
readonly hudModel: HudModel
|
||
readonly commandMapper: CommandMapper
|
||
readonly sceneSource: ClientWorldSceneSource
|
||
levelView?: LevelView | null | undefined
|
||
|
||
private readonly resolver: EndpointResolver
|
||
private readonly flowFactory: (config: D2OnlineConfig) => D2OnlineFlow
|
||
private readonly itemTables: ItemDataTables
|
||
private readonly defaultHost: string
|
||
private readonly defaultBncsPort: number
|
||
private readonly replayS2cFramer = new D2gsS2cFramer(false)
|
||
private lastDecodedReplayRecord: {
|
||
readonly record: ReplayedD2CapFrame['record']
|
||
readonly events: readonly ServerEvent[]
|
||
} | null = null
|
||
|
||
private flow: D2OnlineFlow | null = null
|
||
private gameAdapter: GameServerAdapter | null = null
|
||
private unsubGameEvent: (() => void) | null = null
|
||
private unsubGameClose: (() => void) | null = null
|
||
private unsubFlowState: (() => void) | null = null
|
||
private unsubFlowLost: (() => void) | null = null
|
||
private unsubSettings: (() => void) | null = null
|
||
/** The loss that ended the current session lifecycle (reset by `login` / `attachGameServer`). */
|
||
private sessionLostValue: SessionLostError | null = null
|
||
/** The flow that was active when `sessionLostValue` was handled. */
|
||
private lostFlow: D2OnlineFlow | null = null
|
||
private pendingFlowClose: Promise<void> = Promise.resolve()
|
||
|
||
private phaseValue: OnlineSessionPhase = 'idle'
|
||
private usernameValue = ''
|
||
private realmsValue: readonly RealmSummary[] = []
|
||
private selectedRealmValue: RealmSummary | null = null
|
||
private charactersValue: readonly CharSummary[] = []
|
||
private selectedCharValue: string | null = null
|
||
private gamesValue: readonly GameListEntry[] = []
|
||
private activeGameNameValue: string | null = null
|
||
private disconnectReasonValue: string | null = null
|
||
private pingMsValue = 0
|
||
private tickCounter = 0
|
||
|
||
private readonly outboundCommandsLog: ClientCommand[] = []
|
||
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
||
private readonly eventListeners = new Set<(ev: ServerEvent) => void>()
|
||
private readonly chatListeners = new Set<(entry: ChatLogEntry) => void>()
|
||
private readonly disconnectListeners = new Set<(reason: string) => void>()
|
||
private readonly sessionLostListeners = new Set<(ev: OnlineSessionLostEvent) => void>()
|
||
|
||
constructor(options: OnlineSessionOptions = {}) {
|
||
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
|
||
const currentSettings = this.settingsStore.get()
|
||
const initialViewport = getViewportProfile(currentSettings.viewport)
|
||
|
||
this.world = options.world ?? new ClientWorld()
|
||
this.camera = options.camera ?? new ViewportCamera(initialViewport)
|
||
this.hudManager = options.hudManager ?? new HudManager()
|
||
this.hudManager.resize(initialViewport.width, initialViewport.height)
|
||
|
||
this.mapService = options.mapService ?? null
|
||
this.automapView =
|
||
options.automapView ??
|
||
new AutomapView({
|
||
mapService: this.mapService,
|
||
initialMode: currentSettings.automapReveal,
|
||
})
|
||
|
||
this.predictor = options.predictor ?? new LocalMovementPredictor()
|
||
this.interpolator = options.interpolator ?? new RemoteEntityInterpolator()
|
||
this.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter()
|
||
this.soundService =
|
||
options.soundService ??
|
||
new SoundService({
|
||
store: this.settingsStore,
|
||
})
|
||
|
||
this.itemTables = options.itemTables ?? getCanonicalItemDataTables()
|
||
|
||
this.packetTap = new InMemoryPacketTap(4000)
|
||
this.packetInspector =
|
||
options.packetInspector ??
|
||
new PacketInspector({
|
||
store: this.settingsStore,
|
||
tap: this.packetTap,
|
||
decodeReplayEntry: (conn, dir, rawBytes, record) => {
|
||
const events = this.extractEventsFromReplayRecord(conn, dir, rawBytes, record)
|
||
this.lastDecodedReplayRecord = { record, events }
|
||
if (events.length === 1) return events[0]
|
||
if (events.length > 1) return { events }
|
||
return undefined
|
||
},
|
||
onReplayRecord: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array) => {
|
||
this.handleReplayedPacketFrame(frame, rawBytes)
|
||
},
|
||
})
|
||
|
||
this.hudModel = new HudModel({
|
||
world: this.world,
|
||
hudManager: this.hudManager,
|
||
predictor: this.predictor,
|
||
automap: this.automapView,
|
||
onCommand: (cmd) => {
|
||
this.dispatchCommand(cmd)
|
||
},
|
||
vendorPrice: vendorPrice113c,
|
||
vendorRepairAll: vendorRepairAll113c,
|
||
playUiSound: (soundId) => {
|
||
this.soundService.playSoundById(soundId)
|
||
},
|
||
})
|
||
|
||
if (!this.hudManager.onEscapeMenuAction) {
|
||
this.hudManager.onEscapeMenuAction = (action) => {
|
||
if (action === 'saveAndExit') {
|
||
void this.leaveGame()
|
||
}
|
||
}
|
||
}
|
||
|
||
this.commandMapper = new CommandMapper({
|
||
world: this.world,
|
||
camera: this.camera,
|
||
hudManager: this.hudManager,
|
||
hudModel: this.hudModel,
|
||
predictor: this.predictor,
|
||
mapService: this.mapService ?? undefined,
|
||
automapView: this.automapView,
|
||
rateLimiter: this.rateLimiter,
|
||
sendCommand: (cmd) => {
|
||
this.forwardCommandToAdapter(cmd)
|
||
},
|
||
isExternalUiIntercepted: options.isExternalUiIntercepted,
|
||
onToggleChatInput: options.onToggleChatInput,
|
||
onEscapeMenu: options.onEscapeMenu,
|
||
})
|
||
|
||
this.sceneSource = new ClientWorldSceneSource(
|
||
() => {
|
||
// #539: the scene is anchored only on server-provided state (0x03 LoadAct area + 0x07
|
||
// revealed rooms + server player position). No `|| 1` Rogue Encampment default and no
|
||
// "last LevelView MapService happened to build" (AutomapView builds neighbouring levels
|
||
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
|
||
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
|
||
// level exists the scene reports not-ready.
|
||
const predSnap = this.predictor.getSnapshot()
|
||
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||
const levelView =
|
||
this.levelView ??
|
||
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
|
||
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
|
||
this.world.areaId,
|
||
...this.world.getRevealedAreaIds(),
|
||
])
|
||
: null)
|
||
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||
return {
|
||
tick: this.tickCounter,
|
||
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
||
playerSubX,
|
||
playerSubY,
|
||
units: this.buildRenderableUnits(),
|
||
levelView,
|
||
tileAtlas: this.mapService?.getTileAtlas() ?? null,
|
||
}
|
||
},
|
||
initialViewport,
|
||
)
|
||
|
||
this.defaultHost = options.defaultHost ?? '101.37.117.183'
|
||
this.defaultBncsPort = options.defaultBncsPort ?? 6112
|
||
const wsBase = options.wsBridgeBaseUrl ?? this.resolveWsBridgeBaseUrl(currentSettings.serverHost)
|
||
this.resolver = options.resolver ?? createWsBridgeResolver(this.parseWsBridgeUrl(wsBase))
|
||
this.flowFactory = options.flowFactory ?? createD2OnlineFlow
|
||
|
||
// React to viewport & automap reveal changes in SettingsStore
|
||
this.unsubSettings = this.settingsStore.subscribe((next, prev) => {
|
||
if (next.viewport !== prev.viewport) {
|
||
const vp = getViewportProfile(next.viewport)
|
||
this.camera.resize(vp)
|
||
this.hudManager.resize(vp.width, vp.height)
|
||
this.sceneSource.setViewportProfile(vp)
|
||
}
|
||
if (next.automapReveal !== prev.automapReveal) {
|
||
this.automapView.setMode(next.automapReveal)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── State & Listeners ───────────────────────────────────────
|
||
|
||
get phase(): OnlineSessionPhase {
|
||
return this.phaseValue
|
||
}
|
||
|
||
get selectedChar(): string | null {
|
||
return this.selectedCharValue
|
||
}
|
||
|
||
get disconnectReason(): string | null {
|
||
return this.disconnectReasonValue
|
||
}
|
||
|
||
get pingMs(): number {
|
||
return this.gameAdapter ? this.gameAdapter.stats.rttMs || this.pingMsValue : this.pingMsValue
|
||
}
|
||
|
||
getSentCommands(): readonly ClientCommand[] {
|
||
return this.outboundCommandsLog
|
||
}
|
||
|
||
getSnapshot(): OnlineSessionLobbySnapshot {
|
||
return {
|
||
phase: this.phaseValue,
|
||
flowState: this.flow?.state ?? 'idle',
|
||
username: this.usernameValue,
|
||
selectedRealm: this.selectedRealmValue,
|
||
realms: this.realmsValue,
|
||
characters: this.charactersValue,
|
||
selectedChar: this.selectedCharValue,
|
||
games: this.gamesValue,
|
||
activeGameName: this.activeGameNameValue,
|
||
disconnectReason: this.disconnectReasonValue,
|
||
pingMs: this.pingMs,
|
||
}
|
||
}
|
||
|
||
onStateChange(cb: (snapshot: OnlineSessionLobbySnapshot) => void): () => void {
|
||
this.stateListeners.add(cb)
|
||
return () => {
|
||
this.stateListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onServerEvent(cb: (ev: ServerEvent) => void): () => void {
|
||
this.eventListeners.add(cb)
|
||
return () => {
|
||
this.eventListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onChatMessage(cb: (entry: ChatLogEntry) => void): () => void {
|
||
this.chatListeners.add(cb)
|
||
return () => {
|
||
this.chatListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
onDisconnect(cb: (reason: string) => void): () => void {
|
||
this.disconnectListeners.add(cb)
|
||
return () => {
|
||
this.disconnectListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fires once per session lifecycle when the Battle.net session ends: a BNCS/MCP/D2GS connection
|
||
* closed or errored, a request timed out, a command was issued without its connection, or the
|
||
* client ended the session itself (`disconnect`). All connections are already disposed when this
|
||
* fires; the UI returns to the login screen with `ev.reason`.
|
||
*/
|
||
onSessionLost(cb: (ev: OnlineSessionLostEvent) => void): () => void {
|
||
this.sessionLostListeners.add(cb)
|
||
return () => {
|
||
this.sessionLostListeners.delete(cb)
|
||
}
|
||
}
|
||
|
||
/** The loss that ended the current session lifecycle, or `null`. */
|
||
get sessionLost(): OnlineSessionLostEvent | null {
|
||
return this.sessionLostValue?.event ?? null
|
||
}
|
||
|
||
// ─── Online Flow Lifecycle (BNCS -> MCP -> D2GS) ─────────────
|
||
|
||
/**
|
||
* Connect to BNCS, authenticate account, and select Realm (MCP) to list characters.
|
||
*/
|
||
async login(params: OnlineSessionLoginParams): Promise<{
|
||
readonly realms: readonly RealmSummary[]
|
||
readonly characters: readonly CharSummary[]
|
||
}> {
|
||
return await this.establishSession(params, false)
|
||
}
|
||
|
||
/**
|
||
* Create a new Battle.net account on BNCS, log in, and enter the realm character screen.
|
||
*/
|
||
async createAccountAndLogin(params: OnlineSessionLoginParams): Promise<{
|
||
readonly realms: readonly RealmSummary[]
|
||
readonly characters: readonly CharSummary[]
|
||
}> {
|
||
return await this.establishSession(params, true)
|
||
}
|
||
|
||
private async establishSession(
|
||
params: OnlineSessionLoginParams,
|
||
createAccount: boolean,
|
||
): Promise<{
|
||
readonly realms: readonly RealmSummary[]
|
||
readonly characters: readonly CharSummary[]
|
||
}> {
|
||
this.detachGameServerListeners()
|
||
this.gameAdapter = null
|
||
await this.closeFlowQuietly()
|
||
this.sessionLostValue = null
|
||
this.lostFlow = null
|
||
this.disconnectReasonValue = null
|
||
this.usernameValue = params.username
|
||
this.setPhase('connecting')
|
||
|
||
const resolver = params.wsBridgeBaseUrl
|
||
? createWsBridgeResolver(this.parseWsBridgeUrl(params.wsBridgeBaseUrl))
|
||
: this.resolver
|
||
|
||
const flow = this.flowFactory({
|
||
resolver,
|
||
bnetHost: params.host ?? this.defaultHost,
|
||
bnetPort: params.bncsPort ?? this.defaultBncsPort,
|
||
itemTables: this.itemTables,
|
||
tap: this.packetTap,
|
||
})
|
||
|
||
this.bindFlowListeners(flow)
|
||
this.flow = flow
|
||
|
||
try {
|
||
if (createAccount) {
|
||
await flow.createAccount(params.username, params.password)
|
||
}
|
||
await flow.login(params.username, params.password)
|
||
const realms = await flow.listRealms()
|
||
this.realmsValue = realms
|
||
const chosenRealm = realms[params.realmIndex ?? 0] ?? realms[0] ?? null
|
||
this.selectedRealmValue = chosenRealm
|
||
|
||
const characters = await flow.enterRealm(chosenRealm?.title)
|
||
if (this.flow !== flow) {
|
||
throw this.sessionLostFor(flow) ?? new Error('OnlineSession.login: superseded by a newer login')
|
||
}
|
||
this.charactersValue = characters
|
||
if (characters.length > 0 && !this.selectedCharValue) {
|
||
this.selectedCharValue = characters[0]!.name
|
||
}
|
||
this.setPhase('lobby')
|
||
return { realms, characters }
|
||
} catch (err) {
|
||
const lost = this.sessionLostFor(flow)
|
||
if (lost) throw lost
|
||
if (this.flow !== flow) {
|
||
// A newer login replaced this attempt; its state belongs to the newer attempt.
|
||
throw err
|
||
}
|
||
// Rejected login (bad password or unknown account): the attempt's connections are closed
|
||
// and the login screen shows the server's answer.
|
||
this.pendingFlowClose = this.closeFlowQuietly()
|
||
this.disconnectReasonValue = err instanceof Error ? err.message : String(err)
|
||
this.setPhase('disconnected')
|
||
throw err
|
||
}
|
||
}
|
||
|
||
async refreshCharacters(): Promise<readonly CharSummary[]> {
|
||
return await this.lobbyOp('refreshCharacters', 'mcp', async (flow) => {
|
||
const chars = await flow.listCharacters()
|
||
this.charactersValue = chars
|
||
this.emitState()
|
||
return chars
|
||
})
|
||
}
|
||
|
||
async createCharacter(spec: CharCreateSpec): Promise<readonly CharSummary[]> {
|
||
await this.lobbyOp('createCharacter', 'mcp', (flow) => flow.createCharacter(spec))
|
||
this.selectedCharValue = spec.name
|
||
return await this.refreshCharacters()
|
||
}
|
||
|
||
async deleteCharacter(name: string): Promise<readonly CharSummary[]> {
|
||
await this.lobbyOp('deleteCharacter', 'mcp', (flow) => flow.deleteCharacter(name))
|
||
if (this.selectedCharValue === name) {
|
||
this.selectedCharValue = null
|
||
}
|
||
return await this.refreshCharacters()
|
||
}
|
||
|
||
async convertCharacterToExpansion(name: string): Promise<readonly CharSummary[]> {
|
||
await this.lobbyOp('convertCharacterToExpansion', 'mcp', (flow) => flow.upgradeCharacter(name))
|
||
return await this.refreshCharacters()
|
||
}
|
||
|
||
async selectCharacter(name: string): Promise<void> {
|
||
await this.lobbyOp('selectCharacter', 'mcp', (flow) => flow.selectCharacter(name))
|
||
this.selectedCharValue = name
|
||
const charMeta = this.charactersValue.find((c) => c.name === name)
|
||
if (charMeta) {
|
||
this.world.self.name = charMeta.name
|
||
this.world.self.charClass = charMeta.charClass
|
||
} else {
|
||
this.world.self.name = name
|
||
}
|
||
this.hudModel.syncFromWorld()
|
||
this.emitState()
|
||
}
|
||
|
||
async sendLobbyChat(text: string): Promise<void> {
|
||
const trimmed = text.trim()
|
||
if (!trimmed) return
|
||
await this.lobbyOp('sendLobbyChat', 'bncs', (flow) => flow.sendChat(trimmed))
|
||
const prevChatCount = this.world.chatLog.length
|
||
this.world.apply({
|
||
type: 'Chat',
|
||
kind: 'say',
|
||
from: this.selectedCharValue || this.usernameValue || 'Player',
|
||
text: trimmed,
|
||
})
|
||
if (this.world.chatLog.length > prevChatCount) {
|
||
const entry = this.world.chatLog[this.world.chatLog.length - 1]
|
||
if (entry) {
|
||
for (const listener of this.chatListeners) {
|
||
listener(entry)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async listGames(filter = ''): Promise<readonly GameListEntry[]> {
|
||
return await this.lobbyOp('listGames', 'mcp', async (flow) => {
|
||
const games = await flow.listGames(filter)
|
||
this.gamesValue = games
|
||
this.emitState()
|
||
return games
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Create & join or join an existing D2GS game, binding the `GameServerAdapter` to `ClientWorld`.
|
||
*/
|
||
async enterGame(
|
||
request:
|
||
| { readonly mode: 'create'; readonly spec: GameCreateSpec }
|
||
| { readonly mode: 'join'; readonly name: string; readonly password?: string | undefined },
|
||
): Promise<GameServerAdapter> {
|
||
return await this.lobbyOp('enterGame', 'mcp', async (flow) => {
|
||
this.setPhase('entering-game')
|
||
const savedName = this.world.self.name
|
||
const savedClass = this.world.self.charClass
|
||
this.world.reset()
|
||
this.world.self.name = savedName
|
||
this.world.self.charClass = savedClass
|
||
this.predictor.setPosition(0, 0)
|
||
this.interpolator.clear()
|
||
this.hudModel.syncFromWorld()
|
||
try {
|
||
let adapter: GameServerAdapter
|
||
if (request.mode === 'create') {
|
||
await flow.createGame(request.spec)
|
||
adapter = await flow.joinGame(request.spec.name, request.spec.password ?? '')
|
||
} else {
|
||
adapter = await flow.joinGame(request.name, request.password ?? '')
|
||
}
|
||
|
||
this.activeGameNameValue =
|
||
request.mode === 'create' ? request.spec.name : request.name
|
||
this.attachGameServer(adapter)
|
||
return adapter
|
||
} catch (err) {
|
||
if (!this.sessionLostFor(flow)) {
|
||
// Game creation/join refused while BNCS/MCP stay up (1.13c stays in the lobby).
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
this.disconnectReasonValue = msg
|
||
this.setPhase('lobby')
|
||
}
|
||
throw err
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Attach a live or mock `GameServerAdapter` directly to this session.
|
||
*/
|
||
attachGameServer(adapter: GameServerAdapter): void {
|
||
this.detachGameServerListeners()
|
||
if (!this.flow) {
|
||
// A standalone adapter (replay harness or tests) starts its own session lifecycle.
|
||
this.sessionLostValue = null
|
||
this.lostFlow = null
|
||
}
|
||
this.gameAdapter = adapter
|
||
this.disconnectReasonValue = null
|
||
this.setPhase('ingame')
|
||
|
||
this.unsubGameEvent = adapter.onEvent((ev) => {
|
||
this.handleServerEvent(ev)
|
||
})
|
||
|
||
if (typeof adapter.onClose === 'function') {
|
||
this.unsubGameClose = adapter.onClose((reason: CloseReason) => {
|
||
if (this.gameAdapter !== adapter) return
|
||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
||
const technical =
|
||
reason.kind === 'error'
|
||
? `D2GS game connection error: ${reason.error.message}`
|
||
: reason.kind === 'remote'
|
||
? `D2GS game connection closed by server${reason.code !== undefined ? ` (code ${reason.code})` : ''}`
|
||
: 'D2GS game connection closed'
|
||
this.handleSessionLost({
|
||
session: 'd2gs',
|
||
cause: reason.kind === 'error' ? 'error' : 'closed',
|
||
technical,
|
||
})
|
||
}
|
||
})
|
||
} else {
|
||
this.unsubGameClose = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Leave the active D2GS game and return to lobby (or idle if standalone).
|
||
*/
|
||
async leaveGame(): Promise<void> {
|
||
const flow = this.flow
|
||
this.detachGameServerListeners()
|
||
this.hudModel.closeAllPanels()
|
||
this.hudManager.closeAllPanels()
|
||
if (flow && flow.state === 'ingame') {
|
||
try {
|
||
await flow.leaveToLobby()
|
||
} catch {
|
||
// Ignore close errors on leave
|
||
}
|
||
} else if (this.gameAdapter) {
|
||
try {
|
||
if (typeof this.gameAdapter.leave === 'function') {
|
||
this.gameAdapter.leave()
|
||
} else if (typeof (this.gameAdapter as any).disconnect === 'function') {
|
||
;(this.gameAdapter as any).disconnect()
|
||
}
|
||
} catch {
|
||
// Ignore close errors on leave
|
||
}
|
||
}
|
||
this.gameAdapter = null
|
||
this.activeGameNameValue = null
|
||
// `leaveToLobby` reports a BNCS/MCP loss deferred during the game; that already ended the session.
|
||
if (flow && this.sessionLostFor(flow)) return
|
||
this.setPhase(flow ? 'lobby' : 'idle')
|
||
}
|
||
|
||
/**
|
||
* End the session on purpose: close every connection and transition to `'disconnected'` with
|
||
* exactly `reason`. Routed through the same session-loss path so the UI returns to the login screen.
|
||
*/
|
||
async disconnect(reason = 'Disconnected by user'): Promise<void> {
|
||
this.handleSessionLost({ session: 'client', cause: 'local', technical: reason, reason })
|
||
await this.pendingFlowClose
|
||
}
|
||
|
||
/**
|
||
* Run a pre-game operation that needs a live BNCS/MCP session. Without a session the loss is
|
||
* handled centrally (return to login) instead of surfacing "not connected" on the current screen;
|
||
* an operation that fails because the session was lost throws `SessionLostError`. Other failures
|
||
* (status codes such as "name already exists") are rethrown as-is for the current screen.
|
||
*/
|
||
private async lobbyOp<T>(
|
||
op: string,
|
||
required: 'bncs' | 'mcp',
|
||
fn: (flow: D2OnlineFlow) => Promise<T>,
|
||
): Promise<T> {
|
||
const flow = this.flow
|
||
if (!flow || this.sessionLostValue) {
|
||
throw this.handleSessionLost({
|
||
session: required,
|
||
cause: 'not_connected',
|
||
technical: `${LOBBY_SESSION_LABEL[required]} session is not connected for ${op}`,
|
||
})
|
||
}
|
||
try {
|
||
return await fn(flow)
|
||
} catch (err) {
|
||
throw this.sessionLostFor(flow) ?? err
|
||
}
|
||
}
|
||
|
||
/** The handled loss if it ended `flow`'s lifecycle. */
|
||
private sessionLostFor(flow: D2OnlineFlow): SessionLostError | null {
|
||
return this.sessionLostValue && this.lostFlow === flow ? this.sessionLostValue : null
|
||
}
|
||
|
||
/**
|
||
* Single place where a session ends. Idempotent per session lifecycle: the first call disposes the
|
||
* game adapter and the flow (which closes BNCS/MCP/D2GS), records the reason, enters
|
||
* `'disconnected'` and notifies `onSessionLost` / `onDisconnect`; later calls (BNCS and MCP both
|
||
* closing, or a D2GS close seen by both the flow and the adapter) return the same error.
|
||
*/
|
||
private handleSessionLost(input: {
|
||
readonly session: OnlineSessionLostSource
|
||
readonly cause: OnlineSessionLostCause
|
||
readonly technical: string
|
||
readonly reason?: string | undefined
|
||
}): SessionLostError {
|
||
if (this.sessionLostValue) return this.sessionLostValue
|
||
const event: OnlineSessionLostEvent = {
|
||
reason: input.reason ?? formatSessionLostReason(input.session, input.cause, input.technical),
|
||
session: input.session,
|
||
cause: input.cause,
|
||
technical: input.technical,
|
||
}
|
||
const error = new SessionLostError(event)
|
||
this.sessionLostValue = error
|
||
this.lostFlow = this.flow
|
||
|
||
this.detachGameServerListeners()
|
||
const adapter = this.gameAdapter
|
||
this.gameAdapter = null
|
||
this.activeGameNameValue = null
|
||
if (adapter && !this.flow) {
|
||
// No flow owns this adapter (standalone attach), so release its connection here.
|
||
try {
|
||
adapter.leave()
|
||
} catch {
|
||
// Already closed.
|
||
}
|
||
}
|
||
this.pendingFlowClose = this.closeFlowQuietly()
|
||
|
||
this.disconnectReasonValue = event.reason
|
||
this.setPhase('disconnected')
|
||
for (const listener of [...this.sessionLostListeners]) {
|
||
listener(event)
|
||
}
|
||
for (const listener of [...this.disconnectListeners]) {
|
||
listener(event.reason)
|
||
}
|
||
return error
|
||
}
|
||
|
||
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
||
|
||
/**
|
||
* Dispatch a `ClientCommand` through `OutboundRateLimiter` (used by `HudModel` and direct API callers).
|
||
*/
|
||
dispatchCommand(cmd: ClientCommand | Record<string, any>, nowMs = performance.now()): boolean {
|
||
if (!cmd || typeof cmd !== 'object') return false
|
||
const rawCmd = cmd as Record<string, any>
|
||
const normalized: ClientCommand =
|
||
rawCmd.type === 'PlayerMove'
|
||
? {
|
||
type: 'RunToLocation',
|
||
x: Number(rawCmd.targetX ?? rawCmd.x ?? 0),
|
||
y: Number(rawCmd.targetY ?? rawCmd.y ?? 0),
|
||
}
|
||
: (cmd as ClientCommand)
|
||
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
|
||
if (res.allowed) {
|
||
this.forwardCommandToAdapter(normalized)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
private forwardCommandToAdapter(cmd: ClientCommand): void {
|
||
this.outboundCommandsLog.push(cmd)
|
||
if (this.outboundCommandsLog.length > 512) {
|
||
this.outboundCommandsLog.shift()
|
||
}
|
||
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
||
try {
|
||
this.gameAdapter.send(cmd)
|
||
this.world.noteOutgoingCommand(cmd)
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Authoritative ServerEvent Ingestion ─────────────────────
|
||
|
||
/**
|
||
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
|
||
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
|
||
*/
|
||
handleServerEvent(rawEv: ServerEvent): void {
|
||
const prevChatCount = this.world.chatLog.length
|
||
const evAny = rawEv as Record<string, any>
|
||
let ev: ServerEvent = rawEv
|
||
if (evAny.type === 'PortalOwner' || evAny.type === 'PortalInfo') {
|
||
ev = {
|
||
type: 'Portal',
|
||
ownerId: evAny.ownerId,
|
||
ownerName: evAny.ownerName,
|
||
localId: Number(evAny.localId ?? evAny.unitId ?? 0),
|
||
remoteId: evAny.remoteId,
|
||
areaId: Number(evAny.areaId ?? evAny.levelId ?? 0),
|
||
flags: evAny.flags,
|
||
}
|
||
} else if (evAny.type === 'MercAssign') {
|
||
ev = {
|
||
type: 'Merc',
|
||
kind: 'assign',
|
||
ownerId: evAny.ownerId,
|
||
mercId: evAny.mercId,
|
||
classId: evAny.classId,
|
||
nameId: evAny.nameId,
|
||
}
|
||
} else if (evAny.type === 'PlayerKill') {
|
||
ev = {
|
||
type: 'CorpseAssign',
|
||
ownerId: Number(evAny.victimId ?? evAny.ownerId ?? this.world.selfId),
|
||
corpseId: Number(evAny.corpseId ?? 0),
|
||
assign: 1,
|
||
} as unknown as ServerEvent
|
||
} else if (
|
||
evAny.type === 'SkillCast' &&
|
||
(!evAny.caster || (evAny.target === undefined && evAny.targetX !== undefined))
|
||
) {
|
||
ev = {
|
||
...evAny,
|
||
type: 'SkillCast',
|
||
caster: evAny.caster ?? evAny.unit ?? { unitType: evAny.unitType ?? 0, id: evAny.unitId ?? 1 },
|
||
skill: Number(evAny.skill ?? evAny.skillId ?? 0),
|
||
level: Number(evAny.level ?? evAny.skillLevel ?? 1),
|
||
target:
|
||
evAny.target ??
|
||
(evAny.targetX !== undefined && evAny.targetY !== undefined
|
||
? { x: Number(evAny.targetX), y: Number(evAny.targetY) }
|
||
: { x: 0, y: 0 }),
|
||
} as ServerEvent
|
||
}
|
||
this.world.apply(ev)
|
||
|
||
if (
|
||
this.phaseValue === 'entering-game' &&
|
||
(ev.type === 'LoadAct' ||
|
||
ev.type === 'LoadComplete' ||
|
||
(ev.type === 'UnitAssign' && Boolean(ev.isSelf)))
|
||
) {
|
||
this.setPhase('ingame')
|
||
}
|
||
|
||
switch (ev.type) {
|
||
case 'LoadAct': {
|
||
// 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
|
||
// loaded into. Without it (or without a buildable LevelView) the client cannot place the
|
||
// player on the server's map, so fail fast instead of guessing a town (#539).
|
||
if (ev.areaId <= 0) {
|
||
this.handleUnexpectedDisconnect(
|
||
`0x03 LoadAct carried no areaId (act=${String(ev.act)}): cannot build the level the server placed the player in`,
|
||
)
|
||
break
|
||
}
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onLoadActPacket(
|
||
{
|
||
act: ev.act,
|
||
mapSeed: ev.mapSeed,
|
||
areaId: ev.areaId,
|
||
},
|
||
{ difficulty: this.world.difficulty },
|
||
)
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
this.handleUnexpectedDisconnect(
|
||
`MapService failed to build area ${String(ev.areaId)} (act=${String(ev.act)}, seed=0x${(ev.mapSeed >>> 0).toString(16)}): ${msg}`,
|
||
)
|
||
break
|
||
}
|
||
}
|
||
const activeView = this.mapService?.getActiveLevelView() ?? undefined
|
||
this.automapView.setContext(
|
||
ev.mapSeed,
|
||
this.world.difficulty,
|
||
ev.areaId,
|
||
activeView,
|
||
)
|
||
void this.soundService.onEnterArea(ev.areaId)
|
||
break
|
||
}
|
||
|
||
case 'RoomReveal': {
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onAddRoomDataPacket({
|
||
roomX: ev.tileX,
|
||
roomY: ev.tileY,
|
||
areaId: ev.areaId,
|
||
})
|
||
} catch {
|
||
// Ignore if act not yet loaded
|
||
}
|
||
}
|
||
this.automapView.onServerAddRoom(ev.tileX, ev.tileY, ev.areaId)
|
||
if (ev.areaId > 0) {
|
||
void this.soundService.onEnterArea(ev.areaId)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'RoomHide': {
|
||
if (this.mapService) {
|
||
try {
|
||
this.mapService.onRemoveRoomDataPacket({
|
||
roomX: ev.tileX,
|
||
roomY: ev.tileY,
|
||
areaId: ev.areaId,
|
||
})
|
||
} catch {
|
||
// Ignore if act not yet loaded
|
||
}
|
||
}
|
||
this.automapView.onServerRemoveRoom(ev.tileX, ev.tileY, ev.areaId)
|
||
break
|
||
}
|
||
|
||
case 'UnitAssign': {
|
||
const u = ev.unit
|
||
const uTypeId = unitTypeToId(u.unitType)
|
||
const isLocalPlayer =
|
||
Boolean(ev.isSelf) ||
|
||
(uTypeId === 0 && (this.world.selfId === u.id || this.world.selfId === 0))
|
||
|
||
if (isLocalPlayer) {
|
||
if (u.x !== 0 || u.y !== 0) {
|
||
this.predictor.setPosition(u.x, u.y)
|
||
this.camera.snapTo(u.x, u.y)
|
||
}
|
||
} else if (u.x !== 0 || u.y !== 0) {
|
||
this.interpolator.spawnOrSnap({
|
||
unitType: uTypeId,
|
||
unitId: u.id,
|
||
x: u.x,
|
||
y: u.y,
|
||
...(u.direction !== undefined ? { dir16: u.direction } : {}),
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitReassign': {
|
||
const evTypeId = unitTypeToId(ev.unitType)
|
||
const isLocalPlayer =
|
||
evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0)
|
||
if (isLocalPlayer) {
|
||
const prevSnap = this.predictor.getSnapshot()
|
||
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) {
|
||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
} else {
|
||
const res = this.predictor.reconcileServerPosition({
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
teleport: ev.snap,
|
||
})
|
||
if (res.mode === 'hard-snap') {
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
}
|
||
}
|
||
} else {
|
||
this.interpolator.spawnOrSnap({
|
||
unitType: evTypeId,
|
||
unitId: ev.id,
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'LifeMana': {
|
||
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
||
const prevSnap = this.predictor.getSnapshot()
|
||
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) {
|
||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||
this.camera.snapTo(ev.at.x, ev.at.y)
|
||
} else {
|
||
this.predictor.reconcileServerPosition({
|
||
x: ev.at.x,
|
||
y: ev.at.y,
|
||
teleport: false,
|
||
})
|
||
}
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitMove': {
|
||
const evAny = ev as any
|
||
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
|
||
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1)
|
||
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
|
||
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
|
||
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
|
||
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
|
||
const isLocalPlayer =
|
||
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
|
||
if (isLocalPlayer) {
|
||
if (ev.mode === 'stop') {
|
||
this.predictor.reconcileServerPosition({
|
||
x: toX,
|
||
y: toY,
|
||
teleport: false,
|
||
})
|
||
}
|
||
} else if (ev.mode === 'stop') {
|
||
this.interpolator.stopEntity({
|
||
unitType: evTypeId,
|
||
unitId: evUnitId,
|
||
x: toX,
|
||
y: toY,
|
||
})
|
||
} else {
|
||
this.interpolator.startMove({
|
||
unitType: evTypeId,
|
||
unitId: evUnitId,
|
||
...(fromX !== undefined ? { currentX: Number(fromX) } : {}),
|
||
...(fromY !== undefined ? { currentY: Number(fromY) } : {}),
|
||
targetX: toX,
|
||
targetY: toY,
|
||
running: ev.mode === 'run',
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UnitRemove': {
|
||
this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id)
|
||
break
|
||
}
|
||
|
||
case 'Pong': {
|
||
if (ev.rttMs !== undefined) {
|
||
this.pingMsValue = ev.rttMs
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'GameLogout': {
|
||
if (this.phaseValue === 'ingame' || this.phaseValue === 'entering-game') {
|
||
this.handleUnexpectedDisconnect('Game session ended (GameLogout)')
|
||
}
|
||
break
|
||
}
|
||
|
||
default:
|
||
break
|
||
}
|
||
|
||
// Drain sound queue into SoundService
|
||
const queuedSounds = this.world.drainSoundQueue()
|
||
const selfSnap = this.predictor.getSnapshot()
|
||
for (const entry of queuedSounds) {
|
||
if (entry.unit) {
|
||
const unit = this.world.getUnit(entry.unit.unitType, entry.unit.id)
|
||
if (unit && (unit.x !== 0 || unit.y !== 0)) {
|
||
this.soundService.playSoundById(entry.sound, {
|
||
sourceSubX: unit.x,
|
||
sourceSubY: unit.y,
|
||
listenerSubX: selfSnap.x || this.world.self.x,
|
||
listenerSubY: selfSnap.y || this.world.self.y,
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
this.soundService.playSoundById(entry.sound)
|
||
}
|
||
|
||
// Emit any newly added chat entries
|
||
if (this.world.chatLog.length > prevChatCount) {
|
||
for (let i = prevChatCount; i < this.world.chatLog.length; i++) {
|
||
const chatEntry = this.world.chatLog[i]
|
||
if (chatEntry) {
|
||
for (const listener of this.chatListeners) {
|
||
listener(chatEntry)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Synchronize HudManager via HudModel
|
||
this.hudModel.syncFromWorld()
|
||
|
||
for (const listener of this.eventListeners) {
|
||
listener(ev)
|
||
}
|
||
}
|
||
|
||
// ─── Frame Tick & Render State ───────────────────────────────
|
||
|
||
/**
|
||
* Step the session by `dtMs` milliseconds:
|
||
* - Flushes rate-limited movement commands in `CommandMapper`
|
||
* - Steps `LocalMovementPredictor` and `Camera`
|
||
* - Steps `RemoteEntityInterpolator` and syncs interpolated positions to `ClientWorld` units
|
||
*/
|
||
tick(dtMs = 40, nowMs = performance.now()): void {
|
||
this.tickCounter++
|
||
this.commandMapper.tick(nowMs)
|
||
|
||
const predSnap = this.predictor.update(dtMs)
|
||
if (predSnap.x !== 0 || predSnap.y !== 0) {
|
||
this.camera.update(predSnap.x, predSnap.y)
|
||
}
|
||
|
||
this.interpolator.update(dtMs)
|
||
for (const state of this.interpolator.getAllStates()) {
|
||
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
|
||
if (unit) {
|
||
unit.x = state.x
|
||
unit.y = state.y
|
||
unit.direction = state.dir16
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build `RenderableUnitInput[]` from `ClientWorld` + `LocalMovementPredictor` + `RemoteEntityInterpolator`.
|
||
*/
|
||
buildRenderableUnits(): RenderableUnitInput[] {
|
||
const out: RenderableUnitInput[] = []
|
||
const predSnap = this.predictor.getSnapshot()
|
||
const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||
const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||
|
||
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
|
||
const selfClass = this.world.self.charClass
|
||
if (this.world.selfId !== 0 && selfClass !== null) {
|
||
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
||
const isSelfDead = this.world.isSelfDead || this.world.isDead
|
||
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
|
||
const selfMode = isSelfDead
|
||
? 12
|
||
: selfCastState
|
||
? selfCastState.mode || 10
|
||
: predSnap.moving
|
||
? predSnap.running
|
||
? 3
|
||
: 2
|
||
: selfUnit?.mode && selfUnit.mode > 1
|
||
? selfUnit.mode
|
||
: 1
|
||
const selfDirection =
|
||
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16
|
||
out.push({
|
||
id: this.world.selfId,
|
||
kind: 'player',
|
||
classId: selfClass,
|
||
name: this.world.self.name,
|
||
x: selfX,
|
||
y: selfY,
|
||
...(selfCastState?.targetX !== undefined ? { targetX: selfCastState.targetX } : {}),
|
||
...(selfCastState?.targetY !== undefined ? { targetY: selfCastState.targetY } : {}),
|
||
mode: selfMode,
|
||
direction: selfDirection,
|
||
hpRatio:
|
||
this.world.self.maxLife > 0
|
||
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
|
||
: 1,
|
||
isLocalPlayer: true,
|
||
isCorpse: isSelfDead,
|
||
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
|
||
})
|
||
}
|
||
|
||
const activeLevelView =
|
||
this.levelView ??
|
||
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
|
||
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [
|
||
this.world.areaId,
|
||
...this.world.getRevealedAreaIds(),
|
||
])
|
||
: null) ??
|
||
this.mapService?.getActiveLevelView() ??
|
||
this.automapView.getLevelView()
|
||
|
||
for (const u of this.world.units.values()) {
|
||
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
||
continue
|
||
}
|
||
const interp = this.interpolator.getEntityState(
|
||
u.unitType === 'player'
|
||
? 0
|
||
: u.unitType === 'monster'
|
||
? 1
|
||
: u.unitType === 'object'
|
||
? 2
|
||
: u.unitType === 'missile'
|
||
? 3
|
||
: u.unitType === 'item'
|
||
? 4
|
||
: 5,
|
||
u.id,
|
||
)
|
||
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
|
||
const ux = interp ? interp.x : u.x
|
||
const uy = interp ? interp.y : u.y
|
||
|
||
if (u.unitType === 'tile') {
|
||
const warpId = u.warpId ?? u.classId
|
||
let matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
|
||
if (!matchedWarp && activeLevelView && activeLevelView.warps.length > 0) {
|
||
let bestDistSq = 30 * 30
|
||
for (const w of activeLevelView.warps) {
|
||
const wAny = w as Record<string, any>
|
||
const room =
|
||
typeof w.room === 'number' ? activeLevelView.rooms[w.room] : undefined
|
||
const subX = typeof wAny.subX === 'number' ? wAny.subX : (room?.subX ?? 0)
|
||
const subY = typeof wAny.subY === 'number' ? wAny.subY : (room?.subY ?? 0)
|
||
const wx = activeLevelView.subOriginX + subX
|
||
const wy = activeLevelView.subOriginY + subY
|
||
const dSq = (wx - ux) * (wx - ux) + (wy - uy) * (wy - uy)
|
||
if (dSq <= bestDistSq) {
|
||
bestDistSq = dSq
|
||
matchedWarp = w
|
||
}
|
||
}
|
||
}
|
||
const toLevelId = matchedWarp?.toLevelId
|
||
const toAreaName =
|
||
toLevelId !== undefined ? CANONICAL_LEVEL_LIGHTING[toLevelId]?.levelName : undefined
|
||
const warpName =
|
||
u.name && u.name.trim() !== ''
|
||
? u.name
|
||
: toAreaName
|
||
? `To ${toAreaName}`
|
||
: `Entrance (${warpId})`
|
||
out.push({
|
||
id: u.id,
|
||
kind: 'warp',
|
||
classId: u.classId,
|
||
warpId,
|
||
...(toLevelId !== undefined ? { toLevelId } : {}),
|
||
name: warpName,
|
||
x: ux,
|
||
y: uy,
|
||
mode: u.mode,
|
||
direction: interp ? interp.dir16 : u.direction,
|
||
radiusX: 28,
|
||
radiusY: 20,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)
|
||
const castState = !isCorpse ? u.castState : undefined
|
||
const mode = castState ? castState.mode || u.mode : u.mode
|
||
const targetX = castState?.targetX ?? u.targetX
|
||
const targetY = castState?.targetY ?? u.targetY
|
||
|
||
if (u.unitType === 'object' && (u.classId === 59 || u.classId === 60)) {
|
||
const portal =
|
||
this.world.portals.get(u.id) ??
|
||
Array.from(this.world.portals.values()).find(
|
||
(p) => p.localId === u.id || p.remoteId === u.id,
|
||
)
|
||
const destAreaName = portal?.areaId
|
||
? CANONICAL_LEVEL_LIGHTING[portal.areaId]?.levelName
|
||
: undefined
|
||
const basePortalName = portal?.ownerName
|
||
? `${portal.ownerName}'s Town Portal`
|
||
: u.name && u.name.trim() !== ''
|
||
? u.name
|
||
: 'Town Portal'
|
||
const portalName =
|
||
destAreaName && !basePortalName.includes(destAreaName)
|
||
? `${basePortalName} (${destAreaName})`
|
||
: basePortalName
|
||
out.push({
|
||
id: u.id,
|
||
kind: 'object',
|
||
classId: u.classId,
|
||
name: portalName,
|
||
x: ux,
|
||
y: uy,
|
||
mode,
|
||
direction: interp ? interp.dir16 : u.direction,
|
||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||
isCorpse: false,
|
||
states: Array.from(u.states.list()),
|
||
...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}),
|
||
...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}),
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (u.unitType === 'item') {
|
||
const itemCode = u.itemCode ?? groundItem?.code ?? u.itemData?.code ?? (u.name && /^[a-z0-9]{3,4}$/i.test(u.name.trim()) ? u.name.trim() : undefined)
|
||
const itemQuality = u.itemQuality ?? u.quality ?? groundItem?.quality ?? u.itemData?.quality
|
||
const meta = u.itemData
|
||
? resolveGroundItemMetadata(u.itemData, u.goldAmount ?? groundItem?.goldAmount)
|
||
: itemCode
|
||
? resolveGroundItemMetadata(
|
||
{
|
||
code: itemCode,
|
||
...(itemQuality !== undefined ? { quality: itemQuality } : {}),
|
||
...((u.goldAmount ?? groundItem?.goldAmount) !== undefined ? { goldAmount: u.goldAmount ?? groundItem?.goldAmount } : {}),
|
||
...(u.name !== undefined ? { name: u.name } : {}),
|
||
},
|
||
u.goldAmount ?? groundItem?.goldAmount,
|
||
)
|
||
: undefined
|
||
const goldAmount = u.goldAmount ?? groundItem?.goldAmount ?? (meta?.isGold ? meta.amount : undefined)
|
||
const itemName =
|
||
meta?.isGold && goldAmount !== undefined
|
||
? `${goldAmount} Gold`
|
||
: u.name && u.name.trim() !== ''
|
||
? u.name
|
||
: meta?.name
|
||
out.push({
|
||
id: u.id,
|
||
kind: 'item',
|
||
classId: u.classId,
|
||
...(itemName !== undefined ? { name: itemName } : {}),
|
||
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
|
||
x: ux,
|
||
y: uy,
|
||
mode,
|
||
direction: interp ? interp.dir16 : u.direction,
|
||
hpRatio: 1,
|
||
isCorpse: false,
|
||
...(goldAmount !== undefined ? { goldAmount } : {}),
|
||
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
|
||
})
|
||
continue
|
||
}
|
||
|
||
const partyMember = u.unitType === 'player' ? this.world.party.get(u.id) : undefined
|
||
const isPartyMember = Boolean(
|
||
partyMember &&
|
||
(partyMember.partyState === 1 ||
|
||
(partyMember.partyId !== undefined && partyMember.partyId !== 0xffff)),
|
||
)
|
||
|
||
out.push({
|
||
id: u.id,
|
||
kind: u.unitType,
|
||
classId: u.classId,
|
||
name: u.name,
|
||
x: ux,
|
||
y: uy,
|
||
...(targetX !== undefined ? { targetX } : {}),
|
||
...(targetY !== undefined ? { targetY } : {}),
|
||
mode,
|
||
direction: interp ? interp.dir16 : u.direction,
|
||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||
isCorpse,
|
||
isChampion: u.isChampion,
|
||
isUnique: u.isUnique,
|
||
isSuperUnique: u.isSuperUnique,
|
||
states: Array.from(u.states.list()),
|
||
...(castState !== undefined ? { castState } : {}),
|
||
...(u.unitType === 'player' ? { isPartyMember } : {}),
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// ─── Deterministic `.d2cap` Capture Replay (F20) ─────────────
|
||
|
||
/**
|
||
* Load a `.d2cap` JSONL capture into `PacketInspector` and transition to `'replay'` phase.
|
||
*/
|
||
loadD2CapReplay(jsonlText: string): number {
|
||
this.replayS2cFramer.reset(false)
|
||
this.lastDecodedReplayRecord = null
|
||
const records = this.packetInspector.loadD2CapJsonl(jsonlText)
|
||
this.disconnectReasonValue = null
|
||
this.setPhase('replay')
|
||
return records.length
|
||
}
|
||
|
||
/**
|
||
* Step 1 packet from the loaded `.d2cap` capture, decoding `game` `s2c` packets into `ServerEvent`s.
|
||
*/
|
||
stepD2CapReplay(): ReplayedD2CapFrame | null {
|
||
return this.packetInspector.stepReplay()
|
||
}
|
||
|
||
/**
|
||
* Replay all remaining records in the loaded `.d2cap` capture synchronously.
|
||
*/
|
||
replayAllD2Cap(): number {
|
||
this.setPhase('replay')
|
||
return this.packetInspector.replayAll()
|
||
}
|
||
|
||
private extractEventsFromReplayRecord(
|
||
conn: ReplayedD2CapFrame['conn'],
|
||
dir: ReplayedD2CapFrame['dir'],
|
||
rawBytes: Uint8Array,
|
||
record: ReplayedD2CapFrame['record'],
|
||
): readonly ServerEvent[] {
|
||
if (conn !== 'game' || dir !== 's2c') {
|
||
return []
|
||
}
|
||
if (record.stream) {
|
||
const framedPackets = this.replayS2cFramer.push(rawBytes)
|
||
const events: ServerEvent[] = []
|
||
for (const pkt of framedPackets) {
|
||
events.push(decodeCapturedGameS2cPacket(pkt.bytes, this.itemTables))
|
||
}
|
||
return events
|
||
}
|
||
return [decodeCapturedGameS2cPacket(rawBytes, this.itemTables)]
|
||
}
|
||
|
||
private handleReplayedPacketFrame(frame: ReplayedD2CapFrame, rawBytes: Uint8Array): void {
|
||
const events =
|
||
this.lastDecodedReplayRecord?.record === frame.record
|
||
? this.lastDecodedReplayRecord.events
|
||
: this.extractEventsFromReplayRecord(frame.conn, frame.dir, rawBytes, frame.record)
|
||
this.lastDecodedReplayRecord = null
|
||
for (const ev of events) {
|
||
this.handleServerEvent(ev)
|
||
}
|
||
}
|
||
|
||
// ─── Internal Error & Lifecycle Helpers ──────────────────────
|
||
|
||
resolveWsBridgeBaseUrl(serverHost?: string): string {
|
||
const cleanHost = (serverHost || 'www.laiseek.xyz')
|
||
.trim()
|
||
.replace(/^(wss?|https?):\/\//i, '')
|
||
.replace(/\/.*$/, '')
|
||
const isLocalHost = cleanHost.startsWith('localhost') || cleanHost.startsWith('127.0.0.1')
|
||
const scheme =
|
||
!isLocalHost &&
|
||
(cleanHost === 'www.laiseek.xyz' ||
|
||
(typeof window !== 'undefined' && window.location?.protocol === 'https:'))
|
||
? 'wss'
|
||
: 'ws'
|
||
return `${scheme}://${cleanHost}/d2net`
|
||
}
|
||
|
||
private parseWsBridgeUrl(wsBase: string): {
|
||
readonly host: string
|
||
readonly scheme: 'wss' | 'ws'
|
||
readonly basePath: string
|
||
} {
|
||
try {
|
||
const parsed = new URL(wsBase)
|
||
const scheme: 'wss' | 'ws' = parsed.protocol === 'wss:' || parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||
const basePath = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : '/d2net'
|
||
return {
|
||
host: parsed.host || 'www.laiseek.xyz',
|
||
scheme,
|
||
basePath,
|
||
}
|
||
} catch {
|
||
return {
|
||
host: 'www.laiseek.xyz',
|
||
scheme: 'wss',
|
||
basePath: '/d2net',
|
||
}
|
||
}
|
||
}
|
||
|
||
private bindFlowListeners(flow: D2OnlineFlow): void {
|
||
this.unsubFlowState?.()
|
||
this.unsubFlowLost?.()
|
||
|
||
this.unsubFlowState = flow.onState(() => {
|
||
if (this.flow !== flow) return
|
||
this.emitState()
|
||
})
|
||
|
||
this.unsubFlowLost = flow.onSessionLost((ev: D2OnlineSessionLost) => {
|
||
if (this.flow !== flow) return
|
||
this.handleSessionLost({ session: ev.session, cause: ev.cause, technical: ev.message })
|
||
})
|
||
}
|
||
|
||
/** The game ended for a client-side reason (bad server data, send failure, GameLogout). */
|
||
private handleUnexpectedDisconnect(
|
||
reason: string,
|
||
cause: Extract<OnlineSessionLostCause, 'error' | 'closed'> = 'error',
|
||
useTbl = false,
|
||
): void {
|
||
this.handleSessionLost({
|
||
session: 'd2gs',
|
||
cause,
|
||
technical: reason,
|
||
...(useTbl ? {} : { reason }),
|
||
})
|
||
}
|
||
|
||
private detachGameServerListeners(): void {
|
||
this.unsubGameEvent?.()
|
||
this.unsubGameEvent = null
|
||
this.unsubGameClose?.()
|
||
this.unsubGameClose = null
|
||
}
|
||
|
||
/** Detach from and close the current flow (which closes every BNCS/MCP/D2GS connection it owns). */
|
||
private async closeFlowQuietly(): Promise<void> {
|
||
this.unsubFlowState?.()
|
||
this.unsubFlowState = null
|
||
this.unsubFlowLost?.()
|
||
this.unsubFlowLost = null
|
||
if (this.flow) {
|
||
const current = this.flow
|
||
this.flow = null
|
||
try {
|
||
await current.close()
|
||
} catch {
|
||
// Ignore close errors
|
||
}
|
||
}
|
||
}
|
||
|
||
private setPhase(next: OnlineSessionPhase): void {
|
||
this.phaseValue = next
|
||
this.emitState()
|
||
}
|
||
|
||
private emitState(): void {
|
||
const snap = this.getSnapshot()
|
||
for (const listener of this.stateListeners) {
|
||
listener(snap)
|
||
}
|
||
}
|
||
|
||
destroy(): void {
|
||
this.detachGameServerListeners()
|
||
void this.closeFlowQuietly()
|
||
this.unsubSettings?.()
|
||
this.unsubSettings = null
|
||
this.hudModel.dispose()
|
||
this.soundService.destroy()
|
||
}
|
||
}
|