diablo2-web/src/client/world/client-world.ts

875 lines
26 KiB
TypeScript

/**
* Headless ClientWorld Authoritative Server Mirror (`src/client/world/client-world.ts`, Feature F9).
*
* Consumes protocol-neutral `ServerEvent`s and maintains a zero-DOM, deterministic
* mirror of the server world state (act, mapSeed, difficulty, areaId, self player,
* units, item containers, revealed rooms, waypoints, quests, party, trade, chat, sound queue).
*/
import {
ClickButtonId,
unitTypeFromId,
type CharClassId,
type ClientCommand,
type Pt,
type ServerEvent,
type SkillLevel,
type UnitRef,
type UnitType,
} from '../../netproto/index.ts'
import { ClientUnit, makeUnitKey } from './client-unit.ts'
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
export interface PartyMemberState {
readonly unitId: number
charName?: string | undefined
charClass?: number | undefined
charLevel?: number | undefined
partyId?: number | undefined
relation?: number | undefined
lifePct?: number | undefined
areaId?: number | undefined
at?: Pt | undefined
}
export interface TradeSessionState {
active: boolean
lastAction: number
partnerId?: number | undefined
partnerGold: number
selfGold: number
}
export interface MercenaryState {
readonly mercId: number
readonly ownerId: number
readonly classId: number
readonly nameId?: number | undefined
exp?: number | undefined
}
export interface ChatLogEntry {
readonly kind: 'say' | 'whisper' | 'overhead' | 'system' | 'event'
readonly from?: string | undefined
readonly unit?: UnitRef | undefined
readonly text: string
readonly color?: number | undefined
readonly eventType?: number | undefined
}
export interface SoundQueueEntry {
readonly unit?: UnitRef | undefined
readonly sound: number
}
export interface PortalState {
readonly localId: number
readonly remoteId?: number | undefined
readonly ownerName?: string | undefined
readonly ownerId?: number | undefined
readonly areaId: number
readonly flags?: number | undefined
}
/**
* Local NPC/stash interaction state. D2Client tracks this itself: the server never
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
* captures 05 and 08). Stash opening is server-driven (0x77 action 0x10, capture 08).
*/
export interface NpcInteractionState {
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
readonly npcId: number | null
/** Window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
readonly window: 'none' | 'trade' | 'gamble'
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
readonly stashOpen: boolean
}
/** One S2C 0x2A NPCTransaction, numbered so the HUD can react to each reply once. */
export interface NpcTransactionRecord {
readonly seq: number
readonly result: number
readonly flag: number
readonly itemId: number
readonly gold: number
}
/** S2C 0x77 ButtonActions action that opens the stash panel (capture 08; D2Client jump table 0x6fb0a520[0x10]). */
const BUTTON_ACTION_OPEN_STASH = 0x10
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff
export class ClientWorld {
act = 0
mapSeed = 0
objectSeed = 0
difficulty: 0 | 1 | 2 = 0
hardcore = false
expansion = true
ladder = false
areaId = 0
loading = true
selfId = 0
frameCount = 0
lastRttMs: number | null = null
private readonly _self = new MutableSelfPlayerState()
private readonly _units = new Map<string, ClientUnit>()
private readonly _monsters = new Map<number, ClientUnit>()
private readonly _containers = new ClientInventoryManager()
private readonly _revealedRooms = new Set<string>()
private readonly _waypoints = new Set<number>()
/** Unit id of the waypoint object whose menu was last opened (`0x63 WaypointMenu`); `null` when none. */
private _waypointUnitId: number | null = null
private _quests = new Uint16Array(48)
private readonly _party = new Map<number, PartyMemberState>()
private readonly _trade: TradeSessionState = {
active: false,
lastAction: 0,
partnerGold: 0,
selfGold: 0,
}
private _merc: MercenaryState | null = null
private readonly _chatLog: ChatLogEntry[] = []
private readonly _soundQueue: SoundQueueEntry[] = []
private readonly _portals = new Map<number, PortalState>()
private _npcInteraction: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
private _lastNpcTransaction: NpcTransactionRecord | null = null
private _npcTransactionSeq = 0
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
get npcInteraction(): NpcInteractionState {
return this._npcInteraction
}
/** Last S2C 0x2A NPCTransaction; display-only (gold/items come from stat and item packets). */
get lastNpcTransaction(): NpcTransactionRecord | null {
return this._lastNpcTransaction
}
/**
* Records the local side effects of an outgoing command, mirroring what D2Client
* does when it sends it: 0x38 opens the trade/gamble window and the store is
* refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
*/
noteOutgoingCommand(cmd: ClientCommand): void {
const cur = this._npcInteraction
switch (cmd.type) {
case 'NpcInit':
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none' }
break
case 'NpcOpenTrade':
case 'NpcOpenGamble':
this._containers.clearShop()
this._npcInteraction = {
...cur,
npcId: cmd.npcId,
window: cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble',
}
break
case 'NpcCancel':
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: null, window: 'none' }
break
case 'ClickButton':
if (cmd.buttonId === ClickButtonId.CloseStash) {
this._npcInteraction = { ...cur, stashOpen: false }
}
break
default:
break
}
}
onChange(cb: (ev: ServerEvent) => void): () => void {
this._changeListeners.add(cb)
return () => {
this._changeListeners.delete(cb)
}
}
get self(): SelfPlayerState {
return this._self
}
get player(): SelfPlayerState {
return this._self
}
get monsters(): Map<number, ClientUnit> {
return this._monsters
}
get merc(): MercenaryState | null {
return this._merc
}
get units(): ReadonlyMap<string, ClientUnit> {
return this._units
}
get containers(): ClientItemContainers {
return this._containers
}
get revealedRooms(): ReadonlySet<string> {
return this._revealedRooms
}
/**
* Distinct area ids of the rooms currently revealed by the server (`0x07` minus `0x08`), in
* first-seen order. Used to resolve which level contains the player's server position (#539).
*/
getRevealedAreaIds(): number[] {
const out: number[] = []
for (const key of this._revealedRooms) {
const areaId = Number(key.slice(0, key.indexOf(':')))
if (areaId > 0 && !out.includes(areaId)) out.push(areaId)
}
return out
}
get waypoints(): ReadonlySet<number> {
return this._waypoints
}
/** Waypoint object unit id from the last `0x63 WaypointMenu`, required by `0x49 WaypointGo`. */
get waypointUnitId(): number | null {
return this._waypointUnitId
}
get quests(): Uint16Array {
return this._quests
}
get party(): ReadonlyMap<number, PartyMemberState> {
return this._party
}
get trade(): Readonly<TradeSessionState> {
return this._trade
}
get chatLog(): readonly ChatLogEntry[] {
return this._chatLog
}
get soundQueue(): readonly SoundQueueEntry[] {
return this._soundQueue
}
get portals(): ReadonlyMap<number, PortalState> {
return this._portals
}
drainSoundQueue(): SoundQueueEntry[] {
return this._soundQueue.splice(0, this._soundQueue.length)
}
tick(dtMs = 40): void {
this.frameCount++
const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10)
for (const unit of this._units.values()) {
if (unit.dead) continue
if (unit.targetX === undefined || unit.targetY === undefined) continue
const dx = unit.targetX - unit.x
const dy = unit.targetY - unit.y
const dist = Math.hypot(dx, dy)
if (dist === 0) continue
if (dist <= step) {
unit.x = unit.targetX
unit.y = unit.targetY
} else {
unit.x += (dx / dist) * step
unit.y += (dy / dist) * step
}
}
}
getUnit(unitType: UnitType, id: number): ClientUnit | undefined {
return this._units.get(makeUnitKey(unitType, id))
}
getUnitsByType(unitType: UnitType): ClientUnit[] {
const typeName = unitTypeFromId(unitType)
const out: ClientUnit[] = []
for (const u of this._units.values()) {
if (u.unitType === typeName) {
out.push(u)
}
}
return out
}
private ensureUnit(unitType: UnitType, id: number): ClientUnit {
const key = makeUnitKey(unitType, id)
let unit = this._units.get(key)
if (!unit) {
unit = new ClientUnit({
unitType: unitTypeFromId(unitType),
id,
classId: 0,
x: 0,
y: 0,
})
this._units.set(key, unit)
}
return unit
}
applyEvent(ev: ServerEvent | Record<string, any>): void {
this.apply(ev as ServerEvent)
}
applyServerEvent(ev: ServerEvent | Record<string, any>): void {
this.apply(ev as ServerEvent)
}
apply(rawEv: ServerEvent | Record<string, any>): void {
if (!rawEv || typeof rawEv !== 'object') return
let ev: any = rawEv
if (ev.type === 'ActDataLoaded') {
ev = {
type: 'LoadAct',
act: Number(ev.act ?? 0),
mapSeed: Number(ev.mapSeed ?? 0) >>> 0,
areaId: Number(ev.townAreaId ?? ev.areaId ?? 1),
objectSeed: Number(ev.unknown ?? 0) >>> 0,
}
} else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') {
const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1)
const uId = Number(ev.unitId ?? ev.id ?? 1)
ev = {
type: 'UnitAssign',
isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)),
unit: {
unitType: uType,
id: uId,
classId: Number(ev.classId ?? 0),
name: ev.name ?? ev.code,
x: Number(ev.x ?? 0),
y: Number(ev.y ?? 0),
lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128,
},
}
} else if (ev.type === 'EntityRemove') {
ev = {
type: 'UnitRemove',
unitType: unitTypeFromId(ev.unitType ?? 1),
id: Number(ev.unitId ?? ev.id ?? 0),
}
} else if (ev.type === 'UnitMove' && !ev.to) {
ev = {
type: 'UnitMove',
unitType: unitTypeFromId(ev.unitType ?? 0),
id: Number(ev.unitId ?? ev.id ?? 1),
from: {
x: Number(ev.currentX ?? ev.x ?? 0),
y: Number(ev.currentY ?? ev.y ?? 0),
},
to: {
x: Number(ev.targetX ?? ev.x ?? 0),
y: Number(ev.targetY ?? ev.y ?? 0),
},
}
} else if (ev.type === 'ChatMessage') {
ev = {
type: 'Chat',
kind: 'say',
from: ev.speaker ?? ev.from ?? 'Server',
text: String(ev.text ?? ''),
}
} else if (ev.type === 'LifeManaPotUpdate') {
ev = {
type: 'LifeMana',
life: Number(ev.life ?? 0),
mana: Number(ev.mana ?? 0),
stamina: Number(ev.stamina ?? 0),
at: { x: Number(ev.x ?? 0), y: Number(ev.y ?? 0) },
}
}
switch (ev.type) {
case 'GameLoading':
this.loading = true
break
case 'GameFlags':
this.difficulty = ev.difficulty
this.hardcore = ev.hardcore
this.expansion = ev.expansion
this.ladder = ev.ladder
break
case 'LoadAct':
this.act = ev.act
this.mapSeed = ev.mapSeed >>> 0
this.areaId = ev.areaId
if (ev.objectSeed !== undefined) {
this.objectSeed = ev.objectSeed >>> 0
}
break
case 'LoadComplete':
this.loading = false
break
case 'UnloadComplete':
this.loading = true
break
case 'GameLogout':
this.loading = false
break
case 'RoomReveal':
this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`)
if (this.areaId === 0 && ev.areaId > 0) {
this.areaId = ev.areaId
}
break
case 'RoomHide':
this._revealedRooms.delete(`${ev.areaId}:${ev.tileX}:${ev.tileY}`)
break
case 'UnitAssign': {
const key = makeUnitKey(ev.unit.unitType, ev.unit.id)
let unit = this._units.get(key)
if (!unit) {
unit = new ClientUnit(ev.unit)
this._units.set(key, unit)
} else {
unit.updateFromSnapshot(ev.unit)
}
if (unit.unitType === 'monster') {
this._monsters.set(unit.id, unit)
}
const isSelfPlayer =
unit.unitType === 'player' &&
(ev.isSelf === true || this.selfId === 0 || this.selfId === unit.id)
if (isSelfPlayer) {
if (ev.isSelf === true || this.selfId === 0) {
this.selfId = unit.id
this._self.unitId = unit.id
}
if (ev.unit.name) {
this._self.name = ev.unit.name
}
if (ev.unit.classId >= 0 && ev.unit.classId <= 6) {
this._self.charClass = ev.unit.classId as CharClassId
}
if (ev.unit.x !== 0 || ev.unit.y !== 0) {
this._self.x = ev.unit.x
this._self.y = ev.unit.y
this._self.targetX = ev.unit.x
this._self.targetY = ev.unit.y
}
}
break
}
case 'UnitRemove': {
const typeName = unitTypeFromId(ev.unitType)
if (typeName === 'item') {
this._containers.removeItemById(ev.id)
}
if (typeName === 'monster') {
this._monsters.delete(ev.id)
}
const key = makeUnitKey(ev.unitType, ev.id)
this._units.delete(key)
this._portals.delete(ev.id)
break
}
case 'UnitMove': {
const unit = this.ensureUnit(ev.unitType, ev.id)
if (unit.unitType === 'monster') {
this._monsters.set(unit.id, unit)
}
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
unit.x = ev.from.x
unit.y = ev.from.y
} else if (ev.to.x !== 0 || ev.to.y !== 0) {
unit.x = ev.to.x
unit.y = ev.to.y
}
unit.targetX = ev.to.x
unit.targetY = ev.to.y
unit.targetUnit = ev.target
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
this._self.x = ev.from.x
this._self.y = ev.from.y
} else if (ev.to.x !== 0 || ev.to.y !== 0) {
this._self.x = ev.to.x
this._self.y = ev.to.y
}
if (ev.to.x !== 0 || ev.to.y !== 0) {
this._self.targetX = ev.to.x
this._self.targetY = ev.to.y
}
}
break
}
case 'UnitReassign': {
const unit = this.ensureUnit(ev.unitType, ev.id)
unit.x = ev.at.x
unit.y = ev.at.y
unit.targetX = ev.at.x
unit.targetY = ev.at.y
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
if (this.selfId === 0) {
this.selfId = unit.id
this._self.unitId = unit.id
}
this._self.x = ev.at.x
this._self.y = ev.at.y
this._self.targetX = ev.at.x
this._self.targetY = ev.at.y
}
break
}
case 'UnitMode': {
const unit = this.ensureUnit(ev.unitType, ev.id)
unit.mode = ev.mode
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
unit.x = ev.at.x
unit.y = ev.at.y
}
if (ev.lifePct !== undefined) {
unit.lifePct = ev.lifePct
}
break
}
case 'SkillCast':
break
case 'StatSet': {
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
// (0x53 act seed = 0x5300, 0x8000|op / 0x9000|op opaque envelopes) as pseudo StatSet events;
// those must never enter a unit StatList.
if (ev.stat < 0 || ev.stat > MAX_D2_STAT_ID) break
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
if (this.selfId !== 0) {
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
selfUnit?.stats.set(ev.stat, this._self.stats.get(ev.stat), ev.param ?? 0)
}
} else {
const targetUnit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
targetUnit.stats.set(ev.stat, ev.value, ev.param ?? 0)
}
break
}
case 'LifeMana': {
const atX = ev.at?.x ?? 0
const atY = ev.at?.y ?? 0
this._self.applyLifeMana(ev.life, ev.mana, ev.stamina, atX, atY)
if (this.selfId !== 0 && (atX !== 0 || atY !== 0)) {
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
if (selfUnit) {
selfUnit.x = atX
selfUnit.y = atY
}
}
break
}
case 'Experience':
this._self.applyExperience(ev.value, ev.isDelta ?? false)
break
case 'StateSet': {
const unit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
unit.states.add(ev.state, ev.stats)
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
this._self.states.add(ev.state)
}
if (ev.stats) {
unit.stats.setStateStats(ev.state, ev.stats)
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
this._self.stats.setStateStats(ev.state, ev.stats)
}
}
break
}
case 'StateEnd': {
const unit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
unit.states.remove(ev.state)
unit.stats.removeStateStats(ev.state)
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
this._self.states.delete(ev.state)
this._self.stats.removeStateStats(ev.state)
}
break
}
case 'ItemEvent': {
// 0x17 WeaponSwitch
if (ev.action === 0x17) {
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
break
}
this._containers.applyItemEvent(ev.action, ev.item, ev.container, this.selfId)
if (ev.container === 'ground') {
const itemUnit = this.ensureUnit('item', ev.item.id)
itemUnit.x = ev.item.worldX
itemUnit.y = ev.item.worldY
itemUnit.name = ev.item.code
} else {
this._units.delete(makeUnitKey('item', ev.item.id))
}
break
}
case 'SkillList': {
if (this.selfId === 0 && ev.unitId !== 0) {
this.selfId = ev.unitId
this._self.unitId = ev.unitId
}
if (ev.unitId === this.selfId || ev.unitId === 0) {
for (const sk of ev.skills) {
const existing = this._self.skillEntries.get(sk.skillId)
const bonus = sk.bonus ?? existing?.bonus ?? 0
const merged: SkillLevel = {
skillId: sk.skillId,
skill: sk.skillId,
level: sk.level,
baseLevel: sk.baseLevel ?? existing?.baseLevel ?? sk.level,
bonus,
}
this._self.skillEntries.set(sk.skillId, merged)
this._self.skills.set(sk.skillId, sk.level + bonus)
}
}
break
}
case 'ActiveSkill': {
if (this.selfId === 0 && ev.unitId !== 0) {
this.selfId = ev.unitId
this._self.unitId = ev.unitId
}
if (ev.unitId === this.selfId || ev.unitId === 0) {
if (ev.hand === 'left') {
this._self.leftSkill = ev.skill
} else {
this._self.rightSkill = ev.skill
}
}
break
}
case 'Npc': {
break
}
case 'NpcTransaction': {
// Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]).
this._lastNpcTransaction = {
seq: ++this._npcTransactionSeq,
result: ev.result,
flag: ev.flag,
itemId: ev.itemId,
gold: ev.gold,
}
break
}
case 'WaypointMenu': {
this._waypoints.clear()
this._waypointUnitId = ev.unitId
for (const wp of ev.open) {
this._waypoints.add(wp)
}
break
}
case 'QuestState': {
const copy = new Uint16Array(Math.max(this._quests.length, ev.quests.length))
copy.set(this._quests)
copy.set(ev.quests)
this._quests = copy
break
}
case 'Party': {
const id = ev.unitId ?? ev.otherId ?? 0
if (id !== 0) {
if (ev.partyId === 0xffff) {
this._party.delete(id)
break
}
const prev = this._party.get(id) ?? { unitId: id }
this._party.set(id, {
...prev,
...(ev.charName !== undefined ? { charName: ev.charName } : {}),
...(ev.charClass !== undefined ? { charClass: ev.charClass } : {}),
...(ev.charLevel !== undefined ? { charLevel: ev.charLevel } : {}),
...(ev.partyId !== undefined ? { partyId: ev.partyId } : {}),
...(ev.relation !== undefined ? { relation: ev.relation } : {}),
...(ev.lifePct !== undefined ? { lifePct: ev.lifePct } : {}),
...(ev.areaId !== undefined ? { areaId: ev.areaId } : {}),
...(ev.at !== undefined ? { at: ev.at } : {}),
})
}
break
}
case 'Trade': {
this._trade.lastAction = ev.action
if (ev.action === BUTTON_ACTION_OPEN_STASH) {
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
}
if (ev.partnerId !== undefined) {
this._trade.partnerId = ev.partnerId
}
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
if ((ev.action & 0xff) !== 0) {
this._trade.selfGold = ev.gold
} else {
this._trade.partnerGold = ev.gold
}
}
// Trade cancel / close actions
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
this._trade.active = false
} else if (ev.action === 0x00 || ev.action === 0x01 || ev.action === 0x06) {
this._trade.active = true
}
break
}
case 'Chat': {
this._chatLog.push({
kind: ev.kind,
from: ev.from,
unit: ev.unit,
text: ev.text,
color: ev.color,
eventType: ev.eventType,
})
while (this._chatLog.length > 200) {
this._chatLog.shift()
}
if (ev.kind === 'overhead' && ev.unit) {
const unit = this._units.get(makeUnitKey(ev.unit.unitType, ev.unit.id))
if (unit) {
unit.overheadText = ev.text || undefined
}
}
break
}
case 'PlaySound': {
this._soundQueue.push({
unit: ev.unit,
sound: ev.sound,
})
while (this._soundQueue.length > 64) {
this._soundQueue.shift()
}
break
}
case 'Merc': {
if (ev.kind === 'assign' && ev.mercId !== undefined) {
if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) {
this._self.mercId = ev.mercId === 0 ? null : ev.mercId
this._merc =
ev.mercId === 0
? null
: {
mercId: ev.mercId,
ownerId: ev.ownerId ?? this.selfId,
classId: ev.classId ?? 0,
...(ev.nameId !== undefined ? { nameId: ev.nameId } : {}),
}
}
} else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) {
this._merc.exp = ev.value
} else if (ev.kind === 'stat' && ev.mercId !== undefined && ev.stat !== undefined && ev.value !== undefined) {
const mercUnit = this.ensureUnit('monster', ev.mercId)
mercUnit.stats.set(ev.stat, ev.value)
}
break
}
case 'Portal': {
this._portals.set(ev.localId, {
localId: ev.localId,
remoteId: ev.remoteId,
ownerName: ev.ownerName,
ownerId: ev.ownerId,
areaId: ev.areaId,
flags: ev.flags,
})
break
}
case 'Pong':
this.lastRttMs = ev.rttMs
break
case 'Warden':
case 'SaveChunk':
case 'Opaque':
break
}
for (const cb of this._changeListeners) {
cb(ev)
}
}
reset(): void {
this.act = 0
this.mapSeed = 0
this.objectSeed = 0
this.difficulty = 0
this.hardcore = false
this.expansion = true
this.ladder = false
this.areaId = 0
this.loading = true
this.selfId = 0
this.frameCount = 0
this.lastRttMs = null
this._self.reset()
this._merc = null
this._units.clear()
this._monsters.clear()
this._containers.clear()
this._revealedRooms.clear()
this._waypoints.clear()
this._waypointUnitId = null
this._quests = new Uint16Array(48)
this._party.clear()
this._trade.active = false
this._trade.lastAction = 0
this._trade.partnerId = undefined
this._trade.partnerGold = 0
this._trade.selfGold = 0
this._chatLog.length = 0
this._soundQueue.length = 0
this._portals.clear()
}
clear(): void {
this.reset()
}
}