Merge branch 'fix/client-hardcoded-values'

This commit is contained in:
troytt 2026-10-01 05:11:19 +00:00
commit 4ba79b4c82
51 changed files with 1565 additions and 721 deletions

View File

@ -253,8 +253,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
stageTicks: 0,
globalTick: 0,
accountName: 'Wanderer',
accountPassword: 'password',
// No prefilled credentials: the user (or a configured bot) must type them.
accountName: '',
accountPassword: '',
loginError: null,
campfireClasses,
@ -338,7 +339,8 @@ export function cancelConnecting(state: BnetFlowState): void {
export function submitBattleNetLogin(state: BnetFlowState): boolean {
if (state.stage !== 'bnet_login') return false
if (!state.accountName.trim()) {
state.accountName = 'Wanderer'
state.loginError = 'Account name required'
return false
}
state.loginError = null
state.stageTicks = 0
@ -589,7 +591,8 @@ export function charSummaryToRosterCharacter(summary: {
readonly hardcore: boolean
readonly ladder: boolean
}): RosterCharacter {
const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass] ?? 'so'
const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass]
if (!classId) throw new Error(`MCP character "${summary.name}" has unknown class id ${summary.charClass}`)
const spec = CAMPFIRE_CLASS_SPECS[classId]
return {
id: `${spec.heroToken}-${summary.name}`,

View File

@ -634,18 +634,9 @@ export class CommandMapper {
if (event.key === '1' || event.key === '2' || event.key === '3' || event.key === '4') {
const slotIndex = Number(event.key) - 1
event.preventDefault?.()
if (this.hudModel) {
this.hudModel.useBeltSlot(slotIndex, Boolean(event.shiftKey))
} else {
this.dispatchRateLimited(
{
type: 'BeltUse',
itemId: slotIndex + 1,
shift: Boolean(event.shiftKey),
},
nowMs,
)
}
// BeltUse needs the real server item id of the potion, which only HudModel (ClientWorld) knows.
if (!this.hudModel) return false
this.hudModel.useBeltSlot(slotIndex, Boolean(event.shiftKey))
return true
}

View File

@ -209,11 +209,12 @@ async function bootPlayApp(): Promise<PlayAppController> {
await hudManager.load('/ui')
bootProgress.setStageFloor(25, 'manifest.json')
let mapService: MapService | null = null
// Fail fast: without the packed act tiles there is no 1.13c map (automap, collision, picking).
let mapService: MapService
try {
mapService = await MapService.fromPackedUrl('/act-packs/tiles')
} catch {
mapService = null
} catch (err) {
throw new Error(`MapService: failed to load /act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`)
}
bootProgress.setStageFloor(45, 'drlg-bundle.bin')
@ -490,9 +491,11 @@ async function bootPlayApp(): Promise<PlayAppController> {
dz: 5,
as: 6,
}
const classId = st.selectedClass === null ? undefined : classMap[st.selectedClass]
if (classId === undefined) throw new Error(`Unknown selected class ${String(st.selectedClass)}`)
const chars = await session.createCharacter({
name,
classId: classMap[st.selectedClass] ?? 1,
classId,
hardcore: st.isHardcore,
expansion: st.isExpansion,
ladder: st.isLadder,

View File

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

View File

@ -0,0 +1,85 @@
/**
* Character sheet derived values (attack rating, defense, resistances) computed from 1.13c tables.
*
* The 1.13c server does not send these totals: D2Client derives them from the unit StatList and
* `CharStats.txt` / `DifficultyLevels.txt`. Inputs here are the server StatList (absent stat = 0),
* the class `ToHitFactor` (`CANONICAL_CHARSTATS`) and the difficulty `ResistPenalty`.
*
* Formulas (1.13c):
* - Attack rating = (Dexterity - 7) * 5 + ToHitFactor + stat 19 `tohit`, scaled by stat 119 `item_tohit_percent`.
* - Defense = floor(Dexterity / 4) + stat 31 `armorclass`.
* - Resistance = min(75 + stat `max*resist`, stat `*resist` + ResistPenalty) for fire/light/cold/poison
* (stats 39/41/43/45, max stats 40/42/44/46).
*
* Equipment item stats are only included once they are aggregated into the self StatList.
*/
import { RAW_DIFFICULTY_LEVELS } from '../../common/data/canonical-drop-data.ts'
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../../common/units/classes.ts'
export interface StatReader {
get(statId: number): number
}
export interface CharacterSheetDerived {
readonly attackRating: number
readonly defense: number
readonly fireRes: number
readonly coldRes: number
readonly lightRes: number
readonly poisRes: number
}
const STAT_DEXTERITY = 2
const STAT_TOHIT = 19
const STAT_ARMORCLASS = 31
const STAT_ITEM_TOHIT_PERCENT = 119
const STAT_FIRERESIST = 39
const STAT_MAXFIRERESIST = 40
const STAT_LIGHTRESIST = 41
const STAT_MAXLIGHTRESIST = 42
const STAT_COLDRESIST = 43
const STAT_MAXCOLDRESIST = 44
const STAT_POISONRESIST = 45
const STAT_MAXPOISONRESIST = 46
const BASE_MAX_RESIST = 75
function parseResistPenalties(raw: string): readonly number[] {
const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== '')
const header = lines[0]!.split('\t')
const idx = header.indexOf('ResistPenalty')
if (idx < 0) throw new Error('DifficultyLevels.txt: missing ResistPenalty column')
const out = lines.slice(1).map((l) => Number(l.split('\t')[idx]))
if (out.length !== 3 || out.some((v) => !Number.isFinite(v))) {
throw new Error('DifficultyLevels.txt: expected 3 rows with numeric ResistPenalty')
}
return out
}
/** `DifficultyLevels.txt` `ResistPenalty` per difficulty (Normal / Nightmare / Hell). */
export const RESIST_PENALTY_BY_DIFFICULTY: readonly number[] = parseResistPenalties(RAW_DIFFICULTY_LEVELS)
function resist(stats: StatReader, statId: number, maxStatId: number, penalty: number): number {
return Math.min(BASE_MAX_RESIST + stats.get(maxStatId), stats.get(statId) + penalty)
}
export function computeCharacterSheetDerived(
classCode: CharacterClassCode,
stats: StatReader,
difficulty: 0 | 1 | 2,
): CharacterSheetDerived {
const charStats = CANONICAL_CHARSTATS[classCode]
if (!charStats) throw new Error(`computeCharacterSheetDerived: no CharStats row for class ${classCode}`)
const penalty = RESIST_PENALTY_BY_DIFFICULTY[difficulty]
if (penalty === undefined) throw new Error(`computeCharacterSheetDerived: invalid difficulty ${difficulty}`)
const dex = stats.get(STAT_DEXTERITY)
const baseAr = (dex - 7) * 5 + charStats.toHitFactor + stats.get(STAT_TOHIT)
const attackRating = Math.floor((baseAr * (100 + stats.get(STAT_ITEM_TOHIT_PERCENT))) / 100)
return {
attackRating,
defense: Math.floor(dex / 4) + stats.get(STAT_ARMORCLASS),
fireRes: resist(stats, STAT_FIRERESIST, STAT_MAXFIRERESIST, penalty),
coldRes: resist(stats, STAT_COLDRESIST, STAT_MAXCOLDRESIST, penalty),
lightRes: resist(stats, STAT_LIGHTRESIST, STAT_MAXLIGHTRESIST, penalty),
poisRes: resist(stats, STAT_POISONRESIST, STAT_MAXPOISONRESIST, penalty),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { getMissileTxtData } from '../../common/skills/missiles-data.ts'
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts'
import { subTileToWorldOrtho } from './viewport-profile.ts'
@ -112,39 +112,17 @@ export type SceneEntityDrawable =
| SceneOverlayDrawable
/**
* Canonical 1.13c `Missiles.txt` ID -> internal missile key lookup for D2GS missile units.
* 1.13c `Missiles.txt` row id (D2GS missile unit classId) -> missile key, derived from
* `CANONICAL_113C_MISSILES` (Missiles.txt rows only; client visual aliases excluded).
* Ids without art data are absent: callers must treat them as unknown, never substitute another missile.
*/
export const MISSILE_ID_TO_KEY: Readonly<Record<number, string>> = {
1: 'arrow',
2: 'javelin',
6: 'magicarrow',
9: 'firearrow',
11: 'coldarrow',
14: 'guidedarrow',
28: 'firebolt',
29: 'fireball',
35: 'chargedbolt',
41: 'lightning',
44: 'nova',
49: 'icebolt',
53: 'iceblast',
55: 'glacialspike',
56: 'meteor',
59: 'blizzard',
63: 'chainlightning',
64: 'frostnova',
83: 'poisonnova',
92: 'bonespear',
93: 'bonespirit',
97: 'teeth',
101: 'holybolt',
102: 'blessedhammer',
165: 'frozenorb',
232: 'firestorm',
235: 'moltenboulder',
244: 'twister',
245: 'tornado',
}
export const MISSILE_ID_TO_KEY: Readonly<Record<number, string>> = Object.freeze(
Object.fromEntries(
Object.entries(CANONICAL_113C_MISSILES)
.filter(([, m]) => m.clientAlias !== true)
.map(([key, m]) => [m.id, key] as const),
),
)
export interface OverlayMeta {
readonly overlayId: number
@ -186,11 +164,14 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
return unit.token.trim()
}
if (unit.kind === 'player') {
return PLAYER_CLASS_TOKENS[unit.classId] ?? 'SO'
const token = PLAYER_CLASS_TOKENS[unit.classId]
if (!token) throw new Error(`resolveUnitToken: unknown player class id ${unit.classId}`)
return token
}
if (unit.kind === 'missile') {
const key = MISSILE_ID_TO_KEY[unit.classId] ?? 'firebolt'
return getMissileTxtData(key).celFile
const key = MISSILE_ID_TO_KEY[unit.classId]
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
}
if (unit.kind === 'object') {
return `OBJ_${unit.classId}`
@ -204,11 +185,12 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
export function resolveMissileMetadata(
classId: number,
explicitToken?: string,
): { readonly key: string; readonly entry: MissileTxtEntry } {
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
const key =
explicitToken && explicitToken.trim() !== ''
? explicitToken.trim().toLowerCase()
: (MISSILE_ID_TO_KEY[classId] ?? 'firebolt')
: MISSILE_ID_TO_KEY[classId]
if (!key) return null
return {
key,
entry: getMissileTxtData(key),
@ -271,7 +253,9 @@ export function buildSceneDrawables(
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
if (unit.kind === 'missile') {
const { key, entry } = resolveMissileMetadata(unit.classId, unit.token)
const resolved = resolveMissileMetadata(unit.classId, unit.token)
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
const { key, entry } = resolved
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
const dir = computeDirection8Or16(
unit.x,

View File

@ -70,6 +70,9 @@ export interface PortalState {
readonly flags?: number | undefined
}
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff
export class ClientWorld {
act = 0
mapSeed = 0
@ -90,6 +93,8 @@ export class ClientWorld {
private readonly _containers = new ClientInventoryManager()
private readonly _revealedRooms = new Set<string>()
private readonly _waypoints = new Set<number>()
/** Unit id of the waypoint object whose menu was last opened (`0x63 WaypointMenu`); `null` when none. */
private _waypointUnitId: number | null = null
private _quests = new Uint16Array(48)
private readonly _party = new Map<number, PartyMemberState>()
private readonly _trade: TradeSessionState = {
@ -143,6 +148,11 @@ export class ClientWorld {
return this._waypoints
}
/** Waypoint object unit id from the last `0x63 WaypointMenu`, required by `0x49 WaypointGo`. */
get waypointUnitId(): number | null {
return this._waypointUnitId
}
get quests(): Uint16Array {
return this._quests
}
@ -457,6 +467,10 @@ export class ClientWorld {
break
case 'StatSet': {
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
// (0x53 act seed = 0x5300, 0x8000|op / 0x9000|op opaque envelopes) as pseudo StatSet events;
// those must never enter a unit StatList.
if (ev.stat < 0 || ev.stat > MAX_D2_STAT_ID) break
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
if (this.selfId !== 0) {
@ -579,6 +593,7 @@ export class ClientWorld {
case 'WaypointMenu': {
this._waypoints.clear()
this._waypointUnitId = ev.unitId
for (const wp of ev.open) {
this._waypoints.add(wp)
}
@ -621,9 +636,13 @@ export class ClientWorld {
if (ev.partnerId !== undefined) {
this._trade.partnerId = ev.partnerId
}
if (ev.gold !== undefined) {
this._trade.partnerGold = ev.gold
this._trade.selfGold = ev.gold
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
if ((ev.action & 0xff) !== 0) {
this._trade.selfGold = ev.gold
} else {
this._trade.partnerGold = ev.gold
}
}
// Trade cancel / close actions
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
@ -736,6 +755,7 @@ export class ClientWorld {
this._containers.clear()
this._revealedRooms.clear()
this._waypoints.clear()
this._waypointUnitId = null
this._quests = new Uint16Array(48)
this._party.clear()
this._trade.active = false

View File

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

View File

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

View File

@ -188,6 +188,11 @@ export const FACING_VECTORS: readonly (readonly [number, number])[] = [
export interface MissileTxtData {
readonly name: string
readonly id: number
/**
* `true` for client-side visual aliases that are NOT rows of 1.13c Missiles.txt (their `id` points at the
* real row they render for). Aliases are excluded from the wire id -> missile key lookup.
*/
readonly clientAlias?: boolean | undefined
readonly vel: number
readonly maxVel: number
readonly range: number
@ -261,6 +266,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
lightning: {
name: 'lightning',
clientAlias: true,
id: 98,
vel: 30,
maxVel: 30,
@ -326,6 +332,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
thunderstorm: {
name: 'thunderstorm',
clientAlias: true,
id: 166,
vel: 2,
maxVel: 2,
@ -370,6 +377,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
electricnova: {
name: 'electricnova',
clientAlias: true,
id: 90,
vel: 24,
maxVel: 24,
@ -584,6 +592,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
freezeexplode: {
name: 'freezeexplode',
clientAlias: true,
id: 88,
vel: 0,
maxVel: 0,
@ -626,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
glacialspikeejecta: {
name: 'glacialspikeejecta',
clientAlias: true,
id: 89,
vel: 0,
maxVel: 0,
@ -920,6 +930,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
hydrafire: {
name: 'hydrafire',
clientAlias: true,
id: 247,
vel: 16,
maxVel: 16,
@ -941,7 +952,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
firewallmaker: {
name: 'firewallmaker',
id: 82,
id: 68,
vel: 0,
maxVel: 0,
range: 1,
@ -983,6 +994,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
groundfirebig: {
name: 'groundfirebig',
clientAlias: true,
id: 83,
vel: 0,
maxVel: 0,
@ -1004,7 +1016,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
blaze: {
name: 'blaze',
id: 80,
id: 67,
vel: 0,
maxVel: 0,
range: 90,
@ -1025,7 +1037,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
infernoflame1: {
name: 'infernoflame1',
id: 72,
id: 60,
vel: 12,
maxVel: 12,
range: 16,
@ -1046,7 +1058,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
infernoflame2: {
name: 'infernoflame2',
id: 73,
id: 61,
vel: 12,
maxVel: 12,
range: 16,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
RESIST_PENALTY_BY_DIFFICULTY,
computeCharacterSheetDerived,
} from '../../src/client/ui-model/derived-stats.ts'
function statsOf(entries: Record<number, number>) {
return { get: (id: number) => entries[id] ?? 0 }
}
describe('character sheet derived values (1.13c tables)', () => {
it('reads ResistPenalty from DifficultyLevels.txt', () => {
expect(RESIST_PENALTY_BY_DIFFICULTY).toEqual([0, -40, -100])
})
it('level-1 Assassin (dex 20, no bonuses)', () => {
const d = computeCharacterSheetDerived('ass', statsOf({ 2: 20 }), 0)
expect(d.attackRating).toBe((20 - 7) * 5 + 15)
expect(d.defense).toBe(5)
expect([d.fireRes, d.coldRes, d.lightRes, d.poisRes]).toEqual([0, 0, 0, 0])
})
it('applies difficulty penalty and the 75 + max-resist cap', () => {
const d = computeCharacterSheetDerived('sor', statsOf({ 2: 25, 39: 200, 40: 5, 43: 30 }), 2)
expect(d.fireRes).toBe(80)
expect(d.coldRes).toBe(-70)
expect(d.attackRating).toBe((25 - 7) * 5 - 15)
})
})

View File

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

View File

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

View File

@ -0,0 +1,57 @@
/**
* Audit regression: `MISSILE_ID_TO_KEY` must match 1.13c Missiles.txt (row id <-> Missile name),
* and unknown ids must not borrow another missile's art.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { MISSILE_ID_TO_KEY, resolveMissileMetadata, resolveUnitToken } from '../../src/client/view/unit-drawables.ts'
function loadMissilesTxtIdToName(): Map<number, string> {
const text = readFileSync(resolve(__dirname, '../../samples/fixtures/data/global/excel/Missiles.txt'), 'utf8')
const lines = text.split(/\r?\n/)
const header = lines[0]!.split('\t')
const nameIdx = header.indexOf('Missile')
const idIdx = header.indexOf('Id')
const out = new Map<number, string>()
for (const line of lines.slice(1)) {
const cols = line.split('\t')
const name = (cols[nameIdx] ?? '').trim()
const id = Number(cols[idIdx])
if (name && Number.isInteger(id)) out.set(id, name.toLowerCase())
}
return out
}
describe('MISSILE_ID_TO_KEY vs Missiles.txt', () => {
const txt = loadMissilesTxtIdToName()
it('every mapped id names the same Missiles.txt row', () => {
const entries = Object.entries(MISSILE_ID_TO_KEY)
expect(entries.length).toBeGreaterThan(40)
for (const [id, key] of entries) {
expect(txt.get(Number(id)), `id ${id}`).toBe(key)
}
})
it('pins well-known 1.13c ids', () => {
expect(MISSILE_ID_TO_KEY[0]).toBe('arrow')
expect(MISSILE_ID_TO_KEY[56]).toBe('chargedbolt')
expect(MISSILE_ID_TO_KEY[58]).toBe('firebolt')
expect(MISSILE_ID_TO_KEY[59]).toBe('icebolt')
expect(MISSILE_ID_TO_KEY[62]).toBe('fireball')
expect(MISSILE_ID_TO_KEY[90]).toBe('nova')
expect(MISSILE_ID_TO_KEY[92]).toBe('blessedhammer')
expect(MISSILE_ID_TO_KEY[100]).toBe('meteor')
expect(MISSILE_ID_TO_KEY[260]).toBe('frozenorb')
})
it('unknown missile ids are explicit (no firebolt / SO substitution)', () => {
expect(resolveMissileMetadata(9999)).toBeNull()
expect(
resolveUnitToken({ id: 1, kind: 'missile', classId: 9999, x: 0, y: 0 } as any),
).toBe('MIS_9999')
expect(() => resolveUnitToken({ id: 1, kind: 'player', classId: 7, x: 0, y: 0 } as any)).toThrow()
expect(resolveUnitToken({ id: 1, kind: 'player', classId: 6, x: 0, y: 0 } as any)).toBe('AI')
})
})

View File

@ -396,7 +396,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
{
kind: 'missile',
id: 200,
classId: 29, // FireBall (Trans: 1 additive)
classId: 62, // Missiles.txt row 62 = fireball (Trans: 1 additive)
x: 102,
y: 100,
targetX: 110,
@ -405,7 +405,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
{
kind: 'missile',
id: 201,
classId: 1, // Arrow (Trans: 0 normal)
classId: 0, // Missiles.txt row 0 = arrow (Trans: 0 normal)
x: 101,
y: 101,
},

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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