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 selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||||||
const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
const selfY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||||||
|
|
||||||
if (selfX !== 0 || selfY !== 0 || this.world.selfId !== 0) {
|
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
|
||||||
|
const selfClass = this.world.self.charClass
|
||||||
|
if (this.world.selfId !== 0 && selfClass !== null) {
|
||||||
out.push({
|
out.push({
|
||||||
id: this.world.selfId || 1,
|
id: this.world.selfId,
|
||||||
kind: 'player',
|
kind: 'player',
|
||||||
classId: this.world.self.charClass,
|
classId: selfClass,
|
||||||
name: this.world.self.name || 'Player',
|
name: this.world.self.name,
|
||||||
x: selfX,
|
x: selfX,
|
||||||
y: selfY,
|
y: selfY,
|
||||||
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
|
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import type {
|
||||||
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
|
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
|
||||||
import type { BaseStatKey } from '../ui/character-sheet.ts'
|
import type { BaseStatKey } from '../ui/character-sheet.ts'
|
||||||
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
|
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
|
||||||
import type { HudManager } from '../ui/hud-manager.ts'
|
import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
|
||||||
import {
|
import {
|
||||||
resolveAllowedSlots,
|
resolveAllowedSlots,
|
||||||
resolveItemInvFile,
|
resolveItemInvFile,
|
||||||
|
|
@ -38,6 +38,8 @@ import type { LocalMovementPredictor } from '../motion/predictor.ts'
|
||||||
import type { ClientUnit } from '../world/client-unit.ts'
|
import type { ClientUnit } from '../world/client-unit.ts'
|
||||||
import type { ClientWorld } from '../world/client-world.ts'
|
import type { ClientWorld } from '../world/client-world.ts'
|
||||||
import { D2StatId } from '../world/stat-list.ts'
|
import { D2StatId } from '../world/stat-list.ts'
|
||||||
|
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
||||||
|
import { computeCharacterSheetDerived } from './derived-stats.ts'
|
||||||
|
|
||||||
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
||||||
0: 'ama',
|
0: 'ama',
|
||||||
|
|
@ -174,8 +176,9 @@ export interface HudModelOptions {
|
||||||
|
|
||||||
export interface HudModelSnapshot {
|
export interface HudModelSnapshot {
|
||||||
readonly charName: string
|
readonly charName: string
|
||||||
readonly charClass: CharClassId
|
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
|
||||||
readonly classCode: CharacterClassCode
|
readonly charClass: CharClassId | null
|
||||||
|
readonly classCode: CharacterClassCode | null
|
||||||
readonly level: number
|
readonly level: number
|
||||||
readonly xp: number
|
readonly xp: number
|
||||||
readonly hp: number
|
readonly hp: number
|
||||||
|
|
@ -210,7 +213,7 @@ export interface HudModelSnapshot {
|
||||||
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
|
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
|
||||||
* `ClientCommand` actions triggered by user HUD interactions.
|
* `ClientCommand` actions triggered by user HUD interactions.
|
||||||
*/
|
*/
|
||||||
export class HudModel {
|
export class HudModel implements HudCommandSink {
|
||||||
readonly world: ClientWorld
|
readonly world: ClientWorld
|
||||||
hudManager: HudManager | null
|
hudManager: HudManager | null
|
||||||
predictor: LocalMovementPredictor | null
|
predictor: LocalMovementPredictor | null
|
||||||
|
|
@ -218,7 +221,6 @@ export class HudModel {
|
||||||
onCommand?: ((cmd: ClientCommand) => void) | undefined
|
onCommand?: ((cmd: ClientCommand) => void) | undefined
|
||||||
|
|
||||||
private unsubWorld: (() => void) | null = null
|
private unsubWorld: (() => void) | null = null
|
||||||
private lastSyncedClassCode: CharacterClassCode | null = null
|
|
||||||
private hoveredMonsterId: number | null = null
|
private hoveredMonsterId: number | null = null
|
||||||
private escapeMenuOpen = false
|
private escapeMenuOpen = false
|
||||||
|
|
||||||
|
|
@ -228,6 +230,7 @@ export class HudModel {
|
||||||
this.predictor = options.predictor ?? null
|
this.predictor = options.predictor ?? null
|
||||||
this.automap = options.automap ?? null
|
this.automap = options.automap ?? null
|
||||||
this.onCommand = options.onCommand
|
this.onCommand = options.onCommand
|
||||||
|
if (this.hudManager) this.hudManager.commandSink = this
|
||||||
|
|
||||||
this.unsubWorld = this.world.onChange(() => {
|
this.unsubWorld = this.world.onChange(() => {
|
||||||
this.syncFromWorld()
|
this.syncFromWorld()
|
||||||
|
|
@ -238,6 +241,7 @@ export class HudModel {
|
||||||
* Detach the world listener when disposing the session.
|
* Detach the world listener when disposing the session.
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
if (this.hudManager?.commandSink === this) this.hudManager.commandSink = null
|
||||||
if (this.unsubWorld) {
|
if (this.unsubWorld) {
|
||||||
this.unsubWorld()
|
this.unsubWorld()
|
||||||
this.unsubWorld = null
|
this.unsubWorld = null
|
||||||
|
|
@ -248,8 +252,12 @@ export class HudModel {
|
||||||
* Bind or replace the active `HudManager` instance and synchronize immediately.
|
* Bind or replace the active `HudManager` instance and synchronize immediately.
|
||||||
*/
|
*/
|
||||||
attachHudManager(hudManager: HudManager | null): void {
|
attachHudManager(hudManager: HudManager | null): void {
|
||||||
|
if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) {
|
||||||
|
this.hudManager.commandSink = null
|
||||||
|
}
|
||||||
this.hudManager = hudManager
|
this.hudManager = hudManager
|
||||||
if (hudManager) {
|
if (hudManager) {
|
||||||
|
hudManager.commandSink = this
|
||||||
this.syncFromWorld()
|
this.syncFromWorld()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,16 +268,7 @@ export class HudModel {
|
||||||
getSnapshot(): HudModelSnapshot {
|
getSnapshot(): HudModelSnapshot {
|
||||||
const self = this.world.self
|
const self = this.world.self
|
||||||
const stats = self.stats
|
const stats = self.stats
|
||||||
const classCode = CLASS_ID_TO_CODE[self.charClass] ?? 'sor'
|
const classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
|
||||||
const hp = self.life > 0 ? self.life : stats.get(D2StatId.HitPoints) || 100
|
|
||||||
const maxHp = Math.max(hp, self.maxLife > 0 ? self.maxLife : stats.get(D2StatId.MaxHp) || 100)
|
|
||||||
const mana = self.mana >= 0 ? self.mana : stats.get(D2StatId.Mana) || 50
|
|
||||||
const maxMana = Math.max(mana, self.maxMana > 0 ? self.maxMana : stats.get(D2StatId.MaxMana) || 50)
|
|
||||||
const stamina = self.stamina >= 0 ? self.stamina : stats.get(D2StatId.Stamina) || 100
|
|
||||||
const maxStamina = Math.max(
|
|
||||||
stamina,
|
|
||||||
self.maxStamina > 0 ? self.maxStamina : stats.get(D2StatId.MaxStamina) || 100,
|
|
||||||
)
|
|
||||||
|
|
||||||
const invItems = [...this.world.containers.inventory.values()]
|
const invItems = [...this.world.containers.inventory.values()]
|
||||||
const eqItems = [...this.world.containers.equipment.values()]
|
const eqItems = [...this.world.containers.equipment.values()]
|
||||||
|
|
@ -281,25 +280,25 @@ export class HudModel {
|
||||||
const rightPanel = this.hudManager?.rightPanel ?? 'none'
|
const rightPanel = this.hudManager?.rightPanel ?? 'none'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
charName: self.name || 'Wanderer',
|
charName: self.name,
|
||||||
charClass: self.charClass,
|
charClass: self.charClass,
|
||||||
classCode,
|
classCode,
|
||||||
level: Math.max(1, self.level || stats.get(D2StatId.Level) || 1),
|
level: self.level,
|
||||||
xp: self.experience || stats.get(D2StatId.Experience),
|
xp: self.experience,
|
||||||
hp,
|
hp: self.life,
|
||||||
maxHp,
|
maxHp: self.maxLife,
|
||||||
mana,
|
mana: self.mana,
|
||||||
maxMana,
|
maxMana: self.maxMana,
|
||||||
stamina,
|
stamina: self.stamina,
|
||||||
maxStamina,
|
maxStamina: self.maxStamina,
|
||||||
str: stats.get(D2StatId.Strength),
|
str: stats.get(D2StatId.Strength),
|
||||||
dex: stats.get(D2StatId.Dexterity),
|
dex: stats.get(D2StatId.Dexterity),
|
||||||
vit: stats.get(D2StatId.Vitality),
|
vit: stats.get(D2StatId.Vitality),
|
||||||
ene: stats.get(D2StatId.Energy),
|
ene: stats.get(D2StatId.Energy),
|
||||||
statPoints: stats.get(D2StatId.StatPts),
|
statPoints: stats.get(D2StatId.StatPts),
|
||||||
skillPoints: stats.get(D2StatId.NewSkills),
|
skillPoints: stats.get(D2StatId.NewSkills),
|
||||||
gold: self.gold || stats.get(D2StatId.Gold),
|
gold: stats.get(D2StatId.Gold),
|
||||||
stashGold: self.stashGold || stats.get(D2StatId.GoldBank),
|
stashGold: stats.get(D2StatId.GoldBank),
|
||||||
leftSkillId: self.leftSkill,
|
leftSkillId: self.leftSkill,
|
||||||
rightSkillId: self.rightSkill,
|
rightSkillId: self.rightSkill,
|
||||||
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
|
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
|
||||||
|
|
@ -415,183 +414,138 @@ export class HudModel {
|
||||||
|
|
||||||
const self = this.world.self
|
const self = this.world.self
|
||||||
const stats = self.stats
|
const stats = self.stats
|
||||||
const classCode = CLASS_ID_TO_CODE[self.charClass] ?? 'sor'
|
|
||||||
const classNames = CLASS_ID_TO_NAMES[self.charClass] ?? CLASS_ID_TO_NAMES[1]
|
|
||||||
|
|
||||||
if (this.lastSyncedClassCode !== classCode) {
|
// 0. Class: not ready until the server assigns it. No class-dependent panel state is synthesized before that.
|
||||||
this.lastSyncedClassCode = classCode
|
if (self.charClass !== null) {
|
||||||
hud.currentClass = classCode
|
hud.setCharacterClass(resolveClassCode(self.charClass))
|
||||||
|
const classNames = CLASS_ID_TO_NAMES[self.charClass]
|
||||||
|
hud.charSheet.attrs.classNameEn = classNames.en
|
||||||
|
hud.charSheet.attrs.classNameZh = classNames.zh
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Vitals (globes & stamina bar)
|
// 1. Vitals (globes & stamina bar): verbatim server values (0x95 / stats 6-11), absent = 0.
|
||||||
const hp = self.life > 0 ? self.life : stats.get(D2StatId.HitPoints)
|
hud.maxHp = self.maxLife
|
||||||
const maxHp = Math.max(hp, self.maxLife > 0 ? self.maxLife : stats.get(D2StatId.MaxHp))
|
hud.hp = Math.max(0, Math.min(self.maxLife, self.life))
|
||||||
if (maxHp > 0) {
|
hud.maxMana = self.maxMana
|
||||||
hud.maxHp = maxHp
|
hud.mana = Math.max(0, Math.min(self.maxMana, self.mana))
|
||||||
hud.hp = Math.max(0, Math.min(maxHp, hp))
|
hud.maxStamina = self.maxStamina
|
||||||
}
|
hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina))
|
||||||
|
|
||||||
const mana = self.mana > 0 || stats.get(D2StatId.MaxMana) > 0 ? self.mana : stats.get(D2StatId.Mana)
|
// 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c).
|
||||||
const maxMana = Math.max(mana, self.maxMana > 0 ? self.maxMana : stats.get(D2StatId.MaxMana))
|
const attrs = hud.charSheet.attrs
|
||||||
if (maxMana > 0) {
|
attrs.name = self.name
|
||||||
hud.maxMana = maxMana
|
attrs.level = self.level
|
||||||
hud.mana = Math.max(0, Math.min(maxMana, mana))
|
attrs.xp = self.experience
|
||||||
}
|
attrs.nextLevelXp = nextLevelExperience(self.level)
|
||||||
|
attrs.str = stats.get(D2StatId.Strength)
|
||||||
|
attrs.dex = stats.get(D2StatId.Dexterity)
|
||||||
|
attrs.vit = stats.get(D2StatId.Vitality)
|
||||||
|
attrs.ene = stats.get(D2StatId.Energy)
|
||||||
|
attrs.statPoints = stats.get(D2StatId.StatPts)
|
||||||
|
attrs.baseMaxHp = self.maxLife
|
||||||
|
attrs.baseMaxMana = self.maxMana
|
||||||
|
attrs.baseMaxStamina = self.maxStamina
|
||||||
|
// Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known.
|
||||||
|
const derived = self.charClass === null
|
||||||
|
? null
|
||||||
|
: computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty)
|
||||||
|
attrs.defense = derived?.defense ?? 0
|
||||||
|
attrs.attackRating = derived?.attackRating ?? 0
|
||||||
|
attrs.fireRes = derived?.fireRes ?? 0
|
||||||
|
attrs.coldRes = derived?.coldRes ?? 0
|
||||||
|
attrs.lightRes = derived?.lightRes ?? 0
|
||||||
|
attrs.poisRes = derived?.poisRes ?? 0
|
||||||
|
|
||||||
const stamina =
|
// 3. Gold (stat 14 inventory gold, stat 15 stash gold).
|
||||||
self.stamina > 0 || stats.get(D2StatId.MaxStamina) > 0 ? self.stamina : stats.get(D2StatId.Stamina)
|
hud.inventory.gold = stats.get(D2StatId.Gold)
|
||||||
const maxStamina = Math.max(
|
hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
|
||||||
stamina,
|
|
||||||
self.maxStamina > 0 ? self.maxStamina : stats.get(D2StatId.MaxStamina),
|
|
||||||
)
|
|
||||||
if (maxStamina > 0) {
|
|
||||||
hud.maxStamina = maxStamina
|
|
||||||
hud.stamina = Math.max(0, Math.min(maxStamina, stamina))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Character Sheet attributes
|
// 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync.
|
||||||
if (self.name) {
|
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
|
||||||
hud.charSheet.attrs.name = self.name
|
hud.skillTree.characterLevel = self.level
|
||||||
}
|
hud.skillTree.hardPoints.clear()
|
||||||
hud.charSheet.attrs.classNameEn = classNames.en
|
hud.skillTree.bonusLevels.clear()
|
||||||
hud.charSheet.attrs.classNameZh = classNames.zh
|
hud.hotkeys.setAvailableSkills([])
|
||||||
const effLevel = self.level > 0 ? self.level : stats.get(D2StatId.Level)
|
for (const [skillId, sl] of self.skillEntries.entries()) {
|
||||||
if (effLevel > 0) {
|
const hard = Math.max(0, sl.baseLevel ?? sl.level)
|
||||||
hud.charSheet.attrs.level = effLevel
|
const bonus = Math.max(0, sl.bonus ?? 0)
|
||||||
}
|
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
|
||||||
if (stats.has(D2StatId.Experience) || self.experience > 0) {
|
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
|
||||||
hud.charSheet.attrs.xp = self.experience || stats.get(D2StatId.Experience)
|
if (isPassiveSkill(skillId)) continue
|
||||||
}
|
const catalogEntry = SKILLS_BY_ID[skillId]
|
||||||
if (stats.has(D2StatId.NextExp)) {
|
if (!catalogEntry) continue
|
||||||
hud.charSheet.attrs.nextLevelXp = stats.get(D2StatId.NextExp)
|
const effSkillLevel = hard + bonus
|
||||||
}
|
hud.hotkeys.addOrUpdateSkill({
|
||||||
if (stats.has(D2StatId.Strength)) hud.charSheet.attrs.str = stats.get(D2StatId.Strength)
|
skillId,
|
||||||
if (stats.has(D2StatId.Dexterity)) hud.charSheet.attrs.dex = stats.get(D2StatId.Dexterity)
|
name: catalogEntry.name,
|
||||||
if (stats.has(D2StatId.Vitality)) hud.charSheet.attrs.vit = stats.get(D2StatId.Vitality)
|
nameZh: catalogEntry.nameZh,
|
||||||
if (stats.has(D2StatId.Energy)) hud.charSheet.attrs.ene = stats.get(D2StatId.Energy)
|
level: effSkillLevel,
|
||||||
if (stats.has(D2StatId.StatPts)) hud.charSheet.attrs.statPoints = stats.get(D2StatId.StatPts)
|
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
|
||||||
if (maxHp > 0) hud.charSheet.attrs.baseMaxHp = maxHp
|
leftUsable: isLeftUsableSkill(skillId),
|
||||||
if (maxMana > 0) hud.charSheet.attrs.baseMaxMana = maxMana
|
rightUsable: true,
|
||||||
if (maxStamina > 0) hud.charSheet.attrs.baseMaxStamina = maxStamina
|
isAura: isAuraSkill(skillId),
|
||||||
if (stats.has(D2StatId.ArmorClass)) {
|
})
|
||||||
hud.charSheet.attrs.defense = stats.get(D2StatId.ArmorClass)
|
|
||||||
}
|
|
||||||
if (stats.has(D2StatId.ToHit)) {
|
|
||||||
hud.charSheet.attrs.attackRating = stats.get(D2StatId.ToHit)
|
|
||||||
}
|
|
||||||
if (stats.has(D2StatId.FireResist)) hud.charSheet.attrs.fireRes = stats.get(D2StatId.FireResist)
|
|
||||||
if (stats.has(D2StatId.ColdResist)) hud.charSheet.attrs.coldRes = stats.get(D2StatId.ColdResist)
|
|
||||||
if (stats.has(D2StatId.LightResist)) hud.charSheet.attrs.lightRes = stats.get(D2StatId.LightResist)
|
|
||||||
if (stats.has(D2StatId.PoisonResist)) hud.charSheet.attrs.poisRes = stats.get(D2StatId.PoisonResist)
|
|
||||||
|
|
||||||
// 3. Gold
|
|
||||||
if (stats.has(D2StatId.Gold) || self.gold > 0) {
|
|
||||||
hud.inventory.gold = self.gold || stats.get(D2StatId.Gold)
|
|
||||||
}
|
|
||||||
if (stats.has(D2StatId.GoldBank) || self.stashGold > 0) {
|
|
||||||
hud.worldPanels.stashGold = self.stashGold || stats.get(D2StatId.GoldBank)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Skills & Hotkeys
|
|
||||||
if (stats.has(D2StatId.NewSkills)) {
|
|
||||||
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
|
|
||||||
}
|
|
||||||
if (self.skillEntries.size > 0) {
|
|
||||||
for (const [skillId, sl] of self.skillEntries.entries()) {
|
|
||||||
const effSkillLevel = Math.max(1, sl.level + (sl.bonus ?? 0))
|
|
||||||
hud.skillTree.hardPoints.set(skillId, Math.max(0, sl.baseLevel ?? sl.level))
|
|
||||||
if (!isPassiveSkill(skillId)) {
|
|
||||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
|
||||||
if (catalogEntry) {
|
|
||||||
hud.hotkeys.addOrUpdateSkill({
|
|
||||||
skillId,
|
|
||||||
name: catalogEntry.name,
|
|
||||||
nameZh: catalogEntry.nameZh,
|
|
||||||
level: effSkillLevel,
|
|
||||||
manaCost: calculateManaCost(catalogEntry, effSkillLevel),
|
|
||||||
leftUsable: isLeftUsableSkill(skillId),
|
|
||||||
rightUsable: true,
|
|
||||||
isAura: isAuraSkill(skillId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
hud.hotkeys.leftSkillId = self.leftSkill
|
hud.hotkeys.leftSkillId = self.leftSkill
|
||||||
hud.hotkeys.rightSkillId = self.rightSkill
|
hud.hotkeys.rightSkillId = self.rightSkill
|
||||||
hud.inventory.weaponSwapSet = self.weaponSet
|
hud.inventory.weaponSwapSet = self.weaponSet
|
||||||
|
|
||||||
// 5. Containers (only overwrite HudManager containers when authoritative items exist in ClientWorld)
|
// 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
|
||||||
const totalAuthoritativeItems = this.world.containers.allById.size
|
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
|
||||||
if (totalAuthoritativeItems > 0) {
|
delete hud.inventory.equipped[key]
|
||||||
// Equipment
|
}
|
||||||
const eqItems = [...this.world.containers.equipment.values()]
|
for (const item of this.world.containers.equipment.values()) {
|
||||||
if (eqItems.length > 0) {
|
const slot = BODY_LOC_TO_EQUIP_SLOT[item.bodyLoc || item.equippedSlot]
|
||||||
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
|
if (slot) {
|
||||||
delete hud.inventory.equipped[key]
|
hud.inventory.equipped[slot] = itemDataToUiInventoryItem(item)
|
||||||
}
|
|
||||||
for (const item of eqItems) {
|
|
||||||
const slot = BODY_LOC_TO_EQUIP_SLOT[item.bodyLoc || item.equippedSlot]
|
|
||||||
if (slot) {
|
|
||||||
hud.inventory.equipped[slot] = itemDataToUiInventoryItem(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory grid
|
|
||||||
const invItems = [...this.world.containers.inventory.values()]
|
|
||||||
const placements: GridPlacement[] = invItems.map((item) => ({
|
|
||||||
item: itemDataToUiInventoryItem(item),
|
|
||||||
col: Math.max(0, Math.min(9, item.gridX)),
|
|
||||||
row: Math.max(0, Math.min(3, item.gridY)),
|
|
||||||
}))
|
|
||||||
hud.inventory.gridItems = placements
|
|
||||||
|
|
||||||
// Stash grid
|
|
||||||
const stashItems = [...this.world.containers.stash.values()]
|
|
||||||
hud.worldPanels.stashItems = stashItems.map((item) => ({
|
|
||||||
item: itemDataToUiInventoryItem(item),
|
|
||||||
col: Math.max(0, Math.min(5, item.gridX)),
|
|
||||||
row: Math.max(0, Math.min(7, item.gridY)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Cube grid
|
|
||||||
const cubeItems = [...this.world.containers.cube.values()]
|
|
||||||
hud.worldPanels.cubeItems = cubeItems.map((item) => ({
|
|
||||||
item: itemDataToUiInventoryItem(item),
|
|
||||||
col: Math.max(0, Math.min(2, item.gridX)),
|
|
||||||
row: Math.max(0, Math.min(3, item.gridY)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Belt 4x4 grid
|
|
||||||
const beltItems = [...this.world.containers.belt.values()]
|
|
||||||
if (beltItems.length > 0) {
|
|
||||||
for (let r = 0; r < BELT_ROWS; r++) {
|
|
||||||
for (let c = 0; c < BELT_COLS; c++) {
|
|
||||||
hud.belt.grid[r]![c] = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of beltItems) {
|
|
||||||
const slotIndex = Math.max(0, Math.min(15, item.gridX))
|
|
||||||
const row = Math.floor(slotIndex / BELT_COLS)
|
|
||||||
const col = slotIndex % BELT_COLS
|
|
||||||
const pot = itemToBeltPotion({ id: String(item.id), code: item.code })
|
|
||||||
if (pot) {
|
|
||||||
hud.belt.grid[row]![col] = pot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor item
|
|
||||||
const cursorItem = this.world.containers.cursor
|
|
||||||
hud.inventory.cursorItem = cursorItem ? itemDataToUiInventoryItem(cursorItem) : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Area & Waypoints
|
const placements: GridPlacement[] = [...this.world.containers.inventory.values()].map((item) => ({
|
||||||
|
item: itemDataToUiInventoryItem(item),
|
||||||
|
col: item.gridX,
|
||||||
|
row: item.gridY,
|
||||||
|
}))
|
||||||
|
hud.inventory.gridItems = placements
|
||||||
|
|
||||||
|
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
|
||||||
|
item: itemDataToUiInventoryItem(item),
|
||||||
|
col: item.gridX,
|
||||||
|
row: item.gridY,
|
||||||
|
}))
|
||||||
|
|
||||||
|
hud.worldPanels.cubeItems = [...this.world.containers.cube.values()].map((item) => ({
|
||||||
|
item: itemDataToUiInventoryItem(item),
|
||||||
|
col: item.gridX,
|
||||||
|
row: item.gridY,
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (let r = 0; r < BELT_ROWS; r++) {
|
||||||
|
for (let c = 0; c < BELT_COLS; c++) {
|
||||||
|
hud.belt.grid[r]![c] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of this.world.containers.belt.values()) {
|
||||||
|
const slotIndex = item.gridX
|
||||||
|
if (slotIndex < 0 || slotIndex >= BELT_ROWS * BELT_COLS) continue
|
||||||
|
const row = Math.floor(slotIndex / BELT_COLS)
|
||||||
|
const col = slotIndex % BELT_COLS
|
||||||
|
const pot = itemToBeltPotion({ id: String(item.id), code: item.code })
|
||||||
|
if (pot) {
|
||||||
|
hud.belt.grid[row]![col] = pot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorItem = this.world.containers.cursor
|
||||||
|
hud.inventory.cursorItem = cursorItem ? itemDataToUiInventoryItem(cursorItem) : null
|
||||||
|
|
||||||
|
// 6. Area, Waypoints & Quests
|
||||||
if (this.world.areaId > 0) {
|
if (this.world.areaId > 0) {
|
||||||
hud.worldPanels.setCurrentLevel(this.world.areaId)
|
hud.worldPanels.setCurrentLevel(this.world.areaId)
|
||||||
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
|
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
|
||||||
}
|
}
|
||||||
|
hud.worldPanels.setUnlockedWaypoints([...this.world.waypoints])
|
||||||
|
hud.worldPanels.applyServerQuestFlags(this.world.quests)
|
||||||
|
|
||||||
// 7. Target monster plaque
|
// 7. Target monster plaque
|
||||||
this.syncHoveredMonsterTarget()
|
this.syncHoveredMonsterTarget()
|
||||||
|
|
@ -636,70 +590,43 @@ export class HudModel {
|
||||||
*/
|
*/
|
||||||
allocateStat(key: BaseStatKey): boolean {
|
allocateStat(key: BaseStatKey): boolean {
|
||||||
const statId = STAT_KEY_TO_D2_STAT_ID[key]
|
const statId = STAT_KEY_TO_D2_STAT_ID[key]
|
||||||
if (this.hudManager) {
|
|
||||||
this.hudManager.charSheet.allocateStat(key)
|
|
||||||
this.hudManager.syncPublishedState()
|
|
||||||
}
|
|
||||||
this.emitCommand({ type: 'AddStatPoint', statId })
|
this.emitCommand({ type: 'AddStatPoint', statId })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate a skill point and emit `AddSkillPoint (0x3B)`.
|
* Request a skill point allocation (`AddSkillPoint 0x3B`). The tree updates when the server answers (0x21).
|
||||||
*/
|
*/
|
||||||
allocateSkill(skillId: number): boolean {
|
allocateSkill(skillId: number): boolean {
|
||||||
if (this.hudManager) {
|
|
||||||
const current = this.hudManager.skillTree.hardPoints.get(skillId) ?? 0
|
|
||||||
this.hudManager.skillTree.hardPoints.set(skillId, current + 1)
|
|
||||||
if (this.hudManager.skillTree.unspentSkillPoints > 0) {
|
|
||||||
this.hudManager.skillTree.unspentSkillPoints -= 1
|
|
||||||
}
|
|
||||||
this.hudManager.syncPublishedState()
|
|
||||||
}
|
|
||||||
this.emitCommand({ type: 'AddSkillPoint', skillId })
|
this.emitCommand({ type: 'AddSkillPoint', skillId })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select active left or right hand skill (`0x3C SelectSkill`).
|
* Request the active left or right hand skill (`0x3C SelectSkill`). The quickbar updates on `0x23`.
|
||||||
*/
|
*/
|
||||||
selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void {
|
selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void {
|
||||||
if (hand === 'left') {
|
|
||||||
this.world.self.leftSkill = skillId
|
|
||||||
if (this.hudManager) this.hudManager.hotkeys.leftSkillId = skillId
|
|
||||||
} else {
|
|
||||||
this.world.self.rightSkill = skillId
|
|
||||||
if (this.hudManager) this.hudManager.hotkeys.rightSkillId = skillId
|
|
||||||
}
|
|
||||||
this.hudManager?.syncPublishedState()
|
|
||||||
this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId })
|
this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drink a potion from belt column `col` (`0..3`) and emit `BeltUse (0x26)`.
|
* Use the bottom potion of belt column `col` (`0..3`) via `BeltUse (0x26)` with its real server item id.
|
||||||
|
* Returns `false` (no command) when ClientWorld has no item in that column.
|
||||||
*/
|
*/
|
||||||
useBeltSlot(col: number, shift = false): boolean {
|
useBeltSlot(col: number, shift = false): boolean {
|
||||||
if (col < 0 || col >= BELT_COLS) return false
|
if (col < 0 || col >= BELT_COLS) return false
|
||||||
const beltItems = [...this.world.containers.belt.values()]
|
const beltItem = [...this.world.containers.belt.values()]
|
||||||
const beltItem = beltItems.find((it) => it.gridX % BELT_COLS === col)
|
.filter((it) => it.gridX >= 0 && it.gridX % BELT_COLS === col)
|
||||||
const hudPot = this.hudManager?.belt.grid[0]?.[col] ?? null
|
.sort((a, b) => a.gridX - b.gridX)[0]
|
||||||
if (this.hudManager) {
|
if (!beltItem) return false
|
||||||
this.hudManager.drinkBeltSlot(col)
|
this.emitCommand({ type: 'BeltUse', itemId: beltItem.id, shift })
|
||||||
}
|
|
||||||
const numericId = beltItem?.id ?? (hudPot ? Number.parseInt(hudPot.id.replace(/\D+/g, ''), 10) || (col + 1) : col + 1)
|
|
||||||
this.emitCommand({ type: 'BeltUse', itemId: numericId, shift })
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle active weapon set (`I / II`, hotkey `W`) and emit `SwapWeapons (0x60)`.
|
* Request a weapon set swap (`SwapWeapons 0x60`); `weaponSet` changes when the server confirms.
|
||||||
*/
|
*/
|
||||||
swapWeapons(): void {
|
swapWeapons(): void {
|
||||||
this.world.self.weaponSet = this.world.self.weaponSet === 0 ? 1 : 0
|
|
||||||
if (this.hudManager) {
|
|
||||||
this.hudManager.inventory.toggleWeaponSwap()
|
|
||||||
this.hudManager.syncPublishedState()
|
|
||||||
}
|
|
||||||
this.emitCommand({ type: 'SwapWeapons' })
|
this.emitCommand({ type: 'SwapWeapons' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,6 +659,35 @@ export class HudModel {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requireWaypointUnitId(): number {
|
||||||
|
const id = this.world.waypointUnitId
|
||||||
|
if (id === null) throw new Error('HudModel.teleportWaypoint: no waypoint menu has been opened by the server (0x63)')
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waypoint teleport from the HUD waypoint panel, using the waypoint object from the last `0x63`. */
|
||||||
|
teleportToLevel(destAreaId: number): void {
|
||||||
|
this.teleportWaypoint(this.requireWaypointUnitId(), destAreaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop `amount` inventory gold on the ground (`0x50 DropGold`).
|
||||||
|
*/
|
||||||
|
dropGold(amount: number): void {
|
||||||
|
const unitId = this.world.selfId
|
||||||
|
if (!unitId) throw new Error('HudModel.dropGold: self unit id not assigned yet')
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) return
|
||||||
|
this.emitCommand({ type: 'DropGold', unitId, amount })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click use of an inventory item (`0x20 ItemUse`) at the player's current position.
|
||||||
|
*/
|
||||||
|
useItem(itemId: number): void {
|
||||||
|
const self = this.world.self
|
||||||
|
this.emitCommand({ type: 'ItemUse', itemId, at: { x: self.x, y: self.y } })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request casting a Town Portal (`TownPortal`).
|
* Request casting a Town Portal (`TownPortal`).
|
||||||
*/
|
*/
|
||||||
|
|
@ -834,3 +790,22 @@ export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHu
|
||||||
affixes: unit.bossMods ? unit.bossMods.map((m) => `Mod #${m}`) : [],
|
affixes: unit.bossMods ? unit.bossMods.map((m) => `Mod #${m}`) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a server class id to its class code; throws on an id outside 1.13c's 0..6. */
|
||||||
|
export function resolveClassCode(classId: CharClassId | number): CharacterClassCode {
|
||||||
|
const code = (CLASS_ID_TO_CODE as Record<number, CharacterClassCode | undefined>)[classId]
|
||||||
|
if (!code) throw new Error(`HudModel: unknown character class id ${classId}`)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XP needed for the next level from `D2_EXPERIENCE_TABLE_99` (index L = XP to reach level L); 0 at level 99 / unknown. */
|
||||||
|
export function nextLevelExperience(level: number): number {
|
||||||
|
if (level < 1 || level >= 99) return 0
|
||||||
|
return D2_EXPERIENCE_TABLE_99[level + 1] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XP threshold of the current level from `D2_EXPERIENCE_TABLE_99`; 0 when the level is unknown. */
|
||||||
|
export function currentLevelExperience(level: number): number {
|
||||||
|
if (level < 1 || level > 99) return 0
|
||||||
|
return D2_EXPERIENCE_TABLE_99[level] ?? 0
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
import type { D2FontRenderer } from './font.ts'
|
import type { D2FontRenderer } from './font.ts'
|
||||||
import { getTblLang, type TblLang } from '../i18n/lang.ts'
|
import { getTblLang, type TblLang } from '../i18n/lang.ts'
|
||||||
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../../common/units/classes.ts'
|
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||||
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
|
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
|
||||||
|
|
||||||
export type { BaseStatKey, CharacterAttributes }
|
export type { BaseStatKey, CharacterAttributes }
|
||||||
|
|
@ -25,67 +25,41 @@ export const STAT_ALLOC_BUTTONS: Readonly<Record<BaseStatKey, { x: number; y: nu
|
||||||
ene: { x: 80 + 122, y: 60 + 288, w: 30, h: 30 },
|
ene: { x: 80 + 122, y: 60 + 288, w: 30, h: 30 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutral, not-ready character attributes. Every field is filled from server packets
|
||||||
|
* (`ClientWorld.self`) by `HudModel.syncFromWorld()`; nothing is pre-seeded with demo values.
|
||||||
|
*/
|
||||||
|
export function createEmptyCharacterAttributes(): CharacterAttributes {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
classNameZh: '',
|
||||||
|
classNameEn: '',
|
||||||
|
level: 0,
|
||||||
|
xp: 0,
|
||||||
|
nextLevelXp: 0,
|
||||||
|
str: 0,
|
||||||
|
dex: 0,
|
||||||
|
vit: 0,
|
||||||
|
ene: 0,
|
||||||
|
statPoints: 0,
|
||||||
|
baseMaxHp: 0,
|
||||||
|
baseMaxMana: 0,
|
||||||
|
baseMaxStamina: 0,
|
||||||
|
defense: 0,
|
||||||
|
attackRating: 0,
|
||||||
|
fireRes: 0,
|
||||||
|
coldRes: 0,
|
||||||
|
lightRes: 0,
|
||||||
|
poisRes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CharacterSheetPanel {
|
export class CharacterSheetPanel {
|
||||||
visible = false
|
visible = false
|
||||||
lang: TblLang = getTblLang()
|
lang: TblLang = getTblLang()
|
||||||
classCode: CharacterClassCode = 'sor'
|
/** `null` until the server assigns the character class. */
|
||||||
attrs: CharacterAttributes = {
|
classCode: CharacterClassCode | null = null
|
||||||
name: 'Isendra',
|
attrs: CharacterAttributes = createEmptyCharacterAttributes()
|
||||||
classNameZh: '女法师',
|
|
||||||
classNameEn: 'Sorceress',
|
|
||||||
level: 85,
|
|
||||||
xp: 1_068_078_030,
|
|
||||||
nextLevelXp: 1_163_635_493,
|
|
||||||
str: 156,
|
|
||||||
dex: 75,
|
|
||||||
vit: 315,
|
|
||||||
ene: 95,
|
|
||||||
statPoints: 15,
|
|
||||||
baseMaxHp: 1180,
|
|
||||||
baseMaxMana: 940,
|
|
||||||
baseMaxStamina: 465,
|
|
||||||
defense: 1845,
|
|
||||||
attackRating: 1240,
|
|
||||||
fireRes: 75,
|
|
||||||
coldRes: 75,
|
|
||||||
lightRes: 75,
|
|
||||||
poisRes: 75,
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveClassCode(): CharacterClassCode {
|
|
||||||
const en = (this.attrs.classNameEn ?? '').toLowerCase()
|
|
||||||
if (en.includes('barbarian')) return 'bar'
|
|
||||||
if (en.includes('paladin')) return 'pal'
|
|
||||||
if (en.includes('amazon')) return 'ama'
|
|
||||||
if (en.includes('necromancer')) return 'nec'
|
|
||||||
if (en.includes('druid')) return 'dru'
|
|
||||||
if (en.includes('assassin')) return 'ass'
|
|
||||||
if (en.includes('sorceress')) return 'sor'
|
|
||||||
return this.classCode
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocate 1 unspent stat point into `key`, applying 1.13c per-class `CharStats.txt` gains:
|
|
||||||
* - +1 Vitality = +`lifePerVitality` Life, +`staminaPerVitality` Stamina
|
|
||||||
* - +1 Energy = +`manaPerEnergy` Mana
|
|
||||||
* - +1 Dexterity = +5 Attack Rating, +1 Defense
|
|
||||||
*/
|
|
||||||
allocateStat(key: BaseStatKey): boolean {
|
|
||||||
if (this.attrs.statPoints <= 0) return false
|
|
||||||
const cStats = CANONICAL_CHARSTATS[this.resolveClassCode()] ?? CANONICAL_CHARSTATS.sor
|
|
||||||
this.attrs.statPoints -= 1
|
|
||||||
this.attrs[key] += 1
|
|
||||||
if (key === 'vit') {
|
|
||||||
this.attrs.baseMaxHp += cStats.perStatPoint.lifePerVitality
|
|
||||||
this.attrs.baseMaxStamina += cStats.perStatPoint.staminaPerVitality
|
|
||||||
} else if (key === 'ene') {
|
|
||||||
this.attrs.baseMaxMana += cStats.perStatPoint.manaPerEnergy
|
|
||||||
} else if (key === 'dex') {
|
|
||||||
this.attrs.attackRating += 5
|
|
||||||
this.attrs.defense += 1
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean {
|
handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean {
|
||||||
if (!this.visible) return false
|
if (!this.visible) return false
|
||||||
|
|
@ -106,9 +80,8 @@ export class CharacterSheetPanel {
|
||||||
if (this.attrs.statPoints > 0) {
|
if (this.attrs.statPoints > 0) {
|
||||||
for (const [key, b] of Object.entries(STAT_ALLOC_BUTTONS) as [BaseStatKey, typeof STAT_ALLOC_BUTTONS[BaseStatKey]][]) {
|
for (const [key, b] of Object.entries(STAT_ALLOC_BUTTONS) as [BaseStatKey, typeof STAT_ALLOC_BUTTONS[BaseStatKey]][]) {
|
||||||
if (logicalX >= b.x && logicalX <= b.x + b.w && logicalY >= b.y && logicalY <= b.y + b.h) {
|
if (logicalX >= b.x && logicalX <= b.x + b.w && logicalY >= b.y && logicalY <= b.y + b.h) {
|
||||||
if (this.allocateStat(key)) {
|
// Server-authoritative: only request `AddStatPoint (0x3A)`; the new values arrive via 0x1D..0x1F.
|
||||||
onStatAllocated?.(key)
|
onStatAllocated?.(key)
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number {
|
||||||
|
|
||||||
export function getAvailableSkillsForSide(
|
export function getAvailableSkillsForSide(
|
||||||
side: 'left' | 'right',
|
side: 'left' | 'right',
|
||||||
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
|
skills: readonly HotkeySkillEntry[],
|
||||||
): readonly HotkeySkillEntry[] {
|
): readonly HotkeySkillEntry[] {
|
||||||
return skills.filter(s => {
|
return skills.filter(s => {
|
||||||
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
||||||
|
|
@ -203,8 +203,9 @@ export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as c
|
||||||
|
|
||||||
export class SkillHotkeysHud {
|
export class SkillHotkeysHud {
|
||||||
lang: TblLang = 'CHI'
|
lang: TblLang = 'CHI'
|
||||||
leftSkillId = 47 // Fire Ball
|
/** Active left/right skill; mirrors server `0x23 AssignSkill` (0 = Attack until the server says otherwise). */
|
||||||
rightSkillId = 64 // Frozen Orb
|
leftSkillId = 0
|
||||||
|
rightSkillId = 0
|
||||||
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */
|
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */
|
||||||
openPopup: 'left' | 'right' | null = null
|
openPopup: 'left' | 'right' | null = null
|
||||||
/** Hovered skill inside the Speedbar popup. */
|
/** Hovered skill inside the Speedbar popup. */
|
||||||
|
|
@ -226,18 +227,15 @@ export class SkillHotkeysHud {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */
|
/** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */
|
||||||
readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>([
|
readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>()
|
||||||
['F1', { side: 'right', skillId: 64 }],
|
|
||||||
['F2', { side: 'right', skillId: 59 }],
|
|
||||||
['F3', { side: 'right', skillId: 56 }],
|
|
||||||
['F4', { side: 'right', skillId: 54 }],
|
|
||||||
['F5', { side: 'left', skillId: 47 }],
|
|
||||||
['F6', { side: 'left', skillId: 49 }],
|
|
||||||
['F7', { side: 'right', skillId: 40 }],
|
|
||||||
['F8', { side: 'right', skillId: 42 }],
|
|
||||||
])
|
|
||||||
|
|
||||||
availableSkills: HotkeySkillEntry[] = [...DEFAULT_SORCERESS_SKILLS]
|
/** Quickbar skills; rebuilt from the server skill list by `HudModel.syncFromWorld()`. */
|
||||||
|
availableSkills: HotkeySkillEntry[] = []
|
||||||
|
/**
|
||||||
|
* Server-authoritative skill selection sink. When set (online), popup clicks and F-keys only request
|
||||||
|
* `SelectSkill (0x3C)`; the active slots change when the server answers with `0x23 AssignSkill`.
|
||||||
|
*/
|
||||||
|
onSelectSkill?: ((side: 'left' | 'right', skillId: number) => void) | undefined
|
||||||
hasEquippedWeapon = true
|
hasEquippedWeapon = true
|
||||||
fistImage: HTMLImageElement | null = null
|
fistImage: HTMLImageElement | null = null
|
||||||
swordImage: HTMLImageElement | null = null
|
swordImage: HTMLImageElement | null = null
|
||||||
|
|
@ -356,6 +354,21 @@ export class SkillHotkeysHud {
|
||||||
this.preloadSkillIcons()
|
this.preloadSkillIcons()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select `skillId` for `side` if it is legal there (no passives; no auras / right-only skills on the left).
|
||||||
|
* Online (`onSelectSkill` set) this only emits the request; offline widget use applies it directly.
|
||||||
|
*/
|
||||||
|
private requestSelect(side: 'left' | 'right', skillId: number): void {
|
||||||
|
if (isPassiveSkill(skillId)) return
|
||||||
|
if (side === 'left' && (!isLeftUsableSkill(skillId) || isAuraSkill(skillId))) return
|
||||||
|
if (this.onSelectSkill) {
|
||||||
|
this.onSelectSkill(side, skillId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (side === 'left') this.leftSkillId = skillId
|
||||||
|
else this.rightSkillId = skillId
|
||||||
|
}
|
||||||
|
|
||||||
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
|
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
|
||||||
for (const [key, binding] of this.bindings.entries()) {
|
for (const [key, binding] of this.bindings.entries()) {
|
||||||
if (binding.side === side && binding.skillId === skillId) return key
|
if (binding.side === side && binding.skillId === skillId) return key
|
||||||
|
|
@ -419,15 +432,7 @@ export class SkillHotkeysHud {
|
||||||
}
|
}
|
||||||
const bound = this.bindings.get(key)
|
const bound = this.bindings.get(key)
|
||||||
if (!bound) return null
|
if (!bound) return null
|
||||||
if (bound.side === 'left') {
|
this.requestSelect(bound.side, bound.skillId)
|
||||||
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
|
|
||||||
this.leftSkillId = bound.skillId
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!isPassiveSkill(bound.skillId)) {
|
|
||||||
this.rightSkillId = bound.skillId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bound
|
return bound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -565,15 +570,7 @@ export class SkillHotkeysHud {
|
||||||
const cells = this.getPopupCells(this.openPopup)
|
const cells = this.getPopupCells(this.openPopup)
|
||||||
for (const c of cells) {
|
for (const c of cells) {
|
||||||
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
|
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
|
||||||
if (this.openPopup === 'left') {
|
this.requestSelect(this.openPopup, c.skill.skillId)
|
||||||
if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) {
|
|
||||||
this.leftSkillId = c.skill.skillId
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!isPassiveSkill(c.skill.skillId)) {
|
|
||||||
this.rightSkillId = c.skill.skillId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.openPopup = null
|
this.openPopup = null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,12 @@ import {
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
resolveItemSpriteRect,
|
resolveItemSpriteRect,
|
||||||
INV_GRID_ORIGIN,
|
INV_GRID_ORIGIN,
|
||||||
|
EQUIP_SLOTS_LAYOUT,
|
||||||
isUsableRightClickItem,
|
isUsableRightClickItem,
|
||||||
} from './inventory.ts'
|
} from './inventory.ts'
|
||||||
|
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
||||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
import { SkillTreePanel } from './skill-tree-panel.ts'
|
||||||
import {
|
import {
|
||||||
WorldPanelsHud,
|
WorldPanelsHud,
|
||||||
type LeftDockPanelKind,
|
type LeftDockPanelKind,
|
||||||
|
|
@ -291,6 +293,35 @@ export function logicalToClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
|
||||||
|
* the HUD never mutates gameplay state locally while a sink is attached.
|
||||||
|
*/
|
||||||
|
export interface HudCommandSink {
|
||||||
|
allocateStat(key: BaseStatKey): boolean
|
||||||
|
allocateSkill(skillId: number): boolean
|
||||||
|
selectSkill(hand: 'left' | 'right', skillId: number): void
|
||||||
|
useBeltSlot(col: number): boolean
|
||||||
|
swapWeapons(): void
|
||||||
|
pickupItemToCursor(itemId: number): void
|
||||||
|
/** `buffer`: `0` = inventory, `3` = cube, `4` = stash. */
|
||||||
|
placeCursorItem(itemId: number, col: number, row: number, buffer: number): void
|
||||||
|
equipItem(itemId: number, slot: EquipSlotId): void
|
||||||
|
unequipItem(slot: EquipSlotId): void
|
||||||
|
dropItem(itemId: number): void
|
||||||
|
dropGold(amount: number): void
|
||||||
|
useItem(itemId: number): void
|
||||||
|
transmuteCube(): void
|
||||||
|
teleportToLevel(destAreaId: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */
|
||||||
|
export function serverItemIdOf(item: UiInventoryItem): number {
|
||||||
|
const id = Number(item.id)
|
||||||
|
if (!Number.isInteger(id) || id < 0) throw new Error(`HUD item "${item.id}" has no server item id`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
export class HudManager {
|
export class HudManager {
|
||||||
readonly font = new D2FontRenderer()
|
readonly font = new D2FontRenderer()
|
||||||
readonly globes = new GlobesHud()
|
readonly globes = new GlobesHud()
|
||||||
|
|
@ -307,12 +338,13 @@ export class HudManager {
|
||||||
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||||
isRunning = true
|
isRunning = true
|
||||||
showGroundLabels = false
|
showGroundLabels = false
|
||||||
stamina = 465
|
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
|
||||||
maxStamina = 465
|
stamina = 0
|
||||||
hp = 1180
|
maxStamina = 0
|
||||||
maxHp = 1180
|
hp = 0
|
||||||
mana = 940
|
maxHp = 0
|
||||||
maxMana = 940
|
mana = 0
|
||||||
|
maxMana = 0
|
||||||
|
|
||||||
mouseX = 400
|
mouseX = 400
|
||||||
mouseY = 300
|
mouseY = 300
|
||||||
|
|
@ -347,7 +379,7 @@ export class HudManager {
|
||||||
leftPanel: 'none',
|
leftPanel: 'none',
|
||||||
rightPanel: 'none',
|
rightPanel: 'none',
|
||||||
beltExpanded: false,
|
beltExpanded: false,
|
||||||
beltPotionsCount: 16,
|
beltPotionsCount: 0,
|
||||||
miniPanelOpen: true,
|
miniPanelOpen: true,
|
||||||
isRunning: true,
|
isRunning: true,
|
||||||
showGroundLabels: false,
|
showGroundLabels: false,
|
||||||
|
|
@ -355,22 +387,54 @@ export class HudManager {
|
||||||
equippedSlots: [],
|
equippedSlots: [],
|
||||||
bagItemsCount: 0,
|
bagItemsCount: 0,
|
||||||
weaponSwapSet: 0,
|
weaponSwapSet: 0,
|
||||||
leftSkillId: 47,
|
leftSkillId: 0,
|
||||||
rightSkillId: 64,
|
rightSkillId: 0,
|
||||||
characterLevel: 85,
|
characterLevel: 0,
|
||||||
statPoints: 15,
|
statPoints: 0,
|
||||||
skillPoints: 12,
|
skillPoints: 0,
|
||||||
hp: 1180,
|
hp: 0,
|
||||||
maxHp: 1180,
|
maxHp: 0,
|
||||||
mana: 940,
|
mana: 0,
|
||||||
maxMana: 940,
|
maxMana: 0,
|
||||||
stamina: 465,
|
stamina: 0,
|
||||||
maxStamina: 465,
|
maxStamina: 0,
|
||||||
docking: computeDockingLayout(800, 600, 1),
|
docking: computeDockingLayout(800, 600, 1),
|
||||||
vendorTradeState: 'idle',
|
vendorTradeState: 'idle',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `null` until the server assigns the character class (see `setCharacterClass`). */
|
||||||
currentClass: CharacterClassCode | null = null
|
currentClass: CharacterClassCode | null = null
|
||||||
|
private _commandSink: HudCommandSink | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-authoritative command sink (online: `HudModel`). When attached, HUD interactions only emit
|
||||||
|
* `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`.
|
||||||
|
*/
|
||||||
|
get commandSink(): HudCommandSink | null {
|
||||||
|
return this._commandSink
|
||||||
|
}
|
||||||
|
|
||||||
|
set commandSink(sink: HudCommandSink | null) {
|
||||||
|
this._commandSink = sink
|
||||||
|
this.hotkeys.onSelectSkill = sink ? (side, skillId) => sink.selectSkill(side, skillId) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the server-assigned character class (MCP character list / `0x59` UnitAssign classId) to every
|
||||||
|
* class-dependent panel: skill tree layout, quickbar, character sheet and item requirement context.
|
||||||
|
* Hard points, bonus levels and quickbar skills are cleared; `HudModel` rebuilds them from the server skill list.
|
||||||
|
*/
|
||||||
|
setCharacterClass(classCode: CharacterClassCode): void {
|
||||||
|
if (this.currentClass === classCode && this.skillTree.characterClass === classCode) return
|
||||||
|
this.currentClass = classCode
|
||||||
|
this.charSheet.classCode = classCode
|
||||||
|
this.skillTree.setClass(classCode)
|
||||||
|
this.skillTree.hardPoints.clear()
|
||||||
|
this.skillTree.bonusLevels.clear()
|
||||||
|
this.hotkeys.setAvailableSkills([])
|
||||||
|
this.syncPublishedState()
|
||||||
|
}
|
||||||
|
|
||||||
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
|
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
|
||||||
private _lang: TblLang = 'CHI'
|
private _lang: TblLang = 'CHI'
|
||||||
|
|
||||||
|
|
@ -562,27 +626,9 @@ export class HudManager {
|
||||||
: normAttr === 'vitality'
|
: normAttr === 'vitality'
|
||||||
? 'vit'
|
? 'vit'
|
||||||
: 'ene'
|
: 'ene'
|
||||||
const prevMaxHp = this.charSheet.attrs.baseMaxHp
|
// Server authority: only request `AddStatPoint`; attributes/vitals change when the server answers.
|
||||||
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina
|
if (this._commandSink) return this._commandSink.allocateStat(shortKey)
|
||||||
const prevMaxMana = this.charSheet.attrs.baseMaxMana
|
return false
|
||||||
const ok = this.charSheet.allocateStat(shortKey)
|
|
||||||
if (ok) {
|
|
||||||
if (shortKey === 'vit') {
|
|
||||||
const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp)
|
|
||||||
const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina)
|
|
||||||
this.maxHp = this.charSheet.attrs.baseMaxHp
|
|
||||||
this.hp = Math.min(this.maxHp, this.hp + hpGain)
|
|
||||||
this.maxStamina = this.charSheet.attrs.baseMaxStamina
|
|
||||||
this.stamina = Math.min(this.maxStamina, this.stamina + stamGain)
|
|
||||||
} else if (shortKey === 'ene') {
|
|
||||||
const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana)
|
|
||||||
this.maxMana = this.charSheet.attrs.baseMaxMana
|
|
||||||
this.mana = Math.min(this.maxMana, this.mana + manaGain)
|
|
||||||
}
|
|
||||||
this.syncPublishedState()
|
|
||||||
for (const cb of this.changeListeners) cb()
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
grantExperience(amount: number): { levelsGained: number; newLevel: number } {
|
grantExperience(amount: number): { levelsGained: number; newLevel: number } {
|
||||||
|
|
@ -645,11 +691,6 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
useBeltSlot(col: number): boolean {
|
useBeltSlot(col: number): boolean {
|
||||||
if (this.engine) {
|
|
||||||
const ok = this.engine.useBeltSlot(col)
|
|
||||||
if (ok) this.syncFromEngine()
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
return this.drinkBeltSlot(col)
|
return this.drinkBeltSlot(col)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -663,13 +704,9 @@ export class HudManager {
|
||||||
this.syncFromEngine()
|
this.syncFromEngine()
|
||||||
return dropped
|
return dropped
|
||||||
}
|
}
|
||||||
const dropped = this.inventory.dropGold(amount)
|
// Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
|
||||||
if (dropped > 0) {
|
this._commandSink?.dropGold(amount)
|
||||||
this.callbacks.onDropGoldToGround?.(dropped, mousePos)
|
return 0
|
||||||
}
|
|
||||||
this.syncPublishedState()
|
|
||||||
for (const cb of this.changeListeners) cb()
|
|
||||||
return dropped
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pickupGroundGold(groundItemId: number | string): number {
|
pickupGroundGold(groundItemId: number | string): number {
|
||||||
|
|
@ -921,11 +958,14 @@ export class HudManager {
|
||||||
mode: 'trade' | 'gamble' = 'trade',
|
mode: 'trade' | 'gamble' = 'trade',
|
||||||
difficulty: 0 | 1 | 2 = 0,
|
difficulty: 0 | 1 | 2 = 0,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (this.currentClass === null) {
|
||||||
|
throw new Error('HudManager.openVendorSession: character class not assigned by the server yet')
|
||||||
|
}
|
||||||
const playerCtx = {
|
const playerCtx = {
|
||||||
level: this.charSheet.attrs.level,
|
level: this.charSheet.attrs.level,
|
||||||
str: this.charSheet.attrs.str,
|
str: this.charSheet.attrs.str,
|
||||||
dex: this.charSheet.attrs.dex,
|
dex: this.charSheet.attrs.dex,
|
||||||
classCode: this.currentClass ?? 'sor',
|
classCode: this.currentClass,
|
||||||
}
|
}
|
||||||
this.worldPanels.activePlayerInventory = this.inventory
|
this.worldPanels.activePlayerInventory = this.inventory
|
||||||
this.inventory.setPlayerContext(playerCtx)
|
this.inventory.setPlayerContext(playerCtx)
|
||||||
|
|
@ -1112,23 +1152,9 @@ export class HudManager {
|
||||||
if (ok) this.syncFromEngine()
|
if (ok) this.syncFromEngine()
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
const topPotion = this.belt.slots[0]?.[col] ?? null
|
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
|
||||||
const eff = topPotion ? resolvePotionSpec(topPotion.code) : undefined
|
if (this._commandSink) return this._commandSink.useBeltSlot(col)
|
||||||
const consumed = this.belt.useSlot(col, 0)
|
return false
|
||||||
if (!consumed) return false
|
|
||||||
this.hp = Math.min(this.maxHp, this.hp + consumed.healHp)
|
|
||||||
this.mana = Math.min(this.maxMana, this.mana + consumed.healMana)
|
|
||||||
if (eff?.staminaRestorePct || eff?.state === 'staminapot') {
|
|
||||||
this.stamina = this.maxStamina
|
|
||||||
}
|
|
||||||
if (this.engine) {
|
|
||||||
this.engine.world.player.hp = this.hp
|
|
||||||
this.engine.world.player.mana = this.mana
|
|
||||||
this.engine.world.player.stamina = this.stamina
|
|
||||||
}
|
|
||||||
this.syncPublishedState()
|
|
||||||
for (const cb of this.changeListeners) cb()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents(): void {
|
private bindEvents(): void {
|
||||||
|
|
@ -1177,7 +1203,11 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
} else if (key === 'W') {
|
} else if (key === 'W') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.inventory.toggleWeaponSwap()
|
if (this._commandSink) {
|
||||||
|
this._commandSink.swapWeapons()
|
||||||
|
} else {
|
||||||
|
this.inventory.toggleWeaponSwap()
|
||||||
|
}
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
} else if (key === 'R') {
|
} else if (key === 'R') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -1323,8 +1353,12 @@ export class HudManager {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const dropped = this.inventory.cursorItem
|
const dropped = this.inventory.cursorItem
|
||||||
this.inventory.cursorItem = null
|
if (this._commandSink) {
|
||||||
this.callbacks.onDropItemToGround?.(dropped, pt)
|
this._commandSink.dropItem(serverItemIdOf(dropped))
|
||||||
|
} else {
|
||||||
|
this.inventory.cursorItem = null
|
||||||
|
this.callbacks.onDropItemToGround?.(dropped, pt)
|
||||||
|
}
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -1401,9 +1435,6 @@ export class HudManager {
|
||||||
if (pt.x <= 400 + deltaLeft) {
|
if (pt.x <= 400 + deltaLeft) {
|
||||||
const leftX = pt.x - deltaLeft
|
const leftX = pt.x - deltaLeft
|
||||||
if (this.leftPanel === 'char') {
|
if (this.leftPanel === 'char') {
|
||||||
const prevMaxHp = this.charSheet.attrs.baseMaxHp
|
|
||||||
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina
|
|
||||||
const prevMaxMana = this.charSheet.attrs.baseMaxMana
|
|
||||||
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
||||||
if (this.engine) {
|
if (this.engine) {
|
||||||
const normAttr =
|
const normAttr =
|
||||||
|
|
@ -1418,18 +1449,7 @@ export class HudManager {
|
||||||
this.syncFromEngine()
|
this.syncFromEngine()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key === 'vit') {
|
this._commandSink?.allocateStat(key)
|
||||||
const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp)
|
|
||||||
const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina)
|
|
||||||
this.maxHp = this.charSheet.attrs.baseMaxHp
|
|
||||||
this.hp = Math.min(this.maxHp, this.hp + hpGain)
|
|
||||||
this.maxStamina = this.charSheet.attrs.baseMaxStamina
|
|
||||||
this.stamina = Math.min(this.maxStamina, this.stamina + stamGain)
|
|
||||||
} else if (key === 'ene') {
|
|
||||||
const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana)
|
|
||||||
this.maxMana = this.charSheet.attrs.baseMaxMana
|
|
||||||
this.mana = Math.min(this.maxMana, this.mana + manaGain)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
if (!this.charSheet.visible) this.leftPanel = 'none'
|
if (!this.charSheet.visible) this.leftPanel = 'none'
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
|
|
@ -1440,8 +1460,24 @@ export class HudManager {
|
||||||
this.leftPanel = 'none'
|
this.leftPanel = 'none'
|
||||||
},
|
},
|
||||||
onWaypointTeleport: (act, slug, levelId) => {
|
onWaypointTeleport: (act, slug, levelId) => {
|
||||||
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
if (this._commandSink) {
|
||||||
|
this._commandSink.teleportToLevel(levelId)
|
||||||
|
} else {
|
||||||
|
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
...(this._commandSink
|
||||||
|
? {
|
||||||
|
onTransmute: () => this._commandSink?.transmuteCube(),
|
||||||
|
onContainerGridClick: (
|
||||||
|
container: 'stash' | 'cube',
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
hitItem: UiInventoryItem | null,
|
||||||
|
isRightClick: boolean,
|
||||||
|
) => this.forwardGridClick(container === 'stash' ? 4 : 3, col, row, hitItem, isRightClick),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||||
inventory: this.inventory,
|
inventory: this.inventory,
|
||||||
|
|
@ -1457,7 +1493,7 @@ export class HudManager {
|
||||||
if (pt.x >= 400 + deltaRight) {
|
if (pt.x >= 400 + deltaRight) {
|
||||||
const rightX = pt.x - deltaRight
|
const rightX = pt.x - deltaRight
|
||||||
if (this.rightPanel === 'inv') {
|
if (this.rightPanel === 'inv') {
|
||||||
if (!this.inventory.isIdentifyMode()) {
|
if (!this.inventory.isIdentifyMode() && !this._commandSink) {
|
||||||
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
|
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
|
||||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -1546,6 +1582,11 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.syncPlayerContext()
|
this.syncPlayerContext()
|
||||||
this.inventory.handleClick(
|
this.inventory.handleClick(
|
||||||
rightX,
|
rightX,
|
||||||
|
|
@ -1575,7 +1616,63 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward an inventory-panel click (grid / paperdoll) to the server sink. Returns `false` when the point is not
|
||||||
|
* on an item area so the panel's own chrome (close / gold buttons / swap tabs) still handles it.
|
||||||
|
*/
|
||||||
|
private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean {
|
||||||
|
const sink = this._commandSink
|
||||||
|
if (!sink) return false
|
||||||
|
const g = INV_GRID_ORIGIN
|
||||||
|
if (x >= g.x && x < g.x + g.cols * g.cellPx && y >= g.y && y < g.y + g.rows * g.cellPx) {
|
||||||
|
const col = Math.floor((x - g.x) / g.cellPx)
|
||||||
|
const row = Math.floor((y - g.y) / g.cellPx)
|
||||||
|
const hit = this.inventory.gridItems.find(
|
||||||
|
(p) => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
this.forwardGridClick(0, col, row, hit?.item ?? null, isRightClick)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for (const slot of Object.keys(EQUIP_SLOTS_LAYOUT) as EquipSlotId[]) {
|
||||||
|
const r = EQUIP_SLOTS_LAYOUT[slot]
|
||||||
|
if (x < r.x || x >= r.x + r.w || y < r.y || y >= r.y + r.h) continue
|
||||||
|
const cursor = this.inventory.cursorItem
|
||||||
|
if (cursor) {
|
||||||
|
sink.equipItem(serverItemIdOf(cursor), slot)
|
||||||
|
} else if (this.inventory.equipped[slot]) {
|
||||||
|
sink.unequipItem(slot)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forward a container grid click: place the cursor item, use (right-click) or pick up the hit item. */
|
||||||
|
private forwardGridClick(
|
||||||
|
buffer: number,
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
hitItem: UiInventoryItem | null,
|
||||||
|
isRightClick: boolean,
|
||||||
|
): void {
|
||||||
|
const sink = this._commandSink
|
||||||
|
if (!sink) return
|
||||||
|
const cursor = this.inventory.cursorItem
|
||||||
|
if (cursor) {
|
||||||
|
sink.placeCursorItem(serverItemIdOf(cursor), col, row, buffer)
|
||||||
|
} else if (hitItem && isRightClick) {
|
||||||
|
if (isUsableRightClickItem(hitItem.code)) sink.useItem(serverItemIdOf(hitItem))
|
||||||
|
} else if (hitItem) {
|
||||||
|
sink.pickupItemToCursor(serverItemIdOf(hitItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleSkillAllocated(skillId: number): void {
|
private handleSkillAllocated(skillId: number): void {
|
||||||
|
// Server authority: request `AddSkillPoint`; the tree and quickbar update from the server skill list.
|
||||||
|
if (this._commandSink) {
|
||||||
|
this._commandSink.allocateSkill(skillId)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isPassiveSkill(skillId)) return
|
if (isPassiveSkill(skillId)) return
|
||||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
const catalogEntry = SKILLS_BY_ID[skillId]
|
||||||
if (!catalogEntry) return
|
if (!catalogEntry) return
|
||||||
|
|
@ -1607,7 +1704,7 @@ export class HudManager {
|
||||||
return {
|
return {
|
||||||
level: this.charSheet.attrs.level,
|
level: this.charSheet.attrs.level,
|
||||||
xp: this.charSheet.attrs.xp,
|
xp: this.charSheet.attrs.xp,
|
||||||
currentLevelXp: 1_000_000_000,
|
currentLevelXp: D2_EXPERIENCE_TABLE_99[this.charSheet.attrs.level] ?? 0,
|
||||||
nextLevelXp: this.charSheet.attrs.nextLevelXp,
|
nextLevelXp: this.charSheet.attrs.nextLevelXp,
|
||||||
stamina: this.stamina,
|
stamina: this.stamina,
|
||||||
maxStamina: this.maxStamina,
|
maxStamina: this.maxStamina,
|
||||||
|
|
@ -1631,11 +1728,13 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPlayerContext(): void {
|
syncPlayerContext(): void {
|
||||||
|
// Not ready until the server assigns the class; requirement checks keep their neutral context.
|
||||||
|
if (this.currentClass === null) return
|
||||||
const playerCtx = {
|
const playerCtx = {
|
||||||
level: this.charSheet.attrs.level,
|
level: this.charSheet.attrs.level,
|
||||||
str: this.charSheet.attrs.str,
|
str: this.charSheet.attrs.str,
|
||||||
dex: this.charSheet.attrs.dex,
|
dex: this.charSheet.attrs.dex,
|
||||||
classCode: this.currentClass ?? 'sor',
|
classCode: this.currentClass,
|
||||||
}
|
}
|
||||||
this.inventory.setPlayerContext(playerCtx)
|
this.inventory.setPlayerContext(playerCtx)
|
||||||
this.worldPanels.setPlayerContext(playerCtx)
|
this.worldPanels.setPlayerContext(playerCtx)
|
||||||
|
|
@ -1702,12 +1801,8 @@ export class HudManager {
|
||||||
playerMoving: boolean = false,
|
playerMoving: boolean = false,
|
||||||
): void {
|
): void {
|
||||||
this.syncPlayerContext()
|
this.syncPlayerContext()
|
||||||
if (playerMoving && this.isRunning) {
|
// Stamina is server state (stat 10 / 0x95); no client-side drain/regen simulation.
|
||||||
this.stamina = Math.max(0, this.stamina - 0.12)
|
void playerMoving
|
||||||
if (this.stamina <= 0) this.isRunning = false
|
|
||||||
} else {
|
|
||||||
this.stamina = Math.min(this.maxStamina, this.stamina + 0.35)
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = this.viewportProfile
|
const profile = this.viewportProfile
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
|
@ -1755,10 +1850,6 @@ export class HudManager {
|
||||||
|
|
||||||
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
|
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
|
||||||
if (this.leftPanel === 'char') {
|
if (this.leftPanel === 'char') {
|
||||||
const leftNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.leftSkillId)
|
|
||||||
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
|
|
||||||
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
|
|
||||||
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(deltaLeft, panelOffsetY)
|
ctx.translate(deltaLeft, panelOffsetY)
|
||||||
this.charSheet.draw(
|
this.charSheet.draw(
|
||||||
|
|
@ -1770,12 +1861,10 @@ export class HudManager {
|
||||||
maxMana: this.maxMana,
|
maxMana: this.maxMana,
|
||||||
stamina: this.stamina,
|
stamina: this.stamina,
|
||||||
maxStamina: this.maxStamina,
|
maxStamina: this.maxStamina,
|
||||||
leftSkillDmg: leftNode
|
// Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc
|
||||||
? `${leftNode.baseDmgMin + lEff * leftNode.dmgPerLevel}-${leftNode.baseDmgMax + lEff * Math.round(leftNode.dmgPerLevel * 1.25)}`
|
// (no fabricated per-level numbers).
|
||||||
: '45-82',
|
leftSkillDmg: '',
|
||||||
rightSkillDmg: rightNode
|
rightSkillDmg: '',
|
||||||
? `${rightNode.baseDmgMin + rEff * rightNode.dmgPerLevel}-${rightNode.baseDmgMax + rEff * Math.round(rightNode.dmgPerLevel * 1.25)}`
|
|
||||||
: '520-585',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
borderLeftImg: this.images.get('borderLeft') ?? null,
|
borderLeftImg: this.images.get('borderLeft') ?? null,
|
||||||
|
|
|
||||||
|
|
@ -2043,21 +2043,18 @@ export class InventoryPanel {
|
||||||
visible = false
|
visible = false
|
||||||
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
||||||
weaponSwapSet: 0 | 1 = 0
|
weaponSwapSet: 0 | 1 = 0
|
||||||
/** Equipped gear slots. */
|
/** Equipped gear slots (empty until the server sends `0x9C`/`0x9D` equip events). */
|
||||||
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
|
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
|
||||||
get: (slot: string) => UiInventoryItem | undefined
|
get: (slot: string) => UiInventoryItem | undefined
|
||||||
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
|
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
|
||||||
} = { ...STARTER_EQUIPPED_GEAR } as any
|
} = {} as any
|
||||||
/** Secondary weapon swap slots (`Weapon Set II`). */
|
/** Secondary weapon swap slots (`Weapon Set II`). */
|
||||||
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
|
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
|
||||||
weapon1: null,
|
weapon1: null,
|
||||||
weapon2: null,
|
weapon2: null,
|
||||||
}
|
}
|
||||||
/** 10x4 grid items (deep-cloned per instance so quantity/stat mutations do not leak across instances). */
|
/** 10x4 grid items (empty until the server sends inventory item events). */
|
||||||
gridItems: GridPlacement[] = STARTER_BAG_ITEMS.map(p => ({
|
gridItems: GridPlacement[] = []
|
||||||
...p,
|
|
||||||
item: { ...p.item, stats: [...p.item.stats] },
|
|
||||||
}))
|
|
||||||
private _starterCleared = false
|
private _starterCleared = false
|
||||||
private _cursorItem: UiInventoryItem | null = null
|
private _cursorItem: UiInventoryItem | null = null
|
||||||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||||||
|
|
@ -2087,10 +2084,9 @@ export class InventoryPanel {
|
||||||
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
|
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
|
||||||
*/
|
*/
|
||||||
playerContext: PlayerItemRequirementContext = {
|
playerContext: PlayerItemRequirementContext = {
|
||||||
level: 85,
|
level: 0,
|
||||||
str: 156,
|
str: 0,
|
||||||
dex: 75,
|
dex: 0,
|
||||||
classCode: 'sor',
|
|
||||||
}
|
}
|
||||||
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
|
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
|
||||||
playerContextSeq = 0
|
playerContextSeq = 0
|
||||||
|
|
@ -2135,7 +2131,8 @@ export class InventoryPanel {
|
||||||
|
|
||||||
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */
|
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */
|
||||||
_boundEngine?: any | undefined
|
_boundEngine?: any | undefined
|
||||||
private _gold = 850_000
|
/** Server stat 14 (`gold`); 0 until received (D2GS omits zero-valued stats). */
|
||||||
|
private _gold = 0
|
||||||
|
|
||||||
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
|
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
|
||||||
get maxGold(): number {
|
get maxGold(): number {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ export interface SkillNodeDef {
|
||||||
readonly baseMana: number
|
readonly baseMana: number
|
||||||
readonly baseDmgMin: number
|
readonly baseDmgMin: number
|
||||||
readonly baseDmgMax: number
|
readonly baseDmgMax: number
|
||||||
readonly dmgPerLevel: number
|
/**
|
||||||
|
* Legacy hand-entered value of the old Sorceress demo table only; NOT a 1.13c Skills.txt column and never used
|
||||||
|
* for display. Generated class trees leave it undefined.
|
||||||
|
*/
|
||||||
|
readonly dmgPerLevel?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
||||||
|
|
@ -179,7 +183,6 @@ function buildClassSkillTrees(): Record<CharacterClassCode, readonly SkillNodeDe
|
||||||
baseMana: s.mana,
|
baseMana: s.mana,
|
||||||
baseDmgMin: s.minDam,
|
baseDmgMin: s.minDam,
|
||||||
baseDmgMax: s.maxDam,
|
baseDmgMax: s.maxDam,
|
||||||
dmgPerLevel: 5,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -192,31 +195,44 @@ export class SkillTreePanel {
|
||||||
lang: TblLang = 'CHI'
|
lang: TblLang = 'CHI'
|
||||||
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
|
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
|
||||||
activeTab: 0 | 1 | 2 = 2
|
activeTab: 0 | 1 | 2 = 2
|
||||||
unspentSkillPoints = 12
|
/** Server stat 5 (`newskills`). */
|
||||||
bonusAllSkills = 8
|
unspentSkillPoints = 0
|
||||||
characterClass: CharacterClassCode = 'sor'
|
/**
|
||||||
characterLevel = 85
|
* Generic `+all skills` soft points used only by the offline skill calculator state.
|
||||||
readonly hardPoints = new Map<number, number>([
|
* Online, per-skill bonuses come from the server (`0x21` UpdateSkill bonus byte) via `bonusLevels`.
|
||||||
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
|
*/
|
||||||
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
|
bonusAllSkills = 0
|
||||||
[36, 1], [37, 1], [47, 15], [56, 10], [61, 5],
|
/** `null` until the server assigns the character class (no implicit Sorceress default). */
|
||||||
])
|
characterClass: CharacterClassCode | null = null
|
||||||
|
/** Server character level (stat 12); 0 = not yet received. */
|
||||||
|
characterLevel = 0
|
||||||
|
/** Hard (base) skill levels, rebuilt from the server skill list on every sync. */
|
||||||
|
readonly hardPoints = new Map<number, number>()
|
||||||
|
/** Per-skill bonus levels reported by the server (`0x21` bonus byte: +all/+class/+tab/+single). */
|
||||||
|
readonly bonusLevels = new Map<number, number>()
|
||||||
hoveredSkill: SkillNodeDef | null = null
|
hoveredSkill: SkillNodeDef | null = null
|
||||||
|
|
||||||
private tabBgs = new Map<number, HTMLImageElement>()
|
private tabBgs = new Map<number, HTMLImageElement>()
|
||||||
private skillIcons = new Map<number, HTMLImageElement>()
|
private skillIcons = new Map<number, HTMLImageElement>()
|
||||||
private tabBgClass: CharacterClassCode = 'sor'
|
private tabBgClass: CharacterClassCode | null = null
|
||||||
|
|
||||||
getSkillTree(): readonly SkillNodeDef[] {
|
getSkillTree(): readonly SkillNodeDef[] {
|
||||||
return CLASS_SKILL_TREES[this.characterClass] ?? SORCERESS_SKILL_TREE
|
if (this.characterClass === null) return []
|
||||||
|
const tree = CLASS_SKILL_TREES[this.characterClass]
|
||||||
|
if (!tree) throw new Error(`SkillTreePanel: no skill tree for class '${this.characterClass}'`)
|
||||||
|
return tree
|
||||||
}
|
}
|
||||||
|
|
||||||
getVerticalTabs(): readonly SkillTreeTabInfo[] {
|
getVerticalTabs(): readonly SkillTreeTabInfo[] {
|
||||||
return CLASS_VERTICAL_TABS[this.characterClass] ?? CLASS_VERTICAL_TABS.sor
|
if (this.characterClass === null) return []
|
||||||
|
const tabs = CLASS_VERTICAL_TABS[this.characterClass]
|
||||||
|
if (!tabs) throw new Error(`SkillTreePanel: no tab layout for class '${this.characterClass}'`)
|
||||||
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadAssets(): void {
|
preloadAssets(): void {
|
||||||
if (typeof Image === 'undefined') return
|
if (typeof Image === 'undefined') return
|
||||||
|
if (this.characterClass === null) return
|
||||||
for (const tab of [0, 1, 2]) {
|
for (const tab of [0, 1, 2]) {
|
||||||
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
|
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
|
||||||
const bg = new Image()
|
const bg = new Image()
|
||||||
|
|
@ -262,30 +278,48 @@ export class SkillTreePanel {
|
||||||
return this.hardPoints.get(skillId) ?? 0
|
return this.hardPoints.get(skillId) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
getEffectiveLevel(skillId: number): number {
|
/** Server-reported bonus levels for `skillId` (0 when none). */
|
||||||
const hard = this.getHardPoints(skillId)
|
getBonusLevels(skillId: number): number {
|
||||||
return hard > 0 ? hard + this.bonusAllSkills : 0
|
return this.bonusLevels.get(skillId) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
getSkillCalcState(): SkillCalcState {
|
getEffectiveLevel(skillId: number): number {
|
||||||
|
const hard = this.getHardPoints(skillId)
|
||||||
|
const bonus = this.getBonusLevels(skillId) + this.bonusAllSkills
|
||||||
|
return hard > 0 ? hard + bonus : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skill calculator state; `null` while the class is not yet known. */
|
||||||
|
getSkillCalcState(): SkillCalcState | null {
|
||||||
|
if (this.characterClass === null) return null
|
||||||
const hardPoints: Record<number, number> = {}
|
const hardPoints: Record<number, number> = {}
|
||||||
for (const [id, pts] of this.hardPoints.entries()) {
|
for (const [id, pts] of this.hardPoints.entries()) {
|
||||||
if (pts > 0) hardPoints[id] = pts
|
if (pts > 0) hardPoints[id] = pts
|
||||||
}
|
}
|
||||||
|
const specificSoftPoints: Record<number, number> = {}
|
||||||
|
for (const [id, bonus] of this.bonusLevels.entries()) {
|
||||||
|
if (bonus > 0) specificSoftPoints[id] = bonus
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
classCode: this.characterClass,
|
classCode: this.characterClass,
|
||||||
hardPoints,
|
hardPoints,
|
||||||
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
||||||
allSkillsSoftPoints: this.bonusAllSkills,
|
allSkillsSoftPoints: this.bonusAllSkills,
|
||||||
specificSoftPoints: {},
|
specificSoftPoints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getTooltipViewModel(skillId: number): TooltipViewModel | null {
|
getTooltipViewModel(skillId: number): TooltipViewModel | null {
|
||||||
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel)
|
const state = this.getSkillCalcState()
|
||||||
|
if (!state) return null
|
||||||
|
return buildTooltipViewModel(state, skillId, this.characterLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
canAllocate(skillId: number, charLevel = 85): boolean {
|
/**
|
||||||
|
* Whether the 1.13c client would send `AddSkillPoint (0x3B)` for `skillId`: unspent points, class tree
|
||||||
|
* membership, `Skills.txt reqlevel` (+1 per hard point) against the real character level, and prerequisites.
|
||||||
|
*/
|
||||||
|
canAllocate(skillId: number, charLevel: number = this.characterLevel): boolean {
|
||||||
if (this.unspentSkillPoints <= 0) return false
|
if (this.unspentSkillPoints <= 0) return false
|
||||||
const node = this.getSkillTree().find(n => n.skillId === skillId)
|
const node = this.getSkillTree().find(n => n.skillId === skillId)
|
||||||
if (!node) return false
|
if (!node) return false
|
||||||
|
|
@ -295,13 +329,6 @@ export class SkillTreePanel {
|
||||||
return node.prereqs.every(p => this.getHardPoints(p) > 0)
|
return node.prereqs.every(p => this.getHardPoints(p) > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
allocateSkill(skillId: number, charLevel = 85): boolean {
|
|
||||||
if (!this.canAllocate(skillId, charLevel)) return false
|
|
||||||
this.hardPoints.set(skillId, this.getHardPoints(skillId) + 1)
|
|
||||||
this.unspentSkillPoints -= 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } {
|
static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } {
|
||||||
const colIdx = Math.max(0, Math.min(2, node.col - 1))
|
const colIdx = Math.max(0, Math.min(2, node.col - 1))
|
||||||
const rowIdx = Math.max(0, Math.min(5, node.row - 1))
|
const rowIdx = Math.max(0, Math.min(5, node.row - 1))
|
||||||
|
|
@ -310,9 +337,11 @@ export class SkillTreePanel {
|
||||||
return { x, y, w: 48, h: 48 }
|
return { x, y, w: 48, h: 48 }
|
||||||
}
|
}
|
||||||
|
|
||||||
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } {
|
/** Close-button socket for the active class/tab; `null` while the class is unknown. */
|
||||||
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass] ?? SKILL_CLOSE_BTN_OFFSET_X.sor
|
getCloseButtonBounds(): { x: number; y: number; w: number; h: number } | null {
|
||||||
const offsetX = offsets[this.activeTab] ?? 171
|
if (this.characterClass === null) return null
|
||||||
|
const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass]
|
||||||
|
const offsetX = offsets[this.activeTab]
|
||||||
return {
|
return {
|
||||||
x: SKILL_PANEL_ORIGIN.x + offsetX,
|
x: SKILL_PANEL_ORIGIN.x + offsetX,
|
||||||
y: SKILL_PANEL_ORIGIN.y + 385,
|
y: SKILL_PANEL_ORIGIN.y + 385,
|
||||||
|
|
@ -341,6 +370,7 @@ export class SkillTreePanel {
|
||||||
// Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385)
|
// Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385)
|
||||||
const closeBounds = this.getCloseButtonBounds()
|
const closeBounds = this.getCloseButtonBounds()
|
||||||
if (
|
if (
|
||||||
|
closeBounds &&
|
||||||
logicalX >= closeBounds.x &&
|
logicalX >= closeBounds.x &&
|
||||||
logicalX <= closeBounds.x + closeBounds.w &&
|
logicalX <= closeBounds.x + closeBounds.w &&
|
||||||
logicalY >= closeBounds.y &&
|
logicalY >= closeBounds.y &&
|
||||||
|
|
@ -372,7 +402,8 @@ export class SkillTreePanel {
|
||||||
for (const node of tabNodes) {
|
for (const node of tabNodes) {
|
||||||
const r = SkillTreePanel.nodeScreenPos(node)
|
const r = SkillTreePanel.nodeScreenPos(node)
|
||||||
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
||||||
if (this.allocateSkill(node.skillId)) {
|
// Server-authoritative: request `AddSkillPoint (0x3B)` only; levels arrive via 0x21/0x94.
|
||||||
|
if (this.canAllocate(node.skillId)) {
|
||||||
onSkillAllocated?.(node.skillId)
|
onSkillAllocated?.(node.skillId)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
@ -521,8 +552,8 @@ export class SkillTreePanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket)
|
// 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket)
|
||||||
if (assets.buySellBtnImg) {
|
const closeBounds = this.getCloseButtonBounds()
|
||||||
const closeBounds = this.getCloseButtonBounds()
|
if (assets.buySellBtnImg && closeBounds) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
assets.buySellBtnImg,
|
assets.buySellBtnImg,
|
||||||
10 * 32,
|
10 * 32,
|
||||||
|
|
|
||||||
|
|
@ -591,19 +591,41 @@ export interface WaypointEntry {
|
||||||
readonly unlocked: boolean
|
readonly unlocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
|
/**
|
||||||
|
* Static quest catalog entries start as `locked`; the live status comes exclusively from the server
|
||||||
|
* quest-flag block (`0x29` GameQuestLog / `0x28` QuestInfo) via `WorldPanelsHud.applyServerQuestFlags()`.
|
||||||
|
*/
|
||||||
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
|
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
|
||||||
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
status: isCompleted ? 'completed' : 'active',
|
status: 'locked',
|
||||||
description: isCompleted
|
description: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
|
||||||
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
|
|
||||||
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First quest-flag word of each act inside the 1.13c 48-word (96-byte) quest record sent by `0x29`
|
||||||
|
* (same layout as the `.d2s` "Woo!" quest block): word 0 = Act I intro, 1..6 = Act I quests,
|
||||||
|
* 7/8 = Act II travel/intro, 9..14 = Act II, 15/16, 17..22 = Act III, 23/24, 25..27 = Act IV, 35..40 = Act V.
|
||||||
|
*/
|
||||||
|
const QUEST_FLAG_ACT_BASE: Readonly<Record<number, number>> = { 1: 1, 2: 9, 3: 17, 4: 25, 5: 35 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest-flag word index for a canonical quest. The internal quest number is the `qstsa<act>q<n>` string key
|
||||||
|
* (e.g. "The Search for Cain" = `qstsa1q4` -> word 4, "Terror's End" = `qstsa4q2` -> word 26).
|
||||||
|
*/
|
||||||
|
export function questFlagWordIndex(quest: Pick<CanonicalQuestItem, 'act' | 'titleKey' | 'id'>): number {
|
||||||
|
const m = /^qstsa(\d)q(\d)$/.exec(quest.titleKey)
|
||||||
|
const base = QUEST_FLAG_ACT_BASE[quest.act]
|
||||||
|
if (!m || base === undefined || Number(m[1]) !== quest.act) {
|
||||||
|
throw new Error(`questFlagWordIndex: cannot map quest '${quest.id}' (titleKey '${quest.titleKey}')`)
|
||||||
|
}
|
||||||
|
return base + Number(m[2]) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bit 0 of a quest-flag word = quest completed (reward granted). */
|
||||||
|
export const QUEST_FLAG_COMPLETED = 0x0001
|
||||||
|
|
||||||
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
|
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
|
||||||
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
|
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
|
||||||
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
|
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
|
||||||
|
|
@ -623,53 +645,53 @@ export const ALL_QUESTS: readonly QuestEntry[] = [
|
||||||
|
|
||||||
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
|
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
|
||||||
1: [
|
1: [
|
||||||
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true },
|
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: false },
|
||||||
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true },
|
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: false },
|
||||||
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true },
|
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: false },
|
||||||
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true },
|
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: false },
|
||||||
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true },
|
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: false },
|
||||||
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true },
|
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: false },
|
||||||
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true },
|
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: false },
|
||||||
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true },
|
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: false },
|
||||||
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true },
|
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: false },
|
||||||
],
|
],
|
||||||
2: [
|
2: [
|
||||||
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true },
|
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: false },
|
||||||
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true },
|
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: false },
|
||||||
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true },
|
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: false },
|
||||||
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true },
|
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: false },
|
||||||
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true },
|
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: false },
|
||||||
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true },
|
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: false },
|
||||||
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true },
|
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: false },
|
||||||
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true },
|
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: false },
|
||||||
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true },
|
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: false },
|
||||||
],
|
],
|
||||||
3: [
|
3: [
|
||||||
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true },
|
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: false },
|
||||||
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true },
|
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: false },
|
||||||
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true },
|
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: false },
|
||||||
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true },
|
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: false },
|
||||||
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true },
|
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: false },
|
||||||
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true },
|
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: false },
|
||||||
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true },
|
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: false },
|
||||||
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true },
|
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: false },
|
||||||
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true },
|
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: false },
|
||||||
],
|
],
|
||||||
4: [
|
4: [
|
||||||
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true },
|
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: false },
|
||||||
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true },
|
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: false },
|
||||||
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true },
|
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: false },
|
||||||
],
|
],
|
||||||
5: [
|
5: [
|
||||||
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true },
|
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: false },
|
||||||
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true },
|
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: false },
|
||||||
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true },
|
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: false },
|
||||||
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true },
|
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: false },
|
||||||
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true },
|
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: false },
|
||||||
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true },
|
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: false },
|
||||||
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true },
|
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: false },
|
||||||
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true },
|
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: false },
|
||||||
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true },
|
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: false },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,7 +710,8 @@ export class WorldPanelsHud {
|
||||||
lang: TblLang = getTblLang()
|
lang: TblLang = getTblLang()
|
||||||
selectedActTab = 1
|
selectedActTab = 1
|
||||||
selectedQuestIdx = 0
|
selectedQuestIdx = 0
|
||||||
private _stashGold = 2_500_000
|
/** Server stat 15 (`goldbank`); 0 until received. */
|
||||||
|
private _stashGold = 0
|
||||||
private _stashGoldExplicitlySet = false
|
private _stashGoldExplicitlySet = false
|
||||||
get stashGold(): number {
|
get stashGold(): number {
|
||||||
return this._stashGold
|
return this._stashGold
|
||||||
|
|
@ -733,12 +756,12 @@ export class WorldPanelsHud {
|
||||||
vendorMode: 'trade' | 'gamble' = 'trade'
|
vendorMode: 'trade' | 'gamble' = 'trade'
|
||||||
activeVendorTab: VendorTabId = 'armor'
|
activeVendorTab: VendorTabId = 'armor'
|
||||||
vendorDifficulty: 0 | 1 | 2 = 0
|
vendorDifficulty: 0 | 1 | 2 = 0
|
||||||
vendorCharLevel = 85
|
/** Synced from the server character level by `HudManager.syncPlayerContext()`. */
|
||||||
|
vendorCharLevel = 0
|
||||||
vendorPlayerStats: PlayerItemRequirementContext = {
|
vendorPlayerStats: PlayerItemRequirementContext = {
|
||||||
level: 85,
|
level: 0,
|
||||||
str: 156,
|
str: 0,
|
||||||
dex: 75,
|
dex: 0,
|
||||||
classCode: 'sor',
|
|
||||||
}
|
}
|
||||||
vendorTradeState: VendorTradeState = 'idle'
|
vendorTradeState: VendorTradeState = 'idle'
|
||||||
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
||||||
|
|
@ -1435,6 +1458,21 @@ export class WorldPanelsHud {
|
||||||
this.questStatuses.set(questId, status)
|
this.questStatuses.set(questId, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every quest status from the server quest-flag words (`ClientWorld.quests`):
|
||||||
|
* bit 0 set = completed, any other bit set = active (started), 0 = locked (not started).
|
||||||
|
*/
|
||||||
|
applyServerQuestFlags(flags: ArrayLike<number>): void {
|
||||||
|
this.questStatuses.clear()
|
||||||
|
for (const quest of ALL_QUESTS) {
|
||||||
|
const word = flags[questFlagWordIndex(quest)] ?? 0
|
||||||
|
this.questStatuses.set(
|
||||||
|
quest.id,
|
||||||
|
(word & QUEST_FLAG_COMPLETED) !== 0 ? 'completed' : word !== 0 ? 'active' : 'locked',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private questTblLookup?: QuestTblLookup | undefined
|
private questTblLookup?: QuestTblLookup | undefined
|
||||||
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
|
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
|
||||||
|
|
||||||
|
|
@ -2166,6 +2204,21 @@ export class WorldPanelsHud {
|
||||||
inventory?: InventoryPanel
|
inventory?: InventoryPanel
|
||||||
isShiftClick?: boolean
|
isShiftClick?: boolean
|
||||||
isRightClick?: boolean
|
isRightClick?: boolean
|
||||||
|
/**
|
||||||
|
* Server-authoritative mode: the cube Transmute button only requests `ClickButton 0x18`; no local recipe run.
|
||||||
|
*/
|
||||||
|
onTransmute?: () => void
|
||||||
|
/**
|
||||||
|
* Server-authoritative mode: stash / cube grid clicks are forwarded (pick-up / place / use) and never move
|
||||||
|
* items locally. The containers are rebuilt from `ClientWorld` when the server answers.
|
||||||
|
*/
|
||||||
|
onContainerGridClick?: (
|
||||||
|
container: 'stash' | 'cube',
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
hitItem: UiInventoryItem | null,
|
||||||
|
isRightClick: boolean,
|
||||||
|
) => void
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (logicalX > 400) return false
|
if (logicalX > 400) return false
|
||||||
|
|
@ -2232,8 +2285,12 @@ export class WorldPanelsHud {
|
||||||
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
|
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
|
||||||
if (!callbacks.isRightClick) {
|
if (!callbacks.isRightClick) {
|
||||||
this.pressedCubeButton = 'transmute'
|
this.pressedCubeButton = 'transmute'
|
||||||
this.cubeTransmuteCount += 1
|
if (callbacks.onTransmute) {
|
||||||
this.transmuteCube()
|
callbacks.onTransmute()
|
||||||
|
} else {
|
||||||
|
this.cubeTransmuteCount += 1
|
||||||
|
this.transmuteCube()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -2251,6 +2308,11 @@ export class WorldPanelsHud {
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (callbacks.onContainerGridClick) {
|
||||||
|
callbacks.onContainerGridClick('cube', col, row, hit?.item ?? null, callbacks.isRightClick === true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (callbacks.inventory?.isIdentifyMode()) {
|
if (callbacks.inventory?.isIdentifyMode()) {
|
||||||
if (callbacks.isRightClick) {
|
if (callbacks.isRightClick) {
|
||||||
callbacks.inventory.cancelIdentifyMode()
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
|
|
@ -2309,6 +2371,11 @@ export class WorldPanelsHud {
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (callbacks.onContainerGridClick) {
|
||||||
|
callbacks.onContainerGridClick('stash', col, row, hit?.item ?? null, callbacks.isRightClick === true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (callbacks.inventory?.isIdentifyMode()) {
|
if (callbacks.inventory?.isIdentifyMode()) {
|
||||||
if (callbacks.isRightClick) {
|
if (callbacks.isRightClick) {
|
||||||
callbacks.inventory.cancelIdentifyMode()
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ export interface PortalState {
|
||||||
readonly flags?: number | undefined
|
readonly flags?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
||||||
|
const MAX_D2_STAT_ID = 0x1ff
|
||||||
|
|
||||||
export class ClientWorld {
|
export class ClientWorld {
|
||||||
act = 0
|
act = 0
|
||||||
mapSeed = 0
|
mapSeed = 0
|
||||||
|
|
@ -90,6 +93,8 @@ export class ClientWorld {
|
||||||
private readonly _containers = new ClientInventoryManager()
|
private readonly _containers = new ClientInventoryManager()
|
||||||
private readonly _revealedRooms = new Set<string>()
|
private readonly _revealedRooms = new Set<string>()
|
||||||
private readonly _waypoints = new Set<number>()
|
private readonly _waypoints = new Set<number>()
|
||||||
|
/** Unit id of the waypoint object whose menu was last opened (`0x63 WaypointMenu`); `null` when none. */
|
||||||
|
private _waypointUnitId: number | null = null
|
||||||
private _quests = new Uint16Array(48)
|
private _quests = new Uint16Array(48)
|
||||||
private readonly _party = new Map<number, PartyMemberState>()
|
private readonly _party = new Map<number, PartyMemberState>()
|
||||||
private readonly _trade: TradeSessionState = {
|
private readonly _trade: TradeSessionState = {
|
||||||
|
|
@ -143,6 +148,11 @@ export class ClientWorld {
|
||||||
return this._waypoints
|
return this._waypoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Waypoint object unit id from the last `0x63 WaypointMenu`, required by `0x49 WaypointGo`. */
|
||||||
|
get waypointUnitId(): number | null {
|
||||||
|
return this._waypointUnitId
|
||||||
|
}
|
||||||
|
|
||||||
get quests(): Uint16Array {
|
get quests(): Uint16Array {
|
||||||
return this._quests
|
return this._quests
|
||||||
}
|
}
|
||||||
|
|
@ -457,6 +467,10 @@ export class ClientWorld {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'StatSet': {
|
case 'StatSet': {
|
||||||
|
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
|
||||||
|
// (0x53 act seed = 0x5300, 0x8000|op / 0x9000|op opaque envelopes) as pseudo StatSet events;
|
||||||
|
// those must never enter a unit StatList.
|
||||||
|
if (ev.stat < 0 || ev.stat > MAX_D2_STAT_ID) break
|
||||||
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
|
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
|
||||||
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
|
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
|
|
@ -579,6 +593,7 @@ export class ClientWorld {
|
||||||
|
|
||||||
case 'WaypointMenu': {
|
case 'WaypointMenu': {
|
||||||
this._waypoints.clear()
|
this._waypoints.clear()
|
||||||
|
this._waypointUnitId = ev.unitId
|
||||||
for (const wp of ev.open) {
|
for (const wp of ev.open) {
|
||||||
this._waypoints.add(wp)
|
this._waypoints.add(wp)
|
||||||
}
|
}
|
||||||
|
|
@ -621,9 +636,13 @@ export class ClientWorld {
|
||||||
if (ev.partnerId !== undefined) {
|
if (ev.partnerId !== undefined) {
|
||||||
this._trade.partnerId = ev.partnerId
|
this._trade.partnerId = ev.partnerId
|
||||||
}
|
}
|
||||||
if (ev.gold !== undefined) {
|
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
|
||||||
this._trade.partnerGold = ev.gold
|
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
|
||||||
this._trade.selfGold = ev.gold
|
if ((ev.action & 0xff) !== 0) {
|
||||||
|
this._trade.selfGold = ev.gold
|
||||||
|
} else {
|
||||||
|
this._trade.partnerGold = ev.gold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Trade cancel / close actions
|
// Trade cancel / close actions
|
||||||
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
|
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
|
||||||
|
|
@ -736,6 +755,7 @@ export class ClientWorld {
|
||||||
this._containers.clear()
|
this._containers.clear()
|
||||||
this._revealedRooms.clear()
|
this._revealedRooms.clear()
|
||||||
this._waypoints.clear()
|
this._waypoints.clear()
|
||||||
|
this._waypointUnitId = null
|
||||||
this._quests = new Uint16Array(48)
|
this._quests = new Uint16Array(48)
|
||||||
this._party.clear()
|
this._party.clear()
|
||||||
this._trade.active = false
|
this._trade.active = false
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ import {
|
||||||
export interface SelfPlayerState {
|
export interface SelfPlayerState {
|
||||||
unitId: number
|
unitId: number
|
||||||
name: string
|
name: string
|
||||||
charClass: CharClassId
|
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
|
||||||
|
charClass: CharClassId | null
|
||||||
level: number
|
level: number
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|
@ -53,8 +54,8 @@ export interface SelfPlayerState {
|
||||||
export class MutableSelfPlayerState implements SelfPlayerState {
|
export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
unitId = 0
|
unitId = 0
|
||||||
name = ''
|
name = ''
|
||||||
charClass: CharClassId = 0
|
charClass: CharClassId | null = null
|
||||||
level = 1
|
level = 0
|
||||||
x = 0
|
x = 0
|
||||||
y = 0
|
y = 0
|
||||||
targetX = 0
|
targetX = 0
|
||||||
|
|
@ -78,46 +79,42 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
weaponSet: 0 | 1 = 0
|
weaponSet: 0 | 1 = 0
|
||||||
mercId: number | null = null
|
mercId: number | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one server stat update (`0x1D`/`0x1E`/`0x1F` BaseAttribute byte/word/dword, `0x19` gold delta).
|
||||||
|
*
|
||||||
|
* Wire format of the pool stats 6..11 (hitpoints, maxhp, mana, maxmana, stamina, maxstamina): the value is
|
||||||
|
* the engine's internal **8.8 fixed-point** number (`ItemStatCost.txt` ValShift = 8 for these six stats),
|
||||||
|
* so the displayed integer is always `value >> 8` — unconditionally, never by magnitude.
|
||||||
|
* Evidence (tests/fixtures/netproto/0[234]-*.d2cap, fresh level-1 Assassin "WebBotOne"): raw packets
|
||||||
|
* `1e 07 00 32` / `1e 09 00 19` / `1e 0b 00 5f` decode (u16 LE, as every other D2GS field) to
|
||||||
|
* 12800 / 6400 / 24320 = maxhp 50 (vit 20 + hpadd 30) / maxmana 25 / maxstamina 95 in 8.8, with a zero
|
||||||
|
* fractional byte; `0x95` (15-bit plain integer) confirms life = 50. The StatList keeps the raw value.
|
||||||
|
*/
|
||||||
applyStat(stat: number, value: number, param = 0): void {
|
applyStat(stat: number, value: number, param = 0): void {
|
||||||
if (stat === STAT_GOLD && param === 1) {
|
if (stat === STAT_GOLD && param === 1) {
|
||||||
this.gold = Math.max(0, this.gold + value)
|
this.gold = Math.max(0, this.gold + value)
|
||||||
this.stats.set(STAT_GOLD, this.gold)
|
this.stats.set(STAT_GOLD, this.gold)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const isShiftedPoolStat =
|
this.stats.set(stat, value, param)
|
||||||
stat === STAT_HITPOINTS ||
|
|
||||||
stat === STAT_MAXHP ||
|
|
||||||
stat === STAT_MANA ||
|
|
||||||
stat === STAT_MAXMANA ||
|
|
||||||
stat === STAT_STAMINA ||
|
|
||||||
stat === STAT_MAXSTAMINA
|
|
||||||
// In 1.13c 0x1E/0x1F (D2Client.dll 0x6fb5edf0), stats 6..11 have valShift=8 (8.8 fixed point on wire)
|
|
||||||
const normVal = isShiftedPoolStat && value >= 256 ? value >>> 8 : value
|
|
||||||
this.stats.set(stat, normVal, param)
|
|
||||||
switch (stat) {
|
switch (stat) {
|
||||||
case STAT_HITPOINTS:
|
case STAT_HITPOINTS:
|
||||||
this.life = normVal
|
this.life = poolStatValue(value)
|
||||||
if (this.maxLife < normVal) this.maxLife = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_MAXHP:
|
case STAT_MAXHP:
|
||||||
this.maxLife = normVal
|
this.maxLife = poolStatValue(value)
|
||||||
if (this.life === 0) this.life = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_MANA:
|
case STAT_MANA:
|
||||||
this.mana = normVal
|
this.mana = poolStatValue(value)
|
||||||
if (this.maxMana < normVal) this.maxMana = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_MAXMANA:
|
case STAT_MAXMANA:
|
||||||
this.maxMana = normVal
|
this.maxMana = poolStatValue(value)
|
||||||
if (this.mana === 0) this.mana = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_STAMINA:
|
case STAT_STAMINA:
|
||||||
this.stamina = normVal
|
this.stamina = poolStatValue(value)
|
||||||
if (this.maxStamina < normVal) this.maxStamina = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_MAXSTAMINA:
|
case STAT_MAXSTAMINA:
|
||||||
this.maxStamina = normVal
|
this.maxStamina = poolStatValue(value)
|
||||||
if (this.stamina === 0) this.stamina = normVal
|
|
||||||
break
|
break
|
||||||
case STAT_LEVEL:
|
case STAT_LEVEL:
|
||||||
this.level = value
|
this.level = value
|
||||||
|
|
@ -134,21 +131,19 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `0x18` / `0x95` current life / mana / stamina (15-bit integers on the wire). */
|
||||||
applyLifeMana(life: number, mana: number, stamina: number, x: number, y: number): void {
|
applyLifeMana(life: number, mana: number, stamina: number, x: number, y: number): void {
|
||||||
if (life >= 0) {
|
if (life >= 0) {
|
||||||
this.life = life
|
this.life = life
|
||||||
this.stats.set(STAT_HITPOINTS, life)
|
this.stats.set(STAT_HITPOINTS, life)
|
||||||
if (this.maxLife < life) this.maxLife = life
|
|
||||||
}
|
}
|
||||||
if (mana >= 0) {
|
if (mana >= 0) {
|
||||||
this.mana = mana
|
this.mana = mana
|
||||||
this.stats.set(STAT_MANA, mana)
|
this.stats.set(STAT_MANA, mana)
|
||||||
if (this.maxMana < mana) this.maxMana = mana
|
|
||||||
}
|
}
|
||||||
if (stamina >= 0) {
|
if (stamina >= 0) {
|
||||||
this.stamina = stamina
|
this.stamina = stamina
|
||||||
this.stats.set(STAT_STAMINA, stamina)
|
this.stats.set(STAT_STAMINA, stamina)
|
||||||
if (this.maxStamina < stamina) this.maxStamina = stamina
|
|
||||||
}
|
}
|
||||||
if (x !== 0 || y !== 0) {
|
if (x !== 0 || y !== 0) {
|
||||||
this.x = x
|
this.x = x
|
||||||
|
|
@ -170,8 +165,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.unitId = 0
|
this.unitId = 0
|
||||||
this.name = ''
|
this.name = ''
|
||||||
this.charClass = 0
|
this.charClass = null
|
||||||
this.level = 1
|
this.level = 0
|
||||||
this.x = 0
|
this.x = 0
|
||||||
this.y = 0
|
this.y = 0
|
||||||
this.targetX = 0
|
this.targetX = 0
|
||||||
|
|
@ -196,3 +191,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
this.mercId = null
|
this.mercId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `ItemStatCost.txt` ValShift of stats 6..11 (hitpoints/maxhp/mana/maxmana/stamina/maxstamina). */
|
||||||
|
export const POOL_STAT_VAL_SHIFT = 8
|
||||||
|
|
||||||
|
/** Integer pool value from the 8.8 fixed-point wire/StatList value of stats 6..11. */
|
||||||
|
export function poolStatValue(raw: number): number {
|
||||||
|
return Math.floor(raw / (1 << POOL_STAT_VAL_SHIFT))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,6 @@ export class BeltModel {
|
||||||
/** Currently hovered slot `(row, col)`. */
|
/** Currently hovered slot `(row, col)`. */
|
||||||
hoveredSlot: { row: number; col: number } | null = null
|
hoveredSlot: { row: number; col: number } | null = null
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.seedDefaultPotions()
|
|
||||||
}
|
|
||||||
|
|
||||||
seedDefaultPotions(): void {
|
seedDefaultPotions(): void {
|
||||||
for (let row = 0; row < BELT_ROWS; row++) {
|
for (let row = 0; row < BELT_ROWS; row++) {
|
||||||
for (let col = 0; col < BELT_COLS; col++) {
|
for (let col = 0; col < BELT_COLS; col++) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ export function encodeC2sItemDrop(itemId: number): Uint8Array {
|
||||||
return validateC2sPacket(w.toUint8Array())
|
return validateC2sPacket(w.toUint8Array())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 0x50 DropGold (9B, `C2S_PACKET_SIZES[0x50] = 9`): u8 0x50 | u32 playerUnitId | u32 amount */
|
||||||
|
export function encodeC2sDropGold(unitId: number, amount: number): Uint8Array {
|
||||||
|
const w = new ByteWriter(9)
|
||||||
|
w.u8(0x50)
|
||||||
|
w.u32LE(unitId >>> 0)
|
||||||
|
w.u32LE(amount >>> 0)
|
||||||
|
return validateC2sPacket(w.toUint8Array())
|
||||||
|
}
|
||||||
|
|
||||||
/** 0x18 InsertItemToBuffer (17B): u8 0x18 | u32 itemId | u32 x | u32 y | u32 bufferType */
|
/** 0x18 InsertItemToBuffer (17B): u8 0x18 | u32 itemId | u32 x | u32 y | u32 bufferType */
|
||||||
export function encodeC2sItemPlace(itemId: number, x: number, y: number, buffer: number): Uint8Array {
|
export function encodeC2sItemPlace(itemId: number, x: number, y: number, buffer: number): Uint8Array {
|
||||||
const w = new ByteWriter(17)
|
const w = new ByteWriter(17)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
encodeC2sBeltUse,
|
encodeC2sBeltUse,
|
||||||
encodeC2sIdentifyItem,
|
encodeC2sIdentifyItem,
|
||||||
encodeC2sItemDrop,
|
encodeC2sItemDrop,
|
||||||
|
encodeC2sDropGold,
|
||||||
encodeC2sItemEquip,
|
encodeC2sItemEquip,
|
||||||
encodeC2sItemPickup,
|
encodeC2sItemPickup,
|
||||||
encodeC2sItemPlace,
|
encodeC2sItemPlace,
|
||||||
|
|
@ -439,6 +440,8 @@ export function encodeD2gsClientCommand(
|
||||||
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
|
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
|
||||||
case 'ItemDrop':
|
case 'ItemDrop':
|
||||||
return [encodeC2sItemDrop(cmd.itemId)]
|
return [encodeC2sItemDrop(cmd.itemId)]
|
||||||
|
case 'DropGold':
|
||||||
|
return [encodeC2sDropGold(cmd.unitId, cmd.amount)]
|
||||||
case 'ItemToCursor':
|
case 'ItemToCursor':
|
||||||
return [encodeC2sItemToCursor(cmd.itemId)]
|
return [encodeC2sItemToCursor(cmd.itemId)]
|
||||||
case 'ItemPlace':
|
case 'ItemPlace':
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,9 @@ export function decode0x1CExpDword(pkt: Uint8Array): ServerEvent {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize 1.13c stat values from 0x1D/0x1E/0x1F (D2Client.dll 0x6fb5edf0):
|
* Normalize 1.13c stat values from 0x1D/0x1E/0x1F (D2Client.dll 0x6fb5edf0):
|
||||||
* Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina),
|
* Note: For stats 6..11 (hp, maxhp, mana, maxmana, stamina, maxstamina) the value is passed through
|
||||||
* 0x6fb5edf0 shifts left by 8 inside the engine's fixed-point stat table,
|
* unchanged: it is the engine's 8.8 fixed-point number (ItemStatCost.txt ValShift = 8), e.g. a fresh
|
||||||
* meaning the wire value is the integer stat (or fixed-point for 0x1F when > 0xFFFF).
|
* level-1 Assassin's maxhp arrives as `1e 07 00 32` = 12800 = 50 << 8. Consumers apply `>> 8`.
|
||||||
*/
|
*/
|
||||||
export function decode0x1DAttrByte(pkt: Uint8Array): ServerEvent {
|
export function decode0x1DAttrByte(pkt: Uint8Array): ServerEvent {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export type ClientCommand =
|
||||||
| { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
| { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||||
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||||
| { readonly type: 'ItemDrop'; readonly itemId: number }
|
| { readonly type: 'ItemDrop'; readonly itemId: number }
|
||||||
|
/** 0x50 DropGold: drop `amount` inventory gold of player `unitId` on the ground. */
|
||||||
|
| { readonly type: 'DropGold'; readonly unitId: number; readonly amount: number }
|
||||||
| { readonly type: 'ItemToCursor'; readonly itemId: number }
|
| { readonly type: 'ItemToCursor'; readonly itemId: number }
|
||||||
| {
|
| {
|
||||||
readonly type: 'ItemPlace'
|
readonly type: 'ItemPlace'
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { readFileSync, existsSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
DEFAULT_SORCERESS_SKILLS,
|
DEFAULT_SORCERESS_SKILLS,
|
||||||
SkillHotkeysHud,
|
|
||||||
resolveSkillIcon,
|
resolveSkillIcon,
|
||||||
getAvailableSkillsForSide,
|
getAvailableSkillsForSide,
|
||||||
} from '../src/client/ui/hotkeys.ts'
|
} from '../src/client/ui/hotkeys.ts'
|
||||||
|
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||||
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts'
|
import { getAttackWeaponInfo, checkAmmunition, getSkillRange, type AttackWeaponInputItem } from '../src/client/world/act-runtime.ts'
|
||||||
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
import { castSkill } from '../src/server/scene/skill-caster.ts'
|
||||||
import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts'
|
import { GameEngine, type GameEngineOptions } from '../src/server/engine/engine.ts'
|
||||||
|
|
@ -29,8 +29,8 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes Skill 0 in both left and right available skill palettes', () => {
|
it('includes Skill 0 in both left and right available skill palettes', () => {
|
||||||
const leftSkills = getAvailableSkillsForSide('left')
|
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||||
const rightSkills = getAvailableSkillsForSide('right')
|
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||||
|
|
||||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||||
|
|
@ -42,9 +42,10 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
|
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
|
||||||
const hud = new SkillHotkeysHud()
|
// Explicitly seeded legacy Sorceress quickbar (the HUD itself starts neutral).
|
||||||
|
const hud = createDemoHotkeys()
|
||||||
|
|
||||||
// Initial defaults
|
// Seeded defaults
|
||||||
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
||||||
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,12 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hud = new SkillHotkeysHud()
|
hud = new SkillHotkeysHud()
|
||||||
|
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
||||||
const leftSkills = getAvailableSkillsForSide('left')
|
const leftSkills = getAvailableSkillsForSide('left', DEFAULT_SORCERESS_SKILLS)
|
||||||
const rightSkills = getAvailableSkillsForSide('right')
|
const rightSkills = getAvailableSkillsForSide('right', DEFAULT_SORCERESS_SKILLS)
|
||||||
|
|
||||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||||
|
|
@ -265,6 +266,7 @@ describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindin
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hud = new SkillHotkeysHud()
|
hud = new SkillHotkeysHud()
|
||||||
|
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,11 @@ describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures
|
||||||
|
|
||||||
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
|
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
|
||||||
const panel = new SkillTreePanel()
|
const panel = new SkillTreePanel()
|
||||||
expect(panel.characterClass).toBe('sor')
|
// Neutral until the server assigns a class: no tree, no tabs, no close socket.
|
||||||
|
expect(panel.characterClass).toBeNull()
|
||||||
|
expect(panel.getSkillTree()).toEqual([])
|
||||||
|
expect(panel.getVerticalTabs()).toEqual([])
|
||||||
|
expect(panel.getCloseButtonBounds()).toBeNull()
|
||||||
|
|
||||||
// Switch to Paladin
|
// Switch to Paladin
|
||||||
panel.setClass('pal')
|
panel.setClass('pal')
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
expect(world.revealedRooms.has('1:100:200')).toBe(false)
|
expect(world.revealedRooms.has('1:100:200')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes 8.8 fixed-point pool stats and ignores negative sentinels from WalkVerify', () => {
|
it('decodes 8.8 fixed-point pool stats 6..11 (ValShift 8) and ignores negative sentinels from WalkVerify', () => {
|
||||||
const world = new ClientWorld()
|
const world = new ClientWorld()
|
||||||
world.apply({
|
world.apply({
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
|
|
@ -137,7 +137,8 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 50 life in 8.8 fixed point = 12800 (0x3200)
|
// 1.13c wire values for stats 6..11 are 8.8 fixed point (ItemStatCost ValShift = 8):
|
||||||
|
// capture bytes `1e 07 00 32` -> u16-LE 12800 = 50 max life for a fresh level-1 character.
|
||||||
world.apply({ type: 'StatSet', stat: 6, value: 12800 })
|
world.apply({ type: 'StatSet', stat: 6, value: 12800 })
|
||||||
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
|
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
|
||||||
world.apply({ type: 'StatSet', stat: 8, value: 6400 })
|
world.apply({ type: 'StatSet', stat: 8, value: 6400 })
|
||||||
|
|
@ -151,6 +152,18 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
expect(world.self.stamina).toBe(95)
|
expect(world.self.stamina).toBe(95)
|
||||||
expect(world.self.maxStamina).toBe(95)
|
expect(world.self.maxStamina).toBe(95)
|
||||||
|
|
||||||
|
// The StatList keeps the raw wire value; only the pool fields are shifted
|
||||||
|
expect(world.self.stats.get(7)).toBe(12800)
|
||||||
|
|
||||||
|
// The shift is unconditional (no magnitude heuristic): raw 128 = 0.5 -> 0, raw 420 << 8 -> 420
|
||||||
|
world.apply({ type: 'StatSet', stat: 7, value: 128 })
|
||||||
|
expect(world.self.maxLife).toBe(0)
|
||||||
|
expect(world.self.stats.get(7)).toBe(128)
|
||||||
|
world.apply({ type: 'StatSet', stat: 7, value: 420 << 8 })
|
||||||
|
expect(world.self.maxLife).toBe(420)
|
||||||
|
world.apply({ type: 'StatSet', stat: 7, value: 12800 })
|
||||||
|
expect(world.self.maxLife).toBe(50)
|
||||||
|
|
||||||
// WalkVerify (0x96) sends life: -1, mana: -1, stamina: 90, at: (5010, 5012)
|
// WalkVerify (0x96) sends life: -1, mana: -1, stamina: 90, at: (5010, 5012)
|
||||||
world.apply({
|
world.apply({
|
||||||
type: 'LifeMana',
|
type: 'LifeMana',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
} from '../../src/netproto/index.ts'
|
||||||
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||||
|
|
||||||
|
type ItemEventItem = Extract<ServerEvent, { type: 'ItemEvent' }>['item']
|
||||||
|
|
||||||
|
/** Minimal Healing Potion (hp4) in the given belt slot, as decoded from a 0x9C ItemEvent. */
|
||||||
|
function makeBeltPotionItem(id: number, slot: number): ItemEventItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
itemId: id,
|
||||||
|
opcode: 0x9c,
|
||||||
|
action: 0x0e,
|
||||||
|
category: 0,
|
||||||
|
flags: 0,
|
||||||
|
rawFlags: 0,
|
||||||
|
version: 101,
|
||||||
|
location: 2,
|
||||||
|
container: 'belt',
|
||||||
|
bodyLoc: 0,
|
||||||
|
equippedSlot: 0,
|
||||||
|
gridX: slot,
|
||||||
|
gridY: 0,
|
||||||
|
storePage: 0,
|
||||||
|
storagePage: 0,
|
||||||
|
worldX: 0,
|
||||||
|
worldY: 0,
|
||||||
|
x: slot,
|
||||||
|
y: 0,
|
||||||
|
code: 'hp4',
|
||||||
|
rawCode: 'hp4 ',
|
||||||
|
compact: true,
|
||||||
|
simple: true,
|
||||||
|
identified: true,
|
||||||
|
socketed: false,
|
||||||
|
ethereal: false,
|
||||||
|
runeword: false,
|
||||||
|
personalized: false,
|
||||||
|
isEar: false,
|
||||||
|
newItem: false,
|
||||||
|
starterItem: false,
|
||||||
|
broken: false,
|
||||||
|
inSocket: false,
|
||||||
|
equipped: false,
|
||||||
|
socketedChildCount: 0,
|
||||||
|
usedSockets: 0,
|
||||||
|
totalSockets: 0,
|
||||||
|
quality: 2,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
stats: [],
|
||||||
|
setBonusStats: [],
|
||||||
|
runewordStats: [],
|
||||||
|
} as ItemEventItem
|
||||||
|
}
|
||||||
|
|
||||||
describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
describe('HudModel (F16)', () => {
|
describe('HudModel (F16)', () => {
|
||||||
it('synchronizes ClientWorld vitals, stats, skills, containers, quests, waypoints, and monster target into HudManager', () => {
|
it('synchronizes ClientWorld vitals, stats, skills, containers, quests, waypoints, and monster target into HudManager', () => {
|
||||||
|
|
@ -66,6 +118,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 150_000 })
|
world.apply({ type: 'StatSet', stat: D2StatId.GoldBank, value: 150_000 })
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.FireResist, value: 75 })
|
world.apply({ type: 'StatSet', stat: D2StatId.FireResist, value: 75 })
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.ColdResist, value: 60 })
|
world.apply({ type: 'StatSet', stat: D2StatId.ColdResist, value: 60 })
|
||||||
|
// Pool stats 6..11 are 8.8 fixed point on the 1.13c wire (ItemStatCost ValShift = 8)
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 420 << 8 })
|
world.apply({ type: 'StatSet', stat: D2StatId.MaxHp, value: 420 << 8 })
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 500 << 8 })
|
world.apply({ type: 'StatSet', stat: D2StatId.MaxMana, value: 500 << 8 })
|
||||||
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 })
|
world.apply({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 220 << 8 })
|
||||||
|
|
@ -291,6 +344,25 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
'ItemDrop',
|
'ItemDrop',
|
||||||
'ClickButton',
|
'ClickButton',
|
||||||
])
|
])
|
||||||
|
// BeltUse carries the real server item id of the bottom potion in column 0
|
||||||
|
expect(commands.find((c) => c.type === 'BeltUse')).toEqual({ type: 'BeltUse', itemId: 501, shift: false })
|
||||||
|
|
||||||
|
// Server authority: command helpers never mutate HUD / world state locally
|
||||||
|
expect(hudManager.charSheet.attrs.statPoints).toBe(15)
|
||||||
|
expect(hudManager.charSheet.attrs.str).toBe(60)
|
||||||
|
expect(hudManager.skillTree.unspentSkillPoints).toBe(3)
|
||||||
|
expect(hudManager.skillTree.getHardPoints(36)).toBe(10)
|
||||||
|
expect(world.self.rightSkill).toBe(54)
|
||||||
|
expect(hudManager.hotkeys.rightSkillId).toBe(54)
|
||||||
|
expect(world.self.weaponSet).toBe(0)
|
||||||
|
expect(hudManager.inventory.weaponSwapSet).toBe(0)
|
||||||
|
expect(hudManager.belt.grid[0]?.[0]?.code).toBe('hp4')
|
||||||
|
expect(hudManager.hp).toBe(380)
|
||||||
|
|
||||||
|
// No belt item in column 3 -> no BeltUse command
|
||||||
|
const before = commands.length
|
||||||
|
expect(hudModel.useBeltSlot(3, false)).toBe(false)
|
||||||
|
expect(commands.length).toBe(before)
|
||||||
hudModel.dispose()
|
hudModel.dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -405,10 +477,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
|
|
||||||
mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 })
|
mapper.bindSkillHotkey('F1', { hand: 'right', skillId: 54 })
|
||||||
expect(mapper.handleKeyDown({ key: 'F1' }, 5000)).toBe(true)
|
expect(mapper.handleKeyDown({ key: 'F1' }, 5000)).toBe(true)
|
||||||
|
// Server authority: F1 only requests SelectSkill; the slot changes when the server answers (0x23)
|
||||||
|
expect(dispatched[dispatched.length - 1]).toMatchObject({ type: 'SelectSkill', hand: 'right', skill: 54 })
|
||||||
|
expect(hudModel.rightSkillId).toBe(0)
|
||||||
|
world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 54, itemId: 0xffffffff })
|
||||||
expect(hudModel.rightSkillId).toBe(54)
|
expect(hudModel.rightSkillId).toBe(54)
|
||||||
|
expect(hudManager.hotkeys.rightSkillId).toBe(54)
|
||||||
|
|
||||||
|
// Belt key '1' with an empty server belt emits nothing; once the server puts a potion there, BeltUse
|
||||||
|
// carries its real item id
|
||||||
expect(mapper.handleKeyDown({ key: '1' }, 6000)).toBe(true)
|
expect(mapper.handleKeyDown({ key: '1' }, 6000)).toBe(true)
|
||||||
expect(dispatched.some((c) => c.type === 'BeltUse')).toBe(true)
|
expect(dispatched.some((c) => c.type === 'BeltUse')).toBe(false)
|
||||||
|
world.apply({ type: 'ItemEvent', action: 0x0e, container: 'belt', item: makeBeltPotionItem(555, 0) })
|
||||||
|
expect(mapper.handleKeyDown({ key: '1' }, 6050)).toBe(true)
|
||||||
|
expect(dispatched.filter((c) => c.type === 'BeltUse')).toEqual([{ type: 'BeltUse', itemId: 555, shift: false }])
|
||||||
|
|
||||||
expect(mapper.handleKeyDown({ key: 'Tab' }, 6100)).toBe(true)
|
expect(mapper.handleKeyDown({ key: 'Tab' }, 6100)).toBe(true)
|
||||||
expect(automapView.getMode()).toBe('level')
|
expect(automapView.getMode()).toBe('level')
|
||||||
|
|
@ -574,6 +656,10 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
unit: { unitType: 0, id: 1, classId: 1, name: 'webbottwo', x: 500, y: 500 },
|
unit: { unitType: 0, id: 1, classId: 1, name: 'webbottwo', x: 500, y: 500 },
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
})
|
})
|
||||||
|
// Max pools arrive as 8.8 fixed-point base attributes (0x1E) before life/mana updates (0x95, plain)
|
||||||
|
cb({ type: 'StatSet', stat: D2StatId.MaxHp, value: 400 << 8 })
|
||||||
|
cb({ type: 'StatSet', stat: D2StatId.MaxMana, value: 450 << 8 })
|
||||||
|
cb({ type: 'StatSet', stat: D2StatId.MaxStamina, value: 200 << 8 })
|
||||||
cb({
|
cb({
|
||||||
type: 'LifeMana',
|
type: 'LifeMana',
|
||||||
life: 320,
|
life: 320,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||||
import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts'
|
import { VENDOR_BUTTON_TABLE_X } from '../src/client/ui/world-panels.ts'
|
||||||
|
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
|
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
|
||||||
describe('1. Asset Extraction & Atlas Metadata', () => {
|
describe('1. Asset Extraction & Atlas Metadata', () => {
|
||||||
|
|
@ -459,6 +460,11 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
|
||||||
it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => {
|
it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => {
|
||||||
const { hud, clickAt } = createTestHud()
|
const { hud, clickAt } = createTestHud()
|
||||||
|
|
||||||
|
// Server-not-ready HUD (no class assigned) refuses to open a vendor session
|
||||||
|
expect(() => hud.openVendorSession('Charsi', 'trade', 0)).toThrow(/character class/)
|
||||||
|
// Explicitly seed the legacy demo Sorceress state (class, level, gold, inventory)
|
||||||
|
seedDemoHud(hud)
|
||||||
|
|
||||||
// Open Charsi vendor panel
|
// Open Charsi vendor panel
|
||||||
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true)
|
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true)
|
||||||
expect(hud.state.leftPanel).toBe('vendor')
|
expect(hud.state.leftPanel).toBe('vendor')
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { findSafeDropPosition } from '../../src/common/items/ground-items.ts'
|
||||||
import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts'
|
import { COLLIDE_WALL, COLLIDE_BLANK, COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
||||||
import { BeltHud } from '../../src/client/ui/belt.ts'
|
import { BeltHud } from '../../src/client/ui/belt.ts'
|
||||||
|
import { seedDemoBelt } from '../fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
let oracle: DropOracleStack
|
let oracle: DropOracleStack
|
||||||
const dropTables = getEmbeddedDropTables()
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
|
@ -624,13 +625,13 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
describe('Feature 14: Collision Bounding Pickup Radius & 2-Pass Auto-Belt Potion Routing (#425)', () => {
|
describe('Feature 14: Collision Bounding Pickup Radius & 2-Pass Auto-Belt Potion Routing (#425)', () => {
|
||||||
it('F14.1 verifies BeltHud has 4 columns and 4 rows', () => {
|
it('F14.1 verifies BeltHud has 4 columns and 4 rows', () => {
|
||||||
const belt = new BeltHud()
|
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||||
expect(belt.grid.length).toBe(4)
|
expect(belt.grid.length).toBe(4)
|
||||||
expect(belt.grid[0].length).toBe(4)
|
expect(belt.grid[0].length).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F14.2 verifies useSlot drinks bottom potion and shifts upper rows down', () => {
|
it('F14.2 verifies useSlot drinks bottom potion and shifts upper rows down', () => {
|
||||||
const belt = new BeltHud()
|
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||||
// Bottom slot in col 0
|
// Bottom slot in col 0
|
||||||
const initialBottom = belt.grid[0][0]
|
const initialBottom = belt.grid[0][0]
|
||||||
expect(initialBottom).not.toBeNull()
|
expect(initialBottom).not.toBeNull()
|
||||||
|
|
@ -642,7 +643,7 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F14.3 verifies placePotion adds potion to first empty row in specified column', () => {
|
it('F14.3 verifies placePotion adds potion to first empty row in specified column', () => {
|
||||||
const belt = new BeltHud()
|
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||||
belt.useSlot(1) // empty top row in col 1
|
belt.useSlot(1) // empty top row in col 1
|
||||||
const dummyPotion = { id: 'test_potion', name: 'Health Potion', col: 1 } as any
|
const dummyPotion = { id: 'test_potion', name: 'Health Potion', col: 1 } as any
|
||||||
const leftover = belt.placePotion(dummyPotion, 1)
|
const leftover = belt.placePotion(dummyPotion, 1)
|
||||||
|
|
@ -651,7 +652,7 @@ describe('Tier 1 — Feature Coverage (All 17 Inventoried Features, Issues #412
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F14.4 verifies countTotalPotions accurately counts active belt items', () => {
|
it('F14.4 verifies countTotalPotions accurately counts active belt items', () => {
|
||||||
const belt = new BeltHud()
|
const belt = seedDemoBelt(new BeltHud()) // explicit legacy 16-potion belt
|
||||||
expect(belt.countTotalPotions()).toBe(16)
|
expect(belt.countTotalPotions()).toBe(16)
|
||||||
belt.useSlot(0)
|
belt.useSlot(0)
|
||||||
expect(belt.countTotalPotions()).toBe(15)
|
expect(belt.countTotalPotions()).toBe(15)
|
||||||
|
|
|
||||||
|
|
@ -609,7 +609,9 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
|
||||||
describe('B14: Full Belt Spilling & Non-Potion Rejection (#425)', () => {
|
describe('B14: Full Belt Spilling & Non-Potion Rejection (#425)', () => {
|
||||||
it('B14.1 completely full belt (16 potions) rejects further potion placement', () => {
|
it('B14.1 completely full belt (16 potions) rejects further potion placement', () => {
|
||||||
const belt = new BeltHud()
|
const belt = new BeltHud()
|
||||||
// All 16 slots are full by default
|
// The belt starts empty; seed the legacy full 16-potion layout explicitly
|
||||||
|
expect(belt.countTotalPotions()).toBe(0)
|
||||||
|
belt.seedDefaultPotions()
|
||||||
expect(belt.countTotalPotions()).toBe(16)
|
expect(belt.countTotalPotions()).toBe(16)
|
||||||
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
|
const extraPotion = { id: 'extra', name: 'Health Potion', col: 0 } as any
|
||||||
// Trying to place in any column returns the unplaced potion
|
// Trying to place in any column returns the unplaced potion
|
||||||
|
|
@ -627,6 +629,7 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues
|
||||||
|
|
||||||
it('B14.3 picked potion targets column with matching potion type first', () => {
|
it('B14.3 picked potion targets column with matching potion type first', () => {
|
||||||
const belt = new BeltHud()
|
const belt = new BeltHud()
|
||||||
|
belt.seedDefaultPotions() // explicit legacy full belt
|
||||||
belt.useSlot(0) // empty top row in col 0 (Health Potion col)
|
belt.useSlot(0) // empty top row in col 0 (Health Potion col)
|
||||||
expect(belt.grid[3][0]).toBeNull()
|
expect(belt.grid[3][0]).toBeNull()
|
||||||
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any
|
const newHealth = { id: 'new_hp', name: 'Health Potion' } as any
|
||||||
|
|
|
||||||
|
|
@ -1521,9 +1521,21 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
|
||||||
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff })
|
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff })
|
||||||
expect(world.party.has(2)).toBe(false)
|
expect(world.party.has(2)).toBe(false)
|
||||||
|
|
||||||
// 4. Trade open (action 0x01) -> gold update -> cancel (action 0x02)
|
// 4. Trade open (action 0x01) -> 0x79 GoldInTrade (action 0x7900 | bySelf) -> cancel (action 0x02)
|
||||||
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 50000 })
|
world.apply({ type: 'Trade', action: 0x01, partnerId: 2 })
|
||||||
expect(world.trade.active).toBe(true)
|
expect(world.trade.active).toBe(true)
|
||||||
|
expect(world.trade.partnerId).toBe(2)
|
||||||
|
// Gold carried by a non-0x79 trade action is not attributed to either side
|
||||||
|
world.apply({ type: 'Trade', action: 0x01, partnerId: 2, gold: 12345 })
|
||||||
|
expect(world.trade.partnerGold).toBe(0)
|
||||||
|
expect(world.trade.selfGold).toBe(0)
|
||||||
|
// bySelf = 0 -> partner's offered gold only
|
||||||
|
world.apply({ type: 'Trade', action: 0x7900, gold: 50000 })
|
||||||
|
expect(world.trade.partnerGold).toBe(50000)
|
||||||
|
expect(world.trade.selfGold).toBe(0)
|
||||||
|
// bySelf != 0 -> own offered gold only
|
||||||
|
world.apply({ type: 'Trade', action: 0x7901, gold: 1200 })
|
||||||
|
expect(world.trade.selfGold).toBe(1200)
|
||||||
expect(world.trade.partnerGold).toBe(50000)
|
expect(world.trade.partnerGold).toBe(50000)
|
||||||
world.apply({ type: 'Trade', action: 0x02 })
|
world.apply({ type: 'Trade', action: 0x02 })
|
||||||
expect(world.trade.active).toBe(false)
|
expect(world.trade.active).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -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 { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||||
import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
|
import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||||
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
||||||
|
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
|
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
|
||||||
let hud: SkillHotkeysHud
|
let hud: SkillHotkeysHud
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hud = new SkillHotkeysHud()
|
// Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
|
||||||
|
hud = createDemoHotkeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => {
|
describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => {
|
||||||
|
|
@ -125,7 +127,7 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
|
||||||
expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true)
|
expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allocates new skill in skill tree and adds it to hotkeys.availableSkills', () => {
|
it('requests skill allocation through the command sink without local mutation', () => {
|
||||||
const hudManager = createMockHud()
|
const hudManager = createMockHud()
|
||||||
// Open Skill tree panel
|
// Open Skill tree panel
|
||||||
hudManager.rightPanel = 'skill'
|
hudManager.rightPanel = 'skill'
|
||||||
|
|
@ -143,17 +145,16 @@ describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speed
|
||||||
const node = hudManager.skillTree.getSkillTree().find(n => n.skillId === 48)!
|
const node = hudManager.skillTree.getSkillTree().find(n => n.skillId === 48)!
|
||||||
expect(node).toBeDefined()
|
expect(node).toBeDefined()
|
||||||
|
|
||||||
|
// Server authority: allocation is only requested through the command sink; nothing changes locally.
|
||||||
const prevPoints = hudManager.skillTree.hardPoints.get(48) ?? 0
|
const prevPoints = hudManager.skillTree.hardPoints.get(48) ?? 0
|
||||||
const allocated = hudManager.skillTree.allocateSkill(48)
|
const requested: number[] = []
|
||||||
expect(allocated).toBe(true)
|
hudManager.commandSink = { allocateSkill: (id: number) => (requested.push(id), true) } as any
|
||||||
|
|
||||||
// Test handleSkillAllocated
|
|
||||||
const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager)
|
const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager)
|
||||||
privateMethod(48)
|
privateMethod(48)
|
||||||
|
|
||||||
const updatedNova = hudManager.hotkeys.availableSkills.find(s => s.skillId === 48)
|
expect(requested).toEqual([48])
|
||||||
expect(updatedNova).toBeDefined()
|
expect(hudManager.skillTree.hardPoints.get(48) ?? 0).toBe(prevPoints)
|
||||||
expect(updatedNova!.level).toBe(prevPoints + 1)
|
expect(hudManager.skillTree.unspentSkillPoints).toBe(5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ function createTestEngine() {
|
||||||
lootSeed: 42,
|
lootSeed: 42,
|
||||||
npcDefs: [],
|
npcDefs: [],
|
||||||
})
|
})
|
||||||
|
// Explicit legacy full belt (16 potions) so picked-up potions route to the bag, not the belt.
|
||||||
|
engine.belt.seedDefaultPotions()
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,26 @@ import {
|
||||||
resolveSkillIcon,
|
resolveSkillIcon,
|
||||||
getAvailableSkillsForSide,
|
getAvailableSkillsForSide,
|
||||||
} from '../src/client/ui/hotkeys.ts'
|
} from '../src/client/ui/hotkeys.ts'
|
||||||
|
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
||||||
let hud: SkillHotkeysHud
|
let hud: SkillHotkeysHud
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hud = new SkillHotkeysHud()
|
// Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
|
||||||
|
hud = createDemoHotkeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes with expected default skills and state', () => {
|
it('starts neutral (server-not-ready): Attack on both slots, no skills, no bindings', () => {
|
||||||
|
const fresh = new SkillHotkeysHud()
|
||||||
|
expect(fresh.leftSkillId).toBe(0)
|
||||||
|
expect(fresh.rightSkillId).toBe(0)
|
||||||
|
expect(fresh.availableSkills).toEqual([])
|
||||||
|
expect(fresh.bindings.size).toBe(0)
|
||||||
|
expect(getAvailableSkillsForSide('left', fresh.availableSkills)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes with expected seeded demo skills and state', () => {
|
||||||
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
||||||
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
||||||
expect(hud.selectionOpen).toBeNull()
|
expect(hud.selectionOpen).toBeNull()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import * as fs from 'node:fs'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { KeyboardInput } from '../src/client/sim/input.ts'
|
import { KeyboardInput } from '../src/client/sim/input.ts'
|
||||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts'
|
||||||
|
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
|
||||||
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
|
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
|
||||||
import { ViewportCamera } from '../src/client/sim/camera.ts'
|
import { ViewportCamera } from '../src/client/sim/camera.ts'
|
||||||
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
||||||
|
|
@ -215,7 +216,7 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
|
||||||
expect(hud.rightPanel).toBe('none')
|
expect(hud.rightPanel).toBe('none')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drinks belt potions from columns 0..3 via keys 1..4 without resetting active skills', () => {
|
it('requests belt potion use for columns 0..3 via keys 1..4 (server-authoritative) without resetting active skills', () => {
|
||||||
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
|
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
|
||||||
const hud = new HudManager(canvas, {
|
const hud = new HudManager(canvas, {
|
||||||
onToggleAutomap: () => {},
|
onToggleAutomap: () => {},
|
||||||
|
|
@ -223,6 +224,8 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
|
||||||
})
|
})
|
||||||
hud['bindEvents']()
|
hud['bindEvents']()
|
||||||
|
|
||||||
|
// Explicit legacy demo state: Sorceress, 16-potion belt, vitals
|
||||||
|
seedDemoHud(hud)
|
||||||
hud.hotkeys.leftSkillId = 47 // Fire Ball
|
hud.hotkeys.leftSkillId = 47 // Fire Ball
|
||||||
hud.hotkeys.rightSkillId = 64 // Frozen Orb
|
hud.hotkeys.rightSkillId = 64 // Frozen Orb
|
||||||
hud.syncPublishedState()
|
hud.syncPublishedState()
|
||||||
|
|
@ -231,36 +234,35 @@ describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)'
|
||||||
|
|
||||||
expect(hud.belt.countTotalPotions()).toBe(16)
|
expect(hud.belt.countTotalPotions()).toBe(16)
|
||||||
|
|
||||||
// Press '1' -> drinks column 0 (hp potion, healHp 180)
|
// No engine and no command sink: keys 1..4 do nothing locally (no client-side healing / belt mutation)
|
||||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
|
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
|
||||||
expect(hud.hp).toBe(400 + 180)
|
expect(hud.hp).toBe(400)
|
||||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
expect(hud.belt.countTotalPotions()).toBe(16)
|
||||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
|
||||||
expect(hud.state.leftSkillId).toBe(47)
|
|
||||||
expect(hud.state.rightSkillId).toBe(64)
|
|
||||||
expect(hud.belt.countTotalPotions()).toBe(15)
|
|
||||||
|
|
||||||
// Press '2' -> drinks column 1 (hp potion, healHp 320)
|
// With a server command sink attached, keys 1..4 only emit BeltUse requests for columns 0..3
|
||||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' }))
|
const beltRequests: number[] = []
|
||||||
expect(hud.hp).toBe(580 + 320)
|
hud.commandSink = {
|
||||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
useBeltSlot: (col: number) => {
|
||||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
beltRequests.push(col)
|
||||||
expect(hud.belt.countTotalPotions()).toBe(14)
|
return true
|
||||||
|
},
|
||||||
|
} as unknown as HudCommandSink
|
||||||
|
|
||||||
// Press '3' -> drinks column 2 (mana potion, healMana 150)
|
for (const [i, key] of ['1', '2', '3', '4'].entries()) {
|
||||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' }))
|
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key }))
|
||||||
expect(hud.mana).toBe(200 + 150)
|
expect(beltRequests).toEqual([0, 1, 2, 3].slice(0, i + 1))
|
||||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
// Active skills are never reset by belt hotkeys
|
||||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
expect(hud.hotkeys.leftSkillId).toBe(47)
|
||||||
expect(hud.belt.countTotalPotions()).toBe(13)
|
expect(hud.hotkeys.rightSkillId).toBe(64)
|
||||||
|
expect(hud.state.leftSkillId).toBe(47)
|
||||||
|
expect(hud.state.rightSkillId).toBe(64)
|
||||||
|
}
|
||||||
|
|
||||||
// Press '4' -> drinks column 3 (rejuv potion, healHp 400, healMana 400)
|
// Local state is unchanged until the server answers (HudModel.syncFromWorld)
|
||||||
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' }))
|
expect(hud.hp).toBe(400)
|
||||||
expect(hud.hp).toBe(900 + 400 > hud.maxHp ? hud.maxHp : 1300)
|
expect(hud.mana).toBe(200)
|
||||||
expect(hud.mana).toBe(350 + 400)
|
expect(hud.belt.countTotalPotions()).toBe(16)
|
||||||
expect(hud.hotkeys.leftSkillId).toBe(47)
|
hud.commandSink = null
|
||||||
expect(hud.hotkeys.rightSkillId).toBe(64)
|
|
||||||
expect(hud.belt.countTotalPotions()).toBe(12)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isolates WASD keys from movement Contribution', () => {
|
it('isolates WASD keys from movement Contribution', () => {
|
||||||
|
|
@ -399,6 +401,8 @@ function createTestScene(options?: {
|
||||||
onToggleAutomap: () => {},
|
onToggleAutomap: () => {},
|
||||||
onWaypointTeleport: () => {},
|
onWaypointTeleport: () => {},
|
||||||
})
|
})
|
||||||
|
// Explicit legacy demo Sorceress state (class, quickbar skill levels / mana costs, vitals incl. maxMana).
|
||||||
|
seedDemoHud(hudManager)
|
||||||
hudManager.mana = options?.mana ?? 200
|
hudManager.mana = options?.mana ?? 200
|
||||||
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
|
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
|
||||||
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
|
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
logicalToClient,
|
logicalToClient,
|
||||||
} from '../src/client/ui/hud-manager.ts'
|
} from '../src/client/ui/hud-manager.ts'
|
||||||
import { parseColoredText } from '../src/client/ui/font.ts'
|
import { parseColoredText } from '../src/client/ui/font.ts'
|
||||||
|
import { createDemoCharSheet, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
|
||||||
import { traceBitmapContours, buildTrueTypeFont } from '../src/baker/extract/build-d2-ttf.ts'
|
import { traceBitmapContours, buildTrueTypeFont } from '../src/baker/extract/build-d2-ttf.ts'
|
||||||
|
|
||||||
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
|
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
|
||||||
|
|
@ -460,7 +461,8 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => {
|
it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => {
|
||||||
const worldPanels = new WorldPanelsHud()
|
// Explicitly seeded demo quest progress (a1q1 completed); the HUD itself starts with all quests locked.
|
||||||
|
const worldPanels = createDemoWorldPanels()
|
||||||
worldPanels.lang = 'ENG'
|
worldPanels.lang = 'ENG'
|
||||||
worldPanels.selectedActTab = 1
|
worldPanels.selectedActTab = 1
|
||||||
worldPanels.selectedQuestIdx = 0
|
worldPanels.selectedQuestIdx = 0
|
||||||
|
|
@ -542,7 +544,8 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
||||||
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
||||||
|
|
||||||
const sheet = new CharacterSheetPanel()
|
// Explicitly seeded demo character (unspent stat points) so the Stat Points / Remaining labels render.
|
||||||
|
const sheet = createDemoCharSheet()
|
||||||
sheet.lang = 'ENG'
|
sheet.lang = 'ENG'
|
||||||
sheet.visible = true
|
sheet.visible = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import {
|
||||||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts'
|
import type { D2FontName, D2ColorCode, D2FontRenderer } from '../src/client/ui/font.ts'
|
||||||
|
import { createDemoInventory, createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
// --- Mock & Test Utilities ---
|
// --- Mock & Test Utilities ---
|
||||||
|
|
||||||
|
|
@ -343,7 +344,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('1.5 formats base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)', () => {
|
it('1.5 formats base stats block (Damage/Defense, Durability, Speed Class, Req Strength, Req Level in White/Grey)', () => {
|
||||||
const worldPanels = new WorldPanelsHud()
|
const worldPanels = createDemoWorldPanels() // seeded level-85 Sorceress requirement context
|
||||||
const mockCtx = createMockCanvasContext()
|
const mockCtx = createMockCanvasContext()
|
||||||
const mockFont = createMockFontRenderer()
|
const mockFont = createMockFontRenderer()
|
||||||
|
|
||||||
|
|
@ -641,7 +642,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', ()
|
||||||
describe('Tier 3: Cross-Feature Interactions', () => {
|
describe('Tier 3: Cross-Feature Interactions', () => {
|
||||||
|
|
||||||
it('3.1 preserves dyed sprite through equip slot pickup, bag placement, and re-equip workflow', () => {
|
it('3.1 preserves dyed sprite through equip slot pickup, bag placement, and re-equip workflow', () => {
|
||||||
const inv = new InventoryPanel()
|
const inv = createDemoInventory() // seeded starter gear (dyed Tal Rasha helm)
|
||||||
|
|
||||||
// 1. Initial State: Equipped Helm has invtransform 'cgrn'
|
// 1. Initial State: Equipped Helm has invtransform 'cgrn'
|
||||||
const equippedHelm = inv.equipped.helm as any
|
const equippedHelm = inv.equipped.helm as any
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import { SceneMouseController } from '../src/client/world/act-runtime.ts'
|
import { SceneMouseController } from '../src/client/world/act-runtime.ts'
|
||||||
import type { Monster } from '../src/server/engine/combat.ts'
|
import type { Monster } from '../src/server/engine/combat.ts'
|
||||||
|
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => {
|
describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration Suite', () => {
|
||||||
const dropTables = getEmbeddedDropTables()
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
|
@ -493,7 +494,7 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S
|
||||||
|
|
||||||
it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => {
|
it('converts dropped ground items via itemToUiInventoryItem with valid invFile, bilingual name, stats, and equips to paperdoll', () => {
|
||||||
const engine = createTestEngine({ lootSeed: 12345678 })
|
const engine = createTestEngine({ lootSeed: 12345678 })
|
||||||
const invPanel = new InventoryPanel()
|
const invPanel = createDemoInventory() // seeded level-85 Sorceress requirement context
|
||||||
invPanel.gridItems = []
|
invPanel.gridItems = []
|
||||||
for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) {
|
for (const k of Object.keys(invPanel.equipped) as EquipSlotId[]) {
|
||||||
delete invPanel.equipped[k]
|
delete invPanel.equipped[k]
|
||||||
|
|
|
||||||
|
|
@ -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)', () => {
|
describe('Issue #522: Ground Items Single Source of Truth (engine.groundItems)', () => {
|
||||||
it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => {
|
it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => {
|
||||||
const engine = new GameEngine('nec')
|
const engine = new GameEngine('nec')
|
||||||
|
// Explicit legacy full belt (16 potions) so the picked-up potion routes to the bag, not the belt.
|
||||||
|
engine.belt.seedDefaultPotions()
|
||||||
const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion')
|
const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion')
|
||||||
const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion')
|
const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,13 @@ describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
|
||||||
const a1q1 = ACT_QUESTS[1]![0]!
|
const a1q1 = ACT_QUESTS[1]![0]!
|
||||||
expect(a1q1.id).toBe('a1q1')
|
expect(a1q1.id).toBe('a1q1')
|
||||||
|
|
||||||
// Default status is completed for a1q1
|
// Neutral (server-not-ready): every quest starts locked
|
||||||
|
expect(hud.getQuestDisplayText(a1q1).status).toBe('locked')
|
||||||
|
|
||||||
|
// Server quest-flag block (0x29): word 1 = a1q1 (Den of Evil), bit 0 = completed
|
||||||
|
const flags = new Uint16Array(48)
|
||||||
|
flags[1] = 0x0001
|
||||||
|
hud.applyServerQuestFlags(flags)
|
||||||
const displayCompleted = hud.getQuestDisplayText(a1q1)
|
const displayCompleted = hud.getQuestDisplayText(a1q1)
|
||||||
expect(displayCompleted.status).toBe('completed')
|
expect(displayCompleted.status).toBe('completed')
|
||||||
expect(displayCompleted.statusTag).toBe('[已完成]')
|
expect(displayCompleted.statusTag).toBe('[已完成]')
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||||
import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.ts'
|
import { D2FontRenderer, type D2ColorCode } from '../src/client/ui/font.ts'
|
||||||
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
import { SKILLS_BY_ID } from '../src/common/data/skills-catalog.ts'
|
||||||
|
import { createDemoHotkeys } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
|
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
|
||||||
let hud: SkillHotkeysHud
|
let hud: SkillHotkeysHud
|
||||||
|
|
@ -20,7 +21,8 @@ describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Toolti
|
||||||
} as unknown as D2FontRenderer
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hud = new SkillHotkeysHud()
|
// Widget tests: explicitly seed the legacy Sorceress quickbar (the HUD itself starts neutral).
|
||||||
|
hud = createDemoHotkeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
|
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
SKILL_CLOSE_BTN_BOUNDS,
|
SKILL_CLOSE_BTN_BOUNDS,
|
||||||
SKILL_CLOSE_BTN_OFFSET_X,
|
SKILL_CLOSE_BTN_OFFSET_X,
|
||||||
} from '../src/client/ui/skill-tree-panel.ts'
|
} from '../src/client/ui/skill-tree-panel.ts'
|
||||||
|
import { createDemoSkillTree } from './fixtures/hud-demo-state.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import type { D2FontRenderer } from '../src/client/ui/font.ts'
|
import type { D2FontRenderer } from '../src/client/ui/font.ts'
|
||||||
|
|
||||||
|
|
@ -33,7 +34,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('draws authentic 64x432 tab column slice according to activeTab', () => {
|
it('draws authentic 64x432 tab column slice according to activeTab', () => {
|
||||||
const panel = new SkillTreePanel()
|
// Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
|
||||||
|
const panel = createDemoSkillTree()
|
||||||
panel.visible = true
|
panel.visible = true
|
||||||
|
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|
@ -132,7 +134,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => {
|
it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => {
|
||||||
const panel = new SkillTreePanel()
|
// Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
|
||||||
|
const panel = createDemoSkillTree()
|
||||||
panel.visible = true
|
panel.visible = true
|
||||||
panel.unspentSkillPoints = 12
|
panel.unspentSkillPoints = 12
|
||||||
|
|
||||||
|
|
@ -199,7 +202,8 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => {
|
it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => {
|
||||||
const panel = new SkillTreePanel()
|
// Explicitly seeded legacy Sorceress tree (the panel starts class-less until the server assigns it).
|
||||||
|
const panel = createDemoSkillTree()
|
||||||
panel.visible = true
|
panel.visible = true
|
||||||
|
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from '../src/client/ui/inventory.ts'
|
} from '../src/client/ui/inventory.ts'
|
||||||
import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts'
|
import { createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts'
|
||||||
import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts'
|
import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts'
|
||||||
|
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => {
|
describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => {
|
||||||
it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => {
|
it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => {
|
||||||
|
|
@ -147,7 +148,7 @@ describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => {
|
it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => {
|
||||||
const inv = new InventoryPanel()
|
const inv = createDemoInventory() // seeded starter bag (tbk / tsc)
|
||||||
inv.visible = true
|
inv.visible = true
|
||||||
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
|
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
|
||||||
expect(tbkPlacement).toBeDefined()
|
expect(tbkPlacement).toBeDefined()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts'
|
import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts'
|
||||||
import { projectNpcLabel } from '../src/client/world/act-runtime.ts'
|
import { projectNpcLabel } from '../src/client/world/act-runtime.ts'
|
||||||
|
import { createDemoHotkeys, createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||||
import {
|
import {
|
||||||
getLevelNames,
|
getLevelNames,
|
||||||
sceneNameZh,
|
sceneNameZh,
|
||||||
|
|
@ -169,6 +170,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
|
|
||||||
it('supports 4x4 belt drinking with automatic column gravity refill', () => {
|
it('supports 4x4 belt drinking with automatic column gravity refill', () => {
|
||||||
const belt = new BeltHud()
|
const belt = new BeltHud()
|
||||||
|
// The belt starts empty (server-authoritative); seed the legacy 16-potion layout explicitly.
|
||||||
|
expect(belt.countTotalPotions()).toBe(0)
|
||||||
|
belt.seedDefaultPotions()
|
||||||
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
|
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
|
||||||
|
|
||||||
// Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null
|
// Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null
|
||||||
|
|
@ -205,7 +209,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles 10x4 inventory grid pickup, placement, swap, equipment slots, and I/II weapon swap', () => {
|
it('handles 10x4 inventory grid pickup, placement, swap, equipment slots, and I/II weapon swap', () => {
|
||||||
const inv = new InventoryPanel()
|
// The panel starts empty (server-authoritative); seed the legacy starter gear explicitly.
|
||||||
|
expect(new InventoryPanel().gridItems).toEqual([])
|
||||||
|
const inv = createDemoInventory()
|
||||||
expect(INV_GRID_ORIGIN.cols).toBe(10)
|
expect(INV_GRID_ORIGIN.cols).toBe(10)
|
||||||
expect(INV_GRID_ORIGIN.rows).toBe(4)
|
expect(INV_GRID_ORIGIN.rows).toBe(4)
|
||||||
expect(Object.keys(EQUIP_SLOTS_LAYOUT)).toHaveLength(10)
|
expect(Object.keys(EQUIP_SLOTS_LAYOUT)).toHaveLength(10)
|
||||||
|
|
@ -236,26 +242,29 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
expect(inv.swapWeapons.weapon1?.code).toBe('crs')
|
expect(inv.swapWeapons.weapon1?.code).toBe('crs')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allocates character attribute points and skill points with 1.13c rules', () => {
|
it('starts with neutral (server-not-ready) character and skill state', () => {
|
||||||
const char = new CharacterSheetPanel()
|
const char = new CharacterSheetPanel()
|
||||||
const beforePts = char.attrs.statPoints
|
expect(char.classCode).toBeNull()
|
||||||
const beforeVit = char.attrs.vit
|
expect(char.attrs.statPoints).toBe(0)
|
||||||
const beforeHp = char.attrs.baseMaxHp
|
expect(char.attrs.level).toBe(0)
|
||||||
expect(char.allocateStat('vit')).toBe(true)
|
|
||||||
expect(char.attrs.statPoints).toBe(beforePts - 1)
|
|
||||||
expect(char.attrs.vit).toBe(beforeVit + 1)
|
|
||||||
expect(char.attrs.baseMaxHp).toBe(beforeHp + 2)
|
|
||||||
|
|
||||||
const tree = new SkillTreePanel()
|
const tree = new SkillTreePanel()
|
||||||
const beforeSkillPts = tree.unspentSkillPoints
|
expect(tree.characterClass).toBeNull()
|
||||||
const beforeIceBolt = tree.getHardPoints(39)
|
expect(tree.unspentSkillPoints).toBe(0)
|
||||||
expect(tree.allocateSkill(39)).toBe(true)
|
expect(tree.getSkillTree()).toEqual([])
|
||||||
expect(tree.unspentSkillPoints).toBe(beforeSkillPts - 1)
|
// Without unspent points the 1.13c allocation rule rejects the request; no local mutation API exists.
|
||||||
expect(tree.getHardPoints(39)).toBe(beforeIceBolt + 1)
|
tree.setClass('sor')
|
||||||
|
tree.characterLevel = 1
|
||||||
|
expect(tree.canAllocate(39)).toBe(false)
|
||||||
|
tree.unspentSkillPoints = 1
|
||||||
|
expect(tree.canAllocate(39)).toBe(true)
|
||||||
|
expect(tree.getHardPoints(39)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => {
|
it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => {
|
||||||
const hotkeys = new SkillHotkeysHud()
|
// No F-key bindings until seeded (server / user); seed the legacy F1..F8 layout explicitly.
|
||||||
|
expect(new SkillHotkeysHud().triggerFunctionKey('F2')).toBeNull()
|
||||||
|
const hotkeys = createDemoHotkeys()
|
||||||
expect(hotkeys.triggerFunctionKey('F2')).toEqual({ side: 'right', skillId: 59 })
|
expect(hotkeys.triggerFunctionKey('F2')).toEqual({ side: 'right', skillId: 59 })
|
||||||
expect(hotkeys.rightSkillId).toBe(59) // Blizzard
|
expect(hotkeys.rightSkillId).toBe(59) // Blizzard
|
||||||
|
|
||||||
|
|
@ -793,7 +802,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => {
|
it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => {
|
||||||
const inv = new InventoryPanel()
|
const inv = createDemoInventory() // seeded starter bag (box / tbk / isc)
|
||||||
inv.visible = true
|
inv.visible = true
|
||||||
const worldPanels = new WorldPanelsHud()
|
const worldPanels = new WorldPanelsHud()
|
||||||
let cubeOpened = 0
|
let cubeOpened = 0
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
} from '../src/client/ui/inventory.ts'
|
} from '../src/client/ui/inventory.ts'
|
||||||
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
import { WorldPanelsHud } from '../src/client/ui/world-panels.ts'
|
||||||
|
import { createDemoInventory } from './fixtures/hud-demo-state.ts'
|
||||||
|
|
||||||
function createMockTooltipCanvas() {
|
function createMockTooltipCanvas() {
|
||||||
const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = []
|
const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = []
|
||||||
|
|
@ -323,7 +324,7 @@ describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System'
|
||||||
|
|
||||||
describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => {
|
describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => {
|
||||||
it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => {
|
it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => {
|
||||||
const inv = new InventoryPanel()
|
const inv = createDemoInventory() // seeded level-85 Sorceress requirement context
|
||||||
inv.visible = true
|
inv.visible = true
|
||||||
delete inv.equipped.helm
|
delete inv.equipped.helm
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
CUBE_TRANSMUTE_BTN_BOUNDS,
|
CUBE_TRANSMUTE_BTN_BOUNDS,
|
||||||
CUBE_CLOSE_BTN_BOUNDS,
|
CUBE_CLOSE_BTN_BOUNDS,
|
||||||
} from '../src/client/ui/world-panels.ts'
|
} from '../src/client/ui/world-panels.ts'
|
||||||
|
import { createDemoWorldPanels } from './fixtures/hud-demo-state.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
|
||||||
import { D2FontRenderer } from '../src/client/ui/font.ts'
|
import { D2FontRenderer } from '../src/client/ui/font.ts'
|
||||||
import { encodeIndexedPng } from '../src/baker/io/png.ts'
|
import { encodeIndexedPng } from '../src/baker/io/png.ts'
|
||||||
|
|
@ -57,7 +58,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
|
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
|
||||||
const hud = new WorldPanelsHud()
|
// Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
|
||||||
|
const hud = createDemoWorldPanels()
|
||||||
const font = new D2FontRenderer()
|
const font = new D2FontRenderer()
|
||||||
|
|
||||||
const drawImageCalls: any[] = []
|
const drawImageCalls: any[] = []
|
||||||
|
|
@ -137,7 +139,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => {
|
it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => {
|
||||||
const hud = new WorldPanelsHud()
|
// Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
|
||||||
|
const hud = createDemoWorldPanels()
|
||||||
const font = new D2FontRenderer()
|
const font = new D2FontRenderer()
|
||||||
|
|
||||||
const mockTabsImg = {
|
const mockTabsImg = {
|
||||||
|
|
@ -225,7 +228,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
|
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
|
||||||
const hud = new WorldPanelsHud()
|
// Explicitly seed the legacy all-waypoints-unlocked demo state (the HUD starts with all waypoints locked).
|
||||||
|
const hud = createDemoWorldPanels()
|
||||||
const onWaypointTeleport = vi.fn()
|
const onWaypointTeleport = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue