diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index 7bbded9..56503de 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -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}`, diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 96ccdcd..2572867 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -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 } diff --git a/src/client/main.ts b/src/client/main.ts index f15619f..08cb3ed 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -209,11 +209,12 @@ async function bootPlayApp(): Promise { 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 { 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, diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 66e3ba2..4af58e7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -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, diff --git a/src/client/ui-model/derived-stats.ts b/src/client/ui-model/derived-stats.ts new file mode 100644 index 0000000..c659977 --- /dev/null +++ b/src/client/ui-model/derived-stats.ts @@ -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), + } +} diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index f94817d..2a59fa4 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -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> = { 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)[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 +} diff --git a/src/client/ui/character-sheet.ts b/src/client/ui/character-sheet.ts index fa6536c..159584e 100644 --- a/src/client/ui/character-sheet.ts +++ b/src/client/ui/character-sheet.ts @@ -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 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 } } diff --git a/src/client/ui/hotkeys.ts b/src/client/ui/hotkeys.ts index 320b938..b5da79a 100644 --- a/src/client/ui/hotkeys.ts +++ b/src/client/ui/hotkeys.ts @@ -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([ - ['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() - 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 } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 6a7a4da..11e8c8b 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -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() 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, diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index 6927e7a..218e1b1 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -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> & { 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 { diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index 0cb1d7d..ef2fb20 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -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([ - [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() + /** Per-skill bonus levels reported by the server (`0x21` bonus byte: +all/+class/+tab/+single). */ + readonly bonusLevels = new Map() hoveredSkill: SkillNodeDef | null = null private tabBgs = new Map() private skillIcons = new Map() - 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 = {} for (const [id, pts] of this.hardPoints.entries()) { if (pts > 0) hardPoints[id] = pts } + const specificSoftPoints: Record = {} + 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, diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index ff0b16a..dce28c7 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -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> = { 1: 1, 2: 9, 3: 17, 4: 25, 5: 35 } + +/** + * Quest-flag word index for a canonical quest. The internal quest number is the `qstsaq` string key + * (e.g. "The Search for Cain" = `qstsa1q4` -> word 4, "Terror's End" = `qstsa4q2` -> word 26). + */ +export function questFlagWordIndex(quest: Pick): 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> = { 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> = { 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): 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() diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index dd6ba03..4c6b344 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -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> = { - 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> = 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, diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index f20f6b1..5927688 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -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() private readonly _waypoints = new Set() + /** 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() 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 diff --git a/src/client/world/self.ts b/src/client/world/self.ts index 0d873c3..948f1e5 100644 --- a/src/client/world/self.ts +++ b/src/client/world/self.ts @@ -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)) +} diff --git a/src/common/items/belt-model.ts b/src/common/items/belt-model.ts index c58da62..6c3e69b 100644 --- a/src/common/items/belt-model.ts +++ b/src/common/items/belt-model.ts @@ -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++) { diff --git a/src/common/skills/missiles-data.ts b/src/common/skills/missiles-data.ts index b452d2f..0a2eb69 100644 --- a/src/common/skills/missiles-data.ts +++ b/src/common/skills/missiles-data.ts @@ -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> = }, lightning: { name: 'lightning', + clientAlias: true, id: 98, vel: 30, maxVel: 30, @@ -326,6 +332,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, thunderstorm: { name: 'thunderstorm', + clientAlias: true, id: 166, vel: 2, maxVel: 2, @@ -370,6 +377,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, electricnova: { name: 'electricnova', + clientAlias: true, id: 90, vel: 24, maxVel: 24, @@ -584,6 +592,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, freezeexplode: { name: 'freezeexplode', + clientAlias: true, id: 88, vel: 0, maxVel: 0, @@ -626,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, glacialspikeejecta: { name: 'glacialspikeejecta', + clientAlias: true, id: 89, vel: 0, maxVel: 0, @@ -920,6 +930,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, hydrafire: { name: 'hydrafire', + clientAlias: true, id: 247, vel: 16, maxVel: 16, @@ -941,7 +952,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, firewallmaker: { name: 'firewallmaker', - id: 82, + id: 68, vel: 0, maxVel: 0, range: 1, @@ -983,6 +994,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, groundfirebig: { name: 'groundfirebig', + clientAlias: true, id: 83, vel: 0, maxVel: 0, @@ -1004,7 +1016,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, blaze: { name: 'blaze', - id: 80, + id: 67, vel: 0, maxVel: 0, range: 90, @@ -1025,7 +1037,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, infernoflame1: { name: 'infernoflame1', - id: 72, + id: 60, vel: 12, maxVel: 12, range: 16, @@ -1046,7 +1058,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, infernoflame2: { name: 'infernoflame2', - id: 73, + id: 61, vel: 12, maxVel: 12, range: 16, diff --git a/src/netproto/d2gs/c2s/items.ts b/src/netproto/d2gs/c2s/items.ts index 3ca36dc..10e4ebe 100644 --- a/src/netproto/d2gs/c2s/items.ts +++ b/src/netproto/d2gs/c2s/items.ts @@ -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) diff --git a/src/netproto/d2gs/registry.ts b/src/netproto/d2gs/registry.ts index b0befdf..1c743fe 100644 --- a/src/netproto/d2gs/registry.ts +++ b/src/netproto/d2gs/registry.ts @@ -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': diff --git a/src/netproto/d2gs/s2c/stats.ts b/src/netproto/d2gs/s2c/stats.ts index f7388ab..6d169b1 100644 --- a/src/netproto/d2gs/s2c/stats.ts +++ b/src/netproto/d2gs/s2c/stats.ts @@ -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 { diff --git a/src/netproto/domain/client-command.ts b/src/netproto/domain/client-command.ts index b0f934c..80c65a5 100644 --- a/src/netproto/domain/client-command.ts +++ b/src/netproto/domain/client-command.ts @@ -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' diff --git a/tests/attack-skill.test.ts b/tests/attack-skill.test.ts index 1f13fe3..f105bc4 100644 --- a/tests/attack-skill.test.ts +++ b/tests/attack-skill.test.ts @@ -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 diff --git a/tests/challenger-m6-attack-bindings.test.ts b/tests/challenger-m6-attack-bindings.test.ts index cb7b4f5..f180f6d 100644 --- a/tests/challenger-m6-attack-bindings.test.ts +++ b/tests/challenger-m6-attack-bindings.test.ts @@ -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', () => { diff --git a/tests/class-switching.test.ts b/tests/class-switching.test.ts index 649e973..528b258 100644 --- a/tests/class-switching.test.ts +++ b/tests/class-switching.test.ts @@ -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') diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index ce4ef7d..0c5e11f 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -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', diff --git a/tests/client/derived-stats.test.ts b/tests/client/derived-stats.test.ts new file mode 100644 index 0000000..707f0a6 --- /dev/null +++ b/tests/client/derived-stats.test.ts @@ -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) { + 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) + }) +}) diff --git a/tests/client/hud-server-authority.test.ts b/tests/client/hud-server-authority.test.ts new file mode 100644 index 0000000..2103683 --- /dev/null +++ b/tests/client/hud-server-authority.test.ts @@ -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) + }) +}) diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index bbb95c1..549f383 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -24,6 +24,58 @@ import { } from '../../src/netproto/index.ts' import { HudManager } from '../../src/client/ui/hud-manager.ts' +type ItemEventItem = Extract['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, diff --git a/tests/client/missile-id-map.test.ts b/tests/client/missile-id-map.test.ts new file mode 100644 index 0000000..4e6f505 --- /dev/null +++ b/tests/client/missile-id-map.test.ts @@ -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 { + 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() + 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') + }) +}) diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index c4a3b91..68df896 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -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, }, diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index a73263d..e44274f 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -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') diff --git a/tests/e2e-drop-parity/tier1-feature-coverage.test.ts b/tests/e2e-drop-parity/tier1-feature-coverage.test.ts index 71adea3..e33a93f 100644 --- a/tests/e2e-drop-parity/tier1-feature-coverage.test.ts +++ b/tests/e2e-drop-parity/tier1-feature-coverage.test.ts @@ -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) diff --git a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts index b05ae12..c373859 100644 --- a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts +++ b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts @@ -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 diff --git a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts index c57658e..4a73c4f 100644 --- a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts @@ -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) diff --git a/tests/fixtures/hud-demo-state.ts b/tests/fixtures/hud-demo-state.ts new file mode 100644 index 0000000..1559586 --- /dev/null +++ b/tests/fixtures/hud-demo-state.ts @@ -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 = 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(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(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()) +} diff --git a/tests/glacial-spike-quickbar.test.ts b/tests/glacial-spike-quickbar.test.ts index e1d2c97..c88fa13 100644 --- a/tests/glacial-spike-quickbar.test.ts +++ b/tests/glacial-spike-quickbar.test.ts @@ -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) }) }) }) diff --git a/tests/ground-items-pickup.test.ts b/tests/ground-items-pickup.test.ts index 0047c99..662df09 100644 --- a/tests/ground-items-pickup.test.ts +++ b/tests/ground-items-pickup.test.ts @@ -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 } diff --git a/tests/hotkeys.test.ts b/tests/hotkeys.test.ts index fc67ce4..8fe4cfb 100644 --- a/tests/hotkeys.test.ts +++ b/tests/hotkeys.test.ts @@ -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() diff --git a/tests/input-controls.test.ts b/tests/input-controls.test.ts index 0abbd5b..7083b4b 100644 --- a/tests/input-controls.test.ts +++ b/tests/input-controls.test.ts @@ -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 diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 82a658e..423bc08 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -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 diff --git a/tests/item-tooltip-color-dye.test.ts b/tests/item-tooltip-color-dye.test.ts index ff5be6c..2d95fdd 100644 --- a/tests/item-tooltip-color-dye.test.ts +++ b/tests/item-tooltip-color-dye.test.ts @@ -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 diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 9e9e16a..3551a17 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -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] diff --git a/tests/netproto/drop-gold.test.ts b/tests/netproto/drop-gold.test.ts new file mode 100644 index 0000000..5737f09 --- /dev/null +++ b/tests/netproto/drop-gold.test.ts @@ -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]) + }) +}) diff --git a/tests/p0-522-ground-items.test.ts b/tests/p0-522-ground-items.test.ts index a5944c9..7a14f17 100644 --- a/tests/p0-522-ground-items.test.ts +++ b/tests/p0-522-ground-items.test.ts @@ -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') diff --git a/tests/quest-tbl.test.ts b/tests/quest-tbl.test.ts index da94896..67d4c0b 100644 --- a/tests/quest-tbl.test.ts +++ b/tests/quest-tbl.test.ts @@ -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('[已完成]') diff --git a/tests/skill-quickbar-tooltip.test.ts b/tests/skill-quickbar-tooltip.test.ts index d5a3de1..ddc79a5 100644 --- a/tests/skill-quickbar-tooltip.test.ts +++ b/tests/skill-quickbar-tooltip.test.ts @@ -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)', () => { diff --git a/tests/skill-tree-panel.test.ts b/tests/skill-tree-panel.test.ts index 9a4b4d3..6f5494a 100644 --- a/tests/skill-tree-panel.test.ts +++ b/tests/skill-tree-panel.test.ts @@ -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 = { diff --git a/tests/town-portal.test.ts b/tests/town-portal.test.ts index 6386564..c7d39c0 100644 --- a/tests/town-portal.test.ts +++ b/tests/town-portal.test.ts @@ -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() diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index ad4dec1..a98103f 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -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 diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts index f5abe9b..df469c7 100644 --- a/tests/unidentified-drops-identify.test.ts +++ b/tests/unidentified-drops-identify.test.ts @@ -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 diff --git a/tests/world-panels.test.ts b/tests/world-panels.test.ts index 4d4abdd..cf48e9b 100644 --- a/tests/world-panels.test.ts +++ b/tests/world-panels.test.ts @@ -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()