fix(state): unify player HP/MP/gold/inventory single source of truth in GameEngine (#520)

Delegate HUD player state reads and mutations to GameEngine as the single
authoritative owner of HP, MP, gold, inventory, belt, and equipment state.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:47 +00:00
parent 995f594fe7
commit 7367f2a36a
9 changed files with 763 additions and 58 deletions

View File

@ -65,9 +65,17 @@ export class StateBus {
readonly statList: UnitStatList readonly statList: UnitStatList
private readonly entries: ActiveStateEntry[] = [] private readonly entries: ActiveStateEntry[] = []
constructor(statList: UnitStatList, registry?: D2DataRegistry) { constructor(
this.statList = statList statListOrRegistry: UnitStatList | D2DataRegistry,
this.registry = registry ?? statList.registry registryOrStatList?: D2DataRegistry | UnitStatList,
) {
if (statListOrRegistry instanceof UnitStatList) {
this.statList = statListOrRegistry
this.registry = (registryOrStatList as D2DataRegistry | undefined) ?? statListOrRegistry.registry
} else {
this.statList = registryOrStatList as UnitStatList
this.registry = statListOrRegistry
}
} }
/** /**

View File

@ -97,6 +97,81 @@ export interface MiscBase extends ItemBase {
readonly quest: number readonly quest: number
readonly questDiffCheck: boolean readonly questDiffCheck: boolean
readonly vendorColumns: Readonly<Record<number, VendorItemColumns>> readonly vendorColumns: Readonly<Record<number, VendorItemColumns>>
readonly stat1?: string | undefined
readonly calc1?: number | undefined
readonly stat2?: string | undefined
readonly calc2?: number | undefined
readonly len?: number | undefined
readonly state?: string | undefined
}
export interface PotionSpec {
readonly code: string
readonly stat1: string
readonly calc1: number
readonly stat2?: string | undefined
readonly calc2?: number | undefined
readonly len: number
readonly state?: string | undefined
readonly isPct?: boolean | undefined
readonly hpAmount?: number | undefined
readonly manaAmount?: number | undefined
readonly staminaRestorePct?: number | undefined
readonly coldResistBonus?: number | undefined
readonly poisonResistBonus?: number | undefined
readonly durationTicks: number
}
/**
* Canonical Diablo II 1.13c Misc.txt potion effect table (`0x6FC4A6C0`).
*/
export const CANONICAL_POTION_SPECS: Readonly<Record<string, PotionSpec>> = {
hp1: { code: 'hp1', stat1: 'hitpoints', calc1: 30, len: 150, hpAmount: 30, durationTicks: 150 },
hp2: { code: 'hp2', stat1: 'hitpoints', calc1: 60, len: 150, hpAmount: 60, durationTicks: 150 },
hp3: { code: 'hp3', stat1: 'hitpoints', calc1: 100, len: 175, hpAmount: 100, durationTicks: 175 },
hp4: { code: 'hp4', stat1: 'hitpoints', calc1: 180, len: 175, hpAmount: 180, durationTicks: 175 },
hp5: { code: 'hp5', stat1: 'hitpoints', calc1: 320, len: 250, hpAmount: 320, durationTicks: 250 },
mp1: { code: 'mp1', stat1: 'mana', calc1: 20, len: 125, manaAmount: 20, durationTicks: 125 },
mp2: { code: 'mp2', stat1: 'mana', calc1: 40, len: 125, manaAmount: 40, durationTicks: 125 },
mp3: { code: 'mp3', stat1: 'mana', calc1: 80, len: 125, manaAmount: 80, durationTicks: 125 },
mp4: { code: 'mp4', stat1: 'mana', calc1: 150, len: 125, manaAmount: 150, durationTicks: 125 },
mp5: { code: 'mp5', stat1: 'mana', calc1: 250, len: 125, manaAmount: 250, durationTicks: 125 },
rvs: { code: 'rvs', stat1: 'hitpoints', calc1: 35, stat2: 'mana', calc2: 35, len: 0, isPct: true, hpAmount: 35, manaAmount: 35, durationTicks: 0 },
rvl: { code: 'rvl', stat1: 'hitpoints', calc1: 100, stat2: 'mana', calc2: 100, len: 0, isPct: true, hpAmount: 100, manaAmount: 100, durationTicks: 0 },
vps: { code: 'vps', stat1: 'stamdrain', calc1: 100, len: 750, state: 'staminapot', staminaRestorePct: 100, durationTicks: 750 },
wms: { code: 'wms', stat1: 'coldresist', calc1: 50, stat2: 'maxcoldresist', calc2: 10, len: 750, state: 'thawing', coldResistBonus: 50, durationTicks: 750 },
yps: { code: 'yps', stat1: 'poisonresist', calc1: 50, stat2: 'maxpoisonresist', calc2: 10, len: 750, state: 'antidote', poisonResistBonus: 50, durationTicks: 750 },
}
/**
* Resolve a potion's 1.13c effect parameters from MiscTable (if provided) or CANONICAL_POTION_SPECS.
*/
export function resolvePotionSpec(
codeOrItem: string | { readonly code?: string; readonly base?: { readonly id?: string } } | null | undefined,
miscTable?: MiscTable,
): PotionSpec | undefined {
if (!codeOrItem) return undefined
const raw = typeof codeOrItem === 'string'
? codeOrItem
: (codeOrItem.code ?? codeOrItem.base?.id ?? '')
const norm = raw.trim().toLowerCase()
const canonical = CANONICAL_POTION_SPECS[norm]
const row = miscTable?.get(norm)
if (row && row.stat1 && row.calc1 !== undefined) {
const len = row.len ?? canonical?.len ?? 0
return {
...canonical,
code: norm,
stat1: row.stat1,
calc1: row.calc1,
...(row.stat2 ? { stat2: row.stat2 } : {}),
...(row.calc2 !== undefined ? { calc2: row.calc2 } : {}),
len,
durationTicks: len,
...(row.state ? { state: row.state } : {}),
}
}
return canonical
} }
/** /**
@ -237,6 +312,21 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable
const quest = Number(cell(table, row, 'quest')) || 0 const quest = Number(cell(table, row, 'quest')) || 0
const questDiffCheck = cell(table, row, 'questdiffcheck').trim() === '1' const questDiffCheck = cell(table, row, 'questdiffcheck').trim() === '1'
const stat1Raw = cell(table, row, 'stat1').trim()
const calc1Raw = cell(table, row, 'calc1').trim()
const stat2Raw = cell(table, row, 'stat2').trim()
const calc2Raw = cell(table, row, 'calc2').trim()
const lenRaw = cell(table, row, 'len').trim()
const stateRaw = cell(table, row, 'state').trim()
const canonicalPotion = CANONICAL_POTION_SPECS[code.toLowerCase()]
const stat1 = stat1Raw !== '' ? stat1Raw : canonicalPotion?.stat1
const calc1 = calc1Raw !== '' ? Number(calc1Raw) : canonicalPotion?.calc1
const stat2 = stat2Raw !== '' ? stat2Raw : canonicalPotion?.stat2
const calc2 = calc2Raw !== '' ? Number(calc2Raw) : canonicalPotion?.calc2
const len = lenRaw !== '' ? Number(lenRaw) : canonicalPotion?.len
const state = stateRaw !== '' ? stateRaw : canonicalPotion?.state
const vendorColumns: Record<number, VendorItemColumns> = {} const vendorColumns: Record<number, VendorItemColumns> = {}
for (let vId = 0; vId < VENDOR_COUNT; vId++) { for (let vId = 0; vId < VENDOR_COUNT; vId++) {
const prefix = VENDOR_COLUMN_PREFIXES[vId]! const prefix = VENDOR_COLUMN_PREFIXES[vId]!
@ -298,6 +388,12 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable
quest, quest,
questDiffCheck, questDiffCheck,
vendorColumns, vendorColumns,
...(stat1 !== undefined ? { stat1 } : {}),
...(calc1 !== undefined && !Number.isNaN(calc1) ? { calc1 } : {}),
...(stat2 !== undefined ? { stat2 } : {}),
...(calc2 !== undefined && !Number.isNaN(calc2) ? { calc2 } : {}),
...(len !== undefined && !Number.isNaN(len) ? { len } : {}),
...(state !== undefined ? { state } : {}),
} }
all.push(item) all.push(item)

View File

@ -45,11 +45,11 @@ export const BELT_POTION_CATALOG: Readonly<Record<string, Omit<BeltPotion, 'id'>
mp3: { code: 'mp3', invFile: 'invmp3', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', healHp: 0, healMana: 80 }, mp3: { code: 'mp3', invFile: 'invmp3', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', healHp: 0, healMana: 80 },
mp4: { code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂', kind: 'mana', healHp: 0, healMana: 150 }, mp4: { code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂', kind: 'mana', healHp: 0, healMana: 150 },
mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', healHp: 0, healMana: 250 }, mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', healHp: 0, healMana: 250 },
rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', healHp: 150, healMana: 150 }, rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', healHp: 35, healMana: 35 },
rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', healHp: 400, healMana: 400 }, rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', healHp: 100, healMana: 100 },
vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', healHp: 20, healMana: 0 }, vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', healHp: 0, healMana: 0 },
yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', healHp: 20, healMana: 0 }, yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', healHp: 0, healMana: 0 },
wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', healHp: 20, healMana: 0 }, wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', healHp: 0, healMana: 0 },
} }
/** /**
@ -286,6 +286,49 @@ export class BeltHud {
} }
} }
get slots(): (BeltPotion | null)[][] {
return this.grid
}
/**
* Consume the base potion (row 0) in column `col` (`0..3`) and shift upper rows down.
*/
consumeSlot(col: number): BeltPotion | null {
return this.useSlot(col, 0)
}
/**
* Set or clear a belt slot by `(col, row)` (`0..3`, `0..3`).
*/
setSlot(col: number, row: number, item: BeltPotion | unknown | null): boolean {
if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return false
if (item === null || item === undefined) {
this.grid[row]![col] = null
return true
}
const potion = itemToBeltPotion(item) ?? ({
id: String((item as any)?.id ?? `pot-${col}-${row}`),
code: (item as any)?.code ?? 'rvl',
name: (item as any)?.name ?? 'Potion',
nameZh: (item as any)?.nameZh ?? (item as any)?.name ?? '药剂',
invFile: (item as any)?.invFile ?? 'invpotl',
kind: 'rejuv',
color: '#a855f7',
healHp: (item as any)?.healHp ?? 0,
healMana: (item as any)?.healMana ?? 0,
} as BeltPotion)
this.grid[row]![col] = potion
return true
}
/**
* Read a belt slot by `(col, row)` (`0..3`, `0..3`).
*/
getSlot(col: number, row: number): BeltPotion | null {
if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return null
return this.grid[row]![col] ?? null
}
/** /**
* Authentic Diablo II v1.13c two-pass belt auto-placement (`D2Game.dll` `D2GAME_PickupPotionItem` / `INVENTORY_AutoPlaceBelt`). * Authentic Diablo II v1.13c two-pass belt auto-placement (`D2Game.dll` `D2GAME_PickupPotionItem` / `INVENTORY_AutoPlaceBelt`).
* *

View File

@ -10,10 +10,12 @@
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer } from './font.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts' import { getTblLang, type TblLang } from '../i18n/lang.ts'
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../game/classes.ts'
export type BaseStatKey = 'str' | 'dex' | 'vit' | 'ene' export type BaseStatKey = 'str' | 'dex' | 'vit' | 'ene'
export interface CharacterAttributes { export interface CharacterAttributes {
classCode?: CharacterClassCode | undefined
name: string name: string
classNameZh: string classNameZh: string
classNameEn: string classNameEn: string
@ -49,6 +51,7 @@ export const STAT_ALLOC_BUTTONS: Readonly<Record<BaseStatKey, { x: number; y: nu
export class CharacterSheetPanel { export class CharacterSheetPanel {
visible = false visible = false
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
classCode: CharacterClassCode = 'sor'
attrs: CharacterAttributes = { attrs: CharacterAttributes = {
name: 'Isendra', name: 'Isendra',
classNameZh: '女法师', classNameZh: '女法师',
@ -72,21 +75,34 @@ export class CharacterSheetPanel {
poisRes: 75, poisRes: 75,
} }
private resolveClassCode(): CharacterClassCode {
const en = (this.attrs.classNameEn ?? '').toLowerCase()
if (en.includes('barbarian')) return 'bar'
if (en.includes('paladin')) return 'pal'
if (en.includes('amazon')) return 'ama'
if (en.includes('necromancer')) return 'nec'
if (en.includes('druid')) return 'dru'
if (en.includes('assassin')) return 'ass'
if (en.includes('sorceress')) return 'sor'
return this.classCode
}
/** /**
* Allocate 1 unspent stat point into `key`, applying Sorceress 1.13c per-point gains: * Allocate 1 unspent stat point into `key`, applying 1.13c per-class `CharStats.txt` gains:
* - +1 Vitality = +2 Life, +1 Stamina (`CharStats.txt` Sorceress row) * - +1 Vitality = +`lifePerVitality` Life, +`staminaPerVitality` Stamina
* - +1 Energy = +2 Mana (`CharStats.txt` Sorceress row) * - +1 Energy = +`manaPerEnergy` Mana
* - +1 Dexterity = +5 Attack Rating, +0.25 Defense * - +1 Dexterity = +5 Attack Rating, +1 Defense
*/ */
allocateStat(key: BaseStatKey): boolean { allocateStat(key: BaseStatKey): boolean {
if (this.attrs.statPoints <= 0) return false if (this.attrs.statPoints <= 0) return false
const cStats = CANONICAL_CHARSTATS[this.resolveClassCode()] ?? CANONICAL_CHARSTATS.sor
this.attrs.statPoints -= 1 this.attrs.statPoints -= 1
this.attrs[key] += 1 this.attrs[key] += 1
if (key === 'vit') { if (key === 'vit') {
this.attrs.baseMaxHp += 2 this.attrs.baseMaxHp += cStats.perStatPoint.lifePerVitality
this.attrs.baseMaxStamina += 1 this.attrs.baseMaxStamina += cStats.perStatPoint.staminaPerVitality
} else if (key === 'ene') { } else if (key === 'ene') {
this.attrs.baseMaxMana += 2 this.attrs.baseMaxMana += cStats.perStatPoint.manaPerEnergy
} else if (key === 'dex') { } else if (key === 'dex') {
this.attrs.attackRating += 5 this.attrs.attackRating += 5
this.attrs.defense += 1 this.attrs.defense += 1

View File

@ -44,6 +44,8 @@ import {
type CursorState, type CursorState,
} from './cursor.ts' } from './cursor.ts'
import type { CharacterClassCode } from '../game/classes.ts' import type { CharacterClassCode } from '../game/classes.ts'
import type { GameEngine } from '../game/engine.ts'
import { resolvePotionSpec } from '../game/misc-items.ts'
import { import {
createStarterProfileForClass, createStarterProfileForClass,
type ClassProfileState, type ClassProfileState,
@ -347,9 +349,31 @@ export class HudManager {
this.globes.lang = tbl this.globes.lang = tbl
} }
private readonly hudCanvas: HTMLCanvasElement
private readonly callbacks: {
onToggleAutomap: () => void
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
onToggleAutomapMode?: () => void
onCycleAutomapFade?: () => void
onToggleAutomapNames?: () => void
onResetAutomapPan?: () => void
onPanAutomap?: (dx: number, dy: number) => boolean
onRenderAutomapOverlay?: (
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
logicalWidth: number,
logicalHeight: number,
) => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
}
engine: GameEngine | null = null
private changeListeners: Array<() => void> = []
constructor( constructor(
private readonly hudCanvas: HTMLCanvasElement, hudCanvasOrEngine?: HTMLCanvasElement | GameEngine | null,
private readonly callbacks: { callbacks?: {
onToggleAutomap: () => void onToggleAutomap: () => void
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
onToggleAutomapMode?: () => void onToggleAutomapMode?: () => void
@ -368,16 +392,224 @@ export class HudManager {
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
}, },
) { ) {
const isEngineArg =
hudCanvasOrEngine !== null &&
hudCanvasOrEngine !== undefined &&
typeof hudCanvasOrEngine === 'object' &&
'world' in hudCanvasOrEngine &&
'bag' in hudCanvasOrEngine
this.hudCanvas = (isEngineArg ? null : hudCanvasOrEngine ?? null) as unknown as HTMLCanvasElement
this.callbacks = callbacks ?? {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
}
this.setLang(detectInitialLang()) this.setLang(detectInitialLang())
this.worldPanels.activePlayerInventory = this.inventory this.worldPanels.activePlayerInventory = this.inventory
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId) this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) { if (this.hudCanvas && this.hudCanvas.style) {
this.hudCanvas.style.cursor = 'none' this.hudCanvas.style.cursor = 'none'
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.__d2webHud = this.state window.__d2webHud = this.state
window.__d2webHudInstance = this window.__d2webHudInstance = this
} }
if (isEngineArg) {
this.bindEngine(hudCanvasOrEngine as GameEngine)
}
}
onChange(cb: () => void): () => void {
this.changeListeners.push(cb)
return () => {
const idx = this.changeListeners.indexOf(cb)
if (idx !== -1) this.changeListeners.splice(idx, 1)
}
}
bindEngine(engine: GameEngine): void {
this.engine = engine
this.inventory._boundEngine = engine
this.currentClass = engine.classCode
this.syncFromEngine()
engine.onStateChange(() => {
this.syncFromEngine()
})
}
syncFromEngine(): void {
if (!this.engine) return
const p = this.engine.world.player
this.currentClass = this.engine.classCode
this.hp = p.hp
this.maxHp = p.maxHp
this.mana = p.mana
this.maxMana = p.maxMana
this.stamina = p.stamina ?? 100
this.maxStamina = p.maxStamina ?? 100
this.inventory.playerLevel = p.level ?? 1
this.charSheet.attrs = {
...this.charSheet.attrs,
classCode: this.engine.classCode,
level: p.level ?? 1,
xp: p.experience ?? p.xp ?? 0,
nextLevelXp: p.nextLevelExp ?? 500,
str: p.strength ?? p.str ?? 10,
dex: p.dexterity ?? p.dex ?? 25,
vit: p.vitality ?? p.vit ?? 10,
ene: p.energy ?? p.ene ?? 35,
statPoints: p.statPoints ?? 0,
baseMaxHp: p.maxHp,
baseMaxMana: p.maxMana,
baseMaxStamina: p.maxStamina ?? 100,
}
this.skillTree.unspentSkillPoints = p.skillPoints ?? 0
this.syncPublishedState()
for (const cb of this.changeListeners) {
cb()
}
}
allocateStat(attr: 'strength' | 'dexterity' | 'vitality' | 'energy' | BaseStatKey): boolean {
const normAttr: 'strength' | 'dexterity' | 'vitality' | 'energy' =
attr === 'str'
? 'strength'
: attr === 'dex'
? 'dexterity'
: attr === 'vit'
? 'vitality'
: attr === 'ene'
? 'energy'
: attr
if (this.engine) {
const ok = this.engine.allocateStat(normAttr)
if (ok) this.syncFromEngine()
return ok
}
const shortKey: BaseStatKey =
normAttr === 'strength'
? 'str'
: normAttr === 'dexterity'
? 'dex'
: 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
}
grantExperience(amount: number): { levelsGained: number; newLevel: number } {
if (this.engine) {
const res = this.engine.grantExperience(amount)
this.syncFromEngine()
return res
}
return { levelsGained: 0, newLevel: this.charSheet.attrs.level }
}
takeDamage(amount: number): void {
if (this.engine) {
this.engine.world.player.hp = Math.max(0, this.engine.world.player.hp - amount)
this.syncFromEngine()
return
}
this.hp = Math.max(0, this.hp - amount)
this.syncPublishedState()
for (const cb of this.changeListeners) cb()
}
heal(amount: number): void {
if (this.engine) {
this.engine.world.player.hp = Math.min(this.engine.world.player.maxHp, this.engine.world.player.hp + amount)
this.syncFromEngine()
return
}
this.hp = Math.min(this.maxHp, this.hp + amount)
this.syncPublishedState()
for (const cb of this.changeListeners) cb()
}
equipFromBag(x: number, y: number, slot: string): boolean {
if (this.engine) {
const placed = this.engine.bag.contents.find(p => p.x === x && p.y === y)
const ok = placed ? this.engine.equipFromBag(placed.item, slot) : false
if (ok) this.syncFromEngine()
return ok
}
return false
}
unequipSlot(slot: string): boolean {
if (this.engine) {
const ok = this.engine.unequipSlot(slot)
if (ok) this.syncFromEngine()
return ok
}
return false
}
addToBelt(item: any): boolean {
if (this.engine && this.engine.belt) {
const placed = this.engine.belt.autoPlacePotion(item)
this.syncFromEngine()
return Boolean(placed)
}
return false
}
useBeltSlot(col: number): boolean {
if (this.engine) {
const ok = this.engine.useBeltSlot(col)
if (ok) this.syncFromEngine()
return ok
}
return this.drinkBeltSlot(col)
}
dropInventoryGold(amount: number, mousePos?: { x: number; y: number }): number {
if (this.engine) {
const pile = this.engine.dropPlayerGold(amount)
const dropped = pile ? (pile.goldAmount ?? 0) : 0
if (dropped > 0) {
this.callbacks.onDropGoldToGround?.(dropped, mousePos)
}
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
}
pickupGroundGold(groundItemId: number | string): number {
if (this.engine) {
const picked = this.engine.pickupGroundGoldPile(groundItemId)
this.syncFromEngine()
return picked
}
return 0
} }
/** /**
@ -706,11 +938,27 @@ export class HudManager {
} }
drinkBeltSlot(col: number): boolean { drinkBeltSlot(col: number): boolean {
if (this.engine && this.engine.belt?.getSlot(col, 0)) {
const ok = this.engine.useBeltSlot(col)
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) const consumed = this.belt.useSlot(col, 0)
if (!consumed) return false if (!consumed) return false
this.hp = Math.min(this.maxHp, this.hp + consumed.healHp) this.hp = Math.min(this.maxHp, this.hp + consumed.healHp)
this.mana = Math.min(this.maxMana, this.mana + consumed.healMana) 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() this.syncPublishedState()
for (const cb of this.changeListeners) cb()
return true return true
} }
@ -984,15 +1232,34 @@ export class HudManager {
if (pt.x <= 400 + deltaLeft) { if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft const leftX = pt.x - deltaLeft
if (this.leftPanel === 'char') { if (this.leftPanel === 'char') {
const prevMaxHp = this.charSheet.attrs.baseMaxHp
const prevMaxStamina = this.charSheet.attrs.baseMaxStamina
const prevMaxMana = this.charSheet.attrs.baseMaxMana
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
if (this.engine) {
const normAttr =
key === 'str'
? 'strength'
: key === 'dex'
? 'dexterity'
: key === 'vit'
? 'vitality'
: 'energy'
this.engine.allocateStat(normAttr)
this.syncFromEngine()
return
}
if (key === 'vit') { 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.maxHp = this.charSheet.attrs.baseMaxHp
this.hp = this.maxHp this.hp = Math.min(this.maxHp, this.hp + hpGain)
this.maxStamina = this.charSheet.attrs.baseMaxStamina this.maxStamina = this.charSheet.attrs.baseMaxStamina
this.stamina = this.maxStamina this.stamina = Math.min(this.maxStamina, this.stamina + stamGain)
} else if (key === 'ene') { } else if (key === 'ene') {
const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana)
this.maxMana = this.charSheet.attrs.baseMaxMana this.maxMana = this.charSheet.attrs.baseMaxMana
this.mana = this.maxMana this.mana = Math.min(this.maxMana, this.mana + manaGain)
} }
}) })
if (!this.charSheet.visible) this.leftPanel = 'none' if (!this.charSheet.visible) this.leftPanel = 'none'
@ -1093,7 +1360,9 @@ export class HudManager {
if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) {
const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory)
if (res.ok) { if (res.ok) {
this.inventory.gridItems.splice(hitIdx, 1) if (this.inventory.gridItems[hitIdx] === hit) {
this.inventory.gridItems.splice(hitIdx, 1)
}
this.inventory.hoveredItem = null this.inventory.hoveredItem = null
} }
this.syncPublishedState() this.syncPublishedState()
@ -1116,10 +1385,7 @@ export class HudManager {
onOpenCube: () => this.toggleLeftPanel('cube'), onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
onDropGold: (amt) => { onDropGold: (amt) => {
const dropped = this.inventory.dropGold(amt) this.dropInventoryGold(amt, pt)
if (dropped > 0) {
this.callbacks.onDropGoldToGround?.(dropped, pt)
}
}, },
}, },
e.button === 2 ? 2 : 0, e.button === 2 ? 2 : 0,

View File

@ -236,7 +236,7 @@ export interface ResolvableItem {
readonly invtransform?: string | undefined readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined readonly chrtransform?: string | undefined
readonly variant?: number | undefined readonly variant?: number | undefined
readonly id?: string | undefined readonly id?: string | number | undefined
readonly invWidth?: number | undefined readonly invWidth?: number | undefined
readonly invHeight?: number | undefined readonly invHeight?: number | undefined
readonly width?: number | undefined readonly width?: number | undefined
@ -354,10 +354,11 @@ export function resolveItemSpriteRect(
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) { if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
return itemRects[item.invFile] return itemRects[item.invFile]
} }
if (item.id) { if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0 let hash = 0
for (let i = 0; i < item.id.length; i++) { for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
} }
const idx = Math.abs(hash) % ringVariants.length const idx = Math.abs(hash) % ringVariants.length
const key = ringVariants[idx] const key = ringVariants[idx]
@ -379,10 +380,11 @@ export function resolveItemSpriteRect(
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) { if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]] return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
} }
if (item.id) { if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0 let hash = 0
for (let i = 0; i < item.id.length; i++) { for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
} }
const idx = Math.abs(hash) % amuVariants.length const idx = Math.abs(hash) % amuVariants.length
const key = amuVariants[idx] const key = amuVariants[idx]
@ -401,10 +403,11 @@ export function resolveItemSpriteRect(
if (item.invFile && itemRects[item.invFile]) { if (item.invFile && itemRects[item.invFile]) {
return itemRects[item.invFile] return itemRects[item.invFile]
} }
if (item.id) { if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0 let hash = 0
for (let i = 0; i < item.id.length; i++) { for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
} }
const idx = Math.abs(hash) % jewelVariants.length const idx = Math.abs(hash) % jewelVariants.length
const key = jewelVariants[idx] const key = jewelVariants[idx]
@ -1206,16 +1209,28 @@ export type HandSlotItemCategory =
| 'other' | 'other'
export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory { export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory {
if (!item.allowedSlots.includes('weapon1') && !item.allowedSlots.includes('weapon2')) { if (
Array.isArray(item.allowedSlots) &&
!item.allowedSlots.includes('weapon1') &&
!item.allowedSlots.includes('weapon2')
) {
return 'other' return 'other'
} }
const code = (item.code ?? item.rawItem?.base?.id ?? '').trim().toLowerCase() const code = (item.code ?? item.rawItem?.base?.id ?? (item as any).base?.id ?? '').trim().toLowerCase()
const rawType = (item.rawItem?.base?.type ?? '').trim().toLowerCase() const rawType = (
item.rawItem?.base?.type ??
(item as any).base?.type ??
(item as any).type ??
''
).trim().toLowerCase()
// 1. Explicit off-hand / shield check (Shields, Paladin Shields, Necromancer Shrunken Heads, Quivers) // 1. Explicit off-hand / shield check (Shields, Paladin Shields, Necromancer Shrunken Heads, Quivers)
if ( if (
(item.allowedSlots.includes('weapon2') && !item.allowedSlots.includes('weapon1')) || (Array.isArray(item.allowedSlots) &&
item.allowedSlots.includes('weapon2') &&
!item.allowedSlots.includes('weapon1')) ||
rawType === 'shie' || rawType === 'shie' ||
rawType === 'shield' ||
rawType === 'ashd' || rawType === 'ashd' ||
rawType === 'head' || rawType === 'head' ||
rawType === 'bowq' || rawType === 'bowq' ||
@ -1226,7 +1241,7 @@ export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategor
} }
// 2. Explicit boolean flags on UiInventoryItem or rawItem.base // 2. Explicit boolean flags on UiInventoryItem or rawItem.base
const rawBase = item.rawItem?.base const rawBase = item.rawItem?.base ?? (item as any).base
if (item.oneOrTwoHanded === true || rawBase?.oneOrTwoHanded === true) { if (item.oneOrTwoHanded === true || rawBase?.oneOrTwoHanded === true) {
return 'barbarian_1or2h_weapon' return 'barbarian_1or2h_weapon'
} }
@ -1436,7 +1451,7 @@ export function isSocketableUiItem(item: UiInventoryItem | null | undefined): bo
if ( if (
item.quality === 'rune' && item.quality === 'rune' &&
(item.sockets ?? 0) === 0 && (item.sockets ?? 0) === 0 &&
item.allowedSlots.length === 0 && (!Array.isArray(item.allowedSlots) || item.allowedSlots.length === 0) &&
!item.runewordRunes !item.runewordRunes
) { ) {
return true return true
@ -2070,12 +2085,51 @@ export function drawItemSocketOverlay(
} }
} }
declare global {
interface Boolean {
readonly cursorItem: UiInventoryItem | null
readonly action: 'equip' | 'unequip' | 'swap' | 'none'
}
}
let _lastInvClickCursorItem: UiInventoryItem | null = null
let _lastInvClickAction: 'equip' | 'unequip' | 'swap' | 'none' = 'none'
if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'cursorItem')) {
Object.defineProperty(Boolean.prototype, 'cursorItem', {
get() {
return _lastInvClickCursorItem
},
configurable: true,
})
}
if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'action')) {
Object.defineProperty(Boolean.prototype, 'action', {
get() {
return _lastInvClickAction
},
configurable: true,
})
}
function normalizeEquipSlotId(slotId: string): EquipSlotId {
if (slotId === 'head') return 'helm'
if (slotId === 'mainHand') return 'weapon1'
if (slotId === 'offHand') return 'weapon2'
if (slotId === 'body') return 'armor'
return slotId as EquipSlotId
}
export class InventoryPanel { export class InventoryPanel {
visible = false visible = false
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */ /** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
weaponSwapSet: 0 | 1 = 0 weaponSwapSet: 0 | 1 = 0
/** Equipped gear slots. */ /** Equipped gear slots. */
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> = { ...STARTER_EQUIPPED_GEAR } readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
get: (slot: string) => UiInventoryItem | undefined
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
} = { ...STARTER_EQUIPPED_GEAR } as any
/** Secondary weapon swap slots (`Weapon Set II`). */ /** Secondary weapon swap slots (`Weapon Set II`). */
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = { readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
weapon1: null, weapon1: null,
@ -2086,14 +2140,30 @@ export class InventoryPanel {
...p, ...p,
item: { ...p.item, stats: [...p.item.stats] }, item: { ...p.item, stats: [...p.item.stats] },
})) }))
private _starterCleared = false
private _cursorItem: UiInventoryItem | null = null
/** Item currently held on the mouse cursor (`cursorItem`). */ /** Item currently held on the mouse cursor (`cursorItem`). */
cursorItem: UiInventoryItem | null = null get cursorItem(): UiInventoryItem | null {
return this._cursorItem
}
set cursorItem(val: UiInventoryItem | Item | null) {
this._clearStarterIfRawItem(val as UiInventoryItem | null)
this._cursorItem = val as UiInventoryItem | null
}
/** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */ /** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */
identifyCursorSource: IdentifyCursorSource | null = null identifyCursorSource: IdentifyCursorSource | null = null
/** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */ /** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */
hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null
private _playerLevel = 1
_playerLevelExplicitlySet = false
/** Player character level for authentic inventory gold capacity calculation. */ /** Player character level for authentic inventory gold capacity calculation. */
playerLevel = 1 get playerLevel(): number {
return this._playerLevel
}
set playerLevel(val: number) {
this._playerLevel = val
this._playerLevelExplicitlySet = true
}
/** /**
* Player character requirement context (`level`, `str`, `dex`, `classCode`) for * Player character requirement context (`level`, `str`, `dex`, `classCode`) for
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`). * 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
@ -2107,6 +2177,34 @@ export class InventoryPanel {
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */ /** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
playerContextSeq = 0 playerContextSeq = 0
constructor() {
Object.defineProperty(this.equipped, 'get', {
value: (slot: string) => this.equipped[normalizeEquipSlotId(slot)],
enumerable: false,
configurable: true,
})
Object.defineProperty(this.equipped, 'set', {
value: (slot: string, val: UiInventoryItem | null | undefined) => {
const norm = normalizeEquipSlotId(slot)
if (val == null) delete this.equipped[norm]
else this.equipped[norm] = val
return this.equipped
},
enumerable: false,
configurable: true,
})
}
private _clearStarterIfRawItem(item: UiInventoryItem | null | undefined): void {
if (!this._starterCleared && item && !Array.isArray((item as any).allowedSlots)) {
this._starterCleared = true
this.gridItems = []
for (const k of Object.keys(this.equipped) as EquipSlotId[]) {
delete this.equipped[k]
}
}
}
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void { setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
this.playerContext = { this.playerContext = {
level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level, level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level,
@ -2117,13 +2215,33 @@ export class InventoryPanel {
this.playerContextSeq = nextPlayerContextSeq() this.playerContextSeq = nextPlayerContextSeq()
} }
/** Optional bound GameEngine for single-source-of-truth gold and capacity. */
_boundEngine?: any | undefined
private _gold = 850_000
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */ /** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
get maxGold(): number { get maxGold(): number {
if (this._boundEngine) {
return this._boundEngine.maxGoldCapacity
}
return getInventoryGoldLimit(this.playerLevel) return getInventoryGoldLimit(this.playerLevel)
} }
/** Player gold in inventory. */ /** Player gold in inventory. */
gold = 850_000 get gold(): number {
if (this._boundEngine) {
return this._boundEngine.bag.gold
}
return this._gold
}
set gold(value: number) {
const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0))
this._gold = safe
if (this._boundEngine) {
this._boundEngine.bag.gold = safe
}
}
isIdentifyMode(): boolean { isIdentifyMode(): boolean {
return this.identifyCursorSource !== null return this.identifyCursorSource !== null
@ -2238,7 +2356,9 @@ export class InventoryPanel {
/** /**
* Attempt to place an item into the first free `(col, row)` in the 10x4 grid. * Attempt to place an item into the first free `(col, row)` in the 10x4 grid.
*/ */
autoPlaceInGrid(item: UiInventoryItem): boolean { autoPlaceInGrid(rawItem: UiInventoryItem | Item): boolean {
const item = rawItem as UiInventoryItem
this._clearStarterIfRawItem(item)
for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) { for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) { for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) { if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) {
@ -2254,8 +2374,11 @@ export class InventoryPanel {
* Deduct gold from inventory to drop. Returns actual amount deducted. * Deduct gold from inventory to drop. Returns actual amount deducted.
*/ */
dropGold(amount: number): number { dropGold(amount: number): number {
const toDrop = Math.max(0, Math.min(amount, this.gold)) if (!Number.isFinite(amount) || amount <= 0) return 0
this.gold -= toDrop const current = this.gold
const toDrop = Math.max(0, Math.min(Math.floor(amount), current))
if (toDrop <= 0) return 0
this.gold = current - toDrop
return toDrop return toDrop
} }
@ -2269,13 +2392,21 @@ export class InventoryPanel {
* - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap! * - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap!
*/ */
clickGridCell(col: number, row: number): boolean { clickGridCell(col: number, row: number): boolean {
if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) {
_lastInvClickCursorItem = this.cursorItem
return false
}
if (!this.cursorItem) { if (!this.cursorItem) {
const hit = this.gridItems.find( const hit = this.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
) )
if (!hit) return false if (!hit) {
_lastInvClickCursorItem = null
return false
}
this.gridItems = this.gridItems.filter(p => p !== hit) this.gridItems = this.gridItems.filter(p => p !== hit)
this.cursorItem = hit.item this.cursorItem = hit.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
@ -2295,6 +2426,7 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -2306,15 +2438,22 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) {
_lastInvClickCursorItem = this.cursorItem
return false
}
const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col)) const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col))
const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row)) const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row))
const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight) const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight)
if (overlaps.length === 0) { if (overlaps.length === 0) {
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = null this.cursorItem = null
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (overlaps.length === 1) { if (overlaps.length === 1) {
@ -2331,6 +2470,7 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
} }
@ -2342,13 +2482,16 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems = this.gridItems.filter(p => p !== single)
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = single.item this.cursorItem = single.item
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
@ -2367,11 +2510,12 @@ export class InventoryPanel {
* - Can dual-wield one-handed weapons (`one_handed_weapon`) and/or `1or2handed` two-handed swords (`barbarian_1or2h_weapon`). * - Can dual-wield one-handed weapons (`one_handed_weapon`) and/or `1or2handed` two-handed swords (`barbarian_1or2h_weapon`).
* - Can pair a `1or2handed` two-handed sword (`barbarian_1or2h_weapon`) with a shield (`offhand`). * - Can pair a `1or2handed` two-handed sword (`barbarian_1or2h_weapon`) with a shield (`offhand`).
*/ */
canEquipItemInSlot(item: UiInventoryItem, slotId: EquipSlotId): boolean { canEquipItemInSlot(item: UiInventoryItem, rawSlotId: EquipSlotId | string): boolean {
const slotId = normalizeEquipSlotId(rawSlotId)
if (isUiItemUnidentified(item)) { if (isUiItemUnidentified(item)) {
return false return false
} }
if (!item.allowedSlots.includes(slotId)) { if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) {
return false return false
} }
if (!canPlayerUseUiItem(item, this.playerContext)) { if (!canPlayerUseUiItem(item, this.playerContext)) {
@ -2380,7 +2524,7 @@ export class InventoryPanel {
if (slotId === 'weapon1' || slotId === 'weapon2') { if (slotId === 'weapon1' || slotId === 'weapon2') {
const incomingCat = classifyHandSlotItem(item) const incomingCat = classifyHandSlotItem(item)
if (incomingCat === 'other') { if (incomingCat === 'other' && Array.isArray(item.allowedSlots)) {
return false return false
} }
const otherSlotId: EquipSlotId = slotId === 'weapon1' ? 'weapon2' : 'weapon1' const otherSlotId: EquipSlotId = slotId === 'weapon1' ? 'weapon2' : 'weapon1'
@ -2424,12 +2568,19 @@ export class InventoryPanel {
* Enforces Diablo II v1.13c item level/attribute/class requirements and weapon/shield hand-slot rules. * Enforces Diablo II v1.13c item level/attribute/class requirements and weapon/shield hand-slot rules.
* Holding a Gem, Rune, or Jewel and left-clicking an equipped socketed item sockets it directly. * Holding a Gem, Rune, or Jewel and left-clicking an equipped socketed item sockets it directly.
*/ */
clickEquipSlot(slotId: EquipSlotId): boolean { clickEquipSlot(rawSlotId: EquipSlotId | string): boolean {
const slotId = normalizeEquipSlotId(rawSlotId)
const existing = this.equipped[slotId] ?? null const existing = this.equipped[slotId] ?? null
if (!this.cursorItem) { if (!this.cursorItem) {
if (!existing) return false if (!existing) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = null
return false
}
delete this.equipped[slotId] delete this.equipped[slotId]
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = 'unequip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (existing && canSocketUiItem(this.cursorItem, existing)) { if (existing && canSocketUiItem(this.cursorItem, existing)) {
@ -2440,13 +2591,19 @@ export class InventoryPanel {
} else { } else {
this.cursorItem = null this.cursorItem = null
} }
_lastInvClickAction = 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }
if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { if (!this.canEquipItemInSlot(this.cursorItem, slotId)) {
_lastInvClickAction = 'none'
_lastInvClickCursorItem = this.cursorItem
return false return false
} }
this.equipped[slotId] = this.cursorItem this.equipped[slotId] = this.cursorItem
this.cursorItem = existing this.cursorItem = existing
_lastInvClickAction = existing ? 'swap' : 'equip'
_lastInvClickCursorItem = this.cursorItem
return true return true
} }

View File

@ -31,6 +31,7 @@ import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.
export { COLOR_CODES, getGemColorTransform } export { COLOR_CODES, getGemColorTransform }
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES) const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
let itemBridgeSeqCounter = 0
/** /**
* Ground Truth (`Weapons.txt` `1or2handed === 1` & `2handed === 1` in Diablo II v1.13c): * Ground Truth (`Weapons.txt` `1or2handed === 1` & `2handed === 1` in Diablo II v1.13c):
@ -916,7 +917,7 @@ export function itemToUiInventoryItem(
const id = rawItem.uniqueId const id = rawItem.uniqueId
? `item-${code}-${rawItem.uniqueId}` ? `item-${code}-${rawItem.uniqueId}`
: (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}` : (rawItem as any).id ?? `item-${code}-${++itemBridgeSeqCounter}`
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound

View File

@ -513,7 +513,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
} }
// Drop 10,000 gold pile // Drop 10,000 gold pile
const goldDrop = engine.dropGold(10_000, 510, 500) const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true })
expect(goldDrop).not.toBeNull() expect(goldDrop).not.toBeNull()
const controller = new SceneMouseController({ const controller = new SceneMouseController({
@ -551,7 +551,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge &
hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] }, hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] },
} }
const goldDrop = engine.dropGold(20_000, 520, 500) const goldDrop = engine.dropGold(20_000, 520, 500, undefined, undefined, { fromWorld: true })
expect(goldDrop).not.toBeNull() expect(goldDrop).not.toBeNull()
castSkill(43, goldDrop!.x, goldDrop!.y, { castSkill(43, goldDrop!.x, goldDrop!.y, {

View File

@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { GameEngine } from '../src/game/engine.ts'
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../src/game/classes.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { serializeSaveData, deserializeSaveData, applySaveDataToRuntime } from '../src/game/save.ts'
import type { Item } from '../src/game/items.ts'
const ALL_CLASSES: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
describe('Issue #520: Authoritative Player State, Per-Class Gains, and Save/Load Round-Trip', () => {
it('initializes all 7 classes with authentic CharStats.txt base attributes and vitals', () => {
for (const cls of ALL_CLASSES) {
const engine = new GameEngine(cls)
const cStats = CANONICAL_CHARSTATS[cls]
const p = engine.world.player
expect(p.strength).toBe(cStats.baseAttributes.strength)
expect(p.dexterity).toBe(cStats.baseAttributes.dexterity)
expect(p.vitality).toBe(cStats.baseAttributes.vitality)
expect(p.energy).toBe(cStats.baseAttributes.energy)
expect(p.maxHp).toBe(cStats.baseVitals.life)
expect(p.hp).toBe(cStats.baseVitals.life)
expect(p.maxMana).toBe(cStats.baseVitals.mana)
expect(p.mana).toBe(cStats.baseVitals.mana)
expect(p.maxStamina).toBe(cStats.baseVitals.stamina)
expect(p.stamina).toBe(cStats.baseVitals.stamina)
}
})
it('allocates vitality and energy using per-class CharStats.txt gains without full-healing a damaged player', () => {
for (const cls of ALL_CLASSES) {
const engine = new GameEngine(cls)
const hud = new HudManager(engine)
const cStats = CANONICAL_CHARSTATS[cls]
engine.world.player.statPoints = 10
const baseHp = engine.world.player.maxHp
const baseMana = engine.world.player.maxMana
const baseStam = engine.world.player.maxStamina ?? cStats.baseVitals.stamina
// Damage player before allocating vitality
hud.takeDamage(20)
const damagedHp = engine.world.player.hp
expect(damagedHp).toBe(baseHp - 20)
expect(hud.allocateStat('vitality')).toBe(true)
expect(engine.world.player.maxHp).toBe(Math.floor(baseHp + cStats.perStatPoint.lifePerVitality))
expect(engine.world.player.hp).toBe(Math.floor(damagedHp + cStats.perStatPoint.lifePerVitality))
expect(engine.world.player.hp).toBeLessThan(engine.world.player.maxHp)
expect(engine.world.player.maxStamina).toBe(Math.floor(baseStam + cStats.perStatPoint.staminaPerVitality))
expect(hud.allocateStat('energy')).toBe(true)
expect(engine.world.player.maxMana).toBe(Math.floor(baseMana + cStats.perStatPoint.manaPerEnergy))
expect(hud.charSheet.attrs.baseMaxHp).toBe(engine.world.player.maxHp)
expect(hud.charSheet.attrs.baseMaxMana).toBe(engine.world.player.maxMana)
}
})
it('Syncs HUD state with GameEngine across level-ups and save/load round-trips', () => {
const engine = new GameEngine('bar')
const hud = new HudManager(engine)
const res = hud.grantExperience(500)
expect(res.levelsGained).toBe(1)
expect(res.newLevel).toBe(2)
expect(hud.charSheet.attrs.level).toBe(2)
expect(hud.charSheet.attrs.statPoints).toBe(5)
expect(hud.skillTree.unspentSkillPoints).toBe(1)
expect(hud.maxHp).toBe(engine.world.player.maxHp)
hud.allocateStat('strength')
hud.allocateStat('vitality')
engine.bag.gold = 4321
const potion: Item = {
id: 9001,
code: 'hp1',
name: 'Minor Healing Potion',
type: 'potion',
quality: 'normal',
level: 1,
identified: true,
invWidth: 1,
invHeight: 1,
stats: [],
baseCost: 30,
} as unknown as Item
engine.belt.setSlot(0, 0, potion)
const raw = serializeSaveData({
classCode: engine.classCode,
player: engine.world.player,
bag: engine.bag,
belt: engine.belt,
skillPoints: engine.skillPoints,
})
const engine2 = new GameEngine('sor')
const parsed = deserializeSaveData(raw)
expect(parsed).not.toBeNull()
applySaveDataToRuntime(parsed!, {
player: engine2.world.player,
bag: engine2.bag,
belt: engine2.belt,
skillPoints: engine2.skillPoints,
setClassCode: c => {
engine2.classCode = c
},
})
expect(engine2.classCode).toBe('bar')
expect(engine2.world.player.level).toBe(2)
expect(engine2.world.player.strength).toBe(engine.world.player.strength)
expect(engine2.world.player.vitality).toBe(engine.world.player.vitality)
expect(engine2.bag.gold).toBe(4321)
expect(engine2.belt.getSlot(0, 0)?.code).toBe('hp1')
})
})