1361 lines
44 KiB
TypeScript
1361 lines
44 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 { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
|
import { dir64FromPoints } from '../../common/units/direction.ts'
|
|
import { ClientUnit, makeUnitKey, type UnitCastState } 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
|
|
levelId?: number | undefined
|
|
at?: Pt | undefined
|
|
x?: number | undefined
|
|
y?: number | undefined
|
|
/** 0 = unpartied, 1 = in-party, 2 = invited-by-me, 3 = invited-me */
|
|
partyState?: number | undefined
|
|
hostile?: boolean | undefined
|
|
lootPermit?: boolean | undefined
|
|
}
|
|
|
|
export interface TradeSessionState {
|
|
active: boolean
|
|
lastAction: number
|
|
partnerId?: number | undefined
|
|
partnerName?: string | undefined
|
|
partnerGold: number
|
|
selfGold: number
|
|
selfAccepted: boolean
|
|
partnerAccepted: boolean
|
|
acceptDisabled: boolean
|
|
}
|
|
|
|
export interface MercHireEntry {
|
|
readonly nameId: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export interface MercenaryState {
|
|
mercId: number
|
|
ownerId: number
|
|
classId: number
|
|
nameId?: number | undefined
|
|
exp?: number | undefined
|
|
lifePct: number
|
|
isDead: boolean
|
|
reviveCost: number
|
|
deadNameId?: number | undefined
|
|
reviveNameId?: number | undefined
|
|
readonly stats: Map<number, number>
|
|
readonly hireList: readonly MercHireEntry[]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export type NpcInteractionWindow =
|
|
| 'none'
|
|
| 'trade'
|
|
| 'gamble'
|
|
| 'hire'
|
|
| 'imbue'
|
|
| 'add_sockets'
|
|
| 'personalize'
|
|
|
|
/**
|
|
* 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
|
|
/** Store window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
|
|
readonly window: 'none' | 'trade' | 'gamble'
|
|
/** Extended window kind including `hire` and quest reward windows (`imbue`, `add_sockets`, `personalize`). */
|
|
readonly activeWindow?: NpcInteractionWindow | undefined
|
|
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
|
|
readonly stashOpen: boolean
|
|
/** Last raw payload from S2C 0x27 (`NPCInfo`). */
|
|
readonly npcInfoRaw?: Uint8Array | undefined
|
|
/** Last raw payload from S2C 0x91 (`NPCGetShow`). */
|
|
readonly npcTalkRaw?: Uint8Array | undefined
|
|
}
|
|
|
|
/** 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
|
|
|
|
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
|
|
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
|
|
0, 2, 6, 7, 10, 19, 20, 24, 96, 97, 106, 107, 116, 126, 133, 139, 140, 144, 147, 151, 152,
|
|
])
|
|
|
|
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 _isSelfDead = false
|
|
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,
|
|
selfAccepted: false,
|
|
partnerAccepted: false,
|
|
acceptDisabled: false,
|
|
}
|
|
private _merc: MercenaryState | null = null
|
|
private readonly _mercHireList: MercHireEntry[] = []
|
|
private readonly _chatLog: ChatLogEntry[] = []
|
|
private readonly _soundQueue: SoundQueueEntry[] = []
|
|
private readonly _portals = new Map<number, PortalState>()
|
|
private _npcInteraction: NpcInteractionState = {
|
|
npcId: null,
|
|
window: 'none',
|
|
activeWindow: '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
|
|
}
|
|
|
|
get isDead(): boolean {
|
|
return this._isSelfDead || this._self.isDead
|
|
}
|
|
|
|
get isSelfDead(): boolean {
|
|
return this._isSelfDead || this._self.isDead
|
|
}
|
|
|
|
get mercHireList(): readonly MercHireEntry[] {
|
|
return this._mercHireList
|
|
}
|
|
|
|
private applySkillCastToUnit(unit: ClientUnit, skillId: number, targetX: number, targetY: number): void {
|
|
unit.targetX = targetX
|
|
unit.targetY = targetY
|
|
// `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the
|
|
// same 1.13c direction table as movement; a target on the unit's own position keeps the facing.
|
|
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
|
|
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
|
|
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
|
|
unit.mode = mode
|
|
const now = Date.now()
|
|
const castState: UnitCastState = {
|
|
skillId,
|
|
targetX,
|
|
targetY,
|
|
mode,
|
|
startedAtMs: now,
|
|
untilMs: now + 400,
|
|
}
|
|
unit.castState = castState
|
|
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
|
this._self.castState = castState
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records the local side effects of an outgoing command, mirroring what D2Client
|
|
* does when it sends it: 0x38 opens the trade/gamble/hire 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', activeWindow: 'none' }
|
|
break
|
|
case 'NpcOpenTrade':
|
|
case 'NpcOpenGamble': {
|
|
const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble'
|
|
this._containers.clearShop()
|
|
this._npcInteraction = {
|
|
...cur,
|
|
npcId: cmd.npcId,
|
|
window: win,
|
|
activeWindow: win,
|
|
}
|
|
break
|
|
}
|
|
case 'NpcEntityAction':
|
|
case 'NpcAction':
|
|
if (cmd.action === 1) {
|
|
this._containers.clearShop()
|
|
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'trade', activeWindow: 'trade' }
|
|
} else if (cmd.action === 2) {
|
|
this._containers.clearShop()
|
|
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' }
|
|
} else if (cmd.action === 3) {
|
|
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' }
|
|
}
|
|
break
|
|
case 'NpcCancel':
|
|
this._containers.clearShop()
|
|
this._npcInteraction = {
|
|
...cur,
|
|
npcId: null,
|
|
window: 'none',
|
|
activeWindow: 'none',
|
|
npcInfoRaw: undefined,
|
|
npcTalkRaw: undefined,
|
|
}
|
|
break
|
|
case 'ClickButton': {
|
|
const buttonId = cmd.buttonId ?? cmd.button ?? 0
|
|
if (buttonId === ClickButtonId.CloseStash) {
|
|
this._npcInteraction = { ...cur, stashOpen: false }
|
|
} else if (buttonId === ClickButtonId.TradeOk) {
|
|
this._trade.selfAccepted = true
|
|
} else if (buttonId === ClickButtonId.TradeUnaccept) {
|
|
this._trade.selfAccepted = false
|
|
} else if (buttonId === ClickButtonId.TradeCancel) {
|
|
this._trade.active = false
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
this._trade.acceptDisabled = false
|
|
this._containers.clearTrade()
|
|
}
|
|
break
|
|
}
|
|
case 'Resurrect':
|
|
this._isSelfDead = false
|
|
this._self.isDead = false
|
|
break
|
|
case 'SkillAt':
|
|
case 'CastLeftSkillOnLocation':
|
|
case 'CastRightSkillOnLocation': {
|
|
const isRight =
|
|
cmd.type === 'CastRightSkillOnLocation' || (cmd.type === 'SkillAt' && cmd.hand === 'right')
|
|
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
|
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
|
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
|
if (this.selfId !== 0) {
|
|
const selfUnit = this.ensureUnit('player', this.selfId)
|
|
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
|
|
}
|
|
break
|
|
}
|
|
case 'SkillOn':
|
|
case 'CastLeftSkillOnTarget':
|
|
case 'CastRightSkillOnTarget': {
|
|
const isRight =
|
|
cmd.type === 'CastRightSkillOnTarget' || (cmd.type === 'SkillOn' && cmd.hand === 'right')
|
|
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
|
const targetRef: UnitRef =
|
|
cmd.type === 'SkillOn' ? cmd.target : { unitType: cmd.unitKind, id: cmd.unitId }
|
|
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
|
const targetX = targetUnit?.x ?? this._self.x
|
|
const targetY = targetUnit?.y ?? this._self.y
|
|
if (this.selfId !== 0) {
|
|
const selfUnit = this.ensureUnit('player', this.selfId)
|
|
selfUnit.targetUnit = targetRef
|
|
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
|
|
}
|
|
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 === 'PlayerLifeManaChange') {
|
|
ev = {
|
|
type: 'LifeMana',
|
|
life: Number(ev.life ?? 0),
|
|
mana: Number(ev.mana ?? 0),
|
|
stamina: Number(ev.stamina ?? 0),
|
|
at: ev.at ?? { x: Number(ev.x ?? 0), y: Number(ev.y ?? 0) },
|
|
}
|
|
} else if (ev.type === 'StatChange') {
|
|
ev = {
|
|
type: 'StatSet',
|
|
stat: Number(ev.stat ?? ev.statId ?? 0),
|
|
value: Number(ev.value ?? 0),
|
|
param: Number(ev.param ?? 0),
|
|
}
|
|
} else if (ev.type === 'MercStatChange') {
|
|
ev = {
|
|
type: 'Merc',
|
|
kind: 'stat',
|
|
mercId: Number(ev.mercId ?? 0),
|
|
stat: Number(ev.statId ?? ev.stat ?? 0),
|
|
value: Number(ev.value ?? 0),
|
|
}
|
|
} else if (ev.type === 'Damage') {
|
|
ev = {
|
|
type: 'UnitMode',
|
|
unitType: ev.unitType ?? 1,
|
|
id: Number(ev.unitId ?? ev.id ?? 0),
|
|
mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)),
|
|
lifePct: Number(ev.lifePct ?? 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)
|
|
if (this._merc && unit.id === this._merc.mercId) {
|
|
this._merc.lifePct = unit.lifePct
|
|
if (unit.lifePct <= 0 || unit.mode === 12) {
|
|
this._merc.isDead = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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 uId = Number(ev.id ?? ev.unitId ?? 0)
|
|
const typeName = unitTypeFromId(ev.unitType)
|
|
if (typeName === 'item') {
|
|
this._containers.removeItemById(uId)
|
|
}
|
|
if (typeName === 'monster') {
|
|
this._monsters.delete(uId)
|
|
}
|
|
const key = makeUnitKey(ev.unitType, uId)
|
|
this._units.delete(key)
|
|
this._portals.delete(uId)
|
|
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
|
|
this._isSelfDead = false
|
|
this._self.isDead = false
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'UnitMode': {
|
|
const uId = Number(ev.id ?? ev.unitId ?? 0)
|
|
const unit = this.ensureUnit(ev.unitType, uId)
|
|
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
|
|
}
|
|
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
|
if (ev.mode === 0 || ev.mode === 16 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
|
|
this._isSelfDead = true
|
|
this._self.isDead = true
|
|
}
|
|
}
|
|
if (unit.unitType === 'monster' && this._merc && unit.id === this._merc.mercId) {
|
|
if (ev.lifePct !== undefined) {
|
|
this._merc.lifePct = ev.lifePct
|
|
}
|
|
if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
|
|
this._merc.isDead = true
|
|
this._merc.lifePct = 0
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'SkillCast': {
|
|
const casterType: UnitType =
|
|
ev.caster?.unitType ??
|
|
(ev.kind === 'monsterAttack' ? 1 : (ev.unitType ?? ev.attackerType ?? 0))
|
|
const casterId = Number(
|
|
ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 0,
|
|
)
|
|
const skillId = Number(ev.skillId ?? ev.skill ?? ev.attackType ?? 0)
|
|
let unit = this.getUnit(casterType, casterId)
|
|
if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) {
|
|
unit = this.ensureUnit('player', casterId || this.selfId || 1)
|
|
}
|
|
if (!unit) break
|
|
|
|
let targetX = unit.x
|
|
let targetY = unit.y
|
|
if (ev.target && typeof ev.target === 'object' && 'x' in ev.target) {
|
|
targetX = Number(ev.target.x ?? unit.x)
|
|
targetY = Number(ev.target.y ?? unit.y)
|
|
} else {
|
|
const targetRef: UnitRef | undefined =
|
|
ev.target && typeof ev.target === 'object' && 'unitType' in ev.target
|
|
? ev.target
|
|
: ev.targetId !== undefined
|
|
? { unitType: ev.targetType ?? 1, id: Number(ev.targetId) }
|
|
: undefined
|
|
if (targetRef) {
|
|
unit.targetUnit = targetRef
|
|
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
|
if (targetUnit) {
|
|
targetX = targetUnit.x
|
|
targetY = targetUnit.y
|
|
} else if (unitTypeFromId(targetRef.unitType) === 'player' && targetRef.id === this.selfId) {
|
|
targetX = this._self.x
|
|
targetY = this._self.y
|
|
}
|
|
}
|
|
}
|
|
this.applySkillCastToUnit(unit, skillId, targetX, targetY)
|
|
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)
|
|
this._isSelfDead = this._self.isDead
|
|
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)
|
|
if (this._merc && targetUnit.unitType === 'monster' && targetUnit.id === this._merc.mercId) {
|
|
this._merc.stats.set(ev.stat, ev.value)
|
|
}
|
|
}
|
|
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 (ev.life === 0) {
|
|
this._isSelfDead = true
|
|
this._self.isDead = true
|
|
} else if (ev.life > 0) {
|
|
this._isSelfDead = false
|
|
this._self.isDead = false
|
|
}
|
|
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 'Item':
|
|
case 'ItemEvent': {
|
|
// 0x17 WeaponSwitch
|
|
if (ev.action === 0x17) {
|
|
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
|
|
break
|
|
}
|
|
const effectiveAction = ev.action ?? ev.item?.action ?? 0
|
|
const effectiveContainer = ev.container ?? ev.item?.container ?? 'inventory'
|
|
this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId)
|
|
if (effectiveContainer === 'ground') {
|
|
const itemUnit = this.ensureUnit('item', ev.item.id)
|
|
itemUnit.x = ev.item.worldX
|
|
itemUnit.y = ev.item.worldY
|
|
const trimmedCode = ev.item.code.trim()
|
|
const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value
|
|
const goldAmt =
|
|
ev.item.goldAmount ??
|
|
(trimmedCode === 'gld' ? (ev.item.quantity ?? goldStatVal ?? 1) : undefined)
|
|
itemUnit.goldAmount = goldAmt
|
|
itemUnit.itemCode = trimmedCode
|
|
itemUnit.itemQuality = ev.item.quality
|
|
itemUnit.itemData = ev.item
|
|
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
|
const uiItem = onlineItemDataToUiInventoryItem(ev.item)
|
|
itemUnit.name = uiItem.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': {
|
|
const targetId = ev.npcId ?? ev.unitId ?? ev.npcUnitId
|
|
if (ev.kind === 'heal' && targetId !== undefined) {
|
|
const healPct = ev.result ?? ev.lifePct ?? 128
|
|
const unit = this.getUnit('monster', targetId)
|
|
if (unit) {
|
|
unit.lifePct = healPct
|
|
}
|
|
if (this._merc && this._merc.mercId === targetId) {
|
|
this._merc.lifePct = healPct
|
|
if (healPct > 0) {
|
|
this._merc.isDead = false
|
|
}
|
|
}
|
|
} else if (ev.kind === 'info') {
|
|
this._npcInteraction = {
|
|
...this._npcInteraction,
|
|
npcInfoRaw: ev.raw,
|
|
}
|
|
} else if (ev.kind === 'menu') {
|
|
this._npcInteraction = {
|
|
...this._npcInteraction,
|
|
...(this._npcInteraction.npcId === null && targetId !== undefined && targetId !== 0
|
|
? { npcId: targetId }
|
|
: {}),
|
|
npcTalkRaw: ev.raw,
|
|
}
|
|
}
|
|
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 'CorpseAssign': {
|
|
const ownerId = ev.ownerId ?? ev.unitId ?? 0
|
|
if ((ownerId === this.selfId || this.selfId === 0) && (ev.assign ?? 1) !== 0) {
|
|
this._isSelfDead = true
|
|
this._self.isDead = true
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'Party': {
|
|
const rawUnitId = ev.unitId ?? ev.playerId ?? ev.subjectId
|
|
const rawOtherId = ev.otherId ?? ev.objectId
|
|
const rawRel = ev.relation ?? ev.state ?? ev.relations
|
|
// 0x74 / 0x8E CorpseAssign for local player
|
|
if (
|
|
ev.kind === 'relation' &&
|
|
rawOtherId !== undefined &&
|
|
rawRel === 1 &&
|
|
(rawUnitId === this.selfId || (this.selfId === 0 && rawUnitId === rawOtherId))
|
|
) {
|
|
this._isSelfDead = true
|
|
this._self.isDead = true
|
|
if (!this._party.has(rawOtherId)) {
|
|
break
|
|
}
|
|
}
|
|
|
|
const id =
|
|
this.selfId !== 0 && rawUnitId === this.selfId && rawOtherId !== undefined
|
|
? rawOtherId
|
|
: (rawUnitId ?? rawOtherId ?? 0)
|
|
if (id !== 0) {
|
|
// Only delete on 0x5C PlayerLeftGame (partyId === 0xffff with no charName or empty charName).
|
|
// 0x5B PlayerInGame sends partyId === 0xffff when the player is unpartied.
|
|
if (ev.kind === 'roster' && ev.partyId === 0xffff && !ev.charName) {
|
|
this._party.delete(id)
|
|
break
|
|
}
|
|
const prev: PartyMemberState = this._party.get(id) ?? {
|
|
unitId: id,
|
|
partyState: 0,
|
|
hostile: false,
|
|
lootPermit: false,
|
|
}
|
|
|
|
let partyState = prev.partyState ?? 0
|
|
let hostile = prev.hostile ?? false
|
|
let lootPermit = prev.lootPermit ?? false
|
|
|
|
if (ev.kind === 'roster' && ev.partyId !== undefined) {
|
|
partyState = ev.partyId !== 0xffff && ev.partyId !== 0 ? 1 : 0
|
|
} else if (ev.kind === 'relation' && rawRel !== undefined) {
|
|
if ((rawOtherId === undefined || ev.subKind === 'partyState') && rawRel <= 3) {
|
|
partyState = rawRel
|
|
}
|
|
if ((rawRel & 0x08) !== 0) {
|
|
hostile = true
|
|
} else if (rawOtherId !== undefined) {
|
|
hostile = false
|
|
}
|
|
if ((rawRel & 0x04) !== 0 || (rawOtherId !== undefined && rawRel === 1)) {
|
|
lootPermit = true
|
|
} else if (rawOtherId !== undefined) {
|
|
lootPermit = false
|
|
}
|
|
}
|
|
|
|
const nextAreaId = ev.areaId ?? ev.levelId ?? prev.areaId
|
|
const nextAt =
|
|
ev.at ??
|
|
(ev.x !== undefined && ev.y !== undefined ? { x: Number(ev.x), y: Number(ev.y) } : prev.at)
|
|
|
|
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 } : {}),
|
|
...(rawRel !== undefined ? { relation: rawRel } : {}),
|
|
...(ev.lifePct !== undefined ? { lifePct: ev.lifePct } : {}),
|
|
...(nextAreaId !== undefined ? { areaId: nextAreaId, levelId: nextAreaId } : {}),
|
|
...(nextAt !== undefined ? { at: nextAt, x: nextAt.x, y: nextAt.y } : {}),
|
|
partyState,
|
|
hostile,
|
|
lootPermit,
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'Trade': {
|
|
const action =
|
|
ev.kind === 'accepted' || ev.kind === 'partnerAccept'
|
|
? 0x78
|
|
: ev.kind === 'gold' && ev.action === undefined
|
|
? 0x7900 | (ev.isSelf ? 1 : 0)
|
|
: (ev.action ?? 0)
|
|
const gold = ev.gold ?? ev.amount
|
|
this._trade.lastAction = action
|
|
if (action === BUTTON_ACTION_OPEN_STASH) {
|
|
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
|
|
break
|
|
}
|
|
if (ev.partnerId !== undefined) {
|
|
this._trade.partnerId = ev.partnerId
|
|
}
|
|
if (ev.partnerName !== undefined) {
|
|
this._trade.partnerName = ev.partnerName
|
|
}
|
|
if (gold !== undefined && (action & 0xff00) === 0x7900) {
|
|
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
|
|
if ((action & 0xff) !== 0) {
|
|
this._trade.selfGold = gold
|
|
} else {
|
|
this._trade.partnerGold = gold
|
|
}
|
|
break
|
|
}
|
|
// 0x92 ClearDisplay
|
|
if (action === 0x92 || (action & 0xff00) === 0x9200) {
|
|
this._containers.clearTrade()
|
|
break
|
|
}
|
|
// 0x78 TradeAccepted
|
|
if (action === 0x78) {
|
|
this._trade.active = true
|
|
this._trade.partnerAccepted = true
|
|
break
|
|
}
|
|
// 0x77 ButtonActions
|
|
if (
|
|
action === 0x02 ||
|
|
action === 0x03 ||
|
|
action === 0x04 ||
|
|
action === 0x06 ||
|
|
action === 0x0c ||
|
|
action === 0x0d
|
|
) {
|
|
this._trade.active = false
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
this._trade.acceptDisabled = false
|
|
this._trade.partnerGold = 0
|
|
this._trade.selfGold = 0
|
|
this._containers.clearTrade()
|
|
} else if (action === 0x00 || action === 0x01) {
|
|
this._trade.active = true
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
this._trade.acceptDisabled = false
|
|
} else if (action === 0x05) {
|
|
if (this._trade.acceptDisabled) {
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
} else {
|
|
this._trade.partnerAccepted = true
|
|
}
|
|
} else if (action === 0x09 || action === 0x07 || action === 0x08) {
|
|
this._trade.acceptDisabled = true
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
} else if (action === 0x0a) {
|
|
this._trade.acceptDisabled = false
|
|
}
|
|
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
|
|
const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined
|
|
this._merc =
|
|
ev.mercId === 0
|
|
? null
|
|
: {
|
|
mercId: ev.mercId,
|
|
ownerId: ev.ownerId ?? this.selfId,
|
|
classId: ev.classId ?? this._merc?.classId ?? 0,
|
|
...(ev.nameId !== undefined
|
|
? { nameId: ev.nameId }
|
|
: this._merc?.nameId !== undefined
|
|
? { nameId: this._merc.nameId }
|
|
: {}),
|
|
exp: this._merc?.exp,
|
|
lifePct: mercUnit?.lifePct ?? 128,
|
|
isDead: false,
|
|
reviveCost: 0,
|
|
stats: this._merc?.stats ?? new Map<number, number>(),
|
|
hireList: this._mercHireList,
|
|
}
|
|
}
|
|
} else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) {
|
|
this._merc.exp = ev.value
|
|
} else if (ev.kind === 'stat' || ev.kind === 'attr' || ev.kind === 'reviveCost') {
|
|
const statId = ev.stat ?? ev.statId
|
|
const revNameId = ev.nameId ?? ev.deadNameId
|
|
const revCost = ev.reviveCost ?? ev.value
|
|
if (ev.mercId !== undefined && statId !== undefined && ev.value !== undefined) {
|
|
const mercUnit = this.ensureUnit('monster', ev.mercId)
|
|
mercUnit.stats.set(statId, ev.value)
|
|
if (this._merc && this._merc.mercId === ev.mercId) {
|
|
this._merc.stats.set(statId, ev.value)
|
|
}
|
|
} else if (ev.mercId === undefined && revNameId !== undefined && revCost !== undefined) {
|
|
// 0x9B MercReviveCost
|
|
if (!this._merc) {
|
|
this._merc = {
|
|
mercId: 0,
|
|
ownerId: this.selfId,
|
|
classId: 0,
|
|
nameId: revNameId,
|
|
lifePct: 0,
|
|
isDead: true,
|
|
reviveCost: revCost,
|
|
deadNameId: revNameId,
|
|
reviveNameId: revNameId,
|
|
stats: new Map<number, number>(),
|
|
hireList: this._mercHireList,
|
|
}
|
|
} else {
|
|
this._merc.nameId = revNameId
|
|
this._merc.deadNameId = revNameId
|
|
this._merc.reviveNameId = revNameId
|
|
this._merc.reviveCost = revCost
|
|
this._merc.lifePct = 0
|
|
this._merc.isDead = true
|
|
}
|
|
}
|
|
} else if (
|
|
ev.kind === 'hireList' ||
|
|
ev.kind === 'forHireListStart' ||
|
|
ev.kind === 'hireListStart' ||
|
|
ev.kind === 'forHire'
|
|
) {
|
|
if (ev.nameId === undefined || ev.kind === 'forHireListStart' || ev.kind === 'hireListStart') {
|
|
// 0x4F StartMercList
|
|
this._mercHireList.length = 0
|
|
} else {
|
|
// 0x4E MercForHire
|
|
this._mercHireList.push({
|
|
nameId: ev.nameId,
|
|
seed: ev.seed ?? ev.value ?? 0,
|
|
})
|
|
}
|
|
}
|
|
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._isSelfDead = false
|
|
this._self.reset()
|
|
this._merc = null
|
|
this._mercHireList.length = 0
|
|
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.partnerName = undefined
|
|
this._trade.partnerGold = 0
|
|
this._trade.selfGold = 0
|
|
this._trade.selfAccepted = false
|
|
this._trade.partnerAccepted = false
|
|
this._trade.acceptDisabled = false
|
|
this._npcInteraction = { npcId: null, window: 'none', activeWindow: 'none', stashOpen: false }
|
|
this._lastNpcTransaction = null
|
|
this._chatLog.length = 0
|
|
this._soundQueue.length = 0
|
|
this._portals.clear()
|
|
}
|
|
|
|
clear(): void {
|
|
this.reset()
|
|
}
|
|
}
|