diablo2-web/src/game/engine/stat-list.ts

310 lines
10 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c Unit `StatList` (`D2StatListStrc` Parity).
*
* Enforces:
* - 256-fixed-point (`<< 8`, `HIT_SHIFT = 8`) integer arithmetic for life (`hitpoints`/`maxhp`),
* mana (`mana`/`maxmana`), stamina (`stamina`/`maxstamina`), and per-frame poison/burn bitrates.
* - Strict separation of base allocated skill points (`STAT_SKILLPTS` -> `getBaseSkillLevel(skillId)`)
* from item/buff `+skills` and item charges (`STAT_ITEM_CHARGED_SKILL`), enforcing the 1.13c
* Marrowwalk / charge synergy fix (`blvl` never includes `+skills` or charges).
* - Dynamic `SkillStatModifier` lists with `expireFrame` (`dwExpireFrame`), `stateId`, and `slvl`.
*/
import type { D2DataRegistry } from './data-registry.ts'
export const HIT_SHIFT = 8
export const FIXED_ONE = 1 << HIT_SHIFT // 256
export interface SkillStatModifier {
readonly id: string
readonly stateName: string
readonly stateId: number
readonly sourceUnitId: string
readonly sourceSkillId: number
readonly slvl: number
/** Frame (`tick`) at which this modifier expires (`0` = permanent/passive/toggle). */
expireFrame: number
readonly isAura?: boolean
readonly isCurse?: boolean
readonly curseType?: number
readonly stats: Readonly<Record<string, number>>
}
export class UnitStatList {
readonly registry: D2DataRegistry | undefined
private readonly baseStats = new Map<string, number>()
private readonly baseSkillLevels = new Map<number, number>()
private readonly bonusSkillLevels = new Map<number, number>()
private readonly chargedSkillLevels = new Map<number, number>()
private allSkillsBonus = 0
private classSkillsBonus = 0
private readonly modifiers: SkillStatModifier[] = []
constructor(registry?: D2DataRegistry, initialStats?: Readonly<Record<string, number>>) {
this.registry = registry
// Sensible defaults in 256-fixed-point for HP/Mana/Stamina
this.baseStats.set('level', 80)
this.baseStats.set('strength', 100)
this.baseStats.set('dexterity', 100)
this.baseStats.set('vitality', 150)
this.baseStats.set('energy', 100)
this.baseStats.set('maxhp', 1000 << HIT_SHIFT)
this.baseStats.set('hitpoints', 1000 << HIT_SHIFT)
this.baseStats.set('maxmana', 1000 << HIT_SHIFT)
this.baseStats.set('mana', 1000 << HIT_SHIFT)
this.baseStats.set('maxstamina', 500 << HIT_SHIFT)
this.baseStats.set('stamina', 500 << HIT_SHIFT)
this.baseStats.set('tohit', 2500)
this.baseStats.set('armorclass', 500)
if (initialStats) {
for (const [k, v] of Object.entries(initialStats)) {
this.setBaseStat(k, v)
}
}
}
private normalizeStatKey(statNameOrId: string | number): string {
if (typeof statNameOrId === 'number') {
const rec = this.registry?.getStatById(statNameOrId)
return rec ? rec.stat.toLowerCase() : String(statNameOrId)
}
return statNameOrId.trim().toLowerCase()
}
setBaseStat(statNameOrId: string | number, value: number): void {
const key = this.normalizeStatKey(statNameOrId)
this.baseStats.set(key, Math.trunc(value))
}
addStat(statNameOrId: string | number, delta: number): void {
const key = this.normalizeStatKey(statNameOrId)
if (key === 'item_allskills') {
this.allSkillsBonus += Math.trunc(delta)
}
this.baseStats.set(key, (this.baseStats.get(key) ?? 0) + Math.trunc(delta))
}
getBaseStat(statNameOrId: string | number): number {
const key = this.normalizeStatKey(statNameOrId)
return this.baseStats.get(key) ?? 0
}
/**
* Set hard-invested `STAT_SKILLPTS` (`blvl`) for a skill.
*/
setBaseSkillLevel(skillId: number, blvl: number): void {
this.baseSkillLevels.set(skillId, Math.max(0, Math.trunc(blvl)))
}
/**
* 1.13c Ground Truth (`D2Common`): returns strictly hard-allocated `STAT_SKILLPTS` (`blvl`).
* Never includes `bonusSkillLevels`, `allSkillsBonus`, or `chargedSkillLevels` (`Marrowwalk` fix).
*/
getBaseSkillLevel(skillId: number): number {
return this.baseSkillLevels.get(skillId) ?? 0
}
setBonusSkillLevel(skillId: number, bonus: number): void {
this.bonusSkillLevels.set(skillId, Math.trunc(bonus))
}
setAllSkillsBonus(bonus: number): void {
this.allSkillsBonus = Math.trunc(bonus)
}
setClassSkillsBonus(bonus: number): void {
this.classSkillsBonus = Math.trunc(bonus)
}
/**
* Set item skill charges (`STAT_ITEM_CHARGED_SKILL`, e.g. Level 33 Bone Prison on Marrowwalk boots).
* Stored in `chargedSkillLevels` and strictly excluded from `getBaseSkillLevel(skillId)` in 1.13c!
*/
setChargedSkillLevel(skillId: number, chargeSlvl: number): void {
this.chargedSkillLevels.set(skillId, Math.max(0, Math.trunc(chargeSlvl)))
}
getChargedSkillLevel(skillId: number): number {
return this.chargedSkillLevels.get(skillId) ?? 0
}
/**
* Effective skill level (`lvl = blvl + bonusSkill + allSkills + classSkills + modAllSkills`).
*/
getEffectiveSkillLevel(skillId: number): number {
const base = this.getBaseSkillLevel(skillId)
const singleBonus = this.bonusSkillLevels.get(skillId) ?? 0
const modAllSkills = this.getModifierBonus('item_allskills') + this.getModifierBonus('allskills')
const total = base + singleBonus + this.allSkillsBonus + this.classSkillsBonus + modAllSkills
return Math.max(0, total)
}
/**
* Sum dynamic modifier bonuses for a given stat.
* For duplicate aura states (`isAura === true` with same `stateId`), only the highest `slvl`
* active modifier contributes its stats (`1.13c` aura stacking/level override rule).
*/
getModifierBonus(statNameOrId: string | number): number {
const key = this.normalizeStatKey(statNameOrId)
const bestAuraByState = new Map<number, SkillStatModifier>()
let sum = 0
for (const mod of this.modifiers) {
if (mod.isAura && mod.stateId > 0) {
const prev = bestAuraByState.get(mod.stateId)
if (!prev || mod.slvl >= prev.slvl) {
bestAuraByState.set(mod.stateId, mod)
}
} else {
sum += mod.stats[key] ?? 0
}
}
for (const auraMod of bestAuraByState.values()) {
sum += auraMod.stats[key] ?? 0
}
return Math.trunc(sum)
}
/**
* Get accrued stat value (`D2Common_GetStatSigned`, `stat('...'.accr)`).
* Handles `%` max life/mana (`item_maxhp_percent`, `item_maxmana_percent`, `item_armor_percent`).
*/
getAccruedStat(statNameOrId: string | number, baseOnly = false): number {
const key = this.normalizeStatKey(statNameOrId)
const base = this.getBaseStat(key)
if (baseOnly) return base
const modBonus = this.getModifierBonus(key)
let total = base + modBonus
if (key === 'maxhp') {
const pct = this.getModifierBonus('item_maxhp_percent') + this.getModifierBonus('maxhp_percent')
if (pct !== 0) {
total = Math.trunc((total * (100 + pct)) / 100)
}
} else if (key === 'maxmana') {
const pct = this.getModifierBonus('item_maxmana_percent') + this.getModifierBonus('maxmana_percent')
if (pct !== 0) {
total = Math.trunc((total * (100 + pct)) / 100)
}
} else if (key === 'armorclass') {
const pct =
this.getModifierBonus('item_armor_percent') +
this.getModifierBonus('skill_armor_percent') +
this.getModifierBonus('armor_percent')
if (pct !== 0) {
total = Math.trunc((total * (100 + pct)) / 100)
}
}
return Math.trunc(total)
}
/**
* Attach or update a `SkillStatModifier`.
*/
addOrUpdateModifier(mod: SkillStatModifier): void {
const normStats: Record<string, number> = {}
for (const [k, v] of Object.entries(mod.stats)) {
normStats[this.normalizeStatKey(k)] = Math.trunc(v)
}
const normalizedMod: SkillStatModifier = {
...mod,
stats: normStats,
}
const existingIdx = this.modifiers.findIndex(
m =>
m.id === normalizedMod.id ||
(!normalizedMod.isAura && m.stateName.toLowerCase() === normalizedMod.stateName.toLowerCase()),
)
if (existingIdx !== -1) {
this.modifiers[existingIdx] = normalizedMod
} else {
this.modifiers.push(normalizedMod)
}
}
removeModifiersByState(stateNameOrId: string | number): SkillStatModifier[] {
const removed: SkillStatModifier[] = []
for (let i = this.modifiers.length - 1; i >= 0; i -= 1) {
const m = this.modifiers[i]!
const match =
typeof stateNameOrId === 'number'
? m.stateId === stateNameOrId
: m.stateName.toLowerCase() === stateNameOrId.trim().toLowerCase()
if (match) {
removed.push(m)
this.modifiers.splice(i, 1)
}
}
return removed
}
removeModifiersByFilter(predicate: (mod: SkillStatModifier) => boolean): SkillStatModifier[] {
const removed: SkillStatModifier[] = []
for (let i = this.modifiers.length - 1; i >= 0; i -= 1) {
const m = this.modifiers[i]!
if (predicate(m)) {
removed.push(m)
this.modifiers.splice(i, 1)
}
}
return removed
}
/**
* Expire any modifier whose `expireFrame > 0 && expireFrame <= currentFrame`.
*/
expireAtFrame(currentFrame: number): SkillStatModifier[] {
return this.removeModifiersByFilter(m => m.expireFrame > 0 && m.expireFrame <= currentFrame)
}
getActiveModifiers(): readonly SkillStatModifier[] {
return this.modifiers
}
/**
* Convenience helpers for 256-fixed-point (`<< 8`) HP and Mana access.
*/
getHp256(): number {
return this.getBaseStat('hitpoints')
}
getHp(): number {
return Math.ceil(this.getHp256() / FIXED_ONE)
}
getMaxHp256(): number {
return this.getAccruedStat('maxhp')
}
getMaxHp(): number {
return Math.ceil(this.getMaxHp256() / FIXED_ONE)
}
setHp256(hp256: number): void {
const clamped = Math.max(0, Math.min(this.getMaxHp256(), Math.trunc(hp256)))
this.setBaseStat('hitpoints', clamped)
}
getMana256(): number {
return this.getBaseStat('mana')
}
getMana(): number {
return Math.floor(this.getMana256() / FIXED_ONE)
}
getMaxMana256(): number {
return this.getAccruedStat('maxmana')
}
setMana256(mana256: number): void {
const clamped = Math.max(0, Math.min(this.getMaxMana256(), Math.trunc(mana256)))
this.setBaseStat('mana', clamped)
}
}