661 lines
19 KiB
TypeScript
661 lines
19 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 {
|
|
unitTypeFromId,
|
|
type CharClassId,
|
|
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
|
|
}
|
|
|
|
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 _containers = new ClientInventoryManager()
|
|
private readonly _revealedRooms = new Set<string>()
|
|
private readonly _waypoints = new Set<number>()
|
|
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 readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
|
|
|
onChange(cb: (ev: ServerEvent) => void): () => void {
|
|
this._changeListeners.add(cb)
|
|
return () => {
|
|
this._changeListeners.delete(cb)
|
|
}
|
|
}
|
|
|
|
get self(): SelfPlayerState {
|
|
return this._self
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
get waypoints(): ReadonlySet<number> {
|
|
return this._waypoints
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
apply(ev: ServerEvent): void {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 (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': {
|
|
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': {
|
|
if (ev.goldRemaining !== undefined) {
|
|
this._self.applyStat(14, ev.goldRemaining)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'WaypointMenu': {
|
|
this._waypoints.clear()
|
|
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.partnerId !== undefined) {
|
|
this._trade.partnerId = ev.partnerId
|
|
}
|
|
if (ev.gold !== undefined) {
|
|
this._trade.partnerGold = ev.gold
|
|
this._trade.selfGold = 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._containers.clear()
|
|
this._revealedRooms.clear()
|
|
this._waypoints.clear()
|
|
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()
|
|
}
|
|
}
|