310 lines
10 KiB
TypeScript
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)
|
|
}
|
|
}
|