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:
parent
58de64610f
commit
7db8c3c9b8
|
|
@ -1001,12 +1001,14 @@ export class OnlineSession {
|
|||
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
|
||||
|
||||
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({
|
||||
id: this.world.selfId || 1,
|
||||
id: this.world.selfId,
|
||||
kind: 'player',
|
||||
classId: this.world.self.charClass,
|
||||
name: this.world.self.name || 'Player',
|
||||
classId: selfClass,
|
||||
name: this.world.self.name,
|
||||
x: selfX,
|
||||
y: selfY,
|
||||
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import type {
|
|||
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
|
||||
import type { BaseStatKey } from '../ui/character-sheet.ts'
|
||||
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
|
||||
import type { HudManager } from '../ui/hud-manager.ts'
|
||||
import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
|
||||
import {
|
||||
resolveAllowedSlots,
|
||||
resolveItemInvFile,
|
||||
|
|
@ -38,6 +38,8 @@ import type { LocalMovementPredictor } from '../motion/predictor.ts'
|
|||
import type { ClientUnit } from '../world/client-unit.ts'
|
||||
import type { ClientWorld } from '../world/client-world.ts'
|
||||
import { D2StatId } from '../world/stat-list.ts'
|
||||
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
||||
import { computeCharacterSheetDerived } from './derived-stats.ts'
|
||||
|
||||
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
||||
0: 'ama',
|
||||
|
|
@ -174,8 +176,9 @@ export interface HudModelOptions {
|
|||
|
||||
export interface HudModelSnapshot {
|
||||
readonly charName: string
|
||||
readonly charClass: CharClassId
|
||||
readonly classCode: CharacterClassCode
|
||||
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
|
||||
readonly charClass: CharClassId | null
|
||||
readonly classCode: CharacterClassCode | null
|
||||
readonly level: number
|
||||
readonly xp: number
|
||||
readonly hp: number
|
||||
|
|
@ -210,7 +213,7 @@ export interface HudModelSnapshot {
|
|||
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
|
||||
* `ClientCommand` actions triggered by user HUD interactions.
|
||||
*/
|
||||
export class HudModel {
|
||||
export class HudModel implements HudCommandSink {
|
||||
readonly world: ClientWorld
|
||||
hudManager: HudManager | null
|
||||
predictor: LocalMovementPredictor | null
|
||||
|
|
@ -218,7 +221,6 @@ export class HudModel {
|
|||
onCommand?: ((cmd: ClientCommand) => void) | undefined
|
||||
|
||||
private unsubWorld: (() => void) | null = null
|
||||
private lastSyncedClassCode: CharacterClassCode | null = null
|
||||
private hoveredMonsterId: number | null = null
|
||||
private escapeMenuOpen = false
|
||||
|
||||
|
|
@ -228,6 +230,7 @@ export class HudModel {
|
|||
this.predictor = options.predictor ?? null
|
||||
this.automap = options.automap ?? null
|
||||
this.onCommand = options.onCommand
|
||||
if (this.hudManager) this.hudManager.commandSink = this
|
||||
|
||||
this.unsubWorld = this.world.onChange(() => {
|
||||
this.syncFromWorld()
|
||||
|
|
@ -238,6 +241,7 @@ export class HudModel {
|
|||
* Detach the world listener when disposing the session.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.hudManager?.commandSink === this) this.hudManager.commandSink = null
|
||||
if (this.unsubWorld) {
|
||||
this.unsubWorld()
|
||||
this.unsubWorld = null
|
||||
|
|
@ -248,8 +252,12 @@ export class HudModel {
|
|||
* Bind or replace the active `HudManager` instance and synchronize immediately.
|
||||
*/
|
||||
attachHudManager(hudManager: HudManager | null): void {
|
||||
if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) {
|
||||
this.hudManager.commandSink = null
|
||||
}
|
||||
this.hudManager = hudManager
|
||||
if (hudManager) {
|
||||
hudManager.commandSink = this
|
||||
this.syncFromWorld()
|
||||
}
|
||||
}
|
||||
|
|
@ -260,16 +268,7 @@ export class HudModel {
|
|||
getSnapshot(): HudModelSnapshot {
|
||||
const self = this.world.self
|
||||
const stats = self.stats
|
||||
const classCode = CLASS_ID_TO_CODE[self.charClass] ?? 'sor'
|
||||
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 classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
|
||||
|
||||
const invItems = [...this.world.containers.inventory.values()]
|
||||
const eqItems = [...this.world.containers.equipment.values()]
|
||||
|
|
@ -281,25 +280,25 @@ export class HudModel {
|
|||
const rightPanel = this.hudManager?.rightPanel ?? 'none'
|
||||
|
||||
return {
|
||||
charName: self.name || 'Wanderer',
|
||||
charName: self.name,
|
||||
charClass: self.charClass,
|
||||
classCode,
|
||||
level: Math.max(1, self.level || stats.get(D2StatId.Level) || 1),
|
||||
xp: self.experience || stats.get(D2StatId.Experience),
|
||||
hp,
|
||||
maxHp,
|
||||
mana,
|
||||
maxMana,
|
||||
stamina,
|
||||
maxStamina,
|
||||
level: self.level,
|
||||
xp: self.experience,
|
||||
hp: self.life,
|
||||
maxHp: self.maxLife,
|
||||
mana: self.mana,
|
||||
maxMana: self.maxMana,
|
||||
stamina: self.stamina,
|
||||
maxStamina: self.maxStamina,
|
||||
str: stats.get(D2StatId.Strength),
|
||||
dex: stats.get(D2StatId.Dexterity),
|
||||
vit: stats.get(D2StatId.Vitality),
|
||||
ene: stats.get(D2StatId.Energy),
|
||||
statPoints: stats.get(D2StatId.StatPts),
|
||||
skillPoints: stats.get(D2StatId.NewSkills),
|
||||
gold: self.gold || stats.get(D2StatId.Gold),
|
||||
stashGold: self.stashGold || stats.get(D2StatId.GoldBank),
|
||||
gold: stats.get(D2StatId.Gold),
|
||||
stashGold: stats.get(D2StatId.GoldBank),
|
||||
leftSkillId: self.leftSkill,
|
||||
rightSkillId: self.rightSkill,
|
||||
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
|
||||
|
|
@ -415,164 +414,120 @@ export class HudModel {
|
|||
|
||||
const self = this.world.self
|
||||
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) {
|
||||
this.lastSyncedClassCode = classCode
|
||||
hud.currentClass = classCode
|
||||
}
|
||||
|
||||
// 1. Vitals (globes & stamina bar)
|
||||
const hp = self.life > 0 ? self.life : stats.get(D2StatId.HitPoints)
|
||||
const maxHp = Math.max(hp, self.maxLife > 0 ? self.maxLife : stats.get(D2StatId.MaxHp))
|
||||
if (maxHp > 0) {
|
||||
hud.maxHp = maxHp
|
||||
hud.hp = Math.max(0, Math.min(maxHp, hp))
|
||||
}
|
||||
|
||||
const mana = self.mana > 0 || stats.get(D2StatId.MaxMana) > 0 ? self.mana : stats.get(D2StatId.Mana)
|
||||
const maxMana = Math.max(mana, self.maxMana > 0 ? self.maxMana : stats.get(D2StatId.MaxMana))
|
||||
if (maxMana > 0) {
|
||||
hud.maxMana = maxMana
|
||||
hud.mana = Math.max(0, Math.min(maxMana, mana))
|
||||
}
|
||||
|
||||
const stamina =
|
||||
self.stamina > 0 || stats.get(D2StatId.MaxStamina) > 0 ? self.stamina : stats.get(D2StatId.Stamina)
|
||||
const maxStamina = Math.max(
|
||||
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
|
||||
if (self.name) {
|
||||
hud.charSheet.attrs.name = self.name
|
||||
}
|
||||
// 0. Class: not ready until the server assigns it. No class-dependent panel state is synthesized before that.
|
||||
if (self.charClass !== null) {
|
||||
hud.setCharacterClass(resolveClassCode(self.charClass))
|
||||
const classNames = CLASS_ID_TO_NAMES[self.charClass]
|
||||
hud.charSheet.attrs.classNameEn = classNames.en
|
||||
hud.charSheet.attrs.classNameZh = classNames.zh
|
||||
const effLevel = self.level > 0 ? self.level : stats.get(D2StatId.Level)
|
||||
if (effLevel > 0) {
|
||||
hud.charSheet.attrs.level = effLevel
|
||||
}
|
||||
if (stats.has(D2StatId.Experience) || self.experience > 0) {
|
||||
hud.charSheet.attrs.xp = self.experience || stats.get(D2StatId.Experience)
|
||||
}
|
||||
if (stats.has(D2StatId.NextExp)) {
|
||||
hud.charSheet.attrs.nextLevelXp = stats.get(D2StatId.NextExp)
|
||||
}
|
||||
if (stats.has(D2StatId.Strength)) hud.charSheet.attrs.str = stats.get(D2StatId.Strength)
|
||||
if (stats.has(D2StatId.Dexterity)) hud.charSheet.attrs.dex = stats.get(D2StatId.Dexterity)
|
||||
if (stats.has(D2StatId.Vitality)) hud.charSheet.attrs.vit = stats.get(D2StatId.Vitality)
|
||||
if (stats.has(D2StatId.Energy)) hud.charSheet.attrs.ene = stats.get(D2StatId.Energy)
|
||||
if (stats.has(D2StatId.StatPts)) hud.charSheet.attrs.statPoints = stats.get(D2StatId.StatPts)
|
||||
if (maxHp > 0) hud.charSheet.attrs.baseMaxHp = maxHp
|
||||
if (maxMana > 0) hud.charSheet.attrs.baseMaxMana = maxMana
|
||||
if (maxStamina > 0) hud.charSheet.attrs.baseMaxStamina = maxStamina
|
||||
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)) {
|
||||
// 1. Vitals (globes & stamina bar): verbatim server values (0x95 / stats 6-11), absent = 0.
|
||||
hud.maxHp = self.maxLife
|
||||
hud.hp = Math.max(0, Math.min(self.maxLife, self.life))
|
||||
hud.maxMana = self.maxMana
|
||||
hud.mana = Math.max(0, Math.min(self.maxMana, self.mana))
|
||||
hud.maxStamina = self.maxStamina
|
||||
hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina))
|
||||
|
||||
// 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c).
|
||||
const attrs = hud.charSheet.attrs
|
||||
attrs.name = self.name
|
||||
attrs.level = self.level
|
||||
attrs.xp = self.experience
|
||||
attrs.nextLevelXp = nextLevelExperience(self.level)
|
||||
attrs.str = stats.get(D2StatId.Strength)
|
||||
attrs.dex = stats.get(D2StatId.Dexterity)
|
||||
attrs.vit = stats.get(D2StatId.Vitality)
|
||||
attrs.ene = stats.get(D2StatId.Energy)
|
||||
attrs.statPoints = stats.get(D2StatId.StatPts)
|
||||
attrs.baseMaxHp = self.maxLife
|
||||
attrs.baseMaxMana = self.maxMana
|
||||
attrs.baseMaxStamina = self.maxStamina
|
||||
// Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known.
|
||||
const derived = self.charClass === null
|
||||
? null
|
||||
: computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty)
|
||||
attrs.defense = derived?.defense ?? 0
|
||||
attrs.attackRating = derived?.attackRating ?? 0
|
||||
attrs.fireRes = derived?.fireRes ?? 0
|
||||
attrs.coldRes = derived?.coldRes ?? 0
|
||||
attrs.lightRes = derived?.lightRes ?? 0
|
||||
attrs.poisRes = derived?.poisRes ?? 0
|
||||
|
||||
// 3. Gold (stat 14 inventory gold, stat 15 stash gold).
|
||||
hud.inventory.gold = stats.get(D2StatId.Gold)
|
||||
hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
|
||||
|
||||
// 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync.
|
||||
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
|
||||
}
|
||||
if (self.skillEntries.size > 0) {
|
||||
hud.skillTree.characterLevel = self.level
|
||||
hud.skillTree.hardPoints.clear()
|
||||
hud.skillTree.bonusLevels.clear()
|
||||
hud.hotkeys.setAvailableSkills([])
|
||||
for (const [skillId, sl] of self.skillEntries.entries()) {
|
||||
const 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 hard = Math.max(0, sl.baseLevel ?? sl.level)
|
||||
const bonus = Math.max(0, sl.bonus ?? 0)
|
||||
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
|
||||
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
|
||||
if (isPassiveSkill(skillId)) continue
|
||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
||||
if (catalogEntry) {
|
||||
if (!catalogEntry) continue
|
||||
const effSkillLevel = hard + bonus
|
||||
hud.hotkeys.addOrUpdateSkill({
|
||||
skillId,
|
||||
name: catalogEntry.name,
|
||||
nameZh: catalogEntry.nameZh,
|
||||
level: effSkillLevel,
|
||||
manaCost: calculateManaCost(catalogEntry, effSkillLevel),
|
||||
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
|
||||
leftUsable: isLeftUsableSkill(skillId),
|
||||
rightUsable: true,
|
||||
isAura: isAuraSkill(skillId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hud.hotkeys.leftSkillId = self.leftSkill
|
||||
hud.hotkeys.rightSkillId = self.rightSkill
|
||||
hud.inventory.weaponSwapSet = self.weaponSet
|
||||
|
||||
// 5. Containers (only overwrite HudManager containers when authoritative items exist in ClientWorld)
|
||||
const totalAuthoritativeItems = this.world.containers.allById.size
|
||||
if (totalAuthoritativeItems > 0) {
|
||||
// Equipment
|
||||
const eqItems = [...this.world.containers.equipment.values()]
|
||||
if (eqItems.length > 0) {
|
||||
// 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
|
||||
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
|
||||
delete hud.inventory.equipped[key]
|
||||
}
|
||||
for (const item of eqItems) {
|
||||
for (const item of this.world.containers.equipment.values()) {
|
||||
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) => ({
|
||||
const placements: GridPlacement[] = [...this.world.containers.inventory.values()].map((item) => ({
|
||||
item: itemDataToUiInventoryItem(item),
|
||||
col: Math.max(0, Math.min(9, item.gridX)),
|
||||
row: Math.max(0, Math.min(3, item.gridY)),
|
||||
col: item.gridX,
|
||||
row: item.gridY,
|
||||
}))
|
||||
hud.inventory.gridItems = placements
|
||||
|
||||
// Stash grid
|
||||
const stashItems = [...this.world.containers.stash.values()]
|
||||
hud.worldPanels.stashItems = stashItems.map((item) => ({
|
||||
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
|
||||
item: itemDataToUiInventoryItem(item),
|
||||
col: Math.max(0, Math.min(5, item.gridX)),
|
||||
row: Math.max(0, Math.min(7, item.gridY)),
|
||||
col: item.gridX,
|
||||
row: item.gridY,
|
||||
}))
|
||||
|
||||
// Cube grid
|
||||
const cubeItems = [...this.world.containers.cube.values()]
|
||||
hud.worldPanels.cubeItems = cubeItems.map((item) => ({
|
||||
hud.worldPanels.cubeItems = [...this.world.containers.cube.values()].map((item) => ({
|
||||
item: itemDataToUiInventoryItem(item),
|
||||
col: Math.max(0, Math.min(2, item.gridX)),
|
||||
row: Math.max(0, Math.min(3, item.gridY)),
|
||||
col: item.gridX,
|
||||
row: 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))
|
||||
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 })
|
||||
|
|
@ -580,18 +535,17 @@ export class HudModel {
|
|||
hud.belt.grid[row]![col] = pot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor item
|
||||
const cursorItem = this.world.containers.cursor
|
||||
hud.inventory.cursorItem = cursorItem ? itemDataToUiInventoryItem(cursorItem) : null
|
||||
}
|
||||
|
||||
// 6. Area & Waypoints
|
||||
// 6. Area, Waypoints & Quests
|
||||
if (this.world.areaId > 0) {
|
||||
hud.worldPanels.setCurrentLevel(this.world.areaId)
|
||||
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
|
||||
}
|
||||
hud.worldPanels.setUnlockedWaypoints([...this.world.waypoints])
|
||||
hud.worldPanels.applyServerQuestFlags(this.world.quests)
|
||||
|
||||
// 7. Target monster plaque
|
||||
this.syncHoveredMonsterTarget()
|
||||
|
|
@ -636,70 +590,43 @@ export class HudModel {
|
|||
*/
|
||||
allocateStat(key: BaseStatKey): boolean {
|
||||
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 })
|
||||
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 {
|
||||
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 })
|
||||
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 {
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (col < 0 || col >= BELT_COLS) return false
|
||||
const beltItems = [...this.world.containers.belt.values()]
|
||||
const beltItem = beltItems.find((it) => it.gridX % BELT_COLS === col)
|
||||
const hudPot = this.hudManager?.belt.grid[0]?.[col] ?? null
|
||||
if (this.hudManager) {
|
||||
this.hudManager.drinkBeltSlot(col)
|
||||
}
|
||||
const numericId = beltItem?.id ?? (hudPot ? Number.parseInt(hudPot.id.replace(/\D+/g, ''), 10) || (col + 1) : col + 1)
|
||||
this.emitCommand({ type: 'BeltUse', itemId: numericId, shift })
|
||||
const beltItem = [...this.world.containers.belt.values()]
|
||||
.filter((it) => it.gridX >= 0 && it.gridX % BELT_COLS === col)
|
||||
.sort((a, b) => a.gridX - b.gridX)[0]
|
||||
if (!beltItem) return false
|
||||
this.emitCommand({ type: 'BeltUse', itemId: beltItem.id, shift })
|
||||
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 {
|
||||
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' })
|
||||
}
|
||||
|
||||
|
|
@ -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`).
|
||||
*/
|
||||
|
|
@ -834,3 +790,22 @@ export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHu
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import type { D2FontRenderer } from './font.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'
|
||||
|
||||
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 },
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
visible = false
|
||||
lang: TblLang = getTblLang()
|
||||
classCode: CharacterClassCode = 'sor'
|
||||
attrs: CharacterAttributes = {
|
||||
name: 'Isendra',
|
||||
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
|
||||
}
|
||||
/** `null` until the server assigns the character class. */
|
||||
classCode: CharacterClassCode | null = null
|
||||
attrs: CharacterAttributes = createEmptyCharacterAttributes()
|
||||
|
||||
handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean {
|
||||
if (!this.visible) return false
|
||||
|
|
@ -106,9 +80,8 @@ export class CharacterSheetPanel {
|
|||
if (this.attrs.statPoints > 0) {
|
||||
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 (this.allocateStat(key)) {
|
||||
// Server-authoritative: only request `AddStatPoint (0x3A)`; the new values arrive via 0x1D..0x1F.
|
||||
onStatAllocated?.(key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number {
|
|||
|
||||
export function getAvailableSkillsForSide(
|
||||
side: 'left' | 'right',
|
||||
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
|
||||
skills: readonly HotkeySkillEntry[],
|
||||
): readonly HotkeySkillEntry[] {
|
||||
return skills.filter(s => {
|
||||
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 {
|
||||
lang: TblLang = 'CHI'
|
||||
leftSkillId = 47 // Fire Ball
|
||||
rightSkillId = 64 // Frozen Orb
|
||||
/** Active left/right skill; mirrors server `0x23 AssignSkill` (0 = Attack until the server says otherwise). */
|
||||
leftSkillId = 0
|
||||
rightSkillId = 0
|
||||
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */
|
||||
openPopup: 'left' | 'right' | null = null
|
||||
/** Hovered skill inside the Speedbar popup. */
|
||||
|
|
@ -226,18 +227,15 @@ export class SkillHotkeysHud {
|
|||
}
|
||||
|
||||
/** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */
|
||||
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 }],
|
||||
])
|
||||
readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>()
|
||||
|
||||
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
|
||||
fistImage: HTMLImageElement | null = null
|
||||
swordImage: HTMLImageElement | null = null
|
||||
|
|
@ -356,6 +354,21 @@ export class SkillHotkeysHud {
|
|||
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 {
|
||||
for (const [key, binding] of this.bindings.entries()) {
|
||||
if (binding.side === side && binding.skillId === skillId) return key
|
||||
|
|
@ -419,15 +432,7 @@ export class SkillHotkeysHud {
|
|||
}
|
||||
const bound = this.bindings.get(key)
|
||||
if (!bound) return null
|
||||
if (bound.side === 'left') {
|
||||
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
|
||||
this.leftSkillId = bound.skillId
|
||||
}
|
||||
} else {
|
||||
if (!isPassiveSkill(bound.skillId)) {
|
||||
this.rightSkillId = bound.skillId
|
||||
}
|
||||
}
|
||||
this.requestSelect(bound.side, bound.skillId)
|
||||
return bound
|
||||
}
|
||||
|
||||
|
|
@ -565,15 +570,7 @@ export class SkillHotkeysHud {
|
|||
const cells = this.getPopupCells(this.openPopup)
|
||||
for (const c of cells) {
|
||||
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
|
||||
if (this.openPopup === 'left') {
|
||||
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.requestSelect(this.openPopup, c.skill.skillId)
|
||||
this.openPopup = null
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ import {
|
|||
type UiInventoryItem,
|
||||
resolveItemSpriteRect,
|
||||
INV_GRID_ORIGIN,
|
||||
EQUIP_SLOTS_LAYOUT,
|
||||
isUsableRightClickItem,
|
||||
} from './inventory.ts'
|
||||
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.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 {
|
||||
WorldPanelsHud,
|
||||
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 {
|
||||
readonly font = new D2FontRenderer()
|
||||
readonly globes = new GlobesHud()
|
||||
|
|
@ -307,12 +338,13 @@ export class HudManager {
|
|||
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||
isRunning = true
|
||||
showGroundLabels = false
|
||||
stamina = 465
|
||||
maxStamina = 465
|
||||
hp = 1180
|
||||
maxHp = 1180
|
||||
mana = 940
|
||||
maxMana = 940
|
||||
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
|
||||
stamina = 0
|
||||
maxStamina = 0
|
||||
hp = 0
|
||||
maxHp = 0
|
||||
mana = 0
|
||||
maxMana = 0
|
||||
|
||||
mouseX = 400
|
||||
mouseY = 300
|
||||
|
|
@ -347,7 +379,7 @@ export class HudManager {
|
|||
leftPanel: 'none',
|
||||
rightPanel: 'none',
|
||||
beltExpanded: false,
|
||||
beltPotionsCount: 16,
|
||||
beltPotionsCount: 0,
|
||||
miniPanelOpen: true,
|
||||
isRunning: true,
|
||||
showGroundLabels: false,
|
||||
|
|
@ -355,22 +387,54 @@ export class HudManager {
|
|||
equippedSlots: [],
|
||||
bagItemsCount: 0,
|
||||
weaponSwapSet: 0,
|
||||
leftSkillId: 47,
|
||||
rightSkillId: 64,
|
||||
characterLevel: 85,
|
||||
statPoints: 15,
|
||||
skillPoints: 12,
|
||||
hp: 1180,
|
||||
maxHp: 1180,
|
||||
mana: 940,
|
||||
maxMana: 940,
|
||||
stamina: 465,
|
||||
maxStamina: 465,
|
||||
leftSkillId: 0,
|
||||
rightSkillId: 0,
|
||||
characterLevel: 0,
|
||||
statPoints: 0,
|
||||
skillPoints: 0,
|
||||
hp: 0,
|
||||
maxHp: 0,
|
||||
mana: 0,
|
||||
maxMana: 0,
|
||||
stamina: 0,
|
||||
maxStamina: 0,
|
||||
docking: computeDockingLayout(800, 600, 1),
|
||||
vendorTradeState: 'idle',
|
||||
}
|
||||
|
||||
/** `null` until the server assigns the character class (see `setCharacterClass`). */
|
||||
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 _lang: TblLang = 'CHI'
|
||||
|
||||
|
|
@ -562,27 +626,9 @@ export class HudManager {
|
|||
: normAttr === 'vitality'
|
||||
? 'vit'
|
||||
: 'ene'
|
||||
const prevMaxHp = this.charSheet.attrs.baseMaxHp
|
||||
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina
|
||||
const prevMaxMana = this.charSheet.attrs.baseMaxMana
|
||||
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
|
||||
// Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers.
|
||||
if (this._commandSink) return this._commandSink.allocateStat(shortKey)
|
||||
return false
|
||||
}
|
||||
|
||||
grantExperience(amount: number): { levelsGained: number; newLevel: number } {
|
||||
|
|
@ -645,11 +691,6 @@ export class HudManager {
|
|||
}
|
||||
|
||||
useBeltSlot(col: number): boolean {
|
||||
if (this.engine) {
|
||||
const ok = this.engine.useBeltSlot(col)
|
||||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
return this.drinkBeltSlot(col)
|
||||
}
|
||||
|
||||
|
|
@ -663,13 +704,9 @@ export class HudManager {
|
|||
this.syncFromEngine()
|
||||
return dropped
|
||||
}
|
||||
const dropped = this.inventory.dropGold(amount)
|
||||
if (dropped > 0) {
|
||||
this.callbacks.onDropGoldToGround?.(dropped, mousePos)
|
||||
}
|
||||
this.syncPublishedState()
|
||||
for (const cb of this.changeListeners) cb()
|
||||
return dropped
|
||||
// Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
|
||||
this._commandSink?.dropGold(amount)
|
||||
return 0
|
||||
}
|
||||
|
||||
pickupGroundGold(groundItemId: number | string): number {
|
||||
|
|
@ -921,11 +958,14 @@ export class HudManager {
|
|||
mode: 'trade' | 'gamble' = 'trade',
|
||||
difficulty: 0 | 1 | 2 = 0,
|
||||
): boolean {
|
||||
if (this.currentClass === null) {
|
||||
throw new Error('HudManager.openVendorSession: character class not assigned by the server yet')
|
||||
}
|
||||
const playerCtx = {
|
||||
level: this.charSheet.attrs.level,
|
||||
str: this.charSheet.attrs.str,
|
||||
dex: this.charSheet.attrs.dex,
|
||||
classCode: this.currentClass ?? 'sor',
|
||||
classCode: this.currentClass,
|
||||
}
|
||||
this.worldPanels.activePlayerInventory = this.inventory
|
||||
this.inventory.setPlayerContext(playerCtx)
|
||||
|
|
@ -1112,23 +1152,9 @@ export class HudManager {
|
|||
if (ok) this.syncFromEngine()
|
||||
return ok
|
||||
}
|
||||
const topPotion = this.belt.slots[0]?.[col] ?? null
|
||||
const eff = topPotion ? resolvePotionSpec(topPotion.code) : undefined
|
||||
const consumed = this.belt.useSlot(col, 0)
|
||||
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
|
||||
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
|
||||
if (this._commandSink) return this._commandSink.useBeltSlot(col)
|
||||
return false
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
|
|
@ -1177,7 +1203,11 @@ export class HudManager {
|
|||
}
|
||||
} else if (key === 'W') {
|
||||
e.preventDefault()
|
||||
if (this._commandSink) {
|
||||
this._commandSink.swapWeapons()
|
||||
} else {
|
||||
this.inventory.toggleWeaponSwap()
|
||||
}
|
||||
this.syncPublishedState()
|
||||
} else if (key === 'R') {
|
||||
e.preventDefault()
|
||||
|
|
@ -1323,8 +1353,12 @@ export class HudManager {
|
|||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const dropped = this.inventory.cursorItem
|
||||
if (this._commandSink) {
|
||||
this._commandSink.dropItem(serverItemIdOf(dropped))
|
||||
} else {
|
||||
this.inventory.cursorItem = null
|
||||
this.callbacks.onDropItemToGround?.(dropped, pt)
|
||||
}
|
||||
this.syncPublishedState()
|
||||
}
|
||||
return
|
||||
|
|
@ -1401,9 +1435,6 @@ export class HudManager {
|
|||
if (pt.x <= 400 + deltaLeft) {
|
||||
const leftX = pt.x - deltaLeft
|
||||
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) => {
|
||||
if (this.engine) {
|
||||
const normAttr =
|
||||
|
|
@ -1418,18 +1449,7 @@ export class HudManager {
|
|||
this.syncFromEngine()
|
||||
return
|
||||
}
|
||||
if (key === '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 (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)
|
||||
}
|
||||
this._commandSink?.allocateStat(key)
|
||||
})
|
||||
if (!this.charSheet.visible) this.leftPanel = 'none'
|
||||
this.syncPublishedState()
|
||||
|
|
@ -1440,8 +1460,24 @@ export class HudManager {
|
|||
this.leftPanel = 'none'
|
||||
},
|
||||
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'),
|
||||
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||
inventory: this.inventory,
|
||||
|
|
@ -1457,7 +1493,7 @@ export class HudManager {
|
|||
if (pt.x >= 400 + deltaRight) {
|
||||
const rightX = pt.x - deltaRight
|
||||
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)
|
||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||
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.inventory.handleClick(
|
||||
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 {
|
||||
// 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
|
||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
||||
if (!catalogEntry) return
|
||||
|
|
@ -1607,7 +1704,7 @@ export class HudManager {
|
|||
return {
|
||||
level: this.charSheet.attrs.level,
|
||||
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,
|
||||
stamina: this.stamina,
|
||||
maxStamina: this.maxStamina,
|
||||
|
|
@ -1631,11 +1728,13 @@ export class HudManager {
|
|||
}
|
||||
|
||||
syncPlayerContext(): void {
|
||||
// Not ready until the server assigns the class; requirement checks keep their neutral context.
|
||||
if (this.currentClass === null) return
|
||||
const playerCtx = {
|
||||
level: this.charSheet.attrs.level,
|
||||
str: this.charSheet.attrs.str,
|
||||
dex: this.charSheet.attrs.dex,
|
||||
classCode: this.currentClass ?? 'sor',
|
||||
classCode: this.currentClass,
|
||||
}
|
||||
this.inventory.setPlayerContext(playerCtx)
|
||||
this.worldPanels.setPlayerContext(playerCtx)
|
||||
|
|
@ -1702,12 +1801,8 @@ export class HudManager {
|
|||
playerMoving: boolean = false,
|
||||
): void {
|
||||
this.syncPlayerContext()
|
||||
if (playerMoving && this.isRunning) {
|
||||
this.stamina = Math.max(0, this.stamina - 0.12)
|
||||
if (this.stamina <= 0) this.isRunning = false
|
||||
} else {
|
||||
this.stamina = Math.min(this.maxStamina, this.stamina + 0.35)
|
||||
}
|
||||
// Stamina is server state (stat 10 / 0x95); no client-side drain/regen simulation.
|
||||
void playerMoving
|
||||
|
||||
const profile = this.viewportProfile
|
||||
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`)
|
||||
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.translate(deltaLeft, panelOffsetY)
|
||||
this.charSheet.draw(
|
||||
|
|
@ -1770,12 +1861,10 @@ export class HudManager {
|
|||
maxMana: this.maxMana,
|
||||
stamina: this.stamina,
|
||||
maxStamina: this.maxStamina,
|
||||
leftSkillDmg: leftNode
|
||||
? `${leftNode.baseDmgMin + lEff * leftNode.dmgPerLevel}-${leftNode.baseDmgMax + lEff * Math.round(leftNode.dmgPerLevel * 1.25)}`
|
||||
: '45-82',
|
||||
rightSkillDmg: rightNode
|
||||
? `${rightNode.baseDmgMin + rEff * rightNode.dmgPerLevel}-${rightNode.baseDmgMax + rEff * Math.round(rightNode.dmgPerLevel * 1.25)}`
|
||||
: '520-585',
|
||||
// Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc
|
||||
// (no fabricated per-level numbers).
|
||||
leftSkillDmg: '',
|
||||
rightSkillDmg: '',
|
||||
},
|
||||
{
|
||||
borderLeftImg: this.images.get('borderLeft') ?? null,
|
||||
|
|
|
|||
|
|
@ -2043,21 +2043,18 @@ export class InventoryPanel {
|
|||
visible = false
|
||||
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
||||
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>> & {
|
||||
get: (slot: string) => UiInventoryItem | undefined
|
||||
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
|
||||
} = { ...STARTER_EQUIPPED_GEAR } as any
|
||||
} = {} as any
|
||||
/** Secondary weapon swap slots (`Weapon Set II`). */
|
||||
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
|
||||
weapon1: null,
|
||||
weapon2: null,
|
||||
}
|
||||
/** 10x4 grid items (deep-cloned per instance so quantity/stat mutations do not leak across instances). */
|
||||
gridItems: GridPlacement[] = STARTER_BAG_ITEMS.map(p => ({
|
||||
...p,
|
||||
item: { ...p.item, stats: [...p.item.stats] },
|
||||
}))
|
||||
/** 10x4 grid items (empty until the server sends inventory item events). */
|
||||
gridItems: GridPlacement[] = []
|
||||
private _starterCleared = false
|
||||
private _cursorItem: UiInventoryItem | null = null
|
||||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||||
|
|
@ -2087,10 +2084,9 @@ export class InventoryPanel {
|
|||
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
|
||||
*/
|
||||
playerContext: PlayerItemRequirementContext = {
|
||||
level: 85,
|
||||
str: 156,
|
||||
dex: 75,
|
||||
classCode: 'sor',
|
||||
level: 0,
|
||||
str: 0,
|
||||
dex: 0,
|
||||
}
|
||||
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
|
||||
playerContextSeq = 0
|
||||
|
|
@ -2135,7 +2131,8 @@ export class InventoryPanel {
|
|||
|
||||
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */
|
||||
_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. */
|
||||
get maxGold(): number {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ export interface SkillNodeDef {
|
|||
readonly baseMana: number
|
||||
readonly baseDmgMin: 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
|
||||
|
|
@ -179,7 +183,6 @@ function buildClassSkillTrees(): Record<CharacterClassCode, readonly SkillNodeDe
|
|||
baseMana: s.mana,
|
||||
baseDmgMin: s.minDam,
|
||||
baseDmgMax: s.maxDam,
|
||||
dmgPerLevel: 5,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
|
@ -192,31 +195,44 @@ export class SkillTreePanel {
|
|||
lang: TblLang = 'CHI'
|
||||
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
|
||||
activeTab: 0 | 1 | 2 = 2
|
||||
unspentSkillPoints = 12
|
||||
bonusAllSkills = 8
|
||||
characterClass: CharacterClassCode = 'sor'
|
||||
characterLevel = 85
|
||||
readonly hardPoints = new Map<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],
|
||||
])
|
||||
/** Server stat 5 (`newskills`). */
|
||||
unspentSkillPoints = 0
|
||||
/**
|
||||
* Generic `+all skills` soft points used only by the offline skill calculator state.
|
||||
* Online, per-skill bonuses come from the server (`0x21` UpdateSkill bonus byte) via `bonusLevels`.
|
||||
*/
|
||||
bonusAllSkills = 0
|
||||
/** `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
|
||||
|
||||
private tabBgs = new Map<number, HTMLImageElement>()
|
||||
private skillIcons = new Map<number, HTMLImageElement>()
|
||||
private tabBgClass: CharacterClassCode = 'sor'
|
||||
private tabBgClass: CharacterClassCode | null = null
|
||||
|
||||
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[] {
|
||||
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 {
|
||||
if (typeof Image === 'undefined') return
|
||||
if (this.characterClass === null) return
|
||||
for (const tab of [0, 1, 2]) {
|
||||
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
|
||||
const bg = new Image()
|
||||
|
|
@ -262,30 +278,48 @@ export class SkillTreePanel {
|
|||
return this.hardPoints.get(skillId) ?? 0
|
||||
}
|
||||
|
||||
getEffectiveLevel(skillId: number): number {
|
||||
const hard = this.getHardPoints(skillId)
|
||||
return hard > 0 ? hard + this.bonusAllSkills : 0
|
||||
/** Server-reported bonus levels for `skillId` (0 when none). */
|
||||
getBonusLevels(skillId: number): number {
|
||||
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> = {}
|
||||
for (const [id, pts] of this.hardPoints.entries()) {
|
||||
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 {
|
||||
classCode: this.characterClass,
|
||||
hardPoints,
|
||||
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
||||
allSkillsSoftPoints: this.bonusAllSkills,
|
||||
specificSoftPoints: {},
|
||||
specificSoftPoints,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const node = this.getSkillTree().find(n => n.skillId === skillId)
|
||||
if (!node) return false
|
||||
|
|
@ -295,13 +329,6 @@ export class SkillTreePanel {
|
|||
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 } {
|
||||
const colIdx = Math.max(0, Math.min(2, node.col - 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 }
|
||||
}
|
||||
|
||||
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } {
|
||||
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass] ?? SKILL_CLOSE_BTN_OFFSET_X.sor
|
||||
const offsetX = offsets[this.activeTab] ?? 171
|
||||
/** Close-button socket for the active class/tab; `null` while the class is unknown. */
|
||||
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } | null {
|
||||
if (this.characterClass === null) return null
|
||||
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass]
|
||||
const offsetX = offsets[this.activeTab]
|
||||
return {
|
||||
x: SKILL_PANEL_ORIGIN.x + offsetX,
|
||||
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)
|
||||
const closeBounds = this.getCloseButtonBounds()
|
||||
if (
|
||||
closeBounds &&
|
||||
logicalX >= closeBounds.x &&
|
||||
logicalX <= closeBounds.x + closeBounds.w &&
|
||||
logicalY >= closeBounds.y &&
|
||||
|
|
@ -372,7 +402,8 @@ export class SkillTreePanel {
|
|||
for (const node of tabNodes) {
|
||||
const r = SkillTreePanel.nodeScreenPos(node)
|
||||
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)
|
||||
}
|
||||
return true
|
||||
|
|
@ -521,8 +552,8 @@ export class SkillTreePanel {
|
|||
}
|
||||
|
||||
// 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket)
|
||||
if (assets.buySellBtnImg) {
|
||||
const closeBounds = this.getCloseButtonBounds()
|
||||
if (assets.buySellBtnImg && closeBounds) {
|
||||
ctx.drawImage(
|
||||
assets.buySellBtnImg,
|
||||
10 * 32,
|
||||
|
|
|
|||
|
|
@ -591,19 +591,41 @@ export interface WaypointEntry {
|
|||
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 {
|
||||
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
|
||||
return {
|
||||
...item,
|
||||
status: isCompleted ? 'completed' : 'active',
|
||||
description: isCompleted
|
||||
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
|
||||
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
|
||||
status: 'locked',
|
||||
description: `${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[]>> = {
|
||||
1: CANONICAL_QUESTS_BY_ACT[1]!.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[]>> = {
|
||||
1: [
|
||||
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true },
|
||||
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true },
|
||||
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true },
|
||||
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true },
|
||||
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true },
|
||||
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true },
|
||||
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true },
|
||||
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true },
|
||||
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: false },
|
||||
],
|
||||
2: [
|
||||
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true },
|
||||
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true },
|
||||
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', 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: true },
|
||||
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true },
|
||||
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true },
|
||||
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true },
|
||||
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true },
|
||||
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: false },
|
||||
],
|
||||
3: [
|
||||
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true },
|
||||
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true },
|
||||
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true },
|
||||
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true },
|
||||
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true },
|
||||
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true },
|
||||
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true },
|
||||
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', 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: 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: false },
|
||||
],
|
||||
4: [
|
||||
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true },
|
||||
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true },
|
||||
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', 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: false },
|
||||
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: false },
|
||||
],
|
||||
5: [
|
||||
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true },
|
||||
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true },
|
||||
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true },
|
||||
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true },
|
||||
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true },
|
||||
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true },
|
||||
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true },
|
||||
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true },
|
||||
{ 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: 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ 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: false },
|
||||
{ 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()
|
||||
selectedActTab = 1
|
||||
selectedQuestIdx = 0
|
||||
private _stashGold = 2_500_000
|
||||
/** Server stat 15 (`goldbank`); 0 until received. */
|
||||
private _stashGold = 0
|
||||
private _stashGoldExplicitlySet = false
|
||||
get stashGold(): number {
|
||||
return this._stashGold
|
||||
|
|
@ -733,12 +756,12 @@ export class WorldPanelsHud {
|
|||
vendorMode: 'trade' | 'gamble' = 'trade'
|
||||
activeVendorTab: VendorTabId = 'armor'
|
||||
vendorDifficulty: 0 | 1 | 2 = 0
|
||||
vendorCharLevel = 85
|
||||
/** Synced from the server character level by `HudManager.syncPlayerContext()`. */
|
||||
vendorCharLevel = 0
|
||||
vendorPlayerStats: PlayerItemRequirementContext = {
|
||||
level: 85,
|
||||
str: 156,
|
||||
dex: 75,
|
||||
classCode: 'sor',
|
||||
level: 0,
|
||||
str: 0,
|
||||
dex: 0,
|
||||
}
|
||||
vendorTradeState: VendorTradeState = 'idle'
|
||||
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
||||
|
|
@ -1435,6 +1458,21 @@ export class WorldPanelsHud {
|
|||
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 questStringTableChain?: { get(key: string): string | undefined } | null | undefined
|
||||
|
||||
|
|
@ -2166,6 +2204,21 @@ export class WorldPanelsHud {
|
|||
inventory?: InventoryPanel
|
||||
isShiftClick?: 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 {
|
||||
if (logicalX > 400) return false
|
||||
|
|
@ -2232,9 +2285,13 @@ export class WorldPanelsHud {
|
|||
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
|
||||
if (!callbacks.isRightClick) {
|
||||
this.pressedCubeButton = 'transmute'
|
||||
if (callbacks.onTransmute) {
|
||||
callbacks.onTransmute()
|
||||
} else {
|
||||
this.cubeTransmuteCount += 1
|
||||
this.transmuteCube()
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
if (callbacks.onContainerGridClick) {
|
||||
callbacks.onContainerGridClick('cube', col, row, hit?.item ?? null, callbacks.isRightClick === true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (callbacks.inventory?.isIdentifyMode()) {
|
||||
if (callbacks.isRightClick) {
|
||||
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,
|
||||
)
|
||||
|
||||
if (callbacks.onContainerGridClick) {
|
||||
callbacks.onContainerGridClick('stash', col, row, hit?.item ?? null, callbacks.isRightClick === true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (callbacks.inventory?.isIdentifyMode()) {
|
||||
if (callbacks.isRightClick) {
|
||||
callbacks.inventory.cancelIdentifyMode()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ export interface PortalState {
|
|||
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 {
|
||||
act = 0
|
||||
mapSeed = 0
|
||||
|
|
@ -90,6 +93,8 @@ export class ClientWorld {
|
|||
private readonly _containers = new ClientInventoryManager()
|
||||
private readonly _revealedRooms = new Set<string>()
|
||||
private readonly _waypoints = new Set<number>()
|
||||
/** Unit id of the waypoint object whose menu was last opened (`0x63 WaypointMenu`); `null` when none. */
|
||||
private _waypointUnitId: number | null = null
|
||||
private _quests = new Uint16Array(48)
|
||||
private readonly _party = new Map<number, PartyMemberState>()
|
||||
private readonly _trade: TradeSessionState = {
|
||||
|
|
@ -143,6 +148,11 @@ export class ClientWorld {
|
|||
return this._waypoints
|
||||
}
|
||||
|
||||
/** Waypoint object unit id from the last `0x63 WaypointMenu`, required by `0x49 WaypointGo`. */
|
||||
get waypointUnitId(): number | null {
|
||||
return this._waypointUnitId
|
||||
}
|
||||
|
||||
get quests(): Uint16Array {
|
||||
return this._quests
|
||||
}
|
||||
|
|
@ -457,6 +467,10 @@ export class ClientWorld {
|
|||
break
|
||||
|
||||
case 'StatSet': {
|
||||
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
|
||||
// (0x53 act seed = 0x5300, 0x8000|op / 0x9000|op opaque envelopes) as pseudo StatSet events;
|
||||
// those must never enter a unit StatList.
|
||||
if (ev.stat < 0 || ev.stat > MAX_D2_STAT_ID) break
|
||||
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
|
||||
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
|
||||
if (this.selfId !== 0) {
|
||||
|
|
@ -579,6 +593,7 @@ export class ClientWorld {
|
|||
|
||||
case 'WaypointMenu': {
|
||||
this._waypoints.clear()
|
||||
this._waypointUnitId = ev.unitId
|
||||
for (const wp of ev.open) {
|
||||
this._waypoints.add(wp)
|
||||
}
|
||||
|
|
@ -621,9 +636,13 @@ export class ClientWorld {
|
|||
if (ev.partnerId !== undefined) {
|
||||
this._trade.partnerId = ev.partnerId
|
||||
}
|
||||
if (ev.gold !== undefined) {
|
||||
this._trade.partnerGold = ev.gold
|
||||
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
|
||||
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
|
||||
if ((ev.action & 0xff) !== 0) {
|
||||
this._trade.selfGold = ev.gold
|
||||
} else {
|
||||
this._trade.partnerGold = ev.gold
|
||||
}
|
||||
}
|
||||
// Trade cancel / close actions
|
||||
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
|
||||
|
|
@ -736,6 +755,7 @@ export class ClientWorld {
|
|||
this._containers.clear()
|
||||
this._revealedRooms.clear()
|
||||
this._waypoints.clear()
|
||||
this._waypointUnitId = null
|
||||
this._quests = new Uint16Array(48)
|
||||
this._party.clear()
|
||||
this._trade.active = false
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ import {
|
|||
export interface SelfPlayerState {
|
||||
unitId: number
|
||||
name: string
|
||||
charClass: CharClassId
|
||||
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
|
||||
charClass: CharClassId | null
|
||||
level: number
|
||||
x: number
|
||||
y: number
|
||||
|
|
@ -53,8 +54,8 @@ export interface SelfPlayerState {
|
|||
export class MutableSelfPlayerState implements SelfPlayerState {
|
||||
unitId = 0
|
||||
name = ''
|
||||
charClass: CharClassId = 0
|
||||
level = 1
|
||||
charClass: CharClassId | null = null
|
||||
level = 0
|
||||
x = 0
|
||||
y = 0
|
||||
targetX = 0
|
||||
|
|
@ -78,46 +79,42 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
|||
weaponSet: 0 | 1 = 0
|
||||
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 {
|
||||
if (stat === STAT_GOLD && param === 1) {
|
||||
this.gold = Math.max(0, this.gold + value)
|
||||
this.stats.set(STAT_GOLD, this.gold)
|
||||
return
|
||||
}
|
||||
const isShiftedPoolStat =
|
||||
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)
|
||||
this.stats.set(stat, value, param)
|
||||
switch (stat) {
|
||||
case STAT_HITPOINTS:
|
||||
this.life = normVal
|
||||
if (this.maxLife < normVal) this.maxLife = normVal
|
||||
this.life = poolStatValue(value)
|
||||
break
|
||||
case STAT_MAXHP:
|
||||
this.maxLife = normVal
|
||||
if (this.life === 0) this.life = normVal
|
||||
this.maxLife = poolStatValue(value)
|
||||
break
|
||||
case STAT_MANA:
|
||||
this.mana = normVal
|
||||
if (this.maxMana < normVal) this.maxMana = normVal
|
||||
this.mana = poolStatValue(value)
|
||||
break
|
||||
case STAT_MAXMANA:
|
||||
this.maxMana = normVal
|
||||
if (this.mana === 0) this.mana = normVal
|
||||
this.maxMana = poolStatValue(value)
|
||||
break
|
||||
case STAT_STAMINA:
|
||||
this.stamina = normVal
|
||||
if (this.maxStamina < normVal) this.maxStamina = normVal
|
||||
this.stamina = poolStatValue(value)
|
||||
break
|
||||
case STAT_MAXSTAMINA:
|
||||
this.maxStamina = normVal
|
||||
if (this.stamina === 0) this.stamina = normVal
|
||||
this.maxStamina = poolStatValue(value)
|
||||
break
|
||||
case STAT_LEVEL:
|
||||
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 {
|
||||
if (life >= 0) {
|
||||
this.life = life
|
||||
this.stats.set(STAT_HITPOINTS, life)
|
||||
if (this.maxLife < life) this.maxLife = life
|
||||
}
|
||||
if (mana >= 0) {
|
||||
this.mana = mana
|
||||
this.stats.set(STAT_MANA, mana)
|
||||
if (this.maxMana < mana) this.maxMana = mana
|
||||
}
|
||||
if (stamina >= 0) {
|
||||
this.stamina = stamina
|
||||
this.stats.set(STAT_STAMINA, stamina)
|
||||
if (this.maxStamina < stamina) this.maxStamina = stamina
|
||||
}
|
||||
if (x !== 0 || y !== 0) {
|
||||
this.x = x
|
||||
|
|
@ -170,8 +165,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
|||
reset(): void {
|
||||
this.unitId = 0
|
||||
this.name = ''
|
||||
this.charClass = 0
|
||||
this.level = 1
|
||||
this.charClass = null
|
||||
this.level = 0
|
||||
this.x = 0
|
||||
this.y = 0
|
||||
this.targetX = 0
|
||||
|
|
@ -196,3 +191,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
|||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,10 +218,6 @@ export class BeltModel {
|
|||
/** Currently hovered slot `(row, col)`. */
|
||||
hoveredSlot: { row: number; col: number } | null = null
|
||||
|
||||
constructor() {
|
||||
this.seedDefaultPotions()
|
||||
}
|
||||
|
||||
seedDefaultPotions(): void {
|
||||
for (let row = 0; row < BELT_ROWS; row++) {
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ export function encodeC2sItemDrop(itemId: number): Uint8Array {
|
|||
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 */
|
||||
export function encodeC2sItemPlace(itemId: number, x: number, y: number, buffer: number): Uint8Array {
|
||||
const w = new ByteWriter(17)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
encodeC2sBeltUse,
|
||||
encodeC2sIdentifyItem,
|
||||
encodeC2sItemDrop,
|
||||
encodeC2sDropGold,
|
||||
encodeC2sItemEquip,
|
||||
encodeC2sItemPickup,
|
||||
encodeC2sItemPlace,
|
||||
|
|
@ -439,6 +440,8 @@ export function encodeD2gsClientCommand(
|
|||
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
|
||||
case 'ItemDrop':
|
||||
return [encodeC2sItemDrop(cmd.itemId)]
|
||||
case 'DropGold':
|
||||
return [encodeC2sDropGold(cmd.unitId, cmd.amount)]
|
||||
case 'ItemToCursor':
|
||||
return [encodeC2sItemToCursor(cmd.itemId)]
|
||||
case 'ItemPlace':
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ export function decode0x1CExpDword(pkt: Uint8Array): ServerEvent {
|
|||
|
||||
/**
|
||||
* Normalize 1.13c stat values from 0x1D/0x1E/0x1F (D2Client.dll 0x6fb5edf0):
|
||||
* Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina),
|
||||
* 0x6fb5edf0 shifts left by 8 inside the engine's fixed-point stat table,
|
||||
* meaning the wire value is the integer stat (or fixed-point for 0x1F when > 0xFFFF).
|
||||
* Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina) the value is passed through
|
||||
* unchanged: it is the engine's 8.8 fixed-point number (ItemStatCost.txt ValShift = 8), e.g. a fresh
|
||||
* level-1 Assassin's maxhp arrives as `1e 07 00 32` = 12800 = 50 << 8. Consumers apply `>> 8`.
|
||||
*/
|
||||
export function decode0x1DAttrByte(pkt: Uint8Array): ServerEvent {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export type ClientCommand =
|
|||
| { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||
| { 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: 'ItemPlace'
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { readFileSync, existsSync } from 'node:fs'
|
|||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
SkillHotkeysHud,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} 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 { castSkill } from '../src/server/scene/skill-caster.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', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left')
|
||||
const rightSkills = getAvailableSkillsForSide('right')
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||
|
||||
const leftAttack = leftSkills.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)', () => {
|
||||
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.rightSkillId).toBe(64) // Frozen Orb
|
||||
|
||||
|
|
|
|||
|
|
@ -131,11 +131,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
|||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||
})
|
||||
|
||||
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left')
|
||||
const rightSkills = getAvailableSkillsForSide('right')
|
||||
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||
|
||||
const leftAttack = leftSkills.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(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||
})
|
||||
|
||||
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
||||
|
|
|
|||
|
|
@ -198,7 +198,11 @@ describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures
|
|||
|
||||
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
|
||||
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
|
||||
panel.setClass('pal')
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
|||
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()
|
||||
world.apply({
|
||||
type: 'UnitAssign',
|
||||
|
|
@ -137,7 +137,8 @@ describe('ClientWorld authoritative state mirror', () => {
|
|||
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: 7, value: 12800 })
|
||||
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.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)
|
||||
world.apply({
|
||||
type: 'LifeMana',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -24,6 +24,58 @@ import {
|
|||
} from '../../src/netproto/index.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('HudModel (F16)', () => {
|
||||
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.FireResist, value: 75 })
|
||||
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.MaxMana, value: 500 << 8 })
|
||||
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 })
|
||||
|
|
@ -291,6 +344,25 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
|||
'ItemDrop',
|
||||
'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()
|
||||
})
|
||||
})
|
||||
|
|
@ -405,10 +477,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
|||
|
||||
mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 })
|
||||
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(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(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(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 },
|
||||
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({
|
||||
type: 'LifeMana',
|
||||
life: 320,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.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('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', () => {
|
||||
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
|
||||
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true)
|
||||
expect(hud.state.leftPanel).toBe('vendor')
|
||||
|
|
|
|||
|
|
@ -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 { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
||||
import { BeltHud } from '../../src/client/ui/belt.ts'
|
||||
import { seedDemoBelt } from '../fixtures/hud-demo-state.ts'
|
||||
|
||||
let oracle: DropOracleStack
|
||||
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)', () => {
|
||||
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[0].length).toBe(4)
|
||||
})
|
||||
|
||||
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
|
||||
const initialBottom = belt.grid[0][0]
|
||||
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', () => {
|
||||
const belt = new BeltHud()
|
||||
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||
belt.useSlot(1) // empty top row in col 1
|
||||
const dummyPotion = { id: 'test_potion', name: 'Health Potion', col: 1 } as any
|
||||
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', () => {
|
||||
const belt = new BeltHud()
|
||||
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||
expect(belt.countTotalPotions()).toBe(16)
|
||||
belt.useSlot(0)
|
||||
expect(belt.countTotalPotions()).toBe(15)
|
||||
|
|
|
|||
|
|
@ -609,7 +609,9 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
|
|||
describe('B14: Full Belt Spilling & Non-Potion Rejection (#425)', () => {
|
||||
it('B14.1 completely full belt (16 potions) rejects further potion placement', () => {
|
||||
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)
|
||||
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
|
||||
// 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', () => {
|
||||
const belt = new BeltHud()
|
||||
belt.seedDefaultPotions() // explicit legacy full belt
|
||||
belt.useSlot(0) // empty top row in col 0 (Health Potion col)
|
||||
expect(belt.grid[3][0]).toBeNull()
|
||||
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any
|
||||
|
|
|
|||
|
|
@ -1521,9 +1521,21 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
|
|||
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff })
|
||||
expect(world.party.has(2)).toBe(false)
|
||||
|
||||
// 4. Trade open (action 0x01) -> gold update -> cancel (action 0x02)
|
||||
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 50000 })
|
||||
// 4. Trade open (action 0x01) -> 0x79 GoldInTrade (action 0x7900 | bySelf) -> cancel (action 0x02)
|
||||
world.apply({ type: 'Trade', action: 0x01, partnerId: 2 })
|
||||
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)
|
||||
world.apply({ type: 'Trade', action: 0x02 })
|
||||
expect(world.trade.active).toBe(false)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ import { SkillHotkeysHud, DEFAULT_SORCERESS_SKILLS, LEFT_SKILL_BOUNDS, RIGHT_SKI
|
|||
import { HudManager } from '../src/client/ui/hud-manager.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 { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||
|
||||
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
||||
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', () => {
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
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()
|
||||
// Open Skill tree panel
|
||||
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)!
|
||||
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 allocated = hudManager.skillTree.allocateSkill(48)
|
||||
expect(allocated).toBe(true)
|
||||
|
||||
// Test handleSkillAllocated
|
||||
const requested: number[] = []
|
||||
hudManager.commandSink = { allocateSkill: (id: number) => (requested.push(id), true) } as any
|
||||
const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager)
|
||||
privateMethod(48)
|
||||
|
||||
const updatedNova = hudManager.hotkeys.availableSkills.find(s => s.skillId === 48)
|
||||
expect(updatedNova).toBeDefined()
|
||||
expect(updatedNova!.level).toBe(prevPoints + 1)
|
||||
expect(requested).toEqual([48])
|
||||
expect(hudManager.skillTree.hardPoints.get(48) ?? 0).toBe(prevPoints)
|
||||
expect(hudManager.skillTree.unspentSkillPoints).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ function createTestEngine() {
|
|||
lootSeed: 42,
|
||||
npcDefs: [],
|
||||
})
|
||||
// Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt.
|
||||
engine.belt.seedDefaultPotions()
|
||||
return engine
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,15 +11,26 @@ import {
|
|||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} from '../src/client/ui/hotkeys.ts'
|
||||
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||
|
||||
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
||||
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.rightSkillId).toBe(64) // Frozen Orb
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import * as fs from 'node:fs'
|
|||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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 { ViewportCamera } from '../src/client/sim/camera.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')
|
||||
})
|
||||
|
||||
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 hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
|
|
@ -223,6 +224,8 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
|
|||
})
|
||||
hud['bindEvents']()
|
||||
|
||||
// Explicit legacy demo state: Sorceress, 16-potion belt, vitals
|
||||
seedDemoHud(hud)
|
||||
hud.hotkeys.leftSkillId = 47 // Fire Ball
|
||||
hud.hotkeys.rightSkillId = 64 // Frozen Orb
|
||||
hud.syncPublishedState()
|
||||
|
|
@ -231,36 +234,35 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
|
|||
|
||||
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' }))
|
||||
expect(hud.hp).toBe(400 + 180)
|
||||
expect(hud.hp).toBe(400)
|
||||
expect(hud.belt.countTotalPotions()).toBe(16)
|
||||
|
||||
// With a server command sink attached, keys 1..4 only emit BeltUse requests for columns 0..3
|
||||
const beltRequests: number[] = []
|
||||
hud.commandSink = {
|
||||
useBeltSlot: (col: number) => {
|
||||
beltRequests.push(col)
|
||||
return true
|
||||
},
|
||||
} as unknown as HudCommandSink
|
||||
|
||||
for (const [i, key] of ['1', '2', '3', '4'].entries()) {
|
||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key }))
|
||||
expect(beltRequests).toEqual([0, 1, 2, 3].slice(0, i + 1))
|
||||
// Active skills are never reset by belt hotkeys
|
||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
||||
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)
|
||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' }))
|
||||
expect(hud.hp).toBe(580 + 320)
|
||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
||||
expect(hud.belt.countTotalPotions()).toBe(14)
|
||||
|
||||
// Press '3' -> drinks column 2 (mana potion, healMana 150)
|
||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' }))
|
||||
expect(hud.mana).toBe(200 + 150)
|
||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
||||
expect(hud.belt.countTotalPotions()).toBe(13)
|
||||
|
||||
// Press '4' -> drinks column 3 (rejuv potion, healHp 400, healMana 400)
|
||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' }))
|
||||
expect(hud.hp).toBe(900 + 400 > hud.maxHp ? hud.maxHp : 1300)
|
||||
expect(hud.mana).toBe(350 + 400)
|
||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
||||
expect(hud.belt.countTotalPotions()).toBe(12)
|
||||
// Local state is unchanged until the server answers (HudModel.syncFromWorld)
|
||||
expect(hud.hp).toBe(400)
|
||||
expect(hud.mana).toBe(200)
|
||||
expect(hud.belt.countTotalPotions()).toBe(16)
|
||||
hud.commandSink = null
|
||||
})
|
||||
|
||||
it('isolates WASD keys from movement Contribution', () => {
|
||||
|
|
@ -399,6 +401,8 @@ function createTestScene(options?: {
|
|||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
// Explicit legacy demo Sorceress state (class, quickbar skill levels / mana costs, vitals incl. maxMana).
|
||||
seedDemoHud(hudManager)
|
||||
hudManager.mana = options?.mana ?? 200
|
||||
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
|
||||
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
logicalToClient,
|
||||
} from '../src/client/ui/hud-manager.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'
|
||||
|
||||
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)', () => {
|
||||
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.selectedActTab = 1
|
||||
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('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.visible = true
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
import { WorldPanelsHud } from '../src/client/ui/world-panels.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 { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
|
||||
|
||||
// --- 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)', () => {
|
||||
const worldPanels = new WorldPanelsHud()
|
||||
const worldPanels = createDemoWorldPanels() // seeded level-85 Sorceress requirement context
|
||||
const mockCtx = createMockCanvasContext()
|
||||
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', () => {
|
||||
|
||||
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'
|
||||
const equippedHelm = inv.equipped.helm as any
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { SceneMouseController } from '../src/client/world/act-runtime.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', () => {
|
||||
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', () => {
|
||||
const engine = createTestEngine({ lootSeed: 12345678 })
|
||||
const invPanel = new InventoryPanel()
|
||||
const invPanel = createDemoInventory() // seeded level-85 Sorceress requirement context
|
||||
invPanel.gridItems = []
|
||||
for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) {
|
||||
delete invPanel.equipped[k]
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -21,6 +21,8 @@ function makeTestItem(id: number, code: string, name: string): Item {
|
|||
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', () => {
|
||||
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 itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion')
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,13 @@ describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
|
|||
const a1q1 = ACT_QUESTS[1]![0]!
|
||||
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)
|
||||
expect(displayCompleted.status).toBe('completed')
|
||||
expect(displayCompleted.statusTag).toBe('[已完成]')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.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', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
|
@ -20,7 +21,8 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
|
|||
} as unknown as D2FontRenderer
|
||||
|
||||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
SKILL_CLOSE_BTN_BOUNDS,
|
||||
SKILL_CLOSE_BTN_OFFSET_X,
|
||||
} 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 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', () => {
|
||||
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
|
||||
|
||||
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', () => {
|
||||
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.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', () => {
|
||||
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
|
||||
|
||||
const mockCtx = {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '../src/client/ui/inventory.ts'
|
||||
import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.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', () => {
|
||||
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', () => {
|
||||
const inv = new InventoryPanel()
|
||||
const inv = createDemoInventory() // seeded starter bag (tbk / tsc)
|
||||
inv.visible = true
|
||||
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
|
||||
expect(tbkPlacement).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||
import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts'
|
||||
import { projectNpcLabel } from '../src/client/world/act-runtime.ts'
|
||||
import { createDemoHotkeys, createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||
import {
|
||||
getLevelNames,
|
||||
sceneNameZh,
|
||||
|
|
@ -169,6 +170,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
|||
|
||||
it('supports 4x4 belt drinking with automatic column gravity refill', () => {
|
||||
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)
|
||||
|
||||
// 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', () => {
|
||||
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.rows).toBe(4)
|
||||
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')
|
||||
})
|
||||
|
||||
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 beforePts = char.attrs.statPoints
|
||||
const beforeVit = char.attrs.vit
|
||||
const beforeHp = char.attrs.baseMaxHp
|
||||
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)
|
||||
expect(char.classCode).toBeNull()
|
||||
expect(char.attrs.statPoints).toBe(0)
|
||||
expect(char.attrs.level).toBe(0)
|
||||
|
||||
const tree = new SkillTreePanel()
|
||||
const beforeSkillPts = tree.unspentSkillPoints
|
||||
const beforeIceBolt = tree.getHardPoints(39)
|
||||
expect(tree.allocateSkill(39)).toBe(true)
|
||||
expect(tree.unspentSkillPoints).toBe(beforeSkillPts - 1)
|
||||
expect(tree.getHardPoints(39)).toBe(beforeIceBolt + 1)
|
||||
expect(tree.characterClass).toBeNull()
|
||||
expect(tree.unspentSkillPoints).toBe(0)
|
||||
expect(tree.getSkillTree()).toEqual([])
|
||||
// Without unspent points the 1.13c allocation rule rejects the request; no local mutation API exists.
|
||||
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', () => {
|
||||
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.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)', () => {
|
||||
const inv = new InventoryPanel()
|
||||
const inv = createDemoInventory() // seeded starter bag (box / tbk / isc)
|
||||
inv.visible = true
|
||||
const worldPanels = new WorldPanelsHud()
|
||||
let cubeOpened = 0
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
type UiInventoryItem,
|
||||
} from '../src/client/ui/inventory.ts'
|
||||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||
|
||||
function createMockTooltipCanvas() {
|
||||
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', () => {
|
||||
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
|
||||
delete inv.equipped.helm
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
CUBE_TRANSMUTE_BTN_BOUNDS,
|
||||
CUBE_CLOSE_BTN_BOUNDS,
|
||||
} 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 { D2FontRenderer } from '../src/client/ui/font.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', () => {
|
||||
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 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', () => {
|
||||
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 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', () => {
|
||||
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 onClose = vi.fn()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue