diablo2-web/src/client/ui-model/hud-model.ts

2094 lines
76 KiB
TypeScript

/**
* HUD Model Bridge (`src/client/ui-model/hud-model.ts`).
*
* Bridges authoritative `ClientWorld` state (`SelfState`, `ContainerStore`, `ClientUnit`s,
* `QuestState`, `WaypointMenu`), `LocalMovementPredictor`, and `AutomapView` into `HudManager`
* (`src/ui/hud-manager.ts`), and translates HUD panel interactions into protocol-neutral
* `ClientCommand` messages.
*
* Zero imports from `src/netproto/{bncs,mcp,d2gs,crypto}/**` — consumes only `src/netproto/index.ts`.
*/
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts'
import {
CANONICAL_ELITE_MODIFIERS,
FIXED_UNIQUE_BOSS_HCIDX_SET,
applyMonsterUModResistances,
getMonsterKindByHcIdx,
getSuperUniqueByIndex,
monsterDisplayName,
rollUniqueMonsterNameKeys,
type Difficulty,
} from '../../common/units/monsters-data.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { ClickButtonId } from '../../netproto/index.ts'
import type {
CharClassId,
ClientCommand,
Hand,
ItemData,
} from '../../netproto/index.ts'
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
import type { BaseStatKey } from '../ui/character-sheet.ts'
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
import {
type EquipSlotId,
type GridPlacement,
type UiInventoryItem,
} from '../ui/inventory.ts'
import {
GAMBLE_NPC_CLASS_IDS,
MONSTER_UMOD_TBL_KEYS,
lookupMonsterTblString,
type MonsterTargetHudInfo,
} from '../ui/world-panels.ts'
import { requireTbl } from '../i18n/lang.ts'
import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts'
import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts'
import type { AutomapView } from '../automap/automap-view.ts'
import type { LocalMovementPredictor } from '../motion/predictor.ts'
import type { ClientUnit } from '../world/client-unit.ts'
import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts'
import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts'
import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts'
import {
CANONICAL_CHI_TBL_DICT,
CANONICAL_ENG_TBL_DICT,
} from '../../common/data/canonical-tbl-strings.ts'
import {
resolveMercPortraitIcon,
resolvePlayerClassPortraitIcon,
resolvePetPortraitInfo,
} from '../../common/units/pet-type-table.ts'
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
0: 'ama',
1: 'sor',
2: 'nec',
3: 'pal',
4: 'bar',
5: 'dru',
6: 'ass',
} as const
export const CLASS_ID_TO_NAMES: Readonly<
Record<CharClassId, { readonly en: string; readonly zh: string }>
> = {
0: { en: CANONICAL_ENG_TBL_DICT['Amazon'] ?? 'Amazon', zh: CANONICAL_CHI_TBL_DICT['Amazon'] ?? '亞馬遜' },
1: { en: CANONICAL_ENG_TBL_DICT['Sorceress'] ?? 'Sorceress', zh: CANONICAL_CHI_TBL_DICT['Sorceress'] ?? '法師' },
2: { en: CANONICAL_ENG_TBL_DICT['Necromancer'] ?? 'Necromancer', zh: CANONICAL_CHI_TBL_DICT['Necromancer'] ?? '死靈法師' },
3: { en: CANONICAL_ENG_TBL_DICT['Paladin'] ?? 'Paladin', zh: CANONICAL_CHI_TBL_DICT['Paladin'] ?? '聖騎士' },
4: { en: CANONICAL_ENG_TBL_DICT['Barbarian'] ?? 'Barbarian', zh: CANONICAL_CHI_TBL_DICT['Barbarian'] ?? '野蠻人' },
5: { en: CANONICAL_ENG_TBL_DICT['Druid'] ?? 'Druid', zh: CANONICAL_CHI_TBL_DICT['Druid'] ?? '德魯依' },
6: { en: CANONICAL_ENG_TBL_DICT['Assassin'] ?? 'Assassin', zh: CANONICAL_CHI_TBL_DICT['Assassin'] ?? '刺客' },
} as const
export const BODY_LOC_TO_EQUIP_SLOT: Readonly<Record<number, EquipSlotId>> = {
1: 'helm',
2: 'amulet',
3: 'armor',
4: 'weapon1',
5: 'weapon2',
6: 'ring1',
7: 'ring2',
8: 'belt',
9: 'boots',
10: 'gloves',
} as const
export const EQUIP_SLOT_TO_BODY_LOC: Readonly<Record<EquipSlotId, number>> = {
helm: 1,
amulet: 2,
armor: 3,
weapon1: 4,
weapon2: 5,
ring1: 6,
ring2: 7,
belt: 8,
boots: 9,
gloves: 10,
} as const
export const STAT_KEY_TO_D2_STAT_ID: Readonly<Record<BaseStatKey, number>> = {
str: D2StatId.Strength,
ene: D2StatId.Energy,
dex: D2StatId.Dexterity,
vit: D2StatId.Vitality,
} as const
/**
* Converts a decoded `ItemData` record (`0x9C` / `0x9D`) from `ClientWorld` into a canonical 1.13c `UiInventoryItem`
* suitable for rendering and interaction inside `HudManager` (`InventoryPanel`, `WorldPanelsHud`, `BeltHud`).
*/
export function itemDataToUiInventoryItem(
item: ItemData,
socketChildren?: readonly ItemData[],
): UiInventoryItem {
return onlineItemDataToUiInventoryItem(item, socketChildren)
}
export interface HudModelOptions {
readonly world: ClientWorld
readonly hudManager?: HudManager | null | undefined
readonly predictor?: LocalMovementPredictor | null | undefined
readonly automap?: AutomapView | null | undefined
readonly onCommand?: ((cmd: ClientCommand) => void) | undefined
/** Store price provider (D2Common #10107); required before a store opens. Production: `vendorPrice113c`. */
readonly vendorPrice?: VendorPriceFn | undefined
/** Repair-all cost provider (D2Common #10071); required before a store opens. Production: `vendorRepairAll113c`. */
readonly vendorRepairAll?: VendorRepairAllFn | undefined
/** UI sound sink (sell sound 0xdd); required before a store opens. */
readonly playUiSound?: ((soundId: number) => void) | undefined
/** Clock for the store click throttle (ms); defaults to `performance.now`. */
readonly now?: (() => number) | undefined
}
/** D2Client store click throttle: one buy / sell / repair request per action every 2000 ms. */
export const NPC_TRADE_THROTTLE_MS = 2000
/**
* S2C `0x2A` NPCTransaction result -> `string.tbl` key of the message D2Client shows (indices 3356..3363).
* Results 0 (buy), 1 (sell) and 2 (repair all) succeed silently; any other unknown code shows TransactionResults9.
*/
export function npcTransactionMessageKey(result: number): string | null {
switch (result) {
case 0:
case 1:
case 2:
return null
case 7:
case 8:
return 'TransactionResults6' // 3358
case 10:
return 'TransactionResults4' // 3356
case 11:
return 'TransactionResults8' // 3360
case 12:
return 'TransactionResults10' // 3362
case 15:
return 'TransactionResults11' // 3363
case 16:
return 'TransactionResults5' // 3357
default:
return 'TransactionResults9' // 3361
}
}
/** Sound D2Client plays on a successful sell (S2C 0x2A result 1). */
export const NPC_SELL_SOUND_ID = 0xdd
/** `UnitType` id of monsters / NPCs in `UnitRef`. */
const UNIT_TYPE_MONSTER = 1
const NPC_STATE_CLOSED: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
export interface HudModelSnapshot {
readonly charName: string
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
readonly charClass: CharClassId | null
readonly classCode: CharacterClassCode | null
readonly level: number
readonly xp: number
readonly hp: number
readonly maxHp: number
readonly mana: number
readonly maxMana: number
readonly stamina: number
readonly maxStamina: number
readonly str: number
readonly dex: number
readonly vit: number
readonly ene: number
readonly statPoints: number
readonly skillPoints: number
readonly gold: number
readonly stashGold: number
readonly leftSkillId: number
readonly rightSkillId: number
readonly isRunning: boolean
readonly weaponSet: 0 | 1
readonly inventoryCount: number
readonly equippedCount: number
readonly beltCount: number
readonly stashCount: number
readonly cubeCount: number
readonly cursorItemId: number | null
readonly unlockedWaypoints: readonly number[]
readonly panels: Readonly<Record<string, boolean>>
}
/**
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
* `ClientCommand` actions triggered by user HUD interactions.
*/
export class HudModel implements HudCommandSink {
readonly world: ClientWorld
hudManager: HudManager | null
predictor: LocalMovementPredictor | null
automap: AutomapView | null
onCommand?: ((cmd: ClientCommand) => void) | undefined
private unsubWorld: (() => void) | null = null
private hoveredMonsterId: number | null = null
private escapeMenuOpen = false
private readonly priceProvider: VendorPriceFn | null
private readonly repairAllProvider: VendorRepairAllFn | null
private readonly uiSound: ((soundId: number) => void) | null
private readonly now: () => number
/** NPC/stash state seen by the last sync; panels react to transitions only. */
private prevNpc: NpcInteractionState = NPC_STATE_CLOSED
private prevTradeActive = false
/** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */
private handledNpcTransactionSeq: number
private readonly lastNpcActionAt = new Map<string, number>()
/** Message shown for the last non-success S2C 0x2A (string.tbl index + localized text). */
lastNpcTransactionMessage: { readonly tblKey: string; readonly text: string } | null = null
constructor(options: HudModelOptions) {
this.world = options.world
this.hudManager = options.hudManager ?? null
this.predictor = options.predictor ?? null
this.automap = options.automap ?? null
this.onCommand = options.onCommand
this.priceProvider = options.vendorPrice ?? null
this.repairAllProvider = options.vendorRepairAll ?? null
this.uiSound = options.playUiSound ?? null
this.now = options.now ?? (() => performance.now())
this.handledNpcTransactionSeq = this.world.lastNpcTransaction?.seq ?? 0
if (this.hudManager) {
this.bindHudCallbacks(this.hudManager)
}
this.unsubWorld = this.world.onChange(() => {
this.syncFromWorld()
})
}
get hud(): HudManager | null {
return this.hudManager
}
set hud(hudManager: HudManager | null) {
this.attachHudManager(hudManager)
}
private bindHudCallbacks(hud: HudManager): void {
hud.commandSink = this
hud.onPlayUiSound = (soundId) => {
this.uiSound?.(soundId)
}
;(hud as any).onWeaponSwitch = () => {
this.swapWeapons()
}
;(hud as any).onMercEquipSlotClick = (slot: number) => this.mercItem(slot)
;(hud as any).onHireMercClick = (npcId: number, mercId: number) =>
this.hireMerc(
npcId ||
this.world.npcInteraction.npcId ||
(hud.worldPanels as any)?.activeNpcId ||
(hud.worldPanels as any)?.vendorNpcId ||
0,
mercId,
)
;(hud as any).onPartyActionClick = (action: number, targetPlayerId: number) =>
this.partyAction(action, targetPlayerId)
;(hud as any).onHostileToggleClick = (targetPlayerId: number, hostile: boolean, button?: number) =>
this.setHostile(targetPlayerId, hostile, button ?? 1)
;(hud as any).onTradeButtonClick = (buttonId: number, complement?: number) =>
this.clickButton(buttonId, complement ?? 0)
;(hud as any).onConfirmQuestRewardClick = (itemId: number, npcId?: number) =>
this.confirmQuestReward(itemId, npcId)
const wp = hud.worldPanels as any
wp.onQuestMessage = (npcId: number, messageId = 0) => this.questMessage(npcId, messageId)
wp.onNpcEntityAction = (action: number, npcId: number, complement?: number) =>
this.npcEntityAction(action, npcId, complement ?? 0)
wp.onHireMerc = (npcId: number, mercId: number) =>
this.hireMerc(
npcId ||
this.world.npcInteraction.npcId ||
wp.activeNpcId ||
wp.vendorNpcId ||
0,
mercId,
)
wp.onResurrectMerc = (npcId?: number) => this.resurrectMerc(npcId)
wp.onMercItem = (slot: number) => this.mercItem(slot)
wp.onPartyAction = (action: number, targetPlayerId: number) => this.partyAction(action, targetPlayerId)
wp.onHostile = (targetPlayerId: number, hostile: boolean, button?: number) =>
this.setHostile(targetPlayerId, hostile, button ?? 1)
wp.onSetHostile = (targetPlayerId: number, hostile: boolean, button?: number) =>
this.setHostile(targetPlayerId, hostile, button ?? 1)
wp.onClickButton = (buttonId: number, complement?: number) => this.clickButton(buttonId, complement ?? 0)
wp.onConfirmQuestReward = (itemId: number, npcId?: number) =>
this.confirmQuestReward(itemId, typeof npcId === 'number' ? npcId : undefined)
wp.onMercEquipSlotClick = (slot: number) => this.mercItem(slot)
wp.onHireMercClick = (npcId: number, mercId: number) =>
this.hireMerc(
npcId ||
(this.world.npcInteraction.npcId ?? 0) ||
wp.activeNpcId ||
wp.vendorNpcId ||
0,
mercId,
)
wp.onPartyActionClick = (action: number, targetPlayerId: number) =>
this.partyAction(action, targetPlayerId)
wp.onHostileToggleClick = (targetPlayerId: number, hostile: boolean, button?: 1 | 2) =>
this.setHostile(targetPlayerId, hostile, button ?? 1)
wp.onTradeButtonClick = (buttonId: number, complement = 0) =>
this.clickButton(buttonId, complement)
wp.onConfirmQuestRewardClick = (itemId: number, npcIdOrPanel?: number | string) =>
this.confirmQuestReward(
itemId,
typeof npcIdOrPanel === 'number' ? npcIdOrPanel : undefined,
)
wp.onNpcMenuAction = (actionId: string, npcId?: number, descriptor?: TownNpcServiceDescriptor) =>
this.handleNpcMenuAction(actionId, npcId, descriptor)
wp.onCloseNpcPanel = () => this.closeActiveNpcPanel()
}
/**
* Detach the world listener when disposing the session.
*/
dispose(): void {
if (this.hudManager?.commandSink === this) {
this.hudManager.commandSink = null
this.hudManager.onPlayUiSound = undefined
}
if (this.unsubWorld) {
this.unsubWorld()
this.unsubWorld = null
}
}
/**
* Bind or replace the active `HudManager` instance and synchronize immediately.
*/
attachHudManager(hudManager: HudManager | null): void {
if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) {
this.hudManager.commandSink = null
this.hudManager.onPlayUiSound = undefined
}
this.hudManager = hudManager
if (hudManager) {
this.bindHudCallbacks(hudManager)
hudManager.escapeMenuOpen = this.escapeMenuOpen
this.syncFromWorld()
}
}
/**
* Convert a `ClientWorld` `ItemData` into a canonical 1.13c `UiInventoryItem` with its socketed children.
*/
toUiItem(item: ItemData): UiInventoryItem {
const ui = itemDataToUiInventoryItem(item, this.world.containers.sockets.get(item.id))
;(ui as any).gridX = (ui as any).gridX ?? item.gridX ?? item.x ?? (ui as any).col ?? 0
;(ui as any).gridY = (ui as any).gridY ?? item.gridY ?? item.y ?? (ui as any).row ?? 0
return ui
}
/**
* Whether the player currently carries any unidentified item across inventory, equipment, stash, or cube.
*/
hasUnidentifiedItems(): boolean {
for (const item of this.world.containers.inventory.values()) {
if (item.identified === false) return true
}
for (const item of this.world.containers.equipment.values()) {
if (item.identified === false) return true
}
for (const item of this.world.containers.stash.values()) {
if (item.identified === false) return true
}
for (const item of this.world.containers.cube.values()) {
if (item.identified === false) return true
}
return false
}
/**
* Build a pure snapshot of the HUD state derived from `ClientWorld`.
*/
getSnapshot(): HudModelSnapshot {
const self = this.world.self
const stats = self.stats
const classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
const invItems = [...this.world.containers.inventory.values()]
const eqItems = [...this.world.containers.equipment.values()]
const beltItems = [...this.world.containers.belt.values()]
const stashItems = [...this.world.containers.stash.values()]
const cubeItems = [...this.world.containers.cube.values()]
const cursorItem = this.world.containers.cursor
const leftPanel = (this.hudManager?.leftPanel ?? 'none') as string
const rightPanel = (this.hudManager?.rightPanel ?? 'none') as string
const escapeMenuOpen = this.hudManager ? this.hudManager.escapeMenuOpen : this.escapeMenuOpen
this.escapeMenuOpen = escapeMenuOpen
return {
charName: self.name,
charClass: self.charClass,
classCode,
level: self.level,
xp: self.experience,
hp: self.life,
maxHp: self.maxLife,
mana: self.mana,
maxMana: self.maxMana,
stamina: self.stamina,
maxStamina: self.maxStamina,
str: stats.get(D2StatId.Strength),
dex: stats.get(D2StatId.Dexterity),
vit: stats.get(D2StatId.Vitality),
ene: stats.get(D2StatId.Energy),
statPoints: stats.get(D2StatId.StatPts),
skillPoints: stats.get(D2StatId.NewSkills),
gold: stats.get(D2StatId.Gold),
stashGold: stats.get(D2StatId.GoldBank),
leftSkillId: self.leftSkill,
rightSkillId: self.rightSkill,
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
weaponSet: self.weaponSet,
inventoryCount: invItems.length,
equippedCount: eqItems.length,
beltCount: beltItems.length,
stashCount: stashItems.length,
cubeCount: cubeItems.length,
cursorItemId: cursorItem ? cursorItem.id : null,
unlockedWaypoints: [...this.world.waypoints].sort((a, b) => a - b),
panels: {
inventory: rightPanel === 'inv' || rightPanel === 'inventory',
inv: rightPanel === 'inv' || rightPanel === 'inventory',
skill: rightPanel === 'skill',
skillTree: rightPanel === 'skill',
char: leftPanel === 'char',
character: leftPanel === 'char',
quest: leftPanel === 'quest',
questLog: leftPanel === 'quest',
waypoint: leftPanel === 'waypoint',
party: leftPanel === 'party' || leftPanel === 'waypoint',
merc: leftPanel === 'merc',
hire: leftPanel === 'hire',
trade: leftPanel === 'trade',
imbue: leftPanel === 'imbue',
add_sockets: leftPanel === 'add_sockets',
personalize: leftPanel === 'personalize',
stash: leftPanel === 'stash',
cube: leftPanel === 'cube',
vendor: leftPanel === 'vendor',
automap: this.automap ? this.automap.getMode() !== 'off' : false,
escapeMenu: escapeMenuOpen,
},
}
}
get isRunMode(): boolean {
return this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true)
}
get leftSkillId(): number {
return this.world.self.leftSkill
}
get rightSkillId(): number {
return this.world.self.rightSkill
}
/**
* Alias for `syncFromWorld()`.
*/
sync(): void {
this.syncFromWorld()
}
/**
* Toggle a left or right HUD panel (`inventory`, `character`, `skillTree`, `quest`/`questLog`,
* `waypoint`/`party`, `merc`, `automap`, `escapeMenu`).
*/
togglePanel(panel: string): boolean {
if (panel === 'escapeMenu') {
if (this.hudManager) {
this.escapeMenuOpen = this.hudManager.toggleEscapeMenu()
return this.escapeMenuOpen
}
this.escapeMenuOpen = !this.escapeMenuOpen
return this.escapeMenuOpen
}
if (panel === 'automap') {
this.toggleAutomap()
return this.automap ? this.automap.getMode() !== 'off' : false
}
if (!this.hudManager) return false
switch (panel) {
case 'inventory':
case 'inv':
this.hudManager.toggleRightPanel('inv')
return this.hudManager.rightPanel === 'inv'
case 'skill':
case 'skillTree':
this.hudManager.toggleRightPanel('skill')
return this.hudManager.rightPanel === 'skill'
case 'char':
case 'character':
this.hudManager.toggleLeftPanel('char')
return this.hudManager.leftPanel === 'char'
case 'quest':
case 'questLog':
this.hudManager.toggleLeftPanel('quest')
return this.hudManager.leftPanel === 'quest'
case 'waypoint':
this.hudManager.toggleLeftPanel('waypoint')
return this.hudManager.leftPanel === 'waypoint'
case 'party':
this.hudManager.toggleLeftPanel('party' as any)
return (this.hudManager.leftPanel as string) === 'party'
case 'merc':
this.hudManager.toggleLeftPanel('merc' as any)
return (this.hudManager.leftPanel as string) === 'merc'
default:
return false
}
}
closeAllPanels(): void {
this.escapeMenuOpen = false
this.hudManager?.closeAllPanels()
}
/**
* Cycle or toggle Automap reveal mode (`off` -> `level` -> `act` -> `off`).
*/
toggleAutomap(): void {
if (!this.automap) return
const cur = this.automap.getMode()
const next = cur === 'off' ? 'level' : cur === 'level' ? 'act' : 'off'
this.automap.setMode(next)
}
/**
* Synchronize `ClientWorld` state into `HudManager` (vitals, character sheet, skills,
* inventory/equipment/stash/cube/belt containers, waypoints, death, mercenary, party, trade, and NPC state).
*/
syncFromWorld(): void {
const hud = this.hudManager
if (!hud) return
this.bindHudCallbacks(hud)
const self = this.world.self
const stats = self.stats
// 0. Class: not ready until the server assigns it. No class-dependent panel state is synthesized before that.
if (self.charClass !== null) {
hud.setCharacterClass(resolveClassCode(self.charClass))
const classNames = CLASS_ID_TO_NAMES[self.charClass]
hud.charSheet.attrs.classNameEn = classNames.en
hud.charSheet.attrs.classNameZh = classNames.zh
}
// 1. Vitals (globes & stamina bar): verbatim server values (0x95 / stats 6-11), absent = 0.
hud.maxHp = self.maxLife
hud.hp = Math.max(0, Math.min(self.maxLife, self.life))
hud.maxMana = self.maxMana
hud.mana = Math.max(0, Math.min(self.maxMana, self.mana))
hud.maxStamina = self.maxStamina
hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina))
hud.isStaminaBlue = Boolean(self.states.has(136) || self.states.has(180))
// 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c).
const attrs = hud.charSheet.attrs
attrs.name = self.name
attrs.level = self.level
attrs.xp = self.experience
attrs.nextLevelXp = nextLevelExperience(self.level)
attrs.str = stats.get(D2StatId.Strength)
attrs.dex = stats.get(D2StatId.Dexterity)
attrs.vit = stats.get(D2StatId.Vitality)
attrs.ene = stats.get(D2StatId.Energy)
attrs.statPoints = stats.get(D2StatId.StatPts)
attrs.baseMaxHp = self.maxLife
attrs.baseMaxMana = self.maxMana
attrs.baseMaxStamina = self.maxStamina
// Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known.
const derived = self.charClass === null
? null
: computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty, this.isRunMode)
attrs.defense = derived?.defense ?? 0
attrs.attackRating = derived?.attackRating ?? 0
attrs.fireRes = derived?.fireRes ?? 0
attrs.coldRes = derived?.coldRes ?? 0
attrs.lightRes = derived?.lightRes ?? 0
attrs.poisRes = derived?.poisRes ?? 0
// 3. Gold (stat 14 inventory gold, stat 15 stash gold).
hud.inventory.gold = stats.get(D2StatId.Gold)
hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
// 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync.
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
hud.skillTree.characterLevel = self.level
hud.skillTree.hardPoints.clear()
hud.skillTree.bonusLevels.clear()
hud.hotkeys.setAvailableSkills([])
for (const [skillId, sl] of self.skillEntries.entries()) {
const hard = Math.max(0, sl.baseLevel ?? sl.level)
const bonus = Math.max(0, sl.bonus ?? 0)
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
if (isPassiveSkill(skillId)) continue
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) continue
const effSkillLevel = hard + bonus
hud.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
nameZh: catalogEntry.nameZh,
level: effSkillLevel,
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
leftUsable: isLeftUsableSkill(skillId),
rightUsable: true,
isAura: isAuraSkill(skillId),
})
}
hud.hotkeys.leftSkillId = self.leftSkill
hud.hotkeys.rightSkillId = self.rightSkill
hud.inventory.weaponSwapSet = self.weaponSet === 1 ? 1 : 0
// 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
delete hud.inventory.equipped[key]
}
hud.inventory.swapWeapons.weapon1 = null
hud.inventory.swapWeapons.weapon2 = null
for (const item of this.world.containers.equipment.values()) {
const bodyLoc = item.bodyLoc || item.equippedSlot
if (bodyLoc === 11) {
hud.inventory.swapWeapons.weapon1 = this.toUiItem(item)
continue
}
if (bodyLoc === 12) {
hud.inventory.swapWeapons.weapon2 = this.toUiItem(item)
continue
}
const slot = BODY_LOC_TO_EQUIP_SLOT[bodyLoc]
if (slot) {
hud.inventory.equipped[slot] = this.toUiItem(item)
}
}
const placements: GridPlacement[] = [...this.world.containers.inventory.values()].map((item) => ({
item: this.toUiItem(item),
col: item.gridX ?? item.x ?? 0,
row: item.gridY ?? item.y ?? 0,
}))
hud.inventory.gridItems = placements
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
item: this.toUiItem(item),
col: item.gridX ?? item.x ?? 0,
row: item.gridY ?? item.y ?? 0,
}))
hud.worldPanels.cubeItems = [...this.world.containers.cube.values()].map((item) => ({
item: this.toUiItem(item),
col: item.gridX ?? item.x ?? 0,
row: item.gridY ?? item.y ?? 0,
}))
for (let r = 0; r < BELT_ROWS; r++) {
for (let c = 0; c < BELT_COLS; c++) {
hud.belt.grid[r]![c] = null
}
}
for (const item of this.world.containers.belt.values()) {
const slotIndex = item.gridX ?? item.x ?? 0
if (slotIndex < 0 || slotIndex >= BELT_ROWS * BELT_COLS) continue
const row = Math.floor(slotIndex / BELT_COLS)
const col = slotIndex % BELT_COLS
const pot = itemToBeltPotion({ id: String(item.id), code: item.code }) ?? {
id: String(item.id),
code: item.code,
name: item.code.toUpperCase(),
nameZh: item.code.toUpperCase(),
kind: 'hp' as const,
healHp: 0,
healMana: 0,
invFile: 'invpotr',
}
hud.belt.grid[row]![col] = pot
}
const cursorItem = this.world.containers.cursor
hud.inventory.cursorItem = cursorItem ? this.toUiItem(cursorItem) : null
// 5b. Death state
;(hud as any).isDead = this.world.isDead
;(hud.worldPanels as any).isDead = this.world.isDead
// 5c. Mercenary state & equipment (slots 1=head/helm, 3=torso/armor, 4=rightHand/weapon1, 5=leftHand/weapon2)
const findMercSlotItem = (slotNum: number): ItemData | undefined => {
const bySlot = this.world.containers.mercBySlot?.get(slotNum) ?? this.world.containers.merc.get(slotNum)
if (bySlot && (bySlot.bodyLoc || bySlot.equippedSlot || slotNum) === slotNum) {
return bySlot
}
for (const it of this.world.containers.merc.values()) {
if ((it.bodyLoc || it.equippedSlot) === slotNum) return it
}
return undefined
}
const rawMerc1 = findMercSlotItem(1)
const rawMerc3 = findMercSlotItem(3)
const rawMerc4 = findMercSlotItem(4)
const rawMerc5 = findMercSlotItem(5)
const mapMercNumericItem = (it: ItemData | undefined) =>
it ? { ...this.toUiItem(it), id: it.id as unknown as string } : null
const mapMercStringItem = (it: ItemData | undefined) =>
it ? { ...this.toUiItem(it), id: String(it.id) } : null
const mercEquipped = {
head: mapMercNumericItem(rawMerc1),
helm: mapMercNumericItem(rawMerc1),
torso: mapMercNumericItem(rawMerc3),
armor: mapMercNumericItem(rawMerc3),
rightHand: mapMercNumericItem(rawMerc4),
weapon1: mapMercNumericItem(rawMerc4),
leftHand: mapMercNumericItem(rawMerc5),
weapon2: mapMercNumericItem(rawMerc5),
}
const mercHeadUi = mapMercStringItem(rawMerc1)
const mercTorsoUi = mapMercStringItem(rawMerc3)
const mercRightUi = mapMercStringItem(rawMerc4)
const mercLeftUi = mapMercStringItem(rawMerc5)
const mercEquipment = {
head: mercHeadUi,
helm: mercHeadUi,
torso: mercTorsoUi,
armor: mercTorsoUi,
rightHand: mercRightUi,
weapon1: mercRightUi,
leftHand: mercLeftUi,
weapon2: mercLeftUi,
}
const buildMercStats = (rawStats: unknown) => {
const map = rawStats instanceof Map ? new Map(rawStats) : new Map<number, number>()
const getStat = (id: number, key: string, fallback = 0): number => {
if (rawStats instanceof Map && rawStats.has(id)) return Number(rawStats.get(id)) || fallback
if (rawStats && typeof rawStats === 'object' && key in (rawStats as Record<string, unknown>)) {
const val = Number((rawStats as Record<string, unknown>)[key])
if (Number.isFinite(val)) return val
}
return fallback
}
return Object.assign(map, {
strength: getStat(0, 'strength', 0),
dexterity: getStat(2, 'dexterity', 0),
damageMin: getStat(21, 'damageMin', 0),
damageMax: getStat(22, 'damageMax', 0),
defense: getStat(31, 'defense', 0),
fireResist: getStat(39, 'fireResist', 0),
lightResist: getStat(41, 'lightResist', 0),
coldResist: getStat(43, 'coldResist', 0),
poisonResist: getStat(45, 'poisonResist', 0),
level: getStat(12, 'level', 1),
})
}
const mercRaw = this.world.merc as any
const mercStats = buildMercStats(mercRaw?.stats)
const mercLevel = Number(mercRaw?.level ?? mercStats.get(12) ?? mercStats.level ?? 0)
const mercMaxHp = Number(mercRaw?.maxLife ?? mercRaw?.maxHp ?? mercStats.get(7) ?? 0)
const mercLifePct = Number(mercRaw?.lifePct ?? 0)
const mercHp = Number(
mercRaw?.life ??
mercRaw?.hp ??
mercStats.get(6) ??
Math.max(0, Math.round((Math.max(0, mercLifePct) / 100) * mercMaxHp)),
)
const mercName =
mercRaw?.name ||
(mercRaw?.nameId !== undefined && mercRaw.nameId > 0
? `Merc #${mercRaw.nameId}`
: mercRaw?.mercId
? `Merc #${mercRaw.mercId}`
: '')
const mercCommonFields = {
name: mercName,
level: mercLevel,
hp: Number.isFinite(mercHp) ? mercHp : 0,
maxHp: Number.isFinite(mercMaxHp) ? mercMaxHp : 0,
life: Number.isFinite(mercHp) ? mercHp : 0,
maxLife: Number.isFinite(mercMaxHp) ? mercMaxHp : 0,
lifePct: Number.isFinite(mercLifePct) ? mercLifePct : 0,
experience: Number(mercRaw?.experience ?? mercStats.get(13) ?? 0) || 0,
strength: Number(mercRaw?.strength ?? mercStats.get(0) ?? mercStats.strength ?? 0),
dexterity: Number(mercRaw?.dexterity ?? mercStats.get(2) ?? mercStats.dexterity ?? 0),
minDamage: Number(mercRaw?.minDamage ?? mercStats.get(21) ?? mercStats.damageMin ?? 0),
maxDamage: Number(mercRaw?.maxDamage ?? mercStats.get(22) ?? mercStats.damageMax ?? 0),
defense: Number(mercRaw?.defense ?? mercStats.get(31) ?? mercStats.defense ?? 0),
fireResist: Number(mercRaw?.fireResist ?? mercStats.get(39) ?? mercStats.fireResist ?? 0),
lightResist: Number(mercRaw?.lightResist ?? mercStats.get(41) ?? mercStats.lightResist ?? 0),
coldResist: Number(mercRaw?.coldResist ?? mercStats.get(43) ?? mercStats.coldResist ?? 0),
poisonResist: Number(mercRaw?.poisonResist ?? mercStats.get(45) ?? mercStats.poisonResist ?? 0),
stats: mercStats,
headItem: mercHeadUi,
bodyItem: mercTorsoUi,
weaponItem: mercRightUi,
shieldItem: mercLeftUi,
helm: mercHeadUi,
armor: mercTorsoUi,
weapon1: mercRightUi,
weapon2: mercLeftUi,
equipment: mercEquipment,
equipped: mercEquipped,
}
const mercIconKey = resolveMercPortraitIcon(this.world.merc?.classId ?? mercRaw?.classId)
const syncedMerc = this.world.merc
? {
...this.world.merc,
unitId: mercRaw.unitId ?? this.world.merc.mercId,
iconKey: mercIconKey,
...mercCommonFields,
}
: null
;(hud as any).merc = syncedMerc
;(hud.worldPanels as any).merc = syncedMerc
// 5c2. Summoned Minions (PetType.txt & Hireables.cpp)
const summonPortraits: Array<{
readonly petType: number
readonly iconKey: string
readonly label: string
readonly count: number
readonly lifePct: number
readonly drawHp: boolean
readonly iconType: number
}> = []
if (this.world.pets && this.world.pets.size > 0) {
const activePets = [...this.world.pets.values()].filter(
p => !p.isDead && (p.ownerId === this.world.selfId || this.world.selfId === 0),
)
const byType = new Map<number, typeof activePets>()
for (const p of activePets) {
if (p.petType === 7) continue // Mercenary handled separately
const arr = byType.get(p.petType) ?? []
arr.push(p)
byType.set(p.petType, arr)
}
for (const [petTypeIdx, pets] of byType.entries()) {
const first = pets[0]!
const portInfo = resolvePetPortraitInfo(petTypeIdx, first.classId)
if (!portInfo) continue
const count = pets.length
const avgLifePct = Math.round(pets.reduce((sum, p) => sum + (p.lifePct ?? 100), 0) / count)
summonPortraits.push({
petType: petTypeIdx,
iconKey: portInfo.iconKey,
label: ((hud as any).lang === 'CHI' || (hud as any)._lang === 'CHI') ? portInfo.nameZh : portInfo.nameEn,
count,
lifePct: avgLifePct,
drawHp: portInfo.meta.drawHp,
iconType: portInfo.meta.iconType,
})
}
}
;(hud as any).summonPortraits = summonPortraits
const rawHireList =
(this.world.mercHireList && this.world.mercHireList.length > 0
? this.world.mercHireList
: (this.world.merc as any)?.hireList) ?? []
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
const difficulty = this.world.difficulty ?? 0
const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined)
const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0
const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI'
const hireCandidates = resolveHirelingCandidates(
rawHireList,
playerLevel,
difficulty,
actIndex,
this.world.expansion ?? true,
hireLang,
)
;(hud as any).hireList = hireCandidates
;(hud as any).mercHireList = hireCandidates
;(hud.worldPanels as any).hireList = hireCandidates
;(hud.worldPanels as any).mercHireList = hireCandidates
;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0
;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0
;(hud.worldPanels as any).hireMercPanel = {
open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire',
candidates: hireCandidates,
}
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 0
;(hud.worldPanels as any).hasDeadMercenary = hasDeadMercenary
;(hud.worldPanels as any).deadMercenaryReviveCost = deadMercenaryReviveCost
;(hud.worldPanels as any).hasUnidentifiedItems = this.hasUnidentifiedItems()
// 5d. Party state
const partyList = [...this.world.party.values()]
.filter((m: any) => {
const pid = m.unitId ?? m.playerId ?? 0
if (pid === 0) return false
if (this.world.selfId !== 0 && pid === this.world.selfId) return false
return true
})
.map((m: any) => {
const pid = m.unitId ?? m.playerId ?? 0
const resolvedName = m.name || m.charName || `Player #${pid}`
const resolvedLevel = m.level ?? m.charLevel ?? 1
const resolvedPartyState =
m.partyState ??
(typeof m.relationship === 'string' && m.relationship.length > 0
? m.relationship
: m.hostile
? 'hostile'
: m.partyId !== undefined && m.partyId !== 0xffff
? 'in_party'
: 'none')
const resolvedRelationship =
typeof m.relationship === 'string' && m.relationship.length > 0
? m.relationship
: resolvedPartyState
const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed)
return {
unitId: pid,
playerId: pid,
name: resolvedName,
charName: resolvedName,
charClass: m.charClass ?? 0,
portraitIcon: resolvePlayerClassPortraitIcon(m.charClass),
level: resolvedLevel,
charLevel: resolvedLevel,
partyId: m.partyId ?? 0xffff,
relationship: resolvedRelationship,
partyState: resolvedPartyState,
hostile: Boolean(m.hostile),
lootPermit: lootFlag,
lootAllowed: lootFlag,
lifePct: m.lifePct ?? 100,
areaId: m.levelId ?? m.areaId ?? 0,
levelId: m.levelId ?? m.areaId ?? 0,
x: m.x ?? 0,
y: m.y ?? 0,
}
})
;(hud as any).partyMembers = partyList
;(hud.worldPanels as any).partyMembers = partyList
// 5e. Player-to-player Trade state
const selfTradeItems = this.world.containers.tradeGrid.map((item) => this.toUiItem(item))
const partnerTradeItems = this.world.containers.partnerTradeGrid.map((item) => this.toUiItem(item))
const selfGold = Number((this.world.trade as any).selfGold ?? (this.world.trade as any).myGold ?? 0) || 0
const partnerGold = Number(this.world.trade.partnerGold ?? 0) || 0
const selfAccepted = Boolean((this.world.trade as any).selfAccepted ?? (this.world.trade as any).myAccepted)
const partnerAccepted = Boolean(this.world.trade.partnerAccepted)
const tradeState = {
...this.world.trade,
selfGold,
myGold: selfGold,
partnerGold,
selfAccepted,
myAccepted: selfAccepted,
partnerAccepted,
selfItems: selfTradeItems,
myItems: selfTradeItems,
partnerItems: partnerTradeItems,
}
;(hud as any).trade = tradeState
;(hud.worldPanels as any).trade = tradeState
if (this.world.trade.active) {
hud.leftPanel = 'trade' as any
;(hud.worldPanels as any).activePanel = 'trade'
hud.rightPanel = 'inv'
hud.inventory.visible = true
} else if (this.prevTradeActive && (hud.leftPanel as string) === 'trade') {
hud.leftPanel = 'none'
;(hud.worldPanels as any).activePanel = 'none'
}
this.prevTradeActive = this.world.trade.active
// 6. Area, Waypoints & Quests
if (this.world.areaId > 0) {
hud.worldPanels.setCurrentLevel(this.world.areaId)
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
}
hud.worldPanels.setUnlockedWaypoints([...this.world.waypoints])
hud.worldPanels.applyServerQuestFlags(this.world.quests)
// 7. Target monster plaque
this.syncHoveredMonsterTarget()
// 8. NPC menu / store / stash panels and S2C 0x2A replies.
this.syncNpcInteraction(hud)
hud.syncPublishedState()
}
private syncNpcInteraction(hud: HudManager): void {
const npc = this.world.npcInteraction
const prev = this.prevNpc
this.prevNpc = npc
const wp = hud.worldPanels
const activeWin = npc.activeWindow ?? npc.window
const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire'
// a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors).
if (
npc.npcId !== null &&
activeWin === 'none' &&
!isHireOpen &&
(npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking))
) {
const desc = this.tryNpcDescriptor(npc.npcId)
if (desc) {
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 0
;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, {
hasUnidentifiedItems: true,
hasDeadMercenary,
deadMercenaryReviveCost,
})
}
}
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {
if (npc.npcId === null || isHireOpen) {
wp.npcMenu = null
}
}
// b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`).
const npcWin = npc.window as string
if ((npcWin === 'trade' || npcWin === 'gamble') && npc.npcId !== null) {
const npcId = npc.npcId
const desc = this.npcDescriptor(npcId)
if (!desc) throw new Error(`HudModel: store opened for NPC #${npcId} without a service descriptor`)
const ctx = this.priceContext(npcId)
const price = this.requireVendorPrice()
const gamble = npcWin === 'gamble'
const items = [...this.world.containers.shop.values()].map((item) => ({
storePage: item.storePage,
placement: {
item: { ...this.toUiItem(item), buyCost: price(item, gamble ? 2 : 0, ctx) },
col: item.gridX,
row: item.gridY,
},
}))
wp.setVendorShop({ npcId, descriptor: desc, mode: npcWin as 'trade' | 'gamble', items })
const canRepair = desc.canRepair && !gamble
wp.vendorRepairAllCost = canRepair ? this.requireVendorRepairAll()(ctx) : null
this.decoratePlayerItemPrices(hud, price, ctx, canRepair)
if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel()
} else if (
npcWin === 'hire' ||
activeWin === 'hire' ||
npcWin === 'imbue' ||
npcWin === 'add_sockets' ||
npcWin === 'personalize'
) {
const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin
hud.leftPanel = targetWin as any
;(wp as any).activePanel = targetWin
if (targetWin === 'hire') {
wp.npcMenu = null
}
if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') {
hud.rightPanel = 'inv'
hud.inventory.visible = true
}
} else if (prev.window !== 'none') {
if (prev.window === 'trade' || prev.window === 'gamble') {
wp.clearVendorShop()
hud.hideLeftPanelFromWorld('vendor')
} else if ((hud.leftPanel as string) === prev.window) {
hud.leftPanel = 'none'
;(wp as any).activePanel = 'none'
}
}
// c. Stash: opened by S2C 0x77 action 0x10, closed when 0x4F 0x12 is sent.
if (npc.stashOpen && !prev.stashOpen) hud.showStashPanel()
else if (!npc.stashOpen && prev.stashOpen) hud.hideLeftPanelFromWorld('stash')
// d. S2C 0x2A NPCTransaction: react once per reply.
const txn = this.world.lastNpcTransaction
if (txn && txn.seq !== this.handledNpcTransactionSeq) {
this.handledNpcTransactionSeq = txn.seq
const msgKey = npcTransactionMessageKey(txn.result)
if (msgKey !== null) {
const text = requireTbl(msgKey, hud.lang)
this.lastNpcTransactionMessage = { tblKey: msgKey, text }
wp.showVendorStatus(text, 'white')
} else if (txn.result === 1) {
this.requirePlayUiSound()(NPC_SELL_SOUND_ID)
}
}
}
/** Sell value (and repair cost at smiths) on the player's bag and equipped items while a store is open. */
private decoratePlayerItemPrices(hud: HudManager, price: VendorPriceFn, ctx: VendorPriceContext, canRepair: boolean): void {
const decorate = (ui: UiInventoryItem, raw: ItemData): UiInventoryItem => ({
...ui,
sellCost: price(raw, 1, ctx),
...(canRepair && raw.maxDurability !== undefined && raw.maxDurability > 0 ? { repairCost: price(raw, 3, ctx) } : {}),
})
hud.inventory.gridItems = hud.inventory.gridItems.map((pl) => {
const raw = this.world.containers.inventory.get(Number(pl.item.id))
if (!raw) throw new Error(`HudModel: inventory item ${pl.item.id} missing from ClientWorld`)
return { ...pl, item: decorate(pl.item, raw) }
})
for (const raw of this.world.containers.equipment.values()) {
const slot = BODY_LOC_TO_EQUIP_SLOT[raw.bodyLoc || raw.equippedSlot]
const ui = slot ? hud.inventory.equipped[slot] : undefined
if (slot && ui) hud.inventory.equipped[slot] = decorate(ui, raw)
}
}
private npcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined {
return getTownNpcDescriptorByHcIdx(this.npcClassOf(npcId))
}
private tryNpcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined {
const unit = this.world.getUnit('monster', npcId)
return unit ? getTownNpcDescriptorByHcIdx(unit.classId) : undefined
}
/** MonStats hcIdx of NPC unit `npcId`; throws when the unit is not in the world. */
private npcClassOf(npcId: number): number {
const unit = this.world.getUnit('monster', npcId)
if (!unit) throw new Error(`HudModel: NPC unit #${npcId} is not in the world`)
return unit.classId
}
private priceContext(npcId: number): VendorPriceContext {
return { world: this.world, npcClass: this.npcClassOf(npcId) }
}
private requireVendorPrice(): VendorPriceFn {
if (!this.priceProvider) throw new Error('HudModel: no vendorPrice provider (HudModelOptions.vendorPrice)')
return this.priceProvider
}
private requireVendorRepairAll(): VendorRepairAllFn {
if (!this.repairAllProvider) throw new Error('HudModel: no vendorRepairAll provider (HudModelOptions.vendorRepairAll)')
return this.repairAllProvider
}
private requirePlayUiSound(): (soundId: number) => void {
if (!this.uiSound) throw new Error('HudModel: no playUiSound sink (HudModelOptions.playUiSound)')
return this.uiSound
}
/** The open store (`npcInteraction.window` trade / gamble); throws when none is open. */
private requireOpenStore(action: string): { npcId: number; gamble: boolean; ctx: VendorPriceContext } {
const npc = this.world.npcInteraction
if (npc.npcId === null || (npc.window !== 'trade' && npc.window !== 'gamble')) {
throw new Error(`HudModel.${action}: no store window is open`)
}
return { npcId: npc.npcId, gamble: npc.window === 'gamble', ctx: this.priceContext(npc.npcId) }
}
/** `true` when `action` ran less than 2000 ms ago (the click is dropped, like D2Client). */
private isThrottled(action: string): boolean {
const t = this.now()
const last = this.lastNpcActionAt.get(action)
if (last !== undefined && t - last < NPC_TRADE_THROTTLE_MS) return true
this.lastNpcActionAt.set(action, t)
return false
}
/** Player item by id from the bag, the paperdoll or the cursor. */
private requirePlayerItem(itemId: number, action: string): ItemData {
const c = this.world.containers
const item = c.inventory.get(itemId) ?? c.equipment.get(itemId) ?? (c.cursor?.id === itemId ? c.cursor : undefined)
if (!item) throw new Error(`HudModel.${action}: item #${itemId} is not carried by the player`)
return item
}
private findWorldItem(itemId: number): ItemData | undefined {
return (
this.world.containers.allById.get(itemId) ??
(this.world as any).items?.get?.(itemId) ??
this.world.containers.inventory.get(itemId) ??
this.world.containers.equipment.get(itemId) ??
this.world.containers.stash.get(itemId) ??
this.world.containers.cube.get(itemId) ??
(this.world.containers.cursor?.id === itemId ? this.world.containers.cursor : undefined)
)
}
/**
* Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`.
*/
setHoveredMonster(monsterUnitId: number | null): void {
this.hoveredMonsterId = monsterUnitId
this.syncHoveredMonsterTarget()
this.hudManager?.syncPublishedState()
}
private syncHoveredMonsterTarget(): void {
const hud = this.hudManager
if (!hud) return
if (this.hoveredMonsterId === null) {
hud.targetMonster = null
return
}
const unit = this.world.getUnit('monster', this.hoveredMonsterId)
if (!unit || unit.lifePct <= 0 || unit.mode === 0 || unit.mode === 12) {
hud.targetMonster = null
return
}
// D2Client.dll 0x6fb708a3..0x6fb708c4: skip town NPCs, non-killable units, and empty NameStr
const kind = getMonsterKindByHcIdx(unit.classId, this.world.difficulty)
if (kind && (kind.inTown || !kind.killable || !kind.nameKey)) {
hud.targetMonster = null
return
}
hud.targetMonster = clientUnitToMonsterTargetInfo(unit, this.world.difficulty)
}
private emitCommand(cmd: ClientCommand): void {
this.onCommand?.(cmd)
}
// ---------------------------------------------------------------------------
// HUD User Action -> ClientCommand Dispatchers
// ---------------------------------------------------------------------------
/**
* Allocate a base attribute point (`str`, `dex`, `vit`, `ene`) and emit `AddStatPoint (0x3A)`.
*/
allocateStat(key: BaseStatKey): boolean {
const statId = STAT_KEY_TO_D2_STAT_ID[key]
this.emitCommand({ type: 'AddStatPoint', statId })
return true
}
/**
* Request a skill point allocation (`AddSkillPoint 0x3B`). The tree updates when the server answers (0x21).
*/
allocateSkill(skillId: number): boolean {
this.emitCommand({ type: 'AddSkillPoint', skillId })
return true
}
/**
* Request the active left or right hand skill (`0x3C SelectSkill`). The quickbar updates on `0x23`.
*/
selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void {
this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId })
}
/**
* Use the potion at belt `slotIndex` (`0..15`, or column `0..3`) via `BeltUse (0x26)` with its real server item id.
* Returns `false` (no command) when ClientWorld has no item at that slot/column.
*/
useBeltSlot(slotIndex: number, shift = false): boolean {
if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= 16) return false
const bySlot = this.world.containers.beltBySlot?.get(slotIndex)
const beltItems = [...this.world.containers.belt.values()]
const getSlot = (it: ItemData): number => it.gridX ?? it.x ?? 0
const exact = bySlot ?? beltItems.find((it) => getSlot(it) === slotIndex)
const beltItem =
exact ??
(slotIndex < BELT_COLS
? beltItems
.filter((it) => getSlot(it) >= 0 && getSlot(it) % BELT_COLS === slotIndex)
.sort((a, b) => getSlot(a) - getSlot(b))[0]
: undefined)
if (!beltItem) return false
if (shift) {
this.emitCommand({
type: 'BeltUse',
itemId: beltItem.id >>> 0,
useOnMerc: 1,
})
} else {
this.emitCommand({
type: 'BeltUse',
itemId: beltItem.id >>> 0,
shift: false,
})
}
return true
}
onUseBeltSlot(slotIndex: number, shift = false): boolean {
return this.useBeltSlot(slotIndex, shift)
}
/**
* Swap the held cursor item with an occupied equipment slot (`0x1D ItemSwap`, 9B).
*/
swapEquipItem(itemId: number, slot: EquipSlotId | string | number): void {
const aliasMap: Record<string, number> = {
...EQUIP_SLOT_TO_BODY_LOC,
head: 1,
neck: 2,
torso: 3,
rightHand: 4,
leftHand: 5,
rightRing: 6,
leftRing: 7,
feet: 9,
hands: 10,
altRightHand: 11,
altLeftHand: 12,
}
const bodyLoc = typeof slot === 'number' ? slot : aliasMap[slot]
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(bodyLoc) || bodyLoc <= 0) return
this.emitCommand({ type: 'ItemSwap', itemId: itemId >>> 0, slot: bodyLoc >>> 0 })
}
onSwapEquipItem(itemId: number, slot: EquipSlotId | string | number): void {
this.swapEquipItem(itemId, slot)
}
/**
* Swap the held cursor item with an occupied container item (`0x1F SwapContainerItem`, 17B).
*/
swapContainerItem(
cursorItemId: number,
targetItemId: number,
x: number,
y: number,
_container?: number,
): void {
if (
!Number.isFinite(cursorItemId) ||
cursorItemId <= 0 ||
!Number.isFinite(targetItemId) ||
targetItemId <= 0
) {
return
}
if (x < 0 || y < 0) return
this.emitCommand({
type: 'SwapContainerItem',
cursorItemId: cursorItemId >>> 0,
targetItemId: targetItemId >>> 0,
replacedItemId: targetItemId >>> 0,
x: x >>> 0,
y: y >>> 0,
})
}
onSwapContainerItem(
cursorItemId: number,
targetItemId: number,
x: number,
y: number,
container?: number,
): void {
this.swapContainerItem(cursorItemId, targetItemId, x, y, container)
}
/**
* Put the held cursor item into belt slot `slot` (`0x23 BeltPut`, 9B).
*/
putBeltItem(itemId: number, slot: number): void {
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(slot) || slot < 0 || slot >= 16) return
this.emitCommand({ type: 'BeltPut', itemId: itemId >>> 0, slot: slot >>> 0 })
}
onPutBeltItem(itemId: number, slot: number): void {
this.putBeltItem(itemId, slot)
}
/**
* Remove a potion from the belt onto the cursor (`0x24 BeltRemove`, 5B).
*/
removeBeltItem(itemId: number): void {
if (!Number.isFinite(itemId) || itemId <= 0) return
this.emitCommand({ type: 'BeltRemove', itemId: itemId >>> 0 })
}
onRemoveBeltItem(itemId: number): void {
this.removeBeltItem(itemId)
}
/**
* Swap the held cursor potion with a belt potion (`0x25 BeltSwap`, 9B).
*/
swapBeltItem(cursorItemId: number, beltItemId: number): void {
if (!Number.isFinite(cursorItemId) || cursorItemId <= 0 || !Number.isFinite(beltItemId) || beltItemId <= 0) return
this.emitCommand({ type: 'BeltSwap', cursorItemId: cursorItemId >>> 0, beltItemId: beltItemId >>> 0 })
}
onSwapBeltItem(cursorItemId: number, beltItemId: number): void {
this.swapBeltItem(cursorItemId, beltItemId)
}
/**
* Identify `itemId` using identify scroll/tome `scrollId` (`0x27 IdentifyItem`, 9B).
*/
identifyItem(itemId: number, scrollId: number): void {
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(scrollId) || scrollId <= 0) return
this.emitCommand({ type: 'IdentifyItem', itemId: itemId >>> 0, scrollId: scrollId >>> 0 })
}
onIdentifyItem(itemId: number, scrollId: number): void {
this.identifyItem(itemId, scrollId)
}
/**
* Insert socket filler `itemId` into socketed host `targetItemId` (`0x28 SocketItem`, 9B).
* No-op when `targetItem` is already at full socket capacity.
*/
socketItem(itemId: number, targetItemId: number): void {
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(targetItemId) || targetItemId <= 0) return
const targetItem = this.findWorldItem(targetItemId)
if (targetItem) {
const maxSockets = (targetItem as any).sockets ?? targetItem.totalSockets ?? 0
if (maxSockets > 0) {
const used = this.world.containers.sockets.get(targetItemId)?.length ?? 0
if (used >= maxSockets) return
}
}
this.emitCommand({ type: 'SocketItem', itemId: itemId >>> 0, targetItemId: targetItemId >>> 0 })
}
onSocketItem(itemId: number, targetItemId: number): void {
this.socketItem(itemId, targetItemId)
}
/**
* Stack scroll `scrollId` into tome `bookId` (`0x29 ScrollToBook`, 9B).
* Validates `isc -> ibk` and `tsc -> tbk` compatibility when both items exist in `ClientWorld`.
*/
scrollToBook(scrollId: number, bookId: number): void {
if (!Number.isFinite(scrollId) || scrollId <= 0 || !Number.isFinite(bookId) || bookId <= 0) return
const scroll = this.findWorldItem(scrollId)
const book = this.findWorldItem(bookId)
if (scroll && book) {
const sCode = scroll.code.trim()
const bCode = book.code.trim()
const compatible = (sCode === 'isc' && bCode === 'ibk') || (sCode === 'tsc' && bCode === 'tbk')
if (!compatible) return
}
this.emitCommand({ type: 'ScrollToBook', scrollId: scrollId >>> 0, bookId: bookId >>> 0 })
}
onScrollToBook(scrollId: number, bookId: number): void {
this.scrollToBook(scrollId, bookId)
}
/**
* Send NPC quest dialogue message (`0x31 QuestMessage`, 9B).
*/
questMessage(npcId: number, messageId = 0): void {
if (!Number.isFinite(npcId) || npcId <= 0) return
this.emitCommand({ type: 'QuestMessage', npcId: npcId >>> 0, messageId: messageId >>> 0 })
}
/**
* Send generic NPC entity action (`0x38 NpcEntityAction`, 13B).
*/
npcEntityAction(action: number, npcId: number, complement = 0): void {
if (!Number.isFinite(npcId) || npcId <= 0) return
this.emitCommand({
type: 'NpcEntityAction',
action: action | 0,
npcId: npcId >>> 0,
complement: complement >>> 0,
})
}
/**
* Hire a mercenary (`0x36 MercHire`, 9B).
*/
hireMerc(npcId: number, mercId: number): void {
if (!Number.isFinite(npcId) || npcId <= 0 || !Number.isFinite(mercId) || mercId <= 0) return
this.emitCommand({ type: 'MercHire', npcId: npcId >>> 0, mercId: mercId >>> 0 })
}
/**
* Resurrect the player's dead mercenary (`0x62 MercResurrect`, 5B).
*/
resurrectMerc(npcId?: number): void {
const resolvedNpcId = npcId ?? this.world.npcInteraction.npcId ?? 0
if (!Number.isFinite(resolvedNpcId) || resolvedNpcId <= 0) return
this.emitCommand({ type: 'MercResurrect', npcId: resolvedNpcId >>> 0 })
}
/**
* Equip/unequip a mercenary item slot (`0x61 MercItem`, 3B).
*/
mercItem(slot: number): void {
if (!Number.isFinite(slot) || slot < 0 || slot > 12) return
this.emitCommand({ type: 'MercItem', slot: slot & 0xffff })
}
/**
* Resurrect the player after death (`0x41 Resurrect`, 1B).
*/
resurrect(): void {
this.emitCommand({ type: 'Resurrect' })
}
onResurrect(): void {
this.resurrect()
}
/**
* Send a party action (`0x5E PartyAction`, 6B: `0x06` invite, `0x07` cancel, `0x08` accept, `0x09` leave).
*/
partyAction(action: number, targetPlayerId: number): void {
if (!Number.isFinite(targetPlayerId) || targetPlayerId <= 0) return
this.emitCommand({ type: 'PartyAction', action: action & 0xff, targetPlayerId: targetPlayerId >>> 0 })
}
/**
* Toggle hostility or corpse loot permission (`0x5D Hostile`, 7B).
*/
setHostile(targetPlayerId: number, hostile: boolean | number, button = 1): void {
if (!Number.isFinite(targetPlayerId) || targetPlayerId <= 0) return
this.emitCommand({
type: 'Hostile',
targetPlayerId: targetPlayerId >>> 0,
hostile: Boolean(hostile),
button: button === 2 ? 2 : 1,
})
}
hostile(targetPlayerId: number, hostile: boolean | number, button = 1): void {
this.setHostile(targetPlayerId, hostile, button)
}
/**
* Send a UI button click (`0x4F ClickButton`, 7B).
*/
clickButton(buttonId: number, complement = 0): void {
this.emitCommand({
type: 'ClickButton',
button: buttonId & 0xffff,
buttonId: buttonId & 0xffff,
complement: complement >>> 0,
amount: complement >>> 0,
})
}
/**
* Confirm a quest reward item action (Charsi imbue, Larzuk socket, Anya personalize) via `0x38 NpcEntityAction`.
*/
confirmQuestReward(itemId: number, explicitNpcId?: number): void {
if (!Number.isFinite(itemId) || itemId <= 0) return
const npcId =
explicitNpcId ||
this.world.npcInteraction.npcId ||
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ||
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ||
(this.hudManager?.worldPanels as any)?.activeNpcId ||
(this.hudManager?.worldPanels as any)?.vendorNpcId ||
0
this.emitCommand({
type: 'NpcEntityAction',
action: 0,
npcId: npcId >>> 0,
complement: itemId >>> 0,
})
}
/**
* Close the active NPC interaction and all HUD panels.
*/
closeActiveNpcPanel(): void {
const npcId =
this.world.npcInteraction.npcId ??
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ??
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ??
(this.hudManager?.worldPanels as any)?.activeNpcId ??
null
if (npcId !== null && npcId > 0) {
this.closeNpc(npcId)
}
this.hudManager?.closeAllPanels()
}
/**
* Handle an NPC menu option selection (`talk`, `identify`, `hire`, `resurrect`, `travel_*`, `imbue`, `add_sockets`, `personalize`).
*/
handleNpcMenuAction(
actionId: string,
npcId?: number,
descriptor?: TownNpcServiceDescriptor,
): void {
const resolvedNpcId =
npcId ||
this.world.npcInteraction.npcId ||
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ||
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ||
(this.hudManager?.worldPanels as any)?.activeNpcId ||
0
if (this.hudManager && resolvedNpcId > 0) {
;(this.hudManager.worldPanels as any).activeNpcId = resolvedNpcId
}
const resolvedDesc =
descriptor ??
(this.hudManager?.worldPanels as any)?.npcMenu?.descriptor ??
(resolvedNpcId > 0 ? this.tryNpcDescriptor(resolvedNpcId) : undefined)
switch (actionId) {
case 'talk': {
if (this.hudManager && resolvedDesc) {
;(this.hudManager.worldPanels as any).openNpcSpeech?.(resolvedDesc)
}
this.questMessage(resolvedNpcId, 0)
break
}
case 'identify': {
this.identifyAllAtNpc(resolvedNpcId)
break
}
case 'hire': {
this.npcEntityAction(3, resolvedNpcId, 0)
;(this.world.npcInteraction as any).window = 'hire'
;(this.world.npcInteraction as any).activeWindow = 'hire'
if (this.hudManager) {
this.hudManager.leftPanel = 'hire' as any
;(this.hudManager.worldPanels as any).activePanel = 'hire'
;(this.hudManager.worldPanels as any).npcMenu = null
}
break
}
case 'resurrect':
case 'resurrect_merc': {
this.resurrectMerc(resolvedNpcId)
break
}
case 'travel_east': {
const complement = resolvedDesc?.hcIdx === 210 ? 75 : 40
this.npcEntityAction(0, resolvedNpcId, complement)
break
}
case 'travel_west': {
const complement = resolvedDesc?.hcIdx === 175 ? 1 : 40
this.npcEntityAction(0, resolvedNpcId, complement)
break
}
case 'travel_harrogath': {
this.npcEntityAction(0, resolvedNpcId, 109)
break
}
case 'imbue':
case 'add_sockets':
case 'personalize': {
;(this.world.npcInteraction as any).window = actionId
if (this.hudManager) {
this.hudManager.leftPanel = actionId as any
;(this.hudManager.worldPanels as any).activePanel = actionId
this.hudManager.rightPanel = 'inv'
this.hudManager.inventory.visible = true
}
break
}
case 'trade':
case 'trade_repair': {
if (this.priceProvider && this.repairAllProvider && this.uiSound) {
this.openNpcTrade(resolvedNpcId)
} else {
this.npcEntityAction(1, resolvedNpcId, 0)
}
break
}
case 'gamble': {
if (this.priceProvider && this.repairAllProvider && this.uiSound) {
this.openNpcGamble(resolvedNpcId)
} else {
this.npcEntityAction(2, resolvedNpcId, 0)
}
break
}
case 'cancel': {
if (resolvedNpcId > 0) this.closeNpc(resolvedNpcId)
break
}
}
}
/**
* Request a weapon set swap (`SwapWeapons 0x60`); `weaponSet` changes when the server confirms.
*/
swapWeapons(): void {
this.emitCommand({ type: 'SwapWeapons' })
}
/**
* Toggle run/walk state (`R` hotkey or control bar button).
*/
toggleRunWalk(): boolean {
const next = this.hudManager
? !this.hudManager.isRunning
: !(this.predictor?.getSnapshot().running ?? true)
if (this.hudManager) {
this.hudManager.isRunning = next
this.hudManager.syncPublishedState()
}
if (this.predictor) {
this.predictor.setRunning(next)
}
return next
}
/**
* Teleport to `destAreaId` via an open waypoint (`0x49 WaypointGo`).
*/
teleportWaypoint(waypointUnitId: number, destAreaId: number): void {
this.hudManager?.closeAllPanels()
this.emitCommand({
type: 'WaypointGo',
waypointId: waypointUnitId,
destAreaId,
})
}
private requireWaypointUnitId(): number {
const id = this.world.waypointUnitId
if (id === null) throw new Error('HudModel.teleportWaypoint: no waypoint menu has been opened by the server (0x63)')
return id
}
/** Waypoint teleport from the HUD waypoint panel, using the waypoint object from the last `0x63`. */
teleportToLevel(destAreaId: number): void {
this.teleportWaypoint(this.requireWaypointUnitId(), destAreaId)
}
/**
* Drop `amount` inventory gold on the ground (`0x50 DropGold`).
*/
dropGold(amount: number): void {
const unitId = this.world.selfId
if (!unitId) throw new Error('HudModel.dropGold: self unit id not assigned yet')
if (!Number.isInteger(amount) || amount <= 0) return
this.emitCommand({ type: 'DropGold', unitId, amount })
}
/**
* Right-click use of an inventory item (`0x20 ItemUse`) at the player's current position.
*/
useItem(itemId: number): void {
const self = this.world.self
this.emitCommand({ type: 'ItemUse', itemId, at: { x: self.x, y: self.y } })
}
onUseInventoryItem(itemId: number): void {
this.useItem(itemId)
}
/**
* Request casting a Town Portal (`TownPortal`).
*/
castTownPortal(): void {
this.emitCommand({ type: 'TownPortal' })
}
/**
* Pick up an inventory/container item to the cursor (`0x19 ItemToCursor`).
*/
pickupItemToCursor(itemId: number): void {
this.emitCommand({ type: 'ItemToCursor', itemId })
}
/**
* Place the held cursor item into a container grid (`0x18 ItemPlace`).
* `buffer`: `0` = inventory, `3` = cube, `4` = stash.
*/
placeCursorItem(itemId: number, col: number, row: number, buffer = 0): void {
this.emitCommand({ type: 'ItemPlace', itemId, x: col, y: row, buffer })
}
/**
* Equip an item onto a body slot (`0x1A ItemEquip`).
*/
equipItem(itemId: number, slot: EquipSlotId | number): void {
const bodyLoc = typeof slot === 'number' ? slot : EQUIP_SLOT_TO_BODY_LOC[slot]
this.emitCommand({ type: 'ItemEquip', itemId, slot: bodyLoc })
}
/**
* Unequip an item from a body slot (`0x1C ItemUnequip`).
*/
unequipItem(slot: EquipSlotId | number): void {
const bodyLoc = typeof slot === 'number' ? slot : EQUIP_SLOT_TO_BODY_LOC[slot]
this.emitCommand({ type: 'ItemUnequip', slot: bodyLoc })
}
/**
* Drop the cursor item onto the ground (`0x17 ItemDrop`).
*/
dropItem(itemId: number): void {
this.emitCommand({ type: 'ItemDrop', itemId })
}
/**
* Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`).
*/
transmuteCube(): void {
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 })
}
/**
* Buy an item from an active NPC vendor (`0x32 NpcBuy`).
*/
buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void {
this.emitCommand({ type: 'NpcBuy', ...p })
}
/**
* Sell an item to an active NPC vendor (`0x33 NpcSell`).
*/
sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void {
this.emitCommand({ type: 'NpcSell', ...p })
}
/**
* Repair one item at an NPC smith (`0x35` with itemMode / current durability).
*/
repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void {
this.emitCommand({ type: 'NpcRepairItem', ...p })
}
/**
* Repair all items at an NPC smith (`0x35` field4 = 0x80000000).
*/
repairAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcRepairAll', npcId })
}
/**
* Identify all items via Deckard Cain (`0x34 NpcIdentifyAll`).
*/
identifyAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcIdentifyAll', npcId })
}
/** NPC menu "trade" / "trade/repair" (`0x38` action 1). Throws when no price provider is configured. */
openNpcTrade(npcId: number): void {
this.requireVendorPrice()
this.requireVendorRepairAll()
this.requirePlayUiSound()
this.emitCommand({ type: 'NpcOpenTrade', npcId })
this.syncFromWorld()
}
/** NPC menu "gamble" (`0x38` action 2); only the six 1.13c gamble NPC classes. */
openNpcGamble(npcId: number): void {
const npcClass = this.npcClassOf(npcId)
if (!GAMBLE_NPC_CLASS_IDS.has(npcClass)) throw new Error(`HudModel.openNpcGamble: NPC class ${npcClass} does not gamble`)
this.requireVendorPrice()
this.requireVendorRepairAll()
this.requirePlayUiSound()
this.emitCommand({ type: 'NpcOpenGamble', npcId })
this.syncFromWorld()
}
/** Close the NPC menu / store (`0x30 NpcCancel`); the server sends nothing back. */
closeNpc(npcId: number): void {
this.emitCommand({ type: 'NpcCancel', target: { unitType: UNIT_TYPE_MONSTER, id: npcId } })
this.syncFromWorld()
}
/** Store grid click on shop item `itemId`: `0x32 NpcBuy` with the #10107 buy / gamble price. */
vendorBuy(itemId: number, shiftFill: boolean): boolean {
const store = this.requireOpenStore('vendorBuy')
const item = this.world.containers.shop.get(itemId)
if (!item) throw new Error(`HudModel.vendorBuy: shop item #${itemId} not in the store`)
const cost = this.requireVendorPrice()(item, store.gamble ? 2 : 0, store.ctx)
if (this.isThrottled('buy')) return false
this.buyFromNpc({
npcId: store.npcId,
itemId,
itemMode: 0,
gamble: store.gamble,
shiftFill: store.gamble ? false : shiftFill,
cost,
})
return true
}
/** Sell player item `itemId`: `0x33 NpcSell` with the #10107 sell price. */
vendorSell(itemId: number): boolean {
const store = this.requireOpenStore('vendorSell')
const item = this.requirePlayerItem(itemId, 'vendorSell')
const cost = this.requireVendorPrice()(item, 1, store.ctx)
if (this.isThrottled('sell')) return false
this.sellToNpc({ npcId: store.npcId, itemId, itemMode: 0, cost })
return true
}
/** Repair player item `itemId` (`0x35`); durability is the item's current value (absent stat = 0). */
vendorRepairItem(itemId: number): boolean {
const store = this.requireOpenStore('vendorRepairItem')
const item = this.requirePlayerItem(itemId, 'vendorRepairItem')
if (this.isThrottled('repair')) return false
this.repairItemAtNpc({ npcId: store.npcId, itemId, itemMode: 0, durability: item.curDurability ?? 0 })
return true
}
/** Repair All button (`0x35` field4 = 0x80000000). */
vendorRepairAll(): boolean {
const store = this.requireOpenStore('vendorRepairAll')
if (this.isThrottled('repair')) return false
this.repairAllAtNpc(store.npcId)
return true
}
/** Gold dialog mode 3: `0x4F ClickButton` 0x14 DepositGold. Non-positive amounts send nothing. */
depositGold(amount: number): void {
if (!Number.isInteger(amount) || amount <= 0) return
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount })
}
/** Gold dialog mode 4: `0x4F ClickButton` 0x13 WithdrawGold. Non-positive amounts send nothing. */
withdrawGold(amount: number): void {
if (!Number.isInteger(amount) || amount <= 0) return
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount })
}
/** Stash panel closed: `0x4F ClickButton` 0x12 CloseStash (no reply). */
closeStash(): void {
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })
this.syncFromWorld()
}
}
export function clientUnitToMonsterTargetInfo(
unit: ClientUnit,
difficulty: 0 | 1 | 2 | Difficulty = 0,
): MonsterTargetHudInfo {
const hpPct = Math.max(0, Math.min(128, unit.lifePct))
const kind = getMonsterKindByHcIdx(unit.classId, difficulty)
const isSuperUnique = Boolean(unit.isSuperUnique || (unit.flags & 0x02) !== 0)
const isUnique = Boolean(!isSuperUnique && (unit.isUnique || (unit.flags & 0x08) !== 0))
const isChampion = Boolean(
!isSuperUnique && !isUnique && (unit.isChampion || (unit.flags & 0x04) !== 0),
)
const isMinion = Boolean(
!isSuperUnique && !isUnique && !isChampion && (unit.flags & 0x10) !== 0,
)
const isBoss = Boolean(kind?.boss)
const rank: MonsterTargetHudInfo['rank'] = isSuperUnique
? 'superunique'
: isUnique
? 'unique'
: isChampion
? 'champion'
: isMinion
? 'minion'
: isBoss
? 'boss'
: 'normal'
const su =
isSuperUnique && unit.superUniqueId !== undefined
? getSuperUniqueByIndex(unit.superUniqueId)
: undefined
const explicitName = unit.name && unit.name.trim().length > 0 ? unit.name.trim() : undefined
const nameKey = su?.nameKey ?? explicitName ?? kind?.nameKey
let resolvedName =
explicitName ??
(nameKey
? (lookupMonsterTblString(nameKey, 'en') ??
monsterDisplayName(nameKey, 'ENG') ??
nameKey)
: `Monster #${unit.classId}`)
if (
!explicitName &&
isUnique &&
unit.nameSeed !== undefined &&
!FIXED_UNIQUE_BOSS_HCIDX_SET.has(unit.classId)
) {
if (unit.bossMods?.includes(12)) {
resolvedName = lookupMonsterTblString('Blood Raven', 'en') ?? 'Blood Raven'
} else {
const partKeys = rollUniqueMonsterNameKeys(unit.nameSeed)
resolvedName = partKeys.map(k => lookupMonsterTblString(k, 'en') ?? k).join(' ')
}
}
// D2Client.dll 0x6facfedb & 0x6fb6fca0: Uber bosses (hcIdx 704..709) suppress MonUMod labels and immunities
const isUberBoss = unit.classId >= 704 && unit.classId <= 709
const rawModIds: number[] = isUberBoss
? []
: unit.bossMods && unit.bossMods.length > 0
? [...unit.bossMods]
: su?.modifiers && su.modifiers.length > 0
? [...su.modifiers]
: []
if (!isUberBoss && isChampion && (unit.flags & 0x40) !== 0 && !rawModIds.includes(36)) {
rawModIds.unshift(36)
}
const modifiers = rawModIds.map(
(m) => CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)?.name ?? String(m),
)
const affixes = rawModIds.map((m) => {
const key = String(m)
const entry =
MONSTER_UMOD_TBL_KEYS[key] ??
CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)
if (entry) {
return lookupMonsterTblString(entry.tblKey, 'en') ?? entry.label
}
return `Mod #${m}`
})
const descKey =
!isUberBoss && kind?.descKey && kind.descKey.length > 0
? kind.descKey
: undefined
const resistances =
!isUberBoss && kind?.resistances
? applyMonsterUModResistances(kind.resistances, rawModIds)
: undefined
return {
name: resolvedName,
hcIdx: unit.classId,
...(unit.nameSeed !== undefined ? { nameSeed: unit.nameSeed } : {}),
...(nameKey !== undefined ? { nameKey } : {}),
...(descKey !== undefined ? { descKey } : {}),
hp: hpPct,
maxHp: 128,
rank,
...(kind?.isUndead ? { isUndead: true } : {}),
...(kind?.isDemon ? { isDemon: true } : {}),
...(isBoss ? { isBoss: true } : {}),
modifiers,
affixes,
...(resistances !== undefined ? { resistances } : {}),
}
}
/** Resolve a server class id to its class code; throws on an id outside 1.13c's 0..6. */
export function resolveClassCode(classId: CharClassId | number): CharacterClassCode {
const code = (CLASS_ID_TO_CODE as Record<number, CharacterClassCode | undefined>)[classId]
if (!code) throw new Error(`HudModel: unknown character class id ${classId}`)
return code
}
/** XP needed for the next level from `D2_EXPERIENCE_TABLE_99` (index L = XP to reach level L); 0 at level 99 / unknown. */
export function nextLevelExperience(level: number): number {
if (level < 1 || level >= 99) return 0
return D2_EXPERIENCE_TABLE_99[level + 1] ?? 0
}
/** XP threshold of the current level from `D2_EXPERIENCE_TABLE_99`; 0 when the level is unknown. */
export function currentLevelExperience(level: number): number {
if (level < 1 || level > 99) return 0
return D2_EXPERIENCE_TABLE_99[level] ?? 0
}