2331 lines
83 KiB
TypeScript
2331 lines
83 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 {
|
||
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()
|
||
}
|
||
}
|