fix(client): neutral not-ready HUD state and server-authoritative HUD commands (P1/P2)

- Stats 6-11 decoded per ItemStatCost ValShift 8 (unconditional >>8, no
  magnitude heuristic); evidence in self.ts doc (capture 1e 07 00 32 = 50<<8).
- Neutral defaults: no Sorceress/lvl85/850k gold/starter gear/belt potions/
  quests/waypoints; class null until server assigns it; XP from
  D2_EXPERIENCE_TABLE_99; no 'Wanderer'/'Player'/'sor'/||100 fallbacks.
- HudCommandSink: HudManager routes stat/skill alloc, skill select, belt use,
  weapon swap, item pick/place/equip/unequip/drop/use, gold drop, transmute and
  waypoint to HudModel commands with no local mutation; render stamina tick
  removed; fabricated skill damage strings removed.
- HudModel.syncFromWorld rebuilds skills/quickbar/containers/waypoints/quests
  unconditionally; derived stats via derived-stats.ts; BeltUse uses real ids.
- netproto: C2S 0x50 DropGold command + encoder.
- ClientWorld: waypoint unitId from 0x63, 0x79 self/partner trade gold split,
  pseudo StatSet ids (>0x1ff) never enter StatLists.
- Tests migrated to explicit demo fixtures (tests/fixtures/hud-demo-state.ts)
  plus new hud-server-authority / drop-gold regression tests.
This commit is contained in:
troytt 2026-10-01 01:41:13 +00:00
parent 58de64610f
commit 7db8c3c9b8
42 changed files with 1336 additions and 656 deletions

View File

@ -1001,12 +1001,14 @@ export class OnlineSession {
const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
if (selfX !== 0 || selfY !== 0 || this.world.selfId !== 0) { // Self is not renderable until the server has assigned both its unit id (0x59) and class.
const selfClass = this.world.self.charClass
if (this.world.selfId !== 0 && selfClass !== null) {
out.push({ out.push({
id: this.world.selfId || 1, id: this.world.selfId,
kind: 'player', kind: 'player',
classId: this.world.self.charClass, classId: selfClass,
name: this.world.self.name || 'Player', name: this.world.self.name,
x: selfX, x: selfX,
y: selfY, y: selfY,
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1, mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,

View File

@ -23,7 +23,7 @@ import type {
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts' import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
import type { BaseStatKey } from '../ui/character-sheet.ts' import type { BaseStatKey } from '../ui/character-sheet.ts'
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts' import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
import type { HudManager } from '../ui/hud-manager.ts' import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
import { import {
resolveAllowedSlots, resolveAllowedSlots,
resolveItemInvFile, resolveItemInvFile,
@ -38,6 +38,8 @@ import type { LocalMovementPredictor } from '../motion/predictor.ts'
import type { ClientUnit } from '../world/client-unit.ts' import type { ClientUnit } from '../world/client-unit.ts'
import type { ClientWorld } from '../world/client-world.ts' import type { ClientWorld } from '../world/client-world.ts'
import { D2StatId } from '../world/stat-list.ts' import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts'
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = { export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
0: 'ama', 0: 'ama',
@ -174,8 +176,9 @@ export interface HudModelOptions {
export interface HudModelSnapshot { export interface HudModelSnapshot {
readonly charName: string readonly charName: string
readonly charClass: CharClassId /** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
readonly classCode: CharacterClassCode readonly charClass: CharClassId | null
readonly classCode: CharacterClassCode | null
readonly level: number readonly level: number
readonly xp: number readonly xp: number
readonly hp: number readonly hp: number
@ -210,7 +213,7 @@ export interface HudModelSnapshot {
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching * Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
* `ClientCommand` actions triggered by user HUD interactions. * `ClientCommand` actions triggered by user HUD interactions.
*/ */
export class HudModel { export class HudModel implements HudCommandSink {
readonly world: ClientWorld readonly world: ClientWorld
hudManager: HudManager | null hudManager: HudManager | null
predictor: LocalMovementPredictor | null predictor: LocalMovementPredictor | null
@ -218,7 +221,6 @@ export class HudModel {
onCommand?: ((cmd: ClientCommand) => void) | undefined onCommand?: ((cmd: ClientCommand) => void) | undefined
private unsubWorld: (() => void) | null = null private unsubWorld: (() => void) | null = null
private lastSyncedClassCode: CharacterClassCode | null = null
private hoveredMonsterId: number | null = null private hoveredMonsterId: number | null = null
private escapeMenuOpen = false private escapeMenuOpen = false
@ -228,6 +230,7 @@ export class HudModel {
this.predictor = options.predictor ?? null this.predictor = options.predictor ?? null
this.automap = options.automap ?? null this.automap = options.automap ?? null
this.onCommand = options.onCommand this.onCommand = options.onCommand
if (this.hudManager) this.hudManager.commandSink = this
this.unsubWorld = this.world.onChange(() => { this.unsubWorld = this.world.onChange(() => {
this.syncFromWorld() this.syncFromWorld()
@ -238,6 +241,7 @@ export class HudModel {
* Detach the world listener when disposing the session. * Detach the world listener when disposing the session.
*/ */
dispose(): void { dispose(): void {
if (this.hudManager?.commandSink === this) this.hudManager.commandSink = null
if (this.unsubWorld) { if (this.unsubWorld) {
this.unsubWorld() this.unsubWorld()
this.unsubWorld = null this.unsubWorld = null
@ -248,8 +252,12 @@ export class HudModel {
* Bind or replace the active `HudManager` instance and synchronize immediately. * Bind or replace the active `HudManager` instance and synchronize immediately.
*/ */
attachHudManager(hudManager: HudManager | null): void { attachHudManager(hudManager: HudManager | null): void {
if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) {
this.hudManager.commandSink = null
}
this.hudManager = hudManager this.hudManager = hudManager
if (hudManager) { if (hudManager) {
hudManager.commandSink = this
this.syncFromWorld() this.syncFromWorld()
} }
} }
@ -260,16 +268,7 @@ export class HudModel {
getSnapshot(): HudModelSnapshot { getSnapshot(): HudModelSnapshot {
const self = this.world.self const self = this.world.self
const stats = self.stats const stats = self.stats
const classCode = CLASS_ID_TO_CODE[self.charClass] ?? 'sor' const classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
const hp = self.life > 0 ? self.life : stats.get(D2StatId.HitPoints) || 100
const maxHp = Math.max(hp, self.maxLife > 0 ? self.maxLife : stats.get(D2StatId.MaxHp) || 100)
const mana = self.mana >= 0 ? self.mana : stats.get(D2StatId.Mana) || 50
const maxMana = Math.max(mana, self.maxMana > 0 ? self.maxMana : stats.get(D2StatId.MaxMana) || 50)
const stamina = self.stamina >= 0 ? self.stamina : stats.get(D2StatId.Stamina) || 100
const maxStamina = Math.max(
stamina,
self.maxStamina > 0 ? self.maxStamina : stats.get(D2StatId.MaxStamina) || 100,
)
const invItems = [...this.world.containers.inventory.values()] const invItems = [...this.world.containers.inventory.values()]
const eqItems = [...this.world.containers.equipment.values()] const eqItems = [...this.world.containers.equipment.values()]
@ -281,25 +280,25 @@ export class HudModel {
const rightPanel = this.hudManager?.rightPanel ?? 'none' const rightPanel = this.hudManager?.rightPanel ?? 'none'
return { return {
charName: self.name || 'Wanderer', charName: self.name,
charClass: self.charClass, charClass: self.charClass,
classCode, classCode,
level: Math.max(1, self.level || stats.get(D2StatId.Level) || 1), level: self.level,
xp: self.experience || stats.get(D2StatId.Experience), xp: self.experience,
hp, hp: self.life,
maxHp, maxHp: self.maxLife,
mana, mana: self.mana,
maxMana, maxMana: self.maxMana,
stamina, stamina: self.stamina,
maxStamina, maxStamina: self.maxStamina,
str: stats.get(D2StatId.Strength), str: stats.get(D2StatId.Strength),
dex: stats.get(D2StatId.Dexterity), dex: stats.get(D2StatId.Dexterity),
vit: stats.get(D2StatId.Vitality), vit: stats.get(D2StatId.Vitality),
ene: stats.get(D2StatId.Energy), ene: stats.get(D2StatId.Energy),
statPoints: stats.get(D2StatId.StatPts), statPoints: stats.get(D2StatId.StatPts),
skillPoints: stats.get(D2StatId.NewSkills), skillPoints: stats.get(D2StatId.NewSkills),
gold: self.gold || stats.get(D2StatId.Gold), gold: stats.get(D2StatId.Gold),
stashGold: self.stashGold || stats.get(D2StatId.GoldBank), stashGold: stats.get(D2StatId.GoldBank),
leftSkillId: self.leftSkill, leftSkillId: self.leftSkill,
rightSkillId: self.rightSkill, rightSkillId: self.rightSkill,
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true), isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
@ -415,183 +414,138 @@ export class HudModel {
const self = this.world.self const self = this.world.self
const stats = self.stats const stats = self.stats
const classCode = CLASS_ID_TO_CODE[self.charClass] ?? 'sor'
const classNames = CLASS_ID_TO_NAMES[self.charClass] ?? CLASS_ID_TO_NAMES[1]
if (this.lastSyncedClassCode !== classCode) { // 0. Class: not ready until the server assigns it. No class-dependent panel state is synthesized before that.
this.lastSyncedClassCode = classCode if (self.charClass !== null) {
hud.currentClass = classCode 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) // 1. Vitals (globes & stamina bar): verbatim server values (0x95 / stats 6-11), absent = 0.
const hp = self.life > 0 ? self.life : stats.get(D2StatId.HitPoints) hud.maxHp = self.maxLife
const maxHp = Math.max(hp, self.maxLife > 0 ? self.maxLife : stats.get(D2StatId.MaxHp)) hud.hp = Math.max(0, Math.min(self.maxLife, self.life))
if (maxHp > 0) { hud.maxMana = self.maxMana
hud.maxHp = maxHp hud.mana = Math.max(0, Math.min(self.maxMana, self.mana))
hud.hp = Math.max(0, Math.min(maxHp, hp)) hud.maxStamina = self.maxStamina
} hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina))
const mana = self.mana > 0 || stats.get(D2StatId.MaxMana) > 0 ? self.mana : stats.get(D2StatId.Mana) // 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c).
const maxMana = Math.max(mana, self.maxMana > 0 ? self.maxMana : stats.get(D2StatId.MaxMana)) const attrs = hud.charSheet.attrs
if (maxMana > 0) { attrs.name = self.name
hud.maxMana = maxMana attrs.level = self.level
hud.mana = Math.max(0, Math.min(maxMana, mana)) 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)
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
const stamina = // 3. Gold (stat 14 inventory gold, stat 15 stash gold).
self.stamina > 0 || stats.get(D2StatId.MaxStamina) > 0 ? self.stamina : stats.get(D2StatId.Stamina) hud.inventory.gold = stats.get(D2StatId.Gold)
const maxStamina = Math.max( hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
stamina,
self.maxStamina > 0 ? self.maxStamina : stats.get(D2StatId.MaxStamina),
)
if (maxStamina > 0) {
hud.maxStamina = maxStamina
hud.stamina = Math.max(0, Math.min(maxStamina, stamina))
}
// 2. Character Sheet attributes // 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync.
if (self.name) { hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
hud.charSheet.attrs.name = self.name hud.skillTree.characterLevel = self.level
} hud.skillTree.hardPoints.clear()
hud.charSheet.attrs.classNameEn = classNames.en hud.skillTree.bonusLevels.clear()
hud.charSheet.attrs.classNameZh = classNames.zh hud.hotkeys.setAvailableSkills([])
const effLevel = self.level > 0 ? self.level : stats.get(D2StatId.Level) for (const [skillId, sl] of self.skillEntries.entries()) {
if (effLevel > 0) { const hard = Math.max(0, sl.baseLevel ?? sl.level)
hud.charSheet.attrs.level = effLevel const bonus = Math.max(0, sl.bonus ?? 0)
} if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
if (stats.has(D2StatId.Experience) || self.experience > 0) { if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
hud.charSheet.attrs.xp = self.experience || stats.get(D2StatId.Experience) if (isPassiveSkill(skillId)) continue
} const catalogEntry = SKILLS_BY_ID[skillId]
if (stats.has(D2StatId.NextExp)) { if (!catalogEntry) continue
hud.charSheet.attrs.nextLevelXp = stats.get(D2StatId.NextExp) const effSkillLevel = hard + bonus
} hud.hotkeys.addOrUpdateSkill({
if (stats.has(D2StatId.Strength)) hud.charSheet.attrs.str = stats.get(D2StatId.Strength) skillId,
if (stats.has(D2StatId.Dexterity)) hud.charSheet.attrs.dex = stats.get(D2StatId.Dexterity) name: catalogEntry.name,
if (stats.has(D2StatId.Vitality)) hud.charSheet.attrs.vit = stats.get(D2StatId.Vitality) nameZh: catalogEntry.nameZh,
if (stats.has(D2StatId.Energy)) hud.charSheet.attrs.ene = stats.get(D2StatId.Energy) level: effSkillLevel,
if (stats.has(D2StatId.StatPts)) hud.charSheet.attrs.statPoints = stats.get(D2StatId.StatPts) manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
if (maxHp > 0) hud.charSheet.attrs.baseMaxHp = maxHp leftUsable: isLeftUsableSkill(skillId),
if (maxMana > 0) hud.charSheet.attrs.baseMaxMana = maxMana rightUsable: true,
if (maxStamina > 0) hud.charSheet.attrs.baseMaxStamina = maxStamina isAura: isAuraSkill(skillId),
if (stats.has(D2StatId.ArmorClass)) { })
hud.charSheet.attrs.defense = stats.get(D2StatId.ArmorClass)
}
if (stats.has(D2StatId.ToHit)) {
hud.charSheet.attrs.attackRating = stats.get(D2StatId.ToHit)
}
if (stats.has(D2StatId.FireResist)) hud.charSheet.attrs.fireRes = stats.get(D2StatId.FireResist)
if (stats.has(D2StatId.ColdResist)) hud.charSheet.attrs.coldRes = stats.get(D2StatId.ColdResist)
if (stats.has(D2StatId.LightResist)) hud.charSheet.attrs.lightRes = stats.get(D2StatId.LightResist)
if (stats.has(D2StatId.PoisonResist)) hud.charSheet.attrs.poisRes = stats.get(D2StatId.PoisonResist)
// 3. Gold
if (stats.has(D2StatId.Gold) || self.gold > 0) {
hud.inventory.gold = self.gold || stats.get(D2StatId.Gold)
}
if (stats.has(D2StatId.GoldBank) || self.stashGold > 0) {
hud.worldPanels.stashGold = self.stashGold || stats.get(D2StatId.GoldBank)
}
// 4. Skills & Hotkeys
if (stats.has(D2StatId.NewSkills)) {
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
}
if (self.skillEntries.size > 0) {
for (const [skillId, sl] of self.skillEntries.entries()) {
const effSkillLevel = Math.max(1, sl.level + (sl.bonus ?? 0))
hud.skillTree.hardPoints.set(skillId, Math.max(0, sl.baseLevel ?? sl.level))
if (!isPassiveSkill(skillId)) {
const catalogEntry = SKILLS_BY_ID[skillId]
if (catalogEntry) {
hud.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
nameZh: catalogEntry.nameZh,
level: effSkillLevel,
manaCost: calculateManaCost(catalogEntry, effSkillLevel),
leftUsable: isLeftUsableSkill(skillId),
rightUsable: true,
isAura: isAuraSkill(skillId),
})
}
}
}
} }
hud.hotkeys.leftSkillId = self.leftSkill hud.hotkeys.leftSkillId = self.leftSkill
hud.hotkeys.rightSkillId = self.rightSkill hud.hotkeys.rightSkillId = self.rightSkill
hud.inventory.weaponSwapSet = self.weaponSet hud.inventory.weaponSwapSet = self.weaponSet
// 5. Containers (only overwrite HudManager containers when authoritative items exist in ClientWorld) // 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
const totalAuthoritativeItems = this.world.containers.allById.size for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
if (totalAuthoritativeItems > 0) { delete hud.inventory.equipped[key]
// Equipment }
const eqItems = [...this.world.containers.equipment.values()] for (const item of this.world.containers.equipment.values()) {
if (eqItems.length > 0) { const slot = BODY_LOC_TO_EQUIP_SLOT[item.bodyLoc || item.equippedSlot]
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) { if (slot) {
delete hud.inventory.equipped[key] hud.inventory.equipped[slot] = itemDataToUiInventoryItem(item)
}
for (const item of eqItems) {
const slot = BODY_LOC_TO_EQUIP_SLOT[item.bodyLoc || item.equippedSlot]
if (slot) {
hud.inventory.equipped[slot] = itemDataToUiInventoryItem(item)
}
}
} }
// Inventory grid
const invItems = [...this.world.containers.inventory.values()]
const placements: GridPlacement[] = invItems.map((item) => ({
item: itemDataToUiInventoryItem(item),
col: Math.max(0, Math.min(9, item.gridX)),
row: Math.max(0, Math.min(3, item.gridY)),
}))
hud.inventory.gridItems = placements
// Stash grid
const stashItems = [...this.world.containers.stash.values()]
hud.worldPanels.stashItems = stashItems.map((item) => ({
item: itemDataToUiInventoryItem(item),
col: Math.max(0, Math.min(5, item.gridX)),
row: Math.max(0, Math.min(7, item.gridY)),
}))
// Cube grid
const cubeItems = [...this.world.containers.cube.values()]
hud.worldPanels.cubeItems = cubeItems.map((item) => ({
item: itemDataToUiInventoryItem(item),
col: Math.max(0, Math.min(2, item.gridX)),
row: Math.max(0, Math.min(3, item.gridY)),
}))
// Belt 4x4 grid
const beltItems = [...this.world.containers.belt.values()]
if (beltItems.length > 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 beltItems) {
const slotIndex = Math.max(0, Math.min(15, item.gridX))
const row = Math.floor(slotIndex / BELT_COLS)
const col = slotIndex % BELT_COLS
const pot = itemToBeltPotion({ id: String(item.id), code: item.code })
if (pot) {
hud.belt.grid[row]![col] = pot
}
}
}
// Cursor item
const cursorItem = this.world.containers.cursor
hud.inventory.cursorItem = cursorItem ? itemDataToUiInventoryItem(cursorItem) : null
} }
// 6. Area & Waypoints const placements: GridPlacement[] = [...this.world.containers.inventory.values()].map((item) => ({
item: itemDataToUiInventoryItem(item),
col: item.gridX,
row: item.gridY,
}))
hud.inventory.gridItems = placements
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
item: itemDataToUiInventoryItem(item),
col: item.gridX,
row: item.gridY,
}))
hud.worldPanels.cubeItems = [...this.world.containers.cube.values()].map((item) => ({
item: itemDataToUiInventoryItem(item),
col: item.gridX,
row: item.gridY,
}))
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
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 })
if (pot) {
hud.belt.grid[row]![col] = pot
}
}
const cursorItem = this.world.containers.cursor
hud.inventory.cursorItem = cursorItem ? itemDataToUiInventoryItem(cursorItem) : null
// 6. Area, Waypoints & Quests
if (this.world.areaId > 0) { if (this.world.areaId > 0) {
hud.worldPanels.setCurrentLevel(this.world.areaId) hud.worldPanels.setCurrentLevel(this.world.areaId)
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1)) 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 // 7. Target monster plaque
this.syncHoveredMonsterTarget() this.syncHoveredMonsterTarget()
@ -636,70 +590,43 @@ export class HudModel {
*/ */
allocateStat(key: BaseStatKey): boolean { allocateStat(key: BaseStatKey): boolean {
const statId = STAT_KEY_TO_D2_STAT_ID[key] const statId = STAT_KEY_TO_D2_STAT_ID[key]
if (this.hudManager) {
this.hudManager.charSheet.allocateStat(key)
this.hudManager.syncPublishedState()
}
this.emitCommand({ type: 'AddStatPoint', statId }) this.emitCommand({ type: 'AddStatPoint', statId })
return true return true
} }
/** /**
* Allocate a skill point and emit `AddSkillPoint (0x3B)`. * Request a skill point allocation (`AddSkillPoint 0x3B`). The tree updates when the server answers (0x21).
*/ */
allocateSkill(skillId: number): boolean { allocateSkill(skillId: number): boolean {
if (this.hudManager) {
const current = this.hudManager.skillTree.hardPoints.get(skillId) ?? 0
this.hudManager.skillTree.hardPoints.set(skillId, current + 1)
if (this.hudManager.skillTree.unspentSkillPoints > 0) {
this.hudManager.skillTree.unspentSkillPoints -= 1
}
this.hudManager.syncPublishedState()
}
this.emitCommand({ type: 'AddSkillPoint', skillId }) this.emitCommand({ type: 'AddSkillPoint', skillId })
return true return true
} }
/** /**
* Select active left or right hand skill (`0x3C SelectSkill`). * Request the active left or right hand skill (`0x3C SelectSkill`). The quickbar updates on `0x23`.
*/ */
selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void { selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void {
if (hand === 'left') {
this.world.self.leftSkill = skillId
if (this.hudManager) this.hudManager.hotkeys.leftSkillId = skillId
} else {
this.world.self.rightSkill = skillId
if (this.hudManager) this.hudManager.hotkeys.rightSkillId = skillId
}
this.hudManager?.syncPublishedState()
this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId }) this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId })
} }
/** /**
* Drink a potion from belt column `col` (`0..3`) and emit `BeltUse (0x26)`. * Use the bottom potion of belt column `col` (`0..3`) via `BeltUse (0x26)` with its real server item id.
* Returns `false` (no command) when ClientWorld has no item in that column.
*/ */
useBeltSlot(col: number, shift = false): boolean { useBeltSlot(col: number, shift = false): boolean {
if (col < 0 || col >= BELT_COLS) return false if (col < 0 || col >= BELT_COLS) return false
const beltItems = [...this.world.containers.belt.values()] const beltItem = [...this.world.containers.belt.values()]
const beltItem = beltItems.find((it) => it.gridX % BELT_COLS === col) .filter((it) => it.gridX >= 0 && it.gridX % BELT_COLS === col)
const hudPot = this.hudManager?.belt.grid[0]?.[col] ?? null .sort((a, b) => a.gridX - b.gridX)[0]
if (this.hudManager) { if (!beltItem) return false
this.hudManager.drinkBeltSlot(col) this.emitCommand({ type: 'BeltUse', itemId: beltItem.id, shift })
}
const numericId = beltItem?.id ?? (hudPot ? Number.parseInt(hudPot.id.replace(/\D+/g, ''), 10) || (col + 1) : col + 1)
this.emitCommand({ type: 'BeltUse', itemId: numericId, shift })
return true return true
} }
/** /**
* Toggle active weapon set (`I / II`, hotkey `W`) and emit `SwapWeapons (0x60)`. * Request a weapon set swap (`SwapWeapons 0x60`); `weaponSet` changes when the server confirms.
*/ */
swapWeapons(): void { swapWeapons(): void {
this.world.self.weaponSet = this.world.self.weaponSet === 0 ? 1 : 0
if (this.hudManager) {
this.hudManager.inventory.toggleWeaponSwap()
this.hudManager.syncPublishedState()
}
this.emitCommand({ type: 'SwapWeapons' }) this.emitCommand({ type: 'SwapWeapons' })
} }
@ -732,6 +659,35 @@ export class HudModel {
}) })
} }
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 } })
}
/** /**
* Request casting a Town Portal (`TownPortal`). * Request casting a Town Portal (`TownPortal`).
*/ */
@ -834,3 +790,22 @@ export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHu
affixes: unit.bossMods ? unit.bossMods.map((m) => `Mod #${m}`) : [], affixes: unit.bossMods ? unit.bossMods.map((m) => `Mod #${m}`) : [],
} }
} }
/** 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
}

View File

@ -10,7 +10,7 @@
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer } from './font.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts' import { getTblLang, type TblLang } from '../i18n/lang.ts'
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts' import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
export type { BaseStatKey, CharacterAttributes } export type { BaseStatKey, CharacterAttributes }
@ -25,67 +25,41 @@ export const STAT_ALLOC_BUTTONS: Readonly<Record<BaseStatKey, { x: number; y: nu
ene: { x: 80 + 122, y: 60 + 288, w: 30, h: 30 }, ene: { x: 80 + 122, y: 60 + 288, w: 30, h: 30 },
} }
/**
* Neutral, not-ready character attributes. Every field is filled from server packets
* (`ClientWorld.self`) by `HudModel.syncFromWorld()`; nothing is pre-seeded with demo values.
*/
export function createEmptyCharacterAttributes(): CharacterAttributes {
return {
name: '',
classNameZh: '',
classNameEn: '',
level: 0,
xp: 0,
nextLevelXp: 0,
str: 0,
dex: 0,
vit: 0,
ene: 0,
statPoints: 0,
baseMaxHp: 0,
baseMaxMana: 0,
baseMaxStamina: 0,
defense: 0,
attackRating: 0,
fireRes: 0,
coldRes: 0,
lightRes: 0,
poisRes: 0,
}
}
export class CharacterSheetPanel { export class CharacterSheetPanel {
visible = false visible = false
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
classCode: CharacterClassCode = 'sor' /** `null` until the server assigns the character class. */
attrs: CharacterAttributes = { classCode: CharacterClassCode | null = null
name: 'Isendra', attrs: CharacterAttributes = createEmptyCharacterAttributes()
classNameZh: '女法师',
classNameEn: 'Sorceress',
level: 85,
xp: 1_068_078_030,
nextLevelXp: 1_163_635_493,
str: 156,
dex: 75,
vit: 315,
ene: 95,
statPoints: 15,
baseMaxHp: 1180,
baseMaxMana: 940,
baseMaxStamina: 465,
defense: 1845,
attackRating: 1240,
fireRes: 75,
coldRes: 75,
lightRes: 75,
poisRes: 75,
}
private resolveClassCode(): CharacterClassCode {
const en = (this.attrs.classNameEn ?? '').toLowerCase()
if (en.includes('barbarian')) return 'bar'
if (en.includes('paladin')) return 'pal'
if (en.includes('amazon')) return 'ama'
if (en.includes('necromancer')) return 'nec'
if (en.includes('druid')) return 'dru'
if (en.includes('assassin')) return 'ass'
if (en.includes('sorceress')) return 'sor'
return this.classCode
}
/**
* Allocate 1 unspent stat point into `key`, applying 1.13c per-class `CharStats.txt` gains:
* - +1 Vitality = +`lifePerVitality` Life, +`staminaPerVitality` Stamina
* - +1 Energy = +`manaPerEnergy` Mana
* - +1 Dexterity = +5 Attack Rating, +1 Defense
*/
allocateStat(key: BaseStatKey): boolean {
if (this.attrs.statPoints <= 0) return false
const cStats = CANONICAL_CHARSTATS[this.resolveClassCode()] ?? CANONICAL_CHARSTATS.sor
this.attrs.statPoints -= 1
this.attrs[key] += 1
if (key === 'vit') {
this.attrs.baseMaxHp += cStats.perStatPoint.lifePerVitality
this.attrs.baseMaxStamina += cStats.perStatPoint.staminaPerVitality
} else if (key === 'ene') {
this.attrs.baseMaxMana += cStats.perStatPoint.manaPerEnergy
} else if (key === 'dex') {
this.attrs.attackRating += 5
this.attrs.defense += 1
}
return true
}
handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean { handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean {
if (!this.visible) return false if (!this.visible) return false
@ -106,9 +80,8 @@ export class CharacterSheetPanel {
if (this.attrs.statPoints > 0) { if (this.attrs.statPoints > 0) {
for (const [key, b] of Object.entries(STAT_ALLOC_BUTTONS) as [BaseStatKey, typeof STAT_ALLOC_BUTTONS[BaseStatKey]][]) { for (const [key, b] of Object.entries(STAT_ALLOC_BUTTONS) as [BaseStatKey, typeof STAT_ALLOC_BUTTONS[BaseStatKey]][]) {
if (logicalX >= b.x && logicalX <= b.x + b.w && logicalY >= b.y && logicalY <= b.y + b.h) { if (logicalX >= b.x && logicalX <= b.x + b.w && logicalY >= b.y && logicalY <= b.y + b.h) {
if (this.allocateStat(key)) { // Server-authoritative: only request `AddStatPoint (0x3A)`; the new values arrive via 0x1D..0x1F.
onStatAllocated?.(key) onStatAllocated?.(key)
}
return true return true
} }
} }

View File

@ -184,7 +184,7 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number {
export function getAvailableSkillsForSide( export function getAvailableSkillsForSide(
side: 'left' | 'right', side: 'left' | 'right',
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS, skills: readonly HotkeySkillEntry[],
): readonly HotkeySkillEntry[] { ): readonly HotkeySkillEntry[] {
return skills.filter(s => { return skills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false if (isPassiveSkill(s.skillId) || s.isPassive) return false
@ -203,8 +203,9 @@ export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as c
export class SkillHotkeysHud { export class SkillHotkeysHud {
lang: TblLang = 'CHI' lang: TblLang = 'CHI'
leftSkillId = 47 // Fire Ball /** Active left/right skill; mirrors server `0x23 AssignSkill` (0 = Attack until the server says otherwise). */
rightSkillId = 64 // Frozen Orb leftSkillId = 0
rightSkillId = 0
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */ /** Which Speedbar popup is currently expanded ('left', 'right', or null). */
openPopup: 'left' | 'right' | null = null openPopup: 'left' | 'right' | null = null
/** Hovered skill inside the Speedbar popup. */ /** Hovered skill inside the Speedbar popup. */
@ -226,18 +227,15 @@ export class SkillHotkeysHud {
} }
/** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */ /** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */
readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>([ readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>()
['F1', { side: 'right', skillId: 64 }],
['F2', { side: 'right', skillId: 59 }],
['F3', { side: 'right', skillId: 56 }],
['F4', { side: 'right', skillId: 54 }],
['F5', { side: 'left', skillId: 47 }],
['F6', { side: 'left', skillId: 49 }],
['F7', { side: 'right', skillId: 40 }],
['F8', { side: 'right', skillId: 42 }],
])
availableSkills: HotkeySkillEntry[] = [...DEFAULT_SORCERESS_SKILLS] /** Quickbar skills; rebuilt from the server skill list by `HudModel.syncFromWorld()`. */
availableSkills: HotkeySkillEntry[] = []
/**
* Server-authoritative skill selection sink. When set (online), popup clicks and F-keys only request
* `SelectSkill (0x3C)`; the active slots change when the server answers with `0x23 AssignSkill`.
*/
onSelectSkill?: ((side: 'left' | 'right', skillId: number) => void) | undefined
hasEquippedWeapon = true hasEquippedWeapon = true
fistImage: HTMLImageElement | null = null fistImage: HTMLImageElement | null = null
swordImage: HTMLImageElement | null = null swordImage: HTMLImageElement | null = null
@ -356,6 +354,21 @@ export class SkillHotkeysHud {
this.preloadSkillIcons() this.preloadSkillIcons()
} }
/**
* Select `skillId` for `side` if it is legal there (no passives; no auras / right-only skills on the left).
* Online (`onSelectSkill` set) this only emits the request; offline widget use applies it directly.
*/
private requestSelect(side: 'left' | 'right', skillId: number): void {
if (isPassiveSkill(skillId)) return
if (side === 'left' && (!isLeftUsableSkill(skillId) || isAuraSkill(skillId))) return
if (this.onSelectSkill) {
this.onSelectSkill(side, skillId)
return
}
if (side === 'left') this.leftSkillId = skillId
else this.rightSkillId = skillId
}
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null { getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
for (const [key, binding] of this.bindings.entries()) { for (const [key, binding] of this.bindings.entries()) {
if (binding.side === side && binding.skillId === skillId) return key if (binding.side === side && binding.skillId === skillId) return key
@ -419,15 +432,7 @@ export class SkillHotkeysHud {
} }
const bound = this.bindings.get(key) const bound = this.bindings.get(key)
if (!bound) return null if (!bound) return null
if (bound.side === 'left') { this.requestSelect(bound.side, bound.skillId)
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
this.leftSkillId = bound.skillId
}
} else {
if (!isPassiveSkill(bound.skillId)) {
this.rightSkillId = bound.skillId
}
}
return bound return bound
} }
@ -565,15 +570,7 @@ export class SkillHotkeysHud {
const cells = this.getPopupCells(this.openPopup) const cells = this.getPopupCells(this.openPopup)
for (const c of cells) { for (const c of cells) {
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) { if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
if (this.openPopup === 'left') { this.requestSelect(this.openPopup, c.skill.skillId)
if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) {
this.leftSkillId = c.skill.skillId
}
} else {
if (!isPassiveSkill(c.skill.skillId)) {
this.rightSkillId = c.skill.skillId
}
}
this.openPopup = null this.openPopup = null
return true return true
} }

View File

@ -27,10 +27,12 @@ import {
type UiInventoryItem, type UiInventoryItem,
resolveItemSpriteRect, resolveItemSpriteRect,
INV_GRID_ORIGIN, INV_GRID_ORIGIN,
EQUIP_SLOTS_LAYOUT,
isUsableRightClickItem, isUsableRightClickItem,
} from './inventory.ts' } from './inventory.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts' import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts' import { SkillTreePanel } from './skill-tree-panel.ts'
import { import {
WorldPanelsHud, WorldPanelsHud,
type LeftDockPanelKind, type LeftDockPanelKind,
@ -291,6 +293,35 @@ export function logicalToClient(
} }
/**
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
* the HUD never mutates gameplay state locally while a sink is attached.
*/
export interface HudCommandSink {
allocateStat(key: BaseStatKey): boolean
allocateSkill(skillId: number): boolean
selectSkill(hand: 'left' | 'right', skillId: number): void
useBeltSlot(col: number): boolean
swapWeapons(): void
pickupItemToCursor(itemId: number): void
/** `buffer`: `0` = inventory, `3` = cube, `4` = stash. */
placeCursorItem(itemId: number, col: number, row: number, buffer: number): void
equipItem(itemId: number, slot: EquipSlotId): void
unequipItem(slot: EquipSlotId): void
dropItem(itemId: number): void
dropGold(amount: number): void
useItem(itemId: number): void
transmuteCube(): void
teleportToLevel(destAreaId: number): void
}
/** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */
export function serverItemIdOf(item: UiInventoryItem): number {
const id = Number(item.id)
if (!Number.isInteger(id) || id < 0) throw new Error(`HUD item "${item.id}" has no server item id`)
return id
}
export class HudManager { export class HudManager {
readonly font = new D2FontRenderer() readonly font = new D2FontRenderer()
readonly globes = new GlobesHud() readonly globes = new GlobesHud()
@ -307,12 +338,13 @@ export class HudManager {
rightPanel: 'none' | 'inv' | 'skill' = 'none' rightPanel: 'none' | 'inv' | 'skill' = 'none'
isRunning = true isRunning = true
showGroundLabels = false showGroundLabels = false
stamina = 465 /** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
maxStamina = 465 stamina = 0
hp = 1180 maxStamina = 0
maxHp = 1180 hp = 0
mana = 940 maxHp = 0
maxMana = 940 mana = 0
maxMana = 0
mouseX = 400 mouseX = 400
mouseY = 300 mouseY = 300
@ -347,7 +379,7 @@ export class HudManager {
leftPanel: 'none', leftPanel: 'none',
rightPanel: 'none', rightPanel: 'none',
beltExpanded: false, beltExpanded: false,
beltPotionsCount: 16, beltPotionsCount: 0,
miniPanelOpen: true, miniPanelOpen: true,
isRunning: true, isRunning: true,
showGroundLabels: false, showGroundLabels: false,
@ -355,22 +387,54 @@ export class HudManager {
equippedSlots: [], equippedSlots: [],
bagItemsCount: 0, bagItemsCount: 0,
weaponSwapSet: 0, weaponSwapSet: 0,
leftSkillId: 47, leftSkillId: 0,
rightSkillId: 64, rightSkillId: 0,
characterLevel: 85, characterLevel: 0,
statPoints: 15, statPoints: 0,
skillPoints: 12, skillPoints: 0,
hp: 1180, hp: 0,
maxHp: 1180, maxHp: 0,
mana: 940, mana: 0,
maxMana: 940, maxMana: 0,
stamina: 465, stamina: 0,
maxStamina: 465, maxStamina: 0,
docking: computeDockingLayout(800, 600, 1), docking: computeDockingLayout(800, 600, 1),
vendorTradeState: 'idle', vendorTradeState: 'idle',
} }
/** `null` until the server assigns the character class (see `setCharacterClass`). */
currentClass: CharacterClassCode | null = null currentClass: CharacterClassCode | null = null
private _commandSink: HudCommandSink | null = null
/**
* Server-authoritative command sink (online: `HudModel`). When attached, HUD interactions only emit
* `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`.
*/
get commandSink(): HudCommandSink | null {
return this._commandSink
}
set commandSink(sink: HudCommandSink | null) {
this._commandSink = sink
this.hotkeys.onSelectSkill = sink ? (side, skillId) => sink.selectSkill(side, skillId) : undefined
}
/**
* Apply the server-assigned character class (MCP character list / `0x59` UnitAssign classId) to every
* class-dependent panel: skill tree layout, quickbar, character sheet and item requirement context.
* Hard points, bonus levels and quickbar skills are cleared; `HudModel` rebuilds them from the server skill list.
*/
setCharacterClass(classCode: CharacterClassCode): void {
if (this.currentClass === classCode && this.skillTree.characterClass === classCode) return
this.currentClass = classCode
this.charSheet.classCode = classCode
this.skillTree.setClass(classCode)
this.skillTree.hardPoints.clear()
this.skillTree.bonusLevels.clear()
this.hotkeys.setAvailableSkills([])
this.syncPublishedState()
}
private classProfiles = new Map<CharacterClassCode, ClassProfileState>() private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
private _lang: TblLang = 'CHI' private _lang: TblLang = 'CHI'
@ -562,27 +626,9 @@ export class HudManager {
: normAttr === 'vitality' : normAttr === 'vitality'
? 'vit' ? 'vit'
: 'ene' : 'ene'
const prevMaxHp = this.charSheet.attrs.baseMaxHp // Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers.
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina if (this._commandSink) return this._commandSink.allocateStat(shortKey)
const prevMaxMana = this.charSheet.attrs.baseMaxMana return false
const ok = this.charSheet.allocateStat(shortKey)
if (ok) {
if (shortKey === 'vit') {
const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp)
const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina)
this.maxHp = this.charSheet.attrs.baseMaxHp
this.hp = Math.min(this.maxHp, this.hp + hpGain)
this.maxStamina = this.charSheet.attrs.baseMaxStamina
this.stamina = Math.min(this.maxStamina, this.stamina + stamGain)
} else if (shortKey === 'ene') {
const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana)
this.maxMana = this.charSheet.attrs.baseMaxMana
this.mana = Math.min(this.maxMana, this.mana + manaGain)
}
this.syncPublishedState()
for (const cb of this.changeListeners) cb()
}
return ok
} }
grantExperience(amount: number): { levelsGained: number; newLevel: number } { grantExperience(amount: number): { levelsGained: number; newLevel: number } {
@ -645,11 +691,6 @@ export class HudManager {
} }
useBeltSlot(col: number): boolean { useBeltSlot(col: number): boolean {
if (this.engine) {
const ok = this.engine.useBeltSlot(col)
if (ok) this.syncFromEngine()
return ok
}
return this.drinkBeltSlot(col) return this.drinkBeltSlot(col)
} }
@ -663,13 +704,9 @@ export class HudManager {
this.syncFromEngine() this.syncFromEngine()
return dropped return dropped
} }
const dropped = this.inventory.dropGold(amount) // Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
if (dropped > 0) { this._commandSink?.dropGold(amount)
this.callbacks.onDropGoldToGround?.(dropped, mousePos) return 0
}
this.syncPublishedState()
for (const cb of this.changeListeners) cb()
return dropped
} }
pickupGroundGold(groundItemId: number | string): number { pickupGroundGold(groundItemId: number | string): number {
@ -921,11 +958,14 @@ export class HudManager {
mode: 'trade' | 'gamble' = 'trade', mode: 'trade' | 'gamble' = 'trade',
difficulty: 0 | 1 | 2 = 0, difficulty: 0 | 1 | 2 = 0,
): boolean { ): boolean {
if (this.currentClass === null) {
throw new Error('HudManager.openVendorSession: character class not assigned by the server yet')
}
const playerCtx = { const playerCtx = {
level: this.charSheet.attrs.level, level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str, str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex, dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor', classCode: this.currentClass,
} }
this.worldPanels.activePlayerInventory = this.inventory this.worldPanels.activePlayerInventory = this.inventory
this.inventory.setPlayerContext(playerCtx) this.inventory.setPlayerContext(playerCtx)
@ -1112,23 +1152,9 @@ export class HudManager {
if (ok) this.syncFromEngine() if (ok) this.syncFromEngine()
return ok return ok
} }
const topPotion = this.belt.slots[0]?.[col] ?? null // Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
const eff = topPotion ? resolvePotionSpec(topPotion.code) : undefined if (this._commandSink) return this._commandSink.useBeltSlot(col)
const consumed = this.belt.useSlot(col, 0) return false
if (!consumed) return false
this.hp = Math.min(this.maxHp, this.hp + consumed.healHp)
this.mana = Math.min(this.maxMana, this.mana + consumed.healMana)
if (eff?.staminaRestorePct || eff?.state === 'staminapot') {
this.stamina = this.maxStamina
}
if (this.engine) {
this.engine.world.player.hp = this.hp
this.engine.world.player.mana = this.mana
this.engine.world.player.stamina = this.stamina
}
this.syncPublishedState()
for (const cb of this.changeListeners) cb()
return true
} }
private bindEvents(): void { private bindEvents(): void {
@ -1177,7 +1203,11 @@ export class HudManager {
} }
} else if (key === 'W') { } else if (key === 'W') {
e.preventDefault() e.preventDefault()
this.inventory.toggleWeaponSwap() if (this._commandSink) {
this._commandSink.swapWeapons()
} else {
this.inventory.toggleWeaponSwap()
}
this.syncPublishedState() this.syncPublishedState()
} else if (key === 'R') { } else if (key === 'R') {
e.preventDefault() e.preventDefault()
@ -1323,8 +1353,12 @@ export class HudManager {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const dropped = this.inventory.cursorItem const dropped = this.inventory.cursorItem
this.inventory.cursorItem = null if (this._commandSink) {
this.callbacks.onDropItemToGround?.(dropped, pt) this._commandSink.dropItem(serverItemIdOf(dropped))
} else {
this.inventory.cursorItem = null
this.callbacks.onDropItemToGround?.(dropped, pt)
}
this.syncPublishedState() this.syncPublishedState()
} }
return return
@ -1401,9 +1435,6 @@ export class HudManager {
if (pt.x <= 400 + deltaLeft) { if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft const leftX = pt.x - deltaLeft
if (this.leftPanel === 'char') { if (this.leftPanel === 'char') {
const prevMaxHp = this.charSheet.attrs.baseMaxHp
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina
const prevMaxMana = this.charSheet.attrs.baseMaxMana
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
if (this.engine) { if (this.engine) {
const normAttr = const normAttr =
@ -1418,18 +1449,7 @@ export class HudManager {
this.syncFromEngine() this.syncFromEngine()
return return
} }
if (key === 'vit') { this._commandSink?.allocateStat(key)
const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp)
const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina)
this.maxHp = this.charSheet.attrs.baseMaxHp
this.hp = Math.min(this.maxHp, this.hp + hpGain)
this.maxStamina = this.charSheet.attrs.baseMaxStamina
this.stamina = Math.min(this.maxStamina, this.stamina + stamGain)
} else if (key === 'ene') {
const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana)
this.maxMana = this.charSheet.attrs.baseMaxMana
this.mana = Math.min(this.maxMana, this.mana + manaGain)
}
}) })
if (!this.charSheet.visible) this.leftPanel = 'none' if (!this.charSheet.visible) this.leftPanel = 'none'
this.syncPublishedState() this.syncPublishedState()
@ -1440,8 +1460,24 @@ export class HudManager {
this.leftPanel = 'none' this.leftPanel = 'none'
}, },
onWaypointTeleport: (act, slug, levelId) => { onWaypointTeleport: (act, slug, levelId) => {
this.callbacks.onWaypointTeleport(act, slug, levelId) if (this._commandSink) {
this._commandSink.teleportToLevel(levelId)
} else {
this.callbacks.onWaypointTeleport(act, slug, levelId)
}
}, },
...(this._commandSink
? {
onTransmute: () => this._commandSink?.transmuteCube(),
onContainerGridClick: (
container: 'stash' | 'cube',
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => this.forwardGridClick(container === 'stash' ? 4 : 3, col, row, hitItem, isRightClick),
}
: {}),
onOpenCube: () => this.toggleLeftPanel('cube'), onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
inventory: this.inventory, inventory: this.inventory,
@ -1457,7 +1493,7 @@ export class HudManager {
if (pt.x >= 400 + deltaRight) { if (pt.x >= 400 + deltaRight) {
const rightX = pt.x - deltaRight const rightX = pt.x - deltaRight
if (this.rightPanel === 'inv') { if (this.rightPanel === 'inv') {
if (!this.inventory.isIdentifyMode()) { if (!this.inventory.isIdentifyMode() && !this._commandSink) {
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items) // Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
if ( if (
@ -1546,6 +1582,11 @@ export class HudManager {
} }
} }
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
this.syncPublishedState()
return
}
this.syncPlayerContext() this.syncPlayerContext()
this.inventory.handleClick( this.inventory.handleClick(
rightX, rightX,
@ -1575,7 +1616,63 @@ export class HudManager {
} }
} }
/**
* Forward an inventory-panel click (grid / paperdoll) to the server sink. Returns `false` when the point is not
* on an item area so the panel's own chrome (close / gold buttons / swap tabs) still handles it.
*/
private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean {
const sink = this._commandSink
if (!sink) return false
const g = INV_GRID_ORIGIN
if (x >= g.x && x < g.x + g.cols * g.cellPx && y >= g.y && y < g.y + g.rows * g.cellPx) {
const col = Math.floor((x - g.x) / g.cellPx)
const row = Math.floor((y - g.y) / g.cellPx)
const hit = this.inventory.gridItems.find(
(p) => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
this.forwardGridClick(0, col, row, hit?.item ?? null, isRightClick)
return true
}
for (const slot of Object.keys(EQUIP_SLOTS_LAYOUT) as EquipSlotId[]) {
const r = EQUIP_SLOTS_LAYOUT[slot]
if (x < r.x || x >= r.x + r.w || y < r.y || y >= r.y + r.h) continue
const cursor = this.inventory.cursorItem
if (cursor) {
sink.equipItem(serverItemIdOf(cursor), slot)
} else if (this.inventory.equipped[slot]) {
sink.unequipItem(slot)
}
return true
}
return false
}
/** Forward a container grid click: place the cursor item, use (right-click) or pick up the hit item. */
private forwardGridClick(
buffer: number,
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
): void {
const sink = this._commandSink
if (!sink) return
const cursor = this.inventory.cursorItem
if (cursor) {
sink.placeCursorItem(serverItemIdOf(cursor), col, row, buffer)
} else if (hitItem && isRightClick) {
if (isUsableRightClickItem(hitItem.code)) sink.useItem(serverItemIdOf(hitItem))
} else if (hitItem) {
sink.pickupItemToCursor(serverItemIdOf(hitItem))
}
}
private handleSkillAllocated(skillId: number): void { private handleSkillAllocated(skillId: number): void {
// Server authority: request `AddSkillPoint`; the tree and quickbar update from the server skill list.
if (this._commandSink) {
this._commandSink.allocateSkill(skillId)
return
}
if (isPassiveSkill(skillId)) return if (isPassiveSkill(skillId)) return
const catalogEntry = SKILLS_BY_ID[skillId] const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) return if (!catalogEntry) return
@ -1607,7 +1704,7 @@ export class HudManager {
return { return {
level: this.charSheet.attrs.level, level: this.charSheet.attrs.level,
xp: this.charSheet.attrs.xp, xp: this.charSheet.attrs.xp,
currentLevelXp: 1_000_000_000, currentLevelXp: D2_EXPERIENCE_TABLE_99[this.charSheet.attrs.level] ?? 0,
nextLevelXp: this.charSheet.attrs.nextLevelXp, nextLevelXp: this.charSheet.attrs.nextLevelXp,
stamina: this.stamina, stamina: this.stamina,
maxStamina: this.maxStamina, maxStamina: this.maxStamina,
@ -1631,11 +1728,13 @@ export class HudManager {
} }
syncPlayerContext(): void { syncPlayerContext(): void {
// Not ready until the server assigns the class; requirement checks keep their neutral context.
if (this.currentClass === null) return
const playerCtx = { const playerCtx = {
level: this.charSheet.attrs.level, level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str, str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex, dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor', classCode: this.currentClass,
} }
this.inventory.setPlayerContext(playerCtx) this.inventory.setPlayerContext(playerCtx)
this.worldPanels.setPlayerContext(playerCtx) this.worldPanels.setPlayerContext(playerCtx)
@ -1702,12 +1801,8 @@ export class HudManager {
playerMoving: boolean = false, playerMoving: boolean = false,
): void { ): void {
this.syncPlayerContext() this.syncPlayerContext()
if (playerMoving && this.isRunning) { // Stamina is server state (stat 10 / 0x95); no client-side drain/regen simulation.
this.stamina = Math.max(0, this.stamina - 0.12) void playerMoving
if (this.stamina <= 0) this.isRunning = false
} else {
this.stamina = Math.min(this.maxStamina, this.stamina + 0.35)
}
const profile = this.viewportProfile const profile = this.viewportProfile
const dpr = Math.min(window.devicePixelRatio || 1, 2) const dpr = Math.min(window.devicePixelRatio || 1, 2)
@ -1755,10 +1850,6 @@ export class HudManager {
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`) // 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
if (this.leftPanel === 'char') { if (this.leftPanel === 'char') {
const leftNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.leftSkillId)
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
ctx.save() ctx.save()
ctx.translate(deltaLeft, panelOffsetY) ctx.translate(deltaLeft, panelOffsetY)
this.charSheet.draw( this.charSheet.draw(
@ -1770,12 +1861,10 @@ export class HudManager {
maxMana: this.maxMana, maxMana: this.maxMana,
stamina: this.stamina, stamina: this.stamina,
maxStamina: this.maxStamina, maxStamina: this.maxStamina,
leftSkillDmg: leftNode // Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc
? `${leftNode.baseDmgMin + lEff * leftNode.dmgPerLevel}-${leftNode.baseDmgMax + lEff * Math.round(leftNode.dmgPerLevel * 1.25)}` // (no fabricated per-level numbers).
: '45-82', leftSkillDmg: '',
rightSkillDmg: rightNode rightSkillDmg: '',
? `${rightNode.baseDmgMin + rEff * rightNode.dmgPerLevel}-${rightNode.baseDmgMax + rEff * Math.round(rightNode.dmgPerLevel * 1.25)}`
: '520-585',
}, },
{ {
borderLeftImg: this.images.get('borderLeft') ?? null, borderLeftImg: this.images.get('borderLeft') ?? null,

View File

@ -2043,21 +2043,18 @@ export class InventoryPanel {
visible = false visible = false
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */ /** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
weaponSwapSet: 0 | 1 = 0 weaponSwapSet: 0 | 1 = 0
/** Equipped gear slots. */ /** Equipped gear slots (empty until the server sends `0x9C`/`0x9D` equip events). */
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & { readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
get: (slot: string) => UiInventoryItem | undefined get: (slot: string) => UiInventoryItem | undefined
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
} = { ...STARTER_EQUIPPED_GEAR } as any } = {} as any
/** Secondary weapon swap slots (`Weapon Set II`). */ /** Secondary weapon swap slots (`Weapon Set II`). */
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = { readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
weapon1: null, weapon1: null,
weapon2: null, weapon2: null,
} }
/** 10x4 grid items (deep-cloned per instance so quantity/stat mutations do not leak across instances). */ /** 10x4 grid items (empty until the server sends inventory item events). */
gridItems: GridPlacement[] = STARTER_BAG_ITEMS.map(p => ({ gridItems: GridPlacement[] = []
...p,
item: { ...p.item, stats: [...p.item.stats] },
}))
private _starterCleared = false private _starterCleared = false
private _cursorItem: UiInventoryItem | null = null private _cursorItem: UiInventoryItem | null = null
/** Item currently held on the mouse cursor (`cursorItem`). */ /** Item currently held on the mouse cursor (`cursorItem`). */
@ -2087,10 +2084,9 @@ export class InventoryPanel {
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`). * 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
*/ */
playerContext: PlayerItemRequirementContext = { playerContext: PlayerItemRequirementContext = {
level: 85, level: 0,
str: 156, str: 0,
dex: 75, dex: 0,
classCode: 'sor',
} }
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */ /** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
playerContextSeq = 0 playerContextSeq = 0
@ -2135,7 +2131,8 @@ export class InventoryPanel {
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */ /** Optional bound GameEngine for single-source-of-truth gold and capacity. */
_boundEngine?: any | undefined _boundEngine?: any | undefined
private _gold = 850_000 /** Server stat 14 (`gold`); 0 until received (D2GS omits zero-valued stats). */
private _gold = 0
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */ /** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
get maxGold(): number { get maxGold(): number {

View File

@ -42,7 +42,11 @@ export interface SkillNodeDef {
readonly baseMana: number readonly baseMana: number
readonly baseDmgMin: number readonly baseDmgMin: number
readonly baseDmgMax: number readonly baseDmgMax: number
readonly dmgPerLevel: number /**
* Legacy hand-entered value of the old Sorceress demo table only; NOT a 1.13c Skills.txt column and never used
* for display. Generated class trees leave it undefined.
*/
readonly dmgPerLevel?: number | undefined
} }
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
@ -179,7 +183,6 @@ function buildClassSkillTrees(): Record<CharacterClassCode, readonly SkillNodeDe
baseMana: s.mana, baseMana: s.mana,
baseDmgMin: s.minDam, baseDmgMin: s.minDam,
baseDmgMax: s.maxDam, baseDmgMax: s.maxDam,
dmgPerLevel: 5,
}) })
} }
return result return result
@ -192,31 +195,44 @@ export class SkillTreePanel {
lang: TblLang = 'CHI' lang: TblLang = 'CHI'
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */ /** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
activeTab: 0 | 1 | 2 = 2 activeTab: 0 | 1 | 2 = 2
unspentSkillPoints = 12 /** Server stat 5 (`newskills`). */
bonusAllSkills = 8 unspentSkillPoints = 0
characterClass: CharacterClassCode = 'sor' /**
characterLevel = 85 * Generic `+all skills` soft points used only by the offline skill calculator state.
readonly hardPoints = new Map<number, number>([ * Online, per-skill bonuses come from the server (`0x21` UpdateSkill bonus byte) via `bonusLevels`.
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10], */
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1], bonusAllSkills = 0
[36, 1], [37, 1], [47, 15], [56, 10], [61, 5], /** `null` until the server assigns the character class (no implicit Sorceress default). */
]) characterClass: CharacterClassCode | null = null
/** Server character level (stat 12); 0 = not yet received. */
characterLevel = 0
/** Hard (base) skill levels, rebuilt from the server skill list on every sync. */
readonly hardPoints = new Map<number, number>()
/** Per-skill bonus levels reported by the server (`0x21` bonus byte: +all/+class/+tab/+single). */
readonly bonusLevels = new Map<number, number>()
hoveredSkill: SkillNodeDef | null = null hoveredSkill: SkillNodeDef | null = null
private tabBgs = new Map<number, HTMLImageElement>() private tabBgs = new Map<number, HTMLImageElement>()
private skillIcons = new Map<number, HTMLImageElement>() private skillIcons = new Map<number, HTMLImageElement>()
private tabBgClass: CharacterClassCode = 'sor' private tabBgClass: CharacterClassCode | null = null
getSkillTree(): readonly SkillNodeDef[] { getSkillTree(): readonly SkillNodeDef[] {
return CLASS_SKILL_TREES[this.characterClass] ?? SORCERESS_SKILL_TREE if (this.characterClass === null) return []
const tree = CLASS_SKILL_TREES[this.characterClass]
if (!tree) throw new Error(`SkillTreePanel: no skill tree for class '${this.characterClass}'`)
return tree
} }
getVerticalTabs(): readonly SkillTreeTabInfo[] { getVerticalTabs(): readonly SkillTreeTabInfo[] {
return CLASS_VERTICAL_TABS[this.characterClass] ?? CLASS_VERTICAL_TABS.sor if (this.characterClass === null) return []
const tabs = CLASS_VERTICAL_TABS[this.characterClass]
if (!tabs) throw new Error(`SkillTreePanel: no tab layout for class '${this.characterClass}'`)
return tabs
} }
preloadAssets(): void { preloadAssets(): void {
if (typeof Image === 'undefined') return if (typeof Image === 'undefined') return
if (this.characterClass === null) return
for (const tab of [0, 1, 2]) { for (const tab of [0, 1, 2]) {
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) { if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
const bg = new Image() const bg = new Image()
@ -262,30 +278,48 @@ export class SkillTreePanel {
return this.hardPoints.get(skillId) ?? 0 return this.hardPoints.get(skillId) ?? 0
} }
getEffectiveLevel(skillId: number): number { /** Server-reported bonus levels for `skillId` (0 when none). */
const hard = this.getHardPoints(skillId) getBonusLevels(skillId: number): number {
return hard > 0 ? hard + this.bonusAllSkills : 0 return this.bonusLevels.get(skillId) ?? 0
} }
getSkillCalcState(): SkillCalcState { getEffectiveLevel(skillId: number): number {
const hard = this.getHardPoints(skillId)
const bonus = this.getBonusLevels(skillId) + this.bonusAllSkills
return hard > 0 ? hard + bonus : 0
}
/** Skill calculator state; `null` while the class is not yet known. */
getSkillCalcState(): SkillCalcState | null {
if (this.characterClass === null) return null
const hardPoints: Record<number, number> = {} const hardPoints: Record<number, number> = {}
for (const [id, pts] of this.hardPoints.entries()) { for (const [id, pts] of this.hardPoints.entries()) {
if (pts > 0) hardPoints[id] = pts if (pts > 0) hardPoints[id] = pts
} }
const specificSoftPoints: Record<number, number> = {}
for (const [id, bonus] of this.bonusLevels.entries()) {
if (bonus > 0) specificSoftPoints[id] = bonus
}
return { return {
classCode: this.characterClass, classCode: this.characterClass,
hardPoints, hardPoints,
tabSoftPoints: { 0: 0, 1: 0, 2: 0 }, tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
allSkillsSoftPoints: this.bonusAllSkills, allSkillsSoftPoints: this.bonusAllSkills,
specificSoftPoints: {}, specificSoftPoints,
} }
} }
getTooltipViewModel(skillId: number): TooltipViewModel | null { getTooltipViewModel(skillId: number): TooltipViewModel | null {
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel) const state = this.getSkillCalcState()
if (!state) return null
return buildTooltipViewModel(state, skillId, this.characterLevel)
} }
canAllocate(skillId: number, charLevel = 85): boolean { /**
* Whether the 1.13c client would send `AddSkillPoint (0x3B)` for `skillId`: unspent points, class tree
* membership, `Skills.txt reqlevel` (+1 per hard point) against the real character level, and prerequisites.
*/
canAllocate(skillId: number, charLevel: number = this.characterLevel): boolean {
if (this.unspentSkillPoints <= 0) return false if (this.unspentSkillPoints <= 0) return false
const node = this.getSkillTree().find(n => n.skillId === skillId) const node = this.getSkillTree().find(n => n.skillId === skillId)
if (!node) return false if (!node) return false
@ -295,13 +329,6 @@ export class SkillTreePanel {
return node.prereqs.every(p => this.getHardPoints(p) > 0) return node.prereqs.every(p => this.getHardPoints(p) > 0)
} }
allocateSkill(skillId: number, charLevel = 85): boolean {
if (!this.canAllocate(skillId, charLevel)) return false
this.hardPoints.set(skillId, this.getHardPoints(skillId) + 1)
this.unspentSkillPoints -= 1
return true
}
static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } { static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } {
const colIdx = Math.max(0, Math.min(2, node.col - 1)) const colIdx = Math.max(0, Math.min(2, node.col - 1))
const rowIdx = Math.max(0, Math.min(5, node.row - 1)) const rowIdx = Math.max(0, Math.min(5, node.row - 1))
@ -310,9 +337,11 @@ export class SkillTreePanel {
return { x, y, w: 48, h: 48 } return { x, y, w: 48, h: 48 }
} }
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } { /** Close-button socket for the active class/tab; `null` while the class is unknown. */
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass] ?? SKILL_CLOSE_BTN_OFFSET_X.sor getCloseButtonBounds(): { x: number; y: number; w: number; h: number } | null {
const offsetX = offsets[this.activeTab] ?? 171 if (this.characterClass === null) return null
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass]
const offsetX = offsets[this.activeTab]
return { return {
x: SKILL_PANEL_ORIGIN.x + offsetX, x: SKILL_PANEL_ORIGIN.x + offsetX,
y: SKILL_PANEL_ORIGIN.y + 385, y: SKILL_PANEL_ORIGIN.y + 385,
@ -341,6 +370,7 @@ export class SkillTreePanel {
// Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385) // Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385)
const closeBounds = this.getCloseButtonBounds() const closeBounds = this.getCloseButtonBounds()
if ( if (
closeBounds &&
logicalX >= closeBounds.x && logicalX >= closeBounds.x &&
logicalX <= closeBounds.x + closeBounds.w && logicalX <= closeBounds.x + closeBounds.w &&
logicalY >= closeBounds.y && logicalY >= closeBounds.y &&
@ -372,7 +402,8 @@ export class SkillTreePanel {
for (const node of tabNodes) { for (const node of tabNodes) {
const r = SkillTreePanel.nodeScreenPos(node) const r = SkillTreePanel.nodeScreenPos(node)
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) { if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
if (this.allocateSkill(node.skillId)) { // Server-authoritative: request `AddSkillPoint (0x3B)` only; levels arrive via 0x21/0x94.
if (this.canAllocate(node.skillId)) {
onSkillAllocated?.(node.skillId) onSkillAllocated?.(node.skillId)
} }
return true return true
@ -521,8 +552,8 @@ export class SkillTreePanel {
} }
// 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket) // 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket)
if (assets.buySellBtnImg) { const closeBounds = this.getCloseButtonBounds()
const closeBounds = this.getCloseButtonBounds() if (assets.buySellBtnImg && closeBounds) {
ctx.drawImage( ctx.drawImage(
assets.buySellBtnImg, assets.buySellBtnImg,
10 * 32, 10 * 32,

View File

@ -591,19 +591,41 @@ export interface WaypointEntry {
readonly unlocked: boolean readonly unlocked: boolean
} }
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3']) /**
* Static quest catalog entries start as `locked`; the live status comes exclusively from the server
* quest-flag block (`0x29` GameQuestLog / `0x28` QuestInfo) via `WorldPanelsHud.applyServerQuestFlags()`.
*/
function createQuestEntry(item: CanonicalQuestItem): QuestEntry { function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
return { return {
...item, ...item,
status: isCompleted ? 'completed' : 'active', status: 'locked',
description: isCompleted description: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
} }
} }
/**
* First quest-flag word of each act inside the 1.13c 48-word (96-byte) quest record sent by `0x29`
* (same layout as the `.d2s` "Woo!" quest block): word 0 = Act I intro, 1..6 = Act I quests,
* 7/8 = Act II travel/intro, 9..14 = Act II, 15/16, 17..22 = Act III, 23/24, 25..27 = Act IV, 35..40 = Act V.
*/
const QUEST_FLAG_ACT_BASE: Readonly<Record<number, number>> = { 1: 1, 2: 9, 3: 17, 4: 25, 5: 35 }
/**
* Quest-flag word index for a canonical quest. The internal quest number is the `qstsa<act>q<n>` string key
* (e.g. "The Search for Cain" = `qstsa1q4` -> word 4, "Terror's End" = `qstsa4q2` -> word 26).
*/
export function questFlagWordIndex(quest: Pick<CanonicalQuestItem, 'act' | 'titleKey' | 'id'>): number {
const m = /^qstsa(\d)q(\d)$/.exec(quest.titleKey)
const base = QUEST_FLAG_ACT_BASE[quest.act]
if (!m || base === undefined || Number(m[1]) !== quest.act) {
throw new Error(`questFlagWordIndex: cannot map quest '${quest.id}' (titleKey '${quest.titleKey}')`)
}
return base + Number(m[2]) - 1
}
/** Bit 0 of a quest-flag word = quest completed (reward granted). */
export const QUEST_FLAG_COMPLETED = 0x0001
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = { export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry), 1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry), 2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
@ -623,53 +645,53 @@ export const ALL_QUESTS: readonly QuestEntry[] = [
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = { export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
1: [ 1: [
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true }, { act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: false },
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true }, { act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: false },
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true }, { act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: false },
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true }, { act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: false },
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true }, { act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: false },
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true }, { act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: false },
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true }, { act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: false },
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true }, { act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: false },
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true }, { act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: false },
], ],
2: [ 2: [
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true }, { act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: false },
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true }, { act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: false },
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true }, { act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: false },
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true }, { act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: false },
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true }, { act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: false },
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true }, { act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: false },
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true }, { act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: false },
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true }, { act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: false },
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true }, { act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: false },
], ],
3: [ 3: [
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true }, { act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: false },
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true }, { act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: false },
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true }, { act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: false },
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true }, { act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: false },
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true }, { act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: false },
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true }, { act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: false },
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true }, { act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: false },
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true }, { act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: false },
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true }, { act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: false },
], ],
4: [ 4: [
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true }, { act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: false },
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true }, { act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: false },
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true }, { act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: false },
], ],
5: [ 5: [
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true }, { act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: false },
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true }, { act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: false },
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true }, { act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: false },
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true }, { act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: false },
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true }, { act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: false },
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true }, { act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: false },
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true }, { act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: false },
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true }, { act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: false },
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true }, { act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: false },
], ],
} }
@ -688,7 +710,8 @@ export class WorldPanelsHud {
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
selectedActTab = 1 selectedActTab = 1
selectedQuestIdx = 0 selectedQuestIdx = 0
private _stashGold = 2_500_000 /** Server stat 15 (`goldbank`); 0 until received. */
private _stashGold = 0
private _stashGoldExplicitlySet = false private _stashGoldExplicitlySet = false
get stashGold(): number { get stashGold(): number {
return this._stashGold return this._stashGold
@ -733,12 +756,12 @@ export class WorldPanelsHud {
vendorMode: 'trade' | 'gamble' = 'trade' vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor' activeVendorTab: VendorTabId = 'armor'
vendorDifficulty: 0 | 1 | 2 = 0 vendorDifficulty: 0 | 1 | 2 = 0
vendorCharLevel = 85 /** Synced from the server character level by `HudManager.syncPlayerContext()`. */
vendorCharLevel = 0
vendorPlayerStats: PlayerItemRequirementContext = { vendorPlayerStats: PlayerItemRequirementContext = {
level: 85, level: 0,
str: 156, str: 0,
dex: 75, dex: 0,
classCode: 'sor',
} }
vendorTradeState: VendorTradeState = 'idle' vendorTradeState: VendorTradeState = 'idle'
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
@ -1435,6 +1458,21 @@ export class WorldPanelsHud {
this.questStatuses.set(questId, status) this.questStatuses.set(questId, status)
} }
/**
* Replace every quest status from the server quest-flag words (`ClientWorld.quests`):
* bit 0 set = completed, any other bit set = active (started), 0 = locked (not started).
*/
applyServerQuestFlags(flags: ArrayLike<number>): void {
this.questStatuses.clear()
for (const quest of ALL_QUESTS) {
const word = flags[questFlagWordIndex(quest)] ?? 0
this.questStatuses.set(
quest.id,
(word & QUEST_FLAG_COMPLETED) !== 0 ? 'completed' : word !== 0 ? 'active' : 'locked',
)
}
}
private questTblLookup?: QuestTblLookup | undefined private questTblLookup?: QuestTblLookup | undefined
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
@ -2166,6 +2204,21 @@ export class WorldPanelsHud {
inventory?: InventoryPanel inventory?: InventoryPanel
isShiftClick?: boolean isShiftClick?: boolean
isRightClick?: boolean isRightClick?: boolean
/**
* Server-authoritative mode: the cube Transmute button only requests `ClickButton 0x18`; no local recipe run.
*/
onTransmute?: () => void
/**
* Server-authoritative mode: stash / cube grid clicks are forwarded (pick-up / place / use) and never move
* items locally. The containers are rebuilt from `ClientWorld` when the server answers.
*/
onContainerGridClick?: (
container: 'stash' | 'cube',
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => void
}, },
): boolean { ): boolean {
if (logicalX > 400) return false if (logicalX > 400) return false
@ -2232,8 +2285,12 @@ export class WorldPanelsHud {
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') { if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
if (!callbacks.isRightClick) { if (!callbacks.isRightClick) {
this.pressedCubeButton = 'transmute' this.pressedCubeButton = 'transmute'
this.cubeTransmuteCount += 1 if (callbacks.onTransmute) {
this.transmuteCube() callbacks.onTransmute()
} else {
this.cubeTransmuteCount += 1
this.transmuteCube()
}
} }
return true return true
} }
@ -2251,6 +2308,11 @@ export class WorldPanelsHud {
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
) )
if (callbacks.onContainerGridClick) {
callbacks.onContainerGridClick('cube', col, row, hit?.item ?? null, callbacks.isRightClick === true)
return true
}
if (callbacks.inventory?.isIdentifyMode()) { if (callbacks.inventory?.isIdentifyMode()) {
if (callbacks.isRightClick) { if (callbacks.isRightClick) {
callbacks.inventory.cancelIdentifyMode() callbacks.inventory.cancelIdentifyMode()
@ -2309,6 +2371,11 @@ export class WorldPanelsHud {
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
) )
if (callbacks.onContainerGridClick) {
callbacks.onContainerGridClick('stash', col, row, hit?.item ?? null, callbacks.isRightClick === true)
return true
}
if (callbacks.inventory?.isIdentifyMode()) { if (callbacks.inventory?.isIdentifyMode()) {
if (callbacks.isRightClick) { if (callbacks.isRightClick) {
callbacks.inventory.cancelIdentifyMode() callbacks.inventory.cancelIdentifyMode()

View File

@ -70,6 +70,9 @@ export interface PortalState {
readonly flags?: number | undefined readonly flags?: number | undefined
} }
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff
export class ClientWorld { export class ClientWorld {
act = 0 act = 0
mapSeed = 0 mapSeed = 0
@ -90,6 +93,8 @@ export class ClientWorld {
private readonly _containers = new ClientInventoryManager() private readonly _containers = new ClientInventoryManager()
private readonly _revealedRooms = new Set<string>() private readonly _revealedRooms = new Set<string>()
private readonly _waypoints = new Set<number>() 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 _quests = new Uint16Array(48)
private readonly _party = new Map<number, PartyMemberState>() private readonly _party = new Map<number, PartyMemberState>()
private readonly _trade: TradeSessionState = { private readonly _trade: TradeSessionState = {
@ -143,6 +148,11 @@ export class ClientWorld {
return this._waypoints 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 { get quests(): Uint16Array {
return this._quests return this._quests
} }
@ -457,6 +467,10 @@ export class ClientWorld {
break break
case 'StatSet': { 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))) { 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._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
if (this.selfId !== 0) { if (this.selfId !== 0) {
@ -579,6 +593,7 @@ export class ClientWorld {
case 'WaypointMenu': { case 'WaypointMenu': {
this._waypoints.clear() this._waypoints.clear()
this._waypointUnitId = ev.unitId
for (const wp of ev.open) { for (const wp of ev.open) {
this._waypoints.add(wp) this._waypoints.add(wp)
} }
@ -621,9 +636,13 @@ export class ClientWorld {
if (ev.partnerId !== undefined) { if (ev.partnerId !== undefined) {
this._trade.partnerId = ev.partnerId this._trade.partnerId = ev.partnerId
} }
if (ev.gold !== undefined) { if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
this._trade.partnerGold = ev.gold // 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
this._trade.selfGold = ev.gold if ((ev.action & 0xff) !== 0) {
this._trade.selfGold = ev.gold
} else {
this._trade.partnerGold = ev.gold
}
} }
// Trade cancel / close actions // Trade cancel / close actions
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) { if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
@ -736,6 +755,7 @@ export class ClientWorld {
this._containers.clear() this._containers.clear()
this._revealedRooms.clear() this._revealedRooms.clear()
this._waypoints.clear() this._waypoints.clear()
this._waypointUnitId = null
this._quests = new Uint16Array(48) this._quests = new Uint16Array(48)
this._party.clear() this._party.clear()
this._trade.active = false this._trade.active = false

View File

@ -24,7 +24,8 @@ import {
export interface SelfPlayerState { export interface SelfPlayerState {
unitId: number unitId: number
name: string name: string
charClass: CharClassId /** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
charClass: CharClassId | null
level: number level: number
x: number x: number
y: number y: number
@ -53,8 +54,8 @@ export interface SelfPlayerState {
export class MutableSelfPlayerState implements SelfPlayerState { export class MutableSelfPlayerState implements SelfPlayerState {
unitId = 0 unitId = 0
name = '' name = ''
charClass: CharClassId = 0 charClass: CharClassId | null = null
level = 1 level = 0
x = 0 x = 0
y = 0 y = 0
targetX = 0 targetX = 0
@ -78,46 +79,42 @@ export class MutableSelfPlayerState implements SelfPlayerState {
weaponSet: 0 | 1 = 0 weaponSet: 0 | 1 = 0
mercId: number | null = null mercId: number | null = null
/**
* Apply one server stat update (`0x1D`/`0x1E`/`0x1F` BaseAttribute byte/word/dword, `0x19` gold delta).
*
* Wire format of the pool stats 6..11 (hitpoints, maxhp, mana, maxmana, stamina, maxstamina): the value is
* the engine's internal **8.8 fixed-point** number (`ItemStatCost.txt` ValShift = 8 for these six stats),
* so the displayed integer is always `value >> 8` — unconditionally, never by magnitude.
* Evidence (tests/fixtures/netproto/0[234]-*.d2cap, fresh level-1 Assassin "WebBotOne"): raw packets
* `1e 07 00 32` / `1e 09 00 19` / `1e 0b 00 5f` decode (u16 LE, as every other D2GS field) to
* 12800 / 6400 / 24320 = maxhp 50 (vit 20 + hpadd 30) / maxmana 25 / maxstamina 95 in 8.8, with a zero
* fractional byte; `0x95` (15-bit plain integer) confirms life = 50. The StatList keeps the raw value.
*/
applyStat(stat: number, value: number, param = 0): void { applyStat(stat: number, value: number, param = 0): void {
if (stat === STAT_GOLD && param === 1) { if (stat === STAT_GOLD && param === 1) {
this.gold = Math.max(0, this.gold + value) this.gold = Math.max(0, this.gold + value)
this.stats.set(STAT_GOLD, this.gold) this.stats.set(STAT_GOLD, this.gold)
return return
} }
const isShiftedPoolStat = this.stats.set(stat, value, param)
stat === STAT_HITPOINTS ||
stat === STAT_MAXHP ||
stat === STAT_MANA ||
stat === STAT_MAXMANA ||
stat === STAT_STAMINA ||
stat === STAT_MAXSTAMINA
// In 1.13c 0x1E/0x1F (D2Client.dll 0x6fb5edf0), stats 6..11 have valShift=8 (8.8 fixed point on wire)
const normVal = isShiftedPoolStat && value >= 256 ? value >>> 8 : value
this.stats.set(stat, normVal, param)
switch (stat) { switch (stat) {
case STAT_HITPOINTS: case STAT_HITPOINTS:
this.life = normVal this.life = poolStatValue(value)
if (this.maxLife < normVal) this.maxLife = normVal
break break
case STAT_MAXHP: case STAT_MAXHP:
this.maxLife = normVal this.maxLife = poolStatValue(value)
if (this.life === 0) this.life = normVal
break break
case STAT_MANA: case STAT_MANA:
this.mana = normVal this.mana = poolStatValue(value)
if (this.maxMana < normVal) this.maxMana = normVal
break break
case STAT_MAXMANA: case STAT_MAXMANA:
this.maxMana = normVal this.maxMana = poolStatValue(value)
if (this.mana === 0) this.mana = normVal
break break
case STAT_STAMINA: case STAT_STAMINA:
this.stamina = normVal this.stamina = poolStatValue(value)
if (this.maxStamina < normVal) this.maxStamina = normVal
break break
case STAT_MAXSTAMINA: case STAT_MAXSTAMINA:
this.maxStamina = normVal this.maxStamina = poolStatValue(value)
if (this.stamina === 0) this.stamina = normVal
break break
case STAT_LEVEL: case STAT_LEVEL:
this.level = value this.level = value
@ -134,21 +131,19 @@ export class MutableSelfPlayerState implements SelfPlayerState {
} }
} }
/** `0x18` / `0x95` current life / mana / stamina (15-bit integers on the wire). */
applyLifeMana(life: number, mana: number, stamina: number, x: number, y: number): void { applyLifeMana(life: number, mana: number, stamina: number, x: number, y: number): void {
if (life >= 0) { if (life >= 0) {
this.life = life this.life = life
this.stats.set(STAT_HITPOINTS, life) this.stats.set(STAT_HITPOINTS, life)
if (this.maxLife < life) this.maxLife = life
} }
if (mana >= 0) { if (mana >= 0) {
this.mana = mana this.mana = mana
this.stats.set(STAT_MANA, mana) this.stats.set(STAT_MANA, mana)
if (this.maxMana < mana) this.maxMana = mana
} }
if (stamina >= 0) { if (stamina >= 0) {
this.stamina = stamina this.stamina = stamina
this.stats.set(STAT_STAMINA, stamina) this.stats.set(STAT_STAMINA, stamina)
if (this.maxStamina < stamina) this.maxStamina = stamina
} }
if (x !== 0 || y !== 0) { if (x !== 0 || y !== 0) {
this.x = x this.x = x
@ -170,8 +165,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
reset(): void { reset(): void {
this.unitId = 0 this.unitId = 0
this.name = '' this.name = ''
this.charClass = 0 this.charClass = null
this.level = 1 this.level = 0
this.x = 0 this.x = 0
this.y = 0 this.y = 0
this.targetX = 0 this.targetX = 0
@ -196,3 +191,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
this.mercId = null this.mercId = null
} }
} }
/** `ItemStatCost.txt` ValShift of stats 6..11 (hitpoints/maxhp/mana/maxmana/stamina/maxstamina). */
export const POOL_STAT_VAL_SHIFT = 8
/** Integer pool value from the 8.8 fixed-point wire/StatList value of stats 6..11. */
export function poolStatValue(raw: number): number {
return Math.floor(raw / (1 << POOL_STAT_VAL_SHIFT))
}

View File

@ -218,10 +218,6 @@ export class BeltModel {
/** Currently hovered slot `(row, col)`. */ /** Currently hovered slot `(row, col)`. */
hoveredSlot: { row: number; col: number } | null = null hoveredSlot: { row: number; col: number } | null = null
constructor() {
this.seedDefaultPotions()
}
seedDefaultPotions(): void { seedDefaultPotions(): void {
for (let row = 0; row < BELT_ROWS; row++) { for (let row = 0; row < BELT_ROWS; row++) {
for (let col = 0; col < BELT_COLS; col++) { for (let col = 0; col < BELT_COLS; col++) {

View File

@ -24,6 +24,15 @@ export function encodeC2sItemDrop(itemId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x50 DropGold (9B, `C2S_PACKET_SIZES[0x50] = 9`): u8 0x50 | u32 playerUnitId | u32 amount */
export function encodeC2sDropGold(unitId: number, amount: number): Uint8Array {
const w = new ByteWriter(9)
w.u8(0x50)
w.u32LE(unitId >>> 0)
w.u32LE(amount >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x18 InsertItemToBuffer (17B): u8 0x18 | u32 itemId | u32 x | u32 y | u32 bufferType */ /** 0x18 InsertItemToBuffer (17B): u8 0x18 | u32 itemId | u32 x | u32 y | u32 bufferType */
export function encodeC2sItemPlace(itemId: number, x: number, y: number, buffer: number): Uint8Array { export function encodeC2sItemPlace(itemId: number, x: number, y: number, buffer: number): Uint8Array {
const w = new ByteWriter(17) const w = new ByteWriter(17)

View File

@ -22,6 +22,7 @@ import {
encodeC2sBeltUse, encodeC2sBeltUse,
encodeC2sIdentifyItem, encodeC2sIdentifyItem,
encodeC2sItemDrop, encodeC2sItemDrop,
encodeC2sDropGold,
encodeC2sItemEquip, encodeC2sItemEquip,
encodeC2sItemPickup, encodeC2sItemPickup,
encodeC2sItemPlace, encodeC2sItemPlace,
@ -439,6 +440,8 @@ export function encodeD2gsClientCommand(
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)] return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
case 'ItemDrop': case 'ItemDrop':
return [encodeC2sItemDrop(cmd.itemId)] return [encodeC2sItemDrop(cmd.itemId)]
case 'DropGold':
return [encodeC2sDropGold(cmd.unitId, cmd.amount)]
case 'ItemToCursor': case 'ItemToCursor':
return [encodeC2sItemToCursor(cmd.itemId)] return [encodeC2sItemToCursor(cmd.itemId)]
case 'ItemPlace': case 'ItemPlace':

View File

@ -113,9 +113,9 @@ export function decode0x1CExpDword(pkt: Uint8Array): ServerEvent {
/** /**
* Normalize 1.13c stat values from 0x1D/0x1E/0x1F (D2Client.dll 0x6fb5edf0): * Normalize 1.13c stat values from 0x1D/0x1E/0x1F (D2Client.dll 0x6fb5edf0):
* Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina), * Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina) the value is passed through
* 0x6fb5edf0 shifts left by 8 inside the engine's fixed-point stat table, * unchanged: it is the engine's 8.8 fixed-point number (ItemStatCost.txt ValShift = 8), e.g. a fresh
* meaning the wire value is the integer stat (or fixed-point for 0x1F when > 0xFFFF). * level-1 Assassin's maxhp arrives as `1e 07 00 32` = 12800 = 50 << 8. Consumers apply `>> 8`.
*/ */
export function decode0x1DAttrByte(pkt: Uint8Array): ServerEvent { export function decode0x1DAttrByte(pkt: Uint8Array): ServerEvent {
return { return {

View File

@ -29,6 +29,8 @@ export type ClientCommand =
| { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined } | { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined } | { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
| { readonly type: 'ItemDrop'; readonly itemId: number } | { readonly type: 'ItemDrop'; readonly itemId: number }
/** 0x50 DropGold: drop `amount` inventory gold of player `unitId` on the ground. */
| { readonly type: 'DropGold'; readonly unitId: number; readonly amount: number }
| { readonly type: 'ItemToCursor'; readonly itemId: number } | { readonly type: 'ItemToCursor'; readonly itemId: number }
| { | {
readonly type: 'ItemPlace' readonly type: 'ItemPlace'

View File

@ -7,10 +7,10 @@ import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { import {
DEFAULT_SORCERESS_SKILLS, DEFAULT_SORCERESS_SKILLS,
SkillHotkeysHud,
resolveSkillIcon, resolveSkillIcon,
getAvailableSkillsForSide, getAvailableSkillsForSide,
} from '../src/client/ui/hotkeys.ts' } from '../src/client/ui/hotkeys.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts' import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts'
import { castSkill } from '../src/server/scene/skill-caster.ts' import { castSkill } from '../src/server/scene/skill-caster.ts'
import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts' import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts'
@ -29,8 +29,8 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
}) })
it('includes Skill 0 in both left and right available skill palettes', () => { it('includes Skill 0 in both left and right available skill palettes', () => {
const leftSkills = getAvailableSkillsForSide('left') const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
const rightSkills = getAvailableSkillsForSide('right') const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
const leftAttack = leftSkills.find((s) => s.skillId === 0) const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0) const rightAttack = rightSkills.find((s) => s.skillId === 0)
@ -42,9 +42,10 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
}) })
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => { it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
const hud = new SkillHotkeysHud() // Explicitly seeded legacy Sorceress quickbar (the HUD itself starts neutral).
const hud = createDemoHotkeys()
// Initial defaults // Seeded defaults
expect(hud.leftSkillId).toBe(47) // Fire Ball expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb expect(hud.rightSkillId).toBe(64) // Frozen Orb

View File

@ -131,11 +131,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() hud = new SkillHotkeysHud()
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
}) })
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => { it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
const leftSkills = getAvailableSkillsForSide('left') const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
const rightSkills = getAvailableSkillsForSide('right') const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
const leftAttack = leftSkills.find((s) => s.skillId === 0) const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0) const rightAttack = rightSkills.find((s) => s.skillId === 0)
@ -265,6 +266,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() hud = new SkillHotkeysHud()
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
}) })
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => { it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {

View File

@ -198,7 +198,11 @@ describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => { it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
const panel = new SkillTreePanel() const panel = new SkillTreePanel()
expect(panel.characterClass).toBe('sor') // Neutral until the server assigns a class: no tree, no tabs, no close socket.
expect(panel.characterClass).toBeNull()
expect(panel.getSkillTree()).toEqual([])
expect(panel.getVerticalTabs()).toEqual([])
expect(panel.getCloseButtonBounds()).toBeNull()
// Switch to Paladin // Switch to Paladin
panel.setClass('pal') panel.setClass('pal')

View File

@ -129,7 +129,7 @@ describe('ClientWorld authoritative state mirror', () => {
expect(world.revealedRooms.has('1:100:200')).toBe(false) expect(world.revealedRooms.has('1:100:200')).toBe(false)
}) })
it('normalizes 8.8 fixed-point pool stats and ignores negative sentinels from WalkVerify', () => { it('decodes 8.8 fixed-point pool stats 6..11 (ValShift 8) and ignores negative sentinels from WalkVerify', () => {
const world = new ClientWorld() const world = new ClientWorld()
world.apply({ world.apply({
type: 'UnitAssign', type: 'UnitAssign',
@ -137,7 +137,8 @@ describe('ClientWorld authoritative state mirror', () => {
isSelf: true, isSelf: true,
}) })
// 50 life in 8.8 fixed point = 12800 (0x3200) // 1.13c wire values for stats 6..11 are 8.8 fixed point (ItemStatCost ValShift = 8):
// capture bytes `1e 07 00 32` -> u16-LE 12800 = 50 max life for a fresh level-1 character.
world.apply({ type: 'StatSet', stat: 6, value: 12800 }) world.apply({ type: 'StatSet', stat: 6, value: 12800 })
world.apply({ type: 'StatSet', stat: 7, value: 12800 }) world.apply({ type: 'StatSet', stat: 7, value: 12800 })
world.apply({ type: 'StatSet', stat: 8, value: 6400 }) world.apply({ type: 'StatSet', stat: 8, value: 6400 })
@ -151,6 +152,18 @@ describe('ClientWorld authoritative state mirror', () => {
expect(world.self.stamina).toBe(95) expect(world.self.stamina).toBe(95)
expect(world.self.maxStamina).toBe(95) expect(world.self.maxStamina).toBe(95)
// The StatList keeps the raw wire value; only the pool fields are shifted
expect(world.self.stats.get(7)).toBe(12800)
// The shift is unconditional (no magnitude heuristic): raw 128 = 0.5 -> 0, raw 420 << 8 -> 420
world.apply({ type: 'StatSet', stat: 7, value: 128 })
expect(world.self.maxLife).toBe(0)
expect(world.self.stats.get(7)).toBe(128)
world.apply({ type: 'StatSet', stat: 7, value: 420 << 8 })
expect(world.self.maxLife).toBe(420)
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
expect(world.self.maxLife).toBe(50)
// WalkVerify (0x96) sends life: -1, mana: -1, stamina: 90, at: (5010, 5012) // WalkVerify (0x96) sends life: -1, mana: -1, stamina: 90, at: (5010, 5012)
world.apply({ world.apply({
type: 'LifeMana', type: 'LifeMana',

View File

@ -0,0 +1,154 @@
/**
* Regression tests for the client hard-coded/demo value audit (server-authoritative HUD).
*
* Covers: neutral level-1 self, class id -> tree/quickbar mapping for all 7 classes, HUD clicks that
* only emit `ClientCommand`s, empty equipment/belt and the plain-integer stat 6-11 wire format.
*/
import { describe, expect, it } from 'vitest'
import { HudModel, CLASS_ID_TO_CODE, nextLevelExperience } from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../src/common/skills/formulas.ts'
import type { ClientCommand } from '../../src/netproto/index.ts'
function setup() {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
return { world, hud, model, commands }
}
function assignSelf(world: ClientWorld, classId: number): void {
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId, name: 'WebBotOne', x: 5000, y: 5000 },
isSelf: true,
})
}
describe('client hardcode audit — server-authoritative HUD', () => {
it('level-1 self with only non-zero stats shows 0 stat/skill points, 0 gold and table XP thresholds', () => {
const { world, hud, model } = setup()
assignSelf(world, 6)
// Values exactly as sent by the 1.13c server for a fresh Assassin (tests/fixtures/netproto/02*.d2cap):
// stats 6..11 arrive as 8.8 fixed point (ItemStatCost ValShift 8).
world.apply({ type: 'StatSet', stat: D2StatId.Strength, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Energy, value: 25 })
world.apply({ type: 'StatSet', stat: D2StatId.Dexterity, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 20 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 6400 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 })
world.apply({ type: 'StatSet', stat: D2StatId.Level, value: 1 })
model.syncFromWorld()
expect(hud.charSheet.attrs.statPoints).toBe(0)
expect(hud.skillTree.unspentSkillPoints).toBe(0)
expect(hud.inventory.gold).toBe(0)
expect(hud.worldPanels.stashGold).toBe(0)
expect(hud.charSheet.attrs.level).toBe(1)
expect(hud.charSheet.attrs.xp).toBe(0)
expect(hud.charSheet.attrs.nextLevelXp).toBe(D2_EXPERIENCE_TABLE_99[2])
expect(nextLevelExperience(1)).toBe(500)
expect(nextLevelExperience(99)).toBe(0)
expect(hud.maxHp).toBe(50)
expect(hud.maxMana).toBe(25)
expect(hud.maxStamina).toBe(95)
})
it('stat 6-11 values are 8.8 fixed point on the wire (ValShift 8, unconditional)', () => {
const { world } = setup()
assignSelf(world, 6)
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 12800 }) // raw 1e 07 00 32
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: (420 << 8) + 128 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 24320 }) // raw 1e 0b 00 5f
world.apply({ type: 'StatSet', stat: D2StatId.HitPoints, value: 128 }) // 0.5 hp: no magnitude heuristic
expect(world.self.maxLife).toBe(50)
expect(world.self.maxMana).toBe(420)
expect(world.self.maxStamina).toBe(95)
expect(world.self.life).toBe(0)
expect(world.self.stats.get(D2StatId.MaxHp)).toBe(12800)
})
it('maps every 1.13c class id to its own skill tree and an empty server-driven quickbar', () => {
for (const [idStr, code] of Object.entries(CLASS_ID_TO_CODE)) {
const { world, hud, model } = setup()
assignSelf(world, Number(idStr))
model.syncFromWorld()
expect(hud.currentClass).toBe(code)
expect(hud.skillTree.characterClass).toBe(code)
expect(hud.skillTree.getSkillTree().length).toBeGreaterThan(0)
expect(hud.hotkeys.availableSkills).toEqual([])
}
const { world, hud, model } = setup()
assignSelf(world, 6)
model.syncFromWorld()
expect(hud.currentClass).toBe('ass')
expect(hud.charSheet.attrs.classNameEn).toBe('Assassin')
})
it('is not ready (no class, no fabricated name/class) before the server assigns the class', () => {
const { hud, model } = setup()
model.syncFromWorld()
expect(hud.currentClass).toBeNull()
expect(hud.skillTree.characterClass).toBeNull()
const snap = model.getSnapshot()
expect(snap.charClass).toBeNull()
expect(snap.classCode).toBeNull()
expect(snap.charName).toBe('')
expect(snap.hp).toBe(0)
})
it('HUD interactions only emit ClientCommands and never mutate local state', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({ type: 'StatSet', stat: D2StatId.StatPts, value: 5 })
world.apply({ type: 'StatSet', stat: D2StatId.NewSkills, value: 2 })
world.apply({ type: 'StatSet', stat: D2StatId.Vitality, value: 10 })
model.syncFromWorld()
expect(hud.commandSink).toBe(model)
expect(hud.allocateStat('vit')).toBe(true)
expect(hud.charSheet.attrs.vit).toBe(10)
expect(hud.charSheet.attrs.statPoints).toBe(5)
;(hud as any).handleSkillAllocated(36)
expect(hud.skillTree.unspentSkillPoints).toBe(2)
expect(hud.skillTree.hardPoints.get(36)).toBeUndefined()
hud.hotkeys.onSelectSkill?.('right', 36)
expect(hud.hotkeys.rightSkillId).toBe(0)
expect(world.self.rightSkill).toBe(0)
// Empty belt: no BeltUse with a made-up item id.
expect(hud.useBeltSlot(0)).toBe(false)
expect(commands).toEqual([
{ type: 'AddStatPoint', statId: D2StatId.Vitality },
{ type: 'AddSkillPoint', skillId: 36 },
{ type: 'SelectSkill', hand: 'right', skill: 36, itemId: 0xffffffff },
])
})
it('no equipment / empty belt / empty containers show no items', () => {
const { world, hud, model } = setup()
assignSelf(world, 1)
model.syncFromWorld()
expect(Object.keys(hud.inventory.equipped)).toEqual([])
expect(hud.inventory.gridItems).toEqual([])
expect(hud.belt.grid.flat().every((p) => p === null)).toBe(true)
expect(hud.worldPanels.stashItems).toEqual([])
expect(hud.worldPanels.cubeItems).toEqual([])
expect(hud.inventory.cursorItem).toBeNull()
})
it('splits 0x79 GoldInTrade into self and partner gold', () => {
const world = new ClientWorld()
world.apply({ type: 'Trade', action: 0x7901, gold: 300 })
world.apply({ type: 'Trade', action: 0x7900, gold: 70 })
expect(world.trade.selfGold).toBe(300)
expect(world.trade.partnerGold).toBe(70)
})
})

View File

@ -24,6 +24,58 @@ import {
} from '../../src/netproto/index.ts' } from '../../src/netproto/index.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts' import { HudManager } from '../../src/client/ui/hud-manager.ts'
type ItemEventItem = Extract<ServerEvent, { type: 'ItemEvent' }>['item']
/** Minimal Healing Potion (hp4) in the given belt slot, as decoded from a 0x9C ItemEvent. */
function makeBeltPotionItem(id: number, slot: number): ItemEventItem {
return {
id,
itemId: id,
opcode: 0x9c,
action: 0x0e,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 2,
container: 'belt',
bodyLoc: 0,
equippedSlot: 0,
gridX: slot,
gridY: 0,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: slot,
y: 0,
code: 'hp4',
rawCode: 'hp4 ',
compact: true,
simple: true,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
usedSockets: 0,
totalSockets: 0,
quality: 2,
invWidth: 1,
invHeight: 1,
stats: [],
setBonusStats: [],
runewordStats: [],
} as ItemEventItem
}
describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
describe('HudModel (F16)', () => { describe('HudModel (F16)', () => {
it('synchronizes ClientWorld vitals, stats, skills, containers, quests, waypoints, and monster target into HudManager', () => { it('synchronizes ClientWorld vitals, stats, skills, containers, quests, waypoints, and monster target into HudManager', () => {
@ -66,6 +118,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 150_000 }) world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 150_000 })
world.apply({ type: 'StatSet', stat: D2StatId.FireResist, value: 75 }) world.apply({ type: 'StatSet', stat: D2StatId.FireResist, value: 75 })
world.apply({ type: 'StatSet', stat: D2StatId.ColdResist, value: 60 }) world.apply({ type: 'StatSet', stat: D2StatId.ColdResist, value: 60 })
// Pool stats 6..11 are 8.8 fixed point on the 1.13c wire (ItemStatCost ValShift = 8)
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 420 << 8 }) world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 420 << 8 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 500 << 8 }) world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 500 << 8 })
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 }) world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 })
@ -291,6 +344,25 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
'ItemDrop', 'ItemDrop',
'ClickButton', 'ClickButton',
]) ])
// BeltUse carries the real server item id of the bottom potion in column 0
expect(commands.find((c) => c.type === 'BeltUse')).toEqual({ type: 'BeltUse', itemId: 501, shift: false })
// Server authority: command helpers never mutate HUD / world state locally
expect(hudManager.charSheet.attrs.statPoints).toBe(15)
expect(hudManager.charSheet.attrs.str).toBe(60)
expect(hudManager.skillTree.unspentSkillPoints).toBe(3)
expect(hudManager.skillTree.getHardPoints(36)).toBe(10)
expect(world.self.rightSkill).toBe(54)
expect(hudManager.hotkeys.rightSkillId).toBe(54)
expect(world.self.weaponSet).toBe(0)
expect(hudManager.inventory.weaponSwapSet).toBe(0)
expect(hudManager.belt.grid[0]?.[0]?.code).toBe('hp4')
expect(hudManager.hp).toBe(380)
// No belt item in column 3 -> no BeltUse command
const before = commands.length
expect(hudModel.useBeltSlot(3, false)).toBe(false)
expect(commands.length).toBe(before)
hudModel.dispose() hudModel.dispose()
}) })
}) })
@ -405,10 +477,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 }) mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 })
expect(mapper.handleKeyDown({ key: 'F1' }, 5000)).toBe(true) expect(mapper.handleKeyDown({ key: 'F1' }, 5000)).toBe(true)
// Server authority: F1 only requests SelectSkill; the slot changes when the server answers (0x23)
expect(dispatched[dispatched.length - 1]).toMatchObject({ type: 'SelectSkill', hand: 'right', skill: 54 })
expect(hudModel.rightSkillId).toBe(0)
world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 54, itemId: 0xffffffff })
expect(hudModel.rightSkillId).toBe(54) expect(hudModel.rightSkillId).toBe(54)
expect(hudManager.hotkeys.rightSkillId).toBe(54)
// Belt key '1' with an empty server belt emits nothing; once the server puts a potion there, BeltUse
// carries its real item id
expect(mapper.handleKeyDown({ key: '1' }, 6000)).toBe(true) expect(mapper.handleKeyDown({ key: '1' }, 6000)).toBe(true)
expect(dispatched.some((c) => c.type === 'BeltUse')).toBe(true) expect(dispatched.some((c) => c.type === 'BeltUse')).toBe(false)
world.apply({ type: 'ItemEvent', action: 0x0e, container: 'belt', item: makeBeltPotionItem(555, 0) })
expect(mapper.handleKeyDown({ key: '1' }, 6050)).toBe(true)
expect(dispatched.filter((c) => c.type === 'BeltUse')).toEqual([{ type: 'BeltUse', itemId: 555, shift: false }])
expect(mapper.handleKeyDown({ key: 'Tab' }, 6100)).toBe(true) expect(mapper.handleKeyDown({ key: 'Tab' }, 6100)).toBe(true)
expect(automapView.getMode()).toBe('level') expect(automapView.getMode()).toBe('level')
@ -574,6 +656,10 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
unit: { unitType: 0, id: 1, classId: 1, name: 'webbottwo', x: 500, y: 500 }, unit: { unitType: 0, id: 1, classId: 1, name: 'webbottwo', x: 500, y: 500 },
isSelf: true, isSelf: true,
}) })
// Max pools arrive as 8.8 fixed-point base attributes (0x1E) before life/mana updates (0x95, plain)
cb({ type: 'StatSet', stat: D2StatId.MaxHp, value: 400 << 8 })
cb({ type: 'StatSet', stat: D2StatId.MaxMana, value: 450 << 8 })
cb({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 200 << 8 })
cb({ cb({
type: 'LifeMana', type: 'LifeMana',
life: 320, life: 320,

View File

@ -33,6 +33,7 @@ import {
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts' import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts'
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
describe('1. Asset Extraction & Atlas Metadata', () => { describe('1. Asset Extraction & Atlas Metadata', () => {
@ -459,6 +460,11 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => { it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => {
const { hud, clickAt } = createTestHud() const { hud, clickAt } = createTestHud()
// Server-not-ready HUD (no class assigned) refuses to open a vendor session
expect(() => hud.openVendorSession('Charsi', 'trade', 0)).toThrow(/character class/)
// Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory)
seedDemoHud(hud)
// Open Charsi vendor panel // Open Charsi vendor panel
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true) expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true)
expect(hud.state.leftPanel).toBe('vendor') expect(hud.state.leftPanel).toBe('vendor')

View File

@ -24,6 +24,7 @@ import { findSafeDropPosition } from '../../src/common/items/ground-items.ts'
import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts' import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts'
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
import { BeltHud } from '../../src/client/ui/belt.ts' import { BeltHud } from '../../src/client/ui/belt.ts'
import { seedDemoBelt } from '../fixtures/hud-demo-state.ts'
let oracle: DropOracleStack let oracle: DropOracleStack
const dropTables = getEmbeddedDropTables() const dropTables = getEmbeddedDropTables()
@ -624,13 +625,13 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
// ============================================================================ // ============================================================================
describe('Feature 14: Collision Bounding Pickup Radius & 2-Pass Auto-Belt Potion Routing (#425)', () => { describe('Feature 14: Collision Bounding Pickup Radius & 2-Pass Auto-Belt Potion Routing (#425)', () => {
it('F14.1 verifies BeltHud has 4 columns and 4 rows', () => { it('F14.1 verifies BeltHud has 4 columns and 4 rows', () => {
const belt = new BeltHud() const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
expect(belt.grid.length).toBe(4) expect(belt.grid.length).toBe(4)
expect(belt.grid[0].length).toBe(4) expect(belt.grid[0].length).toBe(4)
}) })
it('F14.2 verifies useSlot drinks bottom potion and shifts upper rows down', () => { it('F14.2 verifies useSlot drinks bottom potion and shifts upper rows down', () => {
const belt = new BeltHud() const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
// Bottom slot in col 0 // Bottom slot in col 0
const initialBottom = belt.grid[0][0] const initialBottom = belt.grid[0][0]
expect(initialBottom).not.toBeNull() expect(initialBottom).not.toBeNull()
@ -642,7 +643,7 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
}) })
it('F14.3 verifies placePotion adds potion to first empty row in specified column', () => { it('F14.3 verifies placePotion adds potion to first empty row in specified column', () => {
const belt = new BeltHud() const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
belt.useSlot(1) // empty top row in col 1 belt.useSlot(1) // empty top row in col 1
const dummyPotion = { id: 'test_potion', name: 'Health Potion', col: 1 } as any const dummyPotion = { id: 'test_potion', name: 'Health Potion', col: 1 } as any
const leftover = belt.placePotion(dummyPotion, 1) const leftover = belt.placePotion(dummyPotion, 1)
@ -651,7 +652,7 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
}) })
it('F14.4 verifies countTotalPotions accurately counts active belt items', () => { it('F14.4 verifies countTotalPotions accurately counts active belt items', () => {
const belt = new BeltHud() const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
expect(belt.countTotalPotions()).toBe(16) expect(belt.countTotalPotions()).toBe(16)
belt.useSlot(0) belt.useSlot(0)
expect(belt.countTotalPotions()).toBe(15) expect(belt.countTotalPotions()).toBe(15)

View File

@ -609,7 +609,9 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
describe('B14: Full Belt Spilling & Non-Potion Rejection (#425)', () => { describe('B14: Full Belt Spilling & Non-Potion Rejection (#425)', () => {
it('B14.1 completely full belt (16 potions) rejects further potion placement', () => { it('B14.1 completely full belt (16 potions) rejects further potion placement', () => {
const belt = new BeltHud() const belt = new BeltHud()
// All 16 slots are full by default // The belt starts empty; seed the legacy full 16-potion layout explicitly
expect(belt.countTotalPotions()).toBe(0)
belt.seedDefaultPotions()
expect(belt.countTotalPotions()).toBe(16) expect(belt.countTotalPotions()).toBe(16)
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
// Trying to place in any column returns the unplaced potion // Trying to place in any column returns the unplaced potion
@ -627,6 +629,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
it('B14.3 picked potion targets column with matching potion type first', () => { it('B14.3 picked potion targets column with matching potion type first', () => {
const belt = new BeltHud() const belt = new BeltHud()
belt.seedDefaultPotions() // explicit legacy full belt
belt.useSlot(0) // empty top row in col 0 (Health Potion col) belt.useSlot(0) // empty top row in col 0 (Health Potion col)
expect(belt.grid[3][0]).toBeNull() expect(belt.grid[3][0]).toBeNull()
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any const newHealth = { id: 'new_hp', name: 'Health Potion' } as any

View File

@ -1521,9 +1521,21 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff }) world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff })
expect(world.party.has(2)).toBe(false) expect(world.party.has(2)).toBe(false)
// 4. Trade open (action 0x01) -> gold update -> cancel (action 0x02) // 4. Trade open (action 0x01) -> 0x79 GoldInTrade (action 0x7900 | bySelf) -> cancel (action 0x02)
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 50000 }) world.apply({ type: 'Trade', action: 0x01, partnerId: 2 })
expect(world.trade.active).toBe(true) expect(world.trade.active).toBe(true)
expect(world.trade.partnerId).toBe(2)
// Gold carried by a non-0x79 trade action is not attributed to either side
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 12345 })
expect(world.trade.partnerGold).toBe(0)
expect(world.trade.selfGold).toBe(0)
// bySelf = 0 -> partner's offered gold only
world.apply({ type: 'Trade', action: 0x7900, gold: 50000 })
expect(world.trade.partnerGold).toBe(50000)
expect(world.trade.selfGold).toBe(0)
// bySelf != 0 -> own offered gold only
world.apply({ type: 'Trade', action: 0x7901, gold: 1200 })
expect(world.trade.selfGold).toBe(1200)
expect(world.trade.partnerGold).toBe(50000) expect(world.trade.partnerGold).toBe(50000)
world.apply({ type: 'Trade', action: 0x02 }) world.apply({ type: 'Trade', action: 0x02 })
expect(world.trade.active).toBe(false) expect(world.trade.active).toBe(false)

173
tests/fixtures/hud-demo-state.ts vendored Normal file
View File

@ -0,0 +1,173 @@
/**
* Explicit legacy demo / Sorceress HUD state for widget tests.
*
* The production HUD starts from a neutral, server-not-ready state (no class, no skills, no gear,
* zero vitals, locked waypoints/quests). Widget tests that exercise pure layout / interaction
* behavior seed the former demo profile explicitly through these helpers instead of relying on
* hidden defaults in `src/`.
*/
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
import type { HudManager } from '../../src/client/ui/hud-manager.ts'
import { DEFAULT_SORCERESS_SKILLS, SkillHotkeysHud } from '../../src/client/ui/hotkeys.ts'
import {
InventoryPanel,
STARTER_BAG_ITEMS,
STARTER_EQUIPPED_GEAR,
type EquipSlotId,
} from '../../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
import { ACT_WAYPOINTS, ALL_QUESTS, WorldPanelsHud } from '../../src/client/ui/world-panels.ts'
import type { BeltModel } from '../../src/common/items/belt-model.ts'
/** Former demo Sorceress profile (level 85). */
export const DEMO_LEVEL = 85
export const DEMO_STR = 156
export const DEMO_DEX = 75
export const DEMO_VITALS = {
hp: 1180,
maxHp: 1180,
mana: 940,
maxMana: 940,
stamina: 465,
maxStamina: 465,
} as const
/** Former default F1..F8 quickbar bindings. */
export const DEMO_HOTKEY_BINDINGS: ReadonlyArray<[string, { side: 'left' | 'right'; skillId: number }]> = [
['F1', { side: 'right', skillId: 64 }],
['F2', { side: 'right', skillId: 59 }],
['F3', { side: 'right', skillId: 56 }],
['F4', { side: 'right', skillId: 54 }],
['F5', { side: 'left', skillId: 47 }],
['F6', { side: 'left', skillId: 49 }],
['F7', { side: 'right', skillId: 40 }],
['F8', { side: 'right', skillId: 42 }],
]
/** Former demo skill-tree hard points. */
export const DEMO_SKILL_HARD_POINTS: ReadonlyArray<[number, number]> = [
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
[36, 1], [37, 1], [47, 15], [56, 10], [61, 5],
]
/** Quests the former demo profile had completed (all others were active). */
export const DEMO_COMPLETED_QUEST_IDS: ReadonlySet<string> = new Set(['a1q1', 'a1q2', 'a1q3'])
/** Seed Sorceress quickbar: all default skills, Fire Ball (47) left, Frozen Orb (64) right, F1..F8 bindings. */
export function seedDemoHotkeys(hotkeys: SkillHotkeysHud): SkillHotkeysHud {
hotkeys.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
hotkeys.leftSkillId = 47
hotkeys.rightSkillId = 64
hotkeys.bindings.clear()
for (const [key, binding] of DEMO_HOTKEY_BINDINGS) hotkeys.bindings.set(key, { ...binding })
return hotkeys
}
/** Seed Sorceress skill tree (level 85, 12 unspent, +8 all skills, demo hard points). */
export function seedDemoSkillTree(tree: SkillTreePanel): SkillTreePanel {
tree.setClass('sor')
tree.unspentSkillPoints = 12
tree.bonusAllSkills = 8
tree.characterLevel = DEMO_LEVEL
tree.hardPoints.clear()
tree.bonusLevels.clear()
for (const [id, pts] of DEMO_SKILL_HARD_POINTS) tree.hardPoints.set(id, pts)
return tree
}
/** Seed the demo Sorceress character sheet ("Isendra", level 85). */
export function seedDemoCharSheet(sheet: CharacterSheetPanel): CharacterSheetPanel {
sheet.classCode = 'sor'
sheet.attrs = {
name: 'Isendra',
classNameZh: '女法师',
classNameEn: 'Sorceress',
level: DEMO_LEVEL,
xp: 1_068_078_030,
nextLevelXp: 1_163_635_493,
str: DEMO_STR,
dex: DEMO_DEX,
vit: 315,
ene: 95,
statPoints: 15,
baseMaxHp: DEMO_VITALS.maxHp,
baseMaxMana: DEMO_VITALS.maxMana,
baseMaxStamina: DEMO_VITALS.maxStamina,
defense: 1845,
attackRating: 1240,
fireRes: 75,
coldRes: 75,
lightRes: 75,
poisRes: 75,
}
return sheet
}
/** Seed starter gear + bag items (deep-cloned), 850k gold and the level-85 Sorceress requirement context. */
export function seedDemoInventory(inv: InventoryPanel): InventoryPanel {
for (const k of Object.keys(inv.equipped) as EquipSlotId[]) delete inv.equipped[k]
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR) as [EquipSlotId, NonNullable<(typeof STARTER_EQUIPPED_GEAR)[EquipSlotId]>][]) {
inv.equipped[slot] = { ...item, stats: [...item.stats] }
}
inv.gridItems = STARTER_BAG_ITEMS.map(p => ({ ...p, item: { ...p.item, stats: [...p.item.stats] } }))
inv.gold = 850_000
inv.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' })
return inv
}
/** Seed all waypoints unlocked, demo quest progress, 2.5M stash gold and the level-85 Sorceress vendor context. */
export function seedDemoWorldPanels(wp: WorldPanelsHud): WorldPanelsHud {
const allWaypointIds = Object.values(ACT_WAYPOINTS).flatMap(list => list.map(w => w.waypointId))
wp.setUnlockedWaypoints(allWaypointIds)
for (const q of ALL_QUESTS) wp.setQuestStatus(q.id, DEMO_COMPLETED_QUEST_IDS.has(q.id) ? 'completed' : 'active')
wp.stashGold = 2_500_000
wp.setPlayerContext({ level: DEMO_LEVEL, str: DEMO_STR, dex: DEMO_DEX, classCode: 'sor' })
return wp
}
/** Seed the 4x4 belt with the legacy default potions. */
export function seedDemoBelt<T extends BeltModel>(belt: T): T {
belt.seedDefaultPotions()
return belt
}
/**
* Seed a whole `HudManager` with the legacy demo Sorceress state: class `sor`, vitals, character sheet,
* skill tree, quickbar, starter inventory, belt potions, waypoints/quests and stash gold.
*/
export function seedDemoHud<T extends HudManager>(hud: T): T {
hud.setCharacterClass('sor')
seedDemoCharSheet(hud.charSheet)
seedDemoSkillTree(hud.skillTree)
seedDemoHotkeys(hud.hotkeys)
seedDemoInventory(hud.inventory)
seedDemoWorldPanels(hud.worldPanels)
seedDemoBelt(hud.belt)
hud.hp = DEMO_VITALS.hp
hud.maxHp = DEMO_VITALS.maxHp
hud.mana = DEMO_VITALS.mana
hud.maxMana = DEMO_VITALS.maxMana
hud.stamina = DEMO_VITALS.stamina
hud.maxStamina = DEMO_VITALS.maxStamina
hud.syncPublishedState()
return hud
}
/** Convenience constructors for standalone widgets pre-seeded with the demo state. */
export function createDemoHotkeys(): SkillHotkeysHud {
return seedDemoHotkeys(new SkillHotkeysHud())
}
export function createDemoSkillTree(): SkillTreePanel {
return seedDemoSkillTree(new SkillTreePanel())
}
export function createDemoCharSheet(): CharacterSheetPanel {
return seedDemoCharSheet(new CharacterSheetPanel())
}
export function createDemoInventory(): InventoryPanel {
return seedDemoInventory(new InventoryPanel())
}
export function createDemoWorldPanels(): WorldPanelsHud {
return seedDemoWorldPanels(new WorldPanelsHud())
}

View File

@ -3,12 +3,14 @@ import { SkillHotkeysHud, DEFAULT_SORCERESS_SKILLS, LEFT_SKILL_BOUNDS, RIGHT_SKI
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts' import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts' import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => { describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
let hud: SkillHotkeysHud let hud: SkillHotkeysHud
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() // Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
hud = createDemoHotkeys()
}) })
describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => { describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => {
@ -125,7 +127,7 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true) expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true)
}) })
it('allocates new skill in skill tree and adds it to hotkeys.availableSkills', () => { it('requests skill allocation through the command sink without local mutation', () => {
const hudManager = createMockHud() const hudManager = createMockHud()
// Open Skill tree panel // Open Skill tree panel
hudManager.rightPanel = 'skill' hudManager.rightPanel = 'skill'
@ -143,17 +145,16 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
const node = hudManager.skillTree.getSkillTree().find(n => n.skillId === 48)! const node = hudManager.skillTree.getSkillTree().find(n => n.skillId === 48)!
expect(node).toBeDefined() expect(node).toBeDefined()
// Server authority: allocation is only requested through the command sink; nothing changes locally.
const prevPoints = hudManager.skillTree.hardPoints.get(48) ?? 0 const prevPoints = hudManager.skillTree.hardPoints.get(48) ?? 0
const allocated = hudManager.skillTree.allocateSkill(48) const requested: number[] = []
expect(allocated).toBe(true) hudManager.commandSink = { allocateSkill: (id: number) => (requested.push(id), true) } as any
// Test handleSkillAllocated
const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager) const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager)
privateMethod(48) privateMethod(48)
const updatedNova = hudManager.hotkeys.availableSkills.find(s => s.skillId === 48) expect(requested).toEqual([48])
expect(updatedNova).toBeDefined() expect(hudManager.skillTree.hardPoints.get(48) ?? 0).toBe(prevPoints)
expect(updatedNova!.level).toBe(prevPoints + 1) expect(hudManager.skillTree.unspentSkillPoints).toBe(5)
}) })
}) })
}) })

View File

@ -31,6 +31,8 @@ function createTestEngine() {
lootSeed: 42, lootSeed: 42,
npcDefs: [], npcDefs: [],
}) })
// Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt.
engine.belt.seedDefaultPotions()
return engine return engine
} }

View File

@ -11,15 +11,26 @@ import {
resolveSkillIcon, resolveSkillIcon,
getAvailableSkillsForSide, getAvailableSkillsForSide,
} from '../src/client/ui/hotkeys.ts' } from '../src/client/ui/hotkeys.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
describe('SkillHotkeysHud & Dual Slot Mechanics', () => { describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
let hud: SkillHotkeysHud let hud: SkillHotkeysHud
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() // Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
hud = createDemoHotkeys()
}) })
it('initializes with expected default skills and state', () => { it('starts neutral (server-not-ready): Attack on both slots, no skills, no bindings', () => {
const fresh = new SkillHotkeysHud()
expect(fresh.leftSkillId).toBe(0)
expect(fresh.rightSkillId).toBe(0)
expect(fresh.availableSkills).toEqual([])
expect(fresh.bindings.size).toBe(0)
expect(getAvailableSkillsForSide('left', fresh.availableSkills)).toEqual([])
})
it('initializes with expected seeded demo skills and state', () => {
expect(hud.leftSkillId).toBe(47) // Fire Ball expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb expect(hud.rightSkillId).toBe(64) // Frozen Orb
expect(hud.selectionOpen).toBeNull() expect(hud.selectionOpen).toBeNull()

View File

@ -7,7 +7,8 @@ import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { KeyboardInput } from '../src/client/sim/input.ts' import { KeyboardInput } from '../src/client/sim/input.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts'
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts' import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
import { ViewportCamera } from '../src/client/sim/camera.ts' import { ViewportCamera } from '../src/client/sim/camera.ts'
import { WaypointNetwork } from '../src/common/world/portal.ts' import { WaypointNetwork } from '../src/common/world/portal.ts'
@ -215,7 +216,7 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
expect(hud.rightPanel).toBe('none') expect(hud.rightPanel).toBe('none')
}) })
it('drinks belt potions from columns 0..3 via keys 1..4 without resetting active skills', () => { it('requests belt potion use for columns 0..3 via keys 1..4 (server-authoritative) without resetting active skills', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, { const hud = new HudManager(canvas, {
onToggleAutomap: () => {}, onToggleAutomap: () => {},
@ -223,6 +224,8 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
}) })
hud['bindEvents']() hud['bindEvents']()
// Explicit legacy demo state: Sorceress, 16-potion belt, vitals
seedDemoHud(hud)
hud.hotkeys.leftSkillId = 47 // Fire Ball hud.hotkeys.leftSkillId = 47 // Fire Ball
hud.hotkeys.rightSkillId = 64 // Frozen Orb hud.hotkeys.rightSkillId = 64 // Frozen Orb
hud.syncPublishedState() hud.syncPublishedState()
@ -231,36 +234,35 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
expect(hud.belt.countTotalPotions()).toBe(16) expect(hud.belt.countTotalPotions()).toBe(16)
// Press '1' -> drinks column 0 (hp potion, healHp 180) // No engine and no command sink: keys 1..4 do nothing locally (no client-side healing / belt mutation)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' })) mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
expect(hud.hp).toBe(400 + 180) expect(hud.hp).toBe(400)
expect(hud.hotkeys.leftSkillId).toBe(47) expect(hud.belt.countTotalPotions()).toBe(16)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.state.leftSkillId).toBe(47)
expect(hud.state.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(15)
// Press '2' -> drinks column 1 (hp potion, healHp 320) // With a server command sink attached, keys 1..4 only emit BeltUse requests for columns 0..3
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' })) const beltRequests: number[] = []
expect(hud.hp).toBe(580 + 320) hud.commandSink = {
expect(hud.hotkeys.leftSkillId).toBe(47) useBeltSlot: (col: number) => {
expect(hud.hotkeys.rightSkillId).toBe(64) beltRequests.push(col)
expect(hud.belt.countTotalPotions()).toBe(14) return true
},
} as unknown as HudCommandSink
// Press '3' -> drinks column 2 (mana potion, healMana 150) for (const [i, key] of ['1', '2', '3', '4'].entries()) {
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' })) mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key }))
expect(hud.mana).toBe(200 + 150) expect(beltRequests).toEqual([0, 1, 2, 3].slice(0, i + 1))
expect(hud.hotkeys.leftSkillId).toBe(47) // Active skills are never reset by belt hotkeys
expect(hud.hotkeys.rightSkillId).toBe(64) expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.belt.countTotalPotions()).toBe(13) expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.state.leftSkillId).toBe(47)
expect(hud.state.rightSkillId).toBe(64)
}
// Press '4' -> drinks column 3 (rejuv potion, healHp 400, healMana 400) // Local state is unchanged until the server answers (HudModel.syncFromWorld)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' })) expect(hud.hp).toBe(400)
expect(hud.hp).toBe(900 + 400 > hud.maxHp ? hud.maxHp : 1300) expect(hud.mana).toBe(200)
expect(hud.mana).toBe(350 + 400) expect(hud.belt.countTotalPotions()).toBe(16)
expect(hud.hotkeys.leftSkillId).toBe(47) hud.commandSink = null
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(12)
}) })
it('isolates WASD keys from movement Contribution', () => { it('isolates WASD keys from movement Contribution', () => {
@ -399,6 +401,8 @@ function createTestScene(options?: {
onToggleAutomap: () => {}, onToggleAutomap: () => {},
onWaypointTeleport: () => {}, onWaypointTeleport: () => {},
}) })
// Explicit legacy demo Sorceress state (class, quickbar skill levels / mana costs, vitals incl. maxMana).
seedDemoHud(hudManager)
hudManager.mana = options?.mana ?? 200 hudManager.mana = options?.mana ?? 200
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0 hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0 hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0

View File

@ -30,6 +30,7 @@ import {
logicalToClient, logicalToClient,
} from '../src/client/ui/hud-manager.ts' } from '../src/client/ui/hud-manager.ts'
import { parseColoredText } from '../src/client/ui/font.ts' import { parseColoredText } from '../src/client/ui/font.ts'
import { createDemoCharSheet, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
import { traceBitmapContours, buildTrueTypeFont } from '../src/baker/extract/build-d2-ttf.ts' import { traceBitmapContours, buildTrueTypeFont } from '../src/baker/extract/build-d2-ttf.ts'
function signedContourArea(pts: Array<{ x: number; y: number }>): number { function signedContourArea(pts: Array<{ x: number; y: number }>): number {
@ -460,7 +461,8 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
}) })
it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => { it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => {
const worldPanels = new WorldPanelsHud() // Explicitly seeded demo quest progress (a1q1 completed); the HUD itself starts with all quests locked.
const worldPanels = createDemoWorldPanels()
worldPanels.lang = 'ENG' worldPanels.lang = 'ENG'
worldPanels.selectedActTab = 1 worldPanels.selectedActTab = 1
worldPanels.selectedQuestIdx = 0 worldPanels.selectedQuestIdx = 0
@ -542,7 +544,8 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement) renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement) renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
const sheet = new CharacterSheetPanel() // Explicitly seeded demo character (unspent stat points) so the Stat Points / Remaining labels render.
const sheet = createDemoCharSheet()
sheet.lang = 'ENG' sheet.lang = 'ENG'
sheet.visible = true sheet.visible = true

View File

@ -51,6 +51,7 @@ import {
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts' import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts'
import { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
// --- Mock & Test Utilities --- // --- Mock & Test Utilities ---
@ -343,7 +344,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
}) })
it('1.5 formats base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)', () => { it('1.5 formats base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)', () => {
const worldPanels = new WorldPanelsHud() const worldPanels = createDemoWorldPanels() // seeded level-85 Sorceress requirement context
const mockCtx = createMockCanvasContext() const mockCtx = createMockCanvasContext()
const mockFont = createMockFontRenderer() const mockFont = createMockFontRenderer()
@ -641,7 +642,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
describe('Tier 3: Cross-Feature Interactions', () => { describe('Tier 3: Cross-Feature Interactions', () => {
it('3.1 preserves dyed sprite through equip slot pickup, bag placement, and re-equip workflow', () => { it('3.1 preserves dyed sprite through equip slot pickup, bag placement, and re-equip workflow', () => {
const inv = new InventoryPanel() const inv = createDemoInventory() // seeded starter gear (dyed Tal Rasha helm)
// 1. Initial State: Equipped Helm has invtransform 'cgrn' // 1. Initial State: Equipped Helm has invtransform 'cgrn'
const equippedHelm = inv.equipped.helm as any const equippedHelm = inv.equipped.helm as any

View File

@ -44,6 +44,7 @@ import {
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { SceneMouseController } from '../src/client/world/act-runtime.ts' import { SceneMouseController } from '../src/client/world/act-runtime.ts'
import type { Monster } from '../src/server/engine/combat.ts' import type { Monster } from '../src/server/engine/combat.ts'
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => { describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => {
const dropTables = getEmbeddedDropTables() const dropTables = getEmbeddedDropTables()
@ -493,7 +494,7 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S
it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => { it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => {
const engine = createTestEngine({ lootSeed: 12345678 }) const engine = createTestEngine({ lootSeed: 12345678 })
const invPanel = new InventoryPanel() const invPanel = createDemoInventory() // seeded level-85 Sorceress requirement context
invPanel.gridItems = [] invPanel.gridItems = []
for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) { for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) {
delete invPanel.equipped[k] delete invPanel.equipped[k]

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts'
describe('C2S 0x50 DropGold', () => {
it('encodes u8 0x50 | u32 unitId | u32 amount (9 bytes)', () => {
const [pkt] = encodeD2gsClientCommand({ type: 'DropGold', unitId: 0x01020304, amount: 1234 })
expect(Array.from(pkt!)).toEqual([0x50, 0x04, 0x03, 0x02, 0x01, 0xd2, 0x04, 0x00, 0x00])
})
})

View File

@ -21,6 +21,8 @@ function makeTestItem(id: number, code: string, name: string): Item {
describe('Issue #522: Ground Items Single Source of Truth (engine.groundItems)', () => { describe('Issue #522: Ground Items Single Source of Truth (engine.groundItems)', () => {
it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => { it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => {
const engine = new GameEngine('nec') const engine = new GameEngine('nec')
// Explicit legacy full belt (16 potions) so the picked-up potion routes to the bag, not the belt.
engine.belt.seedDefaultPotions()
const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion') const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion')
const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion') const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion')

View File

@ -98,7 +98,13 @@ describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
const a1q1 = ACT_QUESTS[1]![0]! const a1q1 = ACT_QUESTS[1]![0]!
expect(a1q1.id).toBe('a1q1') expect(a1q1.id).toBe('a1q1')
// Default status is completed for a1q1 // Neutral (server-not-ready): every quest starts locked
expect(hud.getQuestDisplayText(a1q1).status).toBe('locked')
// Server quest-flag block (0x29): word 1 = a1q1 (Den of Evil), bit 0 = completed
const flags = new Uint16Array(48)
flags[1] = 0x0001
hud.applyServerQuestFlags(flags)
const displayCompleted = hud.getQuestDisplayText(a1q1) const displayCompleted = hud.getQuestDisplayText(a1q1)
expect(displayCompleted.status).toBe('completed') expect(displayCompleted.status).toBe('completed')
expect(displayCompleted.statusTag).toBe('[已完成]') expect(displayCompleted.statusTag).toBe('[已完成]')

View File

@ -8,6 +8,7 @@ import {
import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudManager } from '../src/client/ui/hud-manager.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.ts' import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.ts'
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts' import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => { describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
let hud: SkillHotkeysHud let hud: SkillHotkeysHud
@ -20,7 +21,8 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
} as unknown as D2FontRenderer } as unknown as D2FontRenderer
beforeEach(() => { beforeEach(() => {
hud = new SkillHotkeysHud() // Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
hud = createDemoHotkeys()
}) })
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => { describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {

View File

@ -7,6 +7,7 @@ import {
SKILL_CLOSE_BTN_BOUNDS, SKILL_CLOSE_BTN_BOUNDS,
SKILL_CLOSE_BTN_OFFSET_X, SKILL_CLOSE_BTN_OFFSET_X,
} from '../src/client/ui/skill-tree-panel.ts' } from '../src/client/ui/skill-tree-panel.ts'
import { createDemoSkillTree } from './fixtures/hud-demo-state.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import type { D2FontRenderer } from '../src/client/ui/font.ts' import type { D2FontRenderer } from '../src/client/ui/font.ts'
@ -33,7 +34,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
}) })
it('draws authentic 64x432 tab column slice according to activeTab', () => { it('draws authentic 64x432 tab column slice according to activeTab', () => {
const panel = new SkillTreePanel() // Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
const panel = createDemoSkillTree()
panel.visible = true panel.visible = true
const mockCtx = { const mockCtx = {
@ -132,7 +134,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
}) })
it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => { it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => {
const panel = new SkillTreePanel() // Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
const panel = createDemoSkillTree()
panel.visible = true panel.visible = true
panel.unspentSkillPoints = 12 panel.unspentSkillPoints = 12
@ -199,7 +202,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
}) })
it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => { it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => {
const panel = new SkillTreePanel() // Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
const panel = createDemoSkillTree()
panel.visible = true panel.visible = true
const mockCtx = { const mockCtx = {

View File

@ -20,6 +20,7 @@ import {
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'
import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts' import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts'
import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts' import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts'
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => { describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => {
it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => { it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => {
@ -147,7 +148,7 @@ describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal'
}) })
it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => { it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => {
const inv = new InventoryPanel() const inv = createDemoInventory() // seeded starter bag (tbk / tsc)
inv.visible = true inv.visible = true
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')! const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
expect(tbkPlacement).toBeDefined() expect(tbkPlacement).toBeDefined()

View File

@ -40,6 +40,7 @@ import {
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts' import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts'
import { projectNpcLabel } from '../src/client/world/act-runtime.ts' import { projectNpcLabel } from '../src/client/world/act-runtime.ts'
import { createDemoHotkeys, createDemoInventory } from './fixtures/hud-demo-state.ts'
import { import {
getLevelNames, getLevelNames,
sceneNameZh, sceneNameZh,
@ -169,6 +170,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
it('supports 4x4 belt drinking with automatic column gravity refill', () => { it('supports 4x4 belt drinking with automatic column gravity refill', () => {
const belt = new BeltHud() const belt = new BeltHud()
// The belt starts empty (server-authoritative); seed the legacy 16-potion layout explicitly.
expect(belt.countTotalPotions()).toBe(0)
belt.seedDefaultPotions()
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS) expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
// Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null // Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null
@ -205,7 +209,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
}) })
it('handles 10x4 inventory grid pickup, placement, swap, equipment slots, and I/II weapon swap', () => { it('handles 10x4 inventory grid pickup, placement, swap, equipment slots, and I/II weapon swap', () => {
const inv = new InventoryPanel() // The panel starts empty (server-authoritative); seed the legacy starter gear explicitly.
expect(new InventoryPanel().gridItems).toEqual([])
const inv = createDemoInventory()
expect(INV_GRID_ORIGIN.cols).toBe(10) expect(INV_GRID_ORIGIN.cols).toBe(10)
expect(INV_GRID_ORIGIN.rows).toBe(4) expect(INV_GRID_ORIGIN.rows).toBe(4)
expect(Object.keys(EQUIP_SLOTS_LAYOUT)).toHaveLength(10) expect(Object.keys(EQUIP_SLOTS_LAYOUT)).toHaveLength(10)
@ -236,26 +242,29 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(inv.swapWeapons.weapon1?.code).toBe('crs') expect(inv.swapWeapons.weapon1?.code).toBe('crs')
}) })
it('allocates character attribute points and skill points with 1.13c rules', () => { it('starts with neutral (server-not-ready) character and skill state', () => {
const char = new CharacterSheetPanel() const char = new CharacterSheetPanel()
const beforePts = char.attrs.statPoints expect(char.classCode).toBeNull()
const beforeVit = char.attrs.vit expect(char.attrs.statPoints).toBe(0)
const beforeHp = char.attrs.baseMaxHp expect(char.attrs.level).toBe(0)
expect(char.allocateStat('vit')).toBe(true)
expect(char.attrs.statPoints).toBe(beforePts - 1)
expect(char.attrs.vit).toBe(beforeVit + 1)
expect(char.attrs.baseMaxHp).toBe(beforeHp + 2)
const tree = new SkillTreePanel() const tree = new SkillTreePanel()
const beforeSkillPts = tree.unspentSkillPoints expect(tree.characterClass).toBeNull()
const beforeIceBolt = tree.getHardPoints(39) expect(tree.unspentSkillPoints).toBe(0)
expect(tree.allocateSkill(39)).toBe(true) expect(tree.getSkillTree()).toEqual([])
expect(tree.unspentSkillPoints).toBe(beforeSkillPts - 1) // Without unspent points the 1.13c allocation rule rejects the request; no local mutation API exists.
expect(tree.getHardPoints(39)).toBe(beforeIceBolt + 1) tree.setClass('sor')
tree.characterLevel = 1
expect(tree.canAllocate(39)).toBe(false)
tree.unspentSkillPoints = 1
expect(tree.canAllocate(39)).toBe(true)
expect(tree.getHardPoints(39)).toBe(0)
}) })
it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => { it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => {
const hotkeys = new SkillHotkeysHud() // No F-key bindings until seeded (server / user); seed the legacy F1..F8 layout explicitly.
expect(new SkillHotkeysHud().triggerFunctionKey('F2')).toBeNull()
const hotkeys = createDemoHotkeys()
expect(hotkeys.triggerFunctionKey('F2')).toEqual({ side: 'right', skillId: 59 }) expect(hotkeys.triggerFunctionKey('F2')).toEqual({ side: 'right', skillId: 59 })
expect(hotkeys.rightSkillId).toBe(59) // Blizzard expect(hotkeys.rightSkillId).toBe(59) // Blizzard
@ -793,7 +802,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
}) })
it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => { it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => {
const inv = new InventoryPanel() const inv = createDemoInventory() // seeded starter bag (box / tbk / isc)
inv.visible = true inv.visible = true
const worldPanels = new WorldPanelsHud() const worldPanels = new WorldPanelsHud()
let cubeOpened = 0 let cubeOpened = 0

View File

@ -39,6 +39,7 @@ import {
type UiInventoryItem, type UiInventoryItem,
} from '../src/client/ui/inventory.ts' } from '../src/client/ui/inventory.ts'
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts' import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
function createMockTooltipCanvas() { function createMockTooltipCanvas() {
const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = [] const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = []
@ -323,7 +324,7 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System'
describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => { describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => {
it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => { it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => {
const inv = new InventoryPanel() const inv = createDemoInventory() // seeded level-85 Sorceress requirement context
inv.visible = true inv.visible = true
delete inv.equipped.helm delete inv.equipped.helm

View File

@ -13,6 +13,7 @@ import {
CUBE_TRANSMUTE_BTN_BOUNDS, CUBE_TRANSMUTE_BTN_BOUNDS,
CUBE_CLOSE_BTN_BOUNDS, CUBE_CLOSE_BTN_BOUNDS,
} from '../src/client/ui/world-panels.ts' } from '../src/client/ui/world-panels.ts'
import { createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { D2FontRenderer } from '../src/client/ui/font.ts' import { D2FontRenderer } from '../src/client/ui/font.ts'
import { encodeIndexedPng } from '../src/baker/io/png.ts' import { encodeIndexedPng } from '../src/baker/io/png.ts'
@ -57,7 +58,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
}) })
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => { it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
const hud = new WorldPanelsHud() // Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
const hud = createDemoWorldPanels()
const font = new D2FontRenderer() const font = new D2FontRenderer()
const drawImageCalls: any[] = [] const drawImageCalls: any[] = []
@ -137,7 +139,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
}) })
it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => { it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => {
const hud = new WorldPanelsHud() // Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
const hud = createDemoWorldPanels()
const font = new D2FontRenderer() const font = new D2FontRenderer()
const mockTabsImg = { const mockTabsImg = {
@ -225,7 +228,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
}) })
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => { it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
const hud = new WorldPanelsHud() // Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
const hud = createDemoWorldPanels()
const onWaypointTeleport = vi.fn() const onWaypointTeleport = vi.fn()
const onClose = vi.fn() const onClose = vi.fn()