503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c Unit `StateBus` (`States.txt` & `auraeventfunc` Parity).
|
|
*
|
|
* Enforces:
|
|
* - Curse Exclusivity (`cursetype === 1` states overwrite each other).
|
|
* - 1.13c `STATE_ATTRACT` (`cursetype === 2`) Overwrite Immunity: `attract` cannot be
|
|
* overwritten by any `cursetype === 1` curse (`Amplify Damage`, `Decrepify`, `Lower Resist`, etc.).
|
|
* - Aura Stacking & Level Override: distinct auras coexist on a unit; duplicate aura states
|
|
* apply the highest `slvl` modifier while retaining lower-level pulses until expiration.
|
|
* - Reactive `auraeventfunc` triggers (`1..5`, `22..27` from `Events.txt` & `Skills.txt`).
|
|
*/
|
|
import type { D2DataRegistry } from './data-registry.ts'
|
|
import { UnitStatList, FIXED_ONE } from './stat-list.ts'
|
|
|
|
export interface ActiveStateEntry {
|
|
readonly stateId: number
|
|
readonly stateName: string
|
|
readonly sourceUnitId: string
|
|
readonly sourceSkillId: number
|
|
readonly slvl: number
|
|
readonly curseType: number
|
|
readonly isAura: boolean
|
|
expireFrame: number
|
|
readonly overlays: readonly string[]
|
|
readonly stats: Readonly<Record<string, number>>
|
|
readonly reactiveEvents?: readonly {
|
|
readonly event: string
|
|
readonly funcId: number
|
|
readonly skillId: number
|
|
readonly slvl: number
|
|
}[]
|
|
}
|
|
|
|
export interface ApplyStateResult {
|
|
readonly applied: boolean
|
|
readonly stateName: string
|
|
readonly stateId: number
|
|
readonly blockedByAttract?: boolean
|
|
readonly evictedState?: string
|
|
readonly activeSlvl: number
|
|
}
|
|
|
|
export interface ReactiveEventOutcome {
|
|
readonly absorbedPhys256: number
|
|
readonly absorbedElem256: number
|
|
readonly manaDrained256: number
|
|
readonly reflectedPhys256: number
|
|
readonly attackerHealed256: number
|
|
readonly ownerHealed256: number
|
|
readonly spawnedMissiles: string[]
|
|
readonly attackerStatesApplied: string[]
|
|
readonly attackerColdDamage256: number
|
|
}
|
|
|
|
export class StateBus {
|
|
readonly registry: D2DataRegistry | undefined
|
|
readonly statList: UnitStatList
|
|
private readonly entries: ActiveStateEntry[] = []
|
|
|
|
constructor(statList: UnitStatList, registry?: D2DataRegistry) {
|
|
this.statList = statList
|
|
this.registry = registry ?? statList.registry
|
|
}
|
|
|
|
/**
|
|
* Apply a state (`States.txt`) to the unit with optional stats and frame expiration.
|
|
*/
|
|
applyState(params: {
|
|
stateNameOrId: string | number
|
|
sourceUnitId?: string
|
|
sourceSkillId?: number
|
|
slvl?: number
|
|
durationFrames?: number
|
|
currentFrame?: number
|
|
stats?: Readonly<Record<string, number>>
|
|
curseTypeOverride?: number
|
|
isAuraOverride?: boolean
|
|
}): ApplyStateResult {
|
|
const stateRec =
|
|
typeof params.stateNameOrId === 'number'
|
|
? this.registry?.getStateById(params.stateNameOrId)
|
|
: this.registry?.getStateByName(params.stateNameOrId)
|
|
|
|
const stateName = stateRec
|
|
? stateRec.state.toLowerCase()
|
|
: String(params.stateNameOrId).trim().toLowerCase().replace(/^state_/, '')
|
|
const stateId = stateRec?.id ?? (typeof params.stateNameOrId === 'number' ? params.stateNameOrId : 0)
|
|
const slvl = Math.max(1, params.slvl ?? 1)
|
|
const currentFrame = params.currentFrame ?? 0
|
|
const durationFrames = params.durationFrames ?? 0
|
|
const expireFrame = durationFrames > 0 ? currentFrame + durationFrames : 0
|
|
const sourceUnitId = params.sourceUnitId ?? 'player'
|
|
const sourceSkillId = params.sourceSkillId ?? 0
|
|
|
|
// Determine curseType (`1` = standard curse, `2` = Attract)
|
|
const isKnownCurse1 = [
|
|
'amplifydamage',
|
|
'dimvision',
|
|
'weaken',
|
|
'ironmaiden',
|
|
'terror',
|
|
'confuse',
|
|
'lifetap',
|
|
'decrepify',
|
|
'lowerresist',
|
|
'cloak_of_shadows',
|
|
'battlecry',
|
|
].includes(stateName)
|
|
|
|
const curseType =
|
|
params.curseTypeOverride ??
|
|
(stateRec && stateRec.cursetype > 0
|
|
? stateRec.cursetype
|
|
: stateName === 'attract'
|
|
? 2
|
|
: isKnownCurse1
|
|
? 1
|
|
: 0)
|
|
|
|
const isAura = params.isAuraOverride ?? stateRec?.aura ?? false
|
|
|
|
// CRITICAL 1.13c RULE: `STATE_ATTRACT` (`cursetype === 2`) cannot be overwritten by
|
|
// any standard `cursetype === 1` curse!
|
|
if (curseType === 1) {
|
|
const existingAttract = this.entries.find(e => e.curseType === 2 || e.stateName === 'attract')
|
|
if (existingAttract) {
|
|
return {
|
|
applied: false,
|
|
stateName,
|
|
stateId,
|
|
blockedByAttract: true,
|
|
activeSlvl: existingAttract.slvl,
|
|
}
|
|
}
|
|
}
|
|
|
|
let evictedState: string | undefined
|
|
|
|
// Standard `cursetype === 1` exclusivity: remove any existing `cursetype === 1` state
|
|
if (curseType === 1) {
|
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
|
const prev = this.entries[i]!
|
|
if (prev.curseType === 1) {
|
|
evictedState = prev.stateName
|
|
this.statList.removeModifiersByState(prev.stateName)
|
|
this.entries.splice(i, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect overlays from States.txt
|
|
const overlays: string[] = []
|
|
if (stateRec) {
|
|
for (const ov of [stateRec.overlay1, stateRec.overlay2, stateRec.overlay3, stateRec.overlay4, stateRec.castoverlay]) {
|
|
if (ov) overlays.push(ov)
|
|
}
|
|
}
|
|
|
|
// Collect reactive `auraeventfunc` from the source skill if applicable
|
|
const reactiveEvents: { event: string; funcId: number; skillId: number; slvl: number }[] = []
|
|
const skillRec = sourceSkillId > 0 ? this.registry?.getSkillById(sourceSkillId) : undefined
|
|
if (skillRec) {
|
|
if (skillRec.auraEvent1 && skillRec.auraEventFunc1 > 0) {
|
|
reactiveEvents.push({ event: skillRec.auraEvent1.toLowerCase(), funcId: skillRec.auraEventFunc1, skillId: sourceSkillId, slvl })
|
|
}
|
|
if (skillRec.auraEvent2 && skillRec.auraEventFunc2 > 0) {
|
|
reactiveEvents.push({ event: skillRec.auraEvent2.toLowerCase(), funcId: skillRec.auraEventFunc2, skillId: sourceSkillId, slvl })
|
|
}
|
|
if (skillRec.auraEvent3 && skillRec.auraEventFunc3 > 0) {
|
|
reactiveEvents.push({ event: skillRec.auraEvent3.toLowerCase(), funcId: skillRec.auraEventFunc3, skillId: sourceSkillId, slvl })
|
|
}
|
|
}
|
|
|
|
const entryId = isAura ? `aura:${stateName}:${sourceUnitId}:${String(slvl)}` : `state:${stateName}`
|
|
const newEntry: ActiveStateEntry = {
|
|
stateId,
|
|
stateName,
|
|
sourceUnitId,
|
|
sourceSkillId,
|
|
slvl,
|
|
curseType,
|
|
isAura,
|
|
expireFrame,
|
|
overlays,
|
|
stats: params.stats ?? {},
|
|
reactiveEvents,
|
|
}
|
|
|
|
if (isAura) {
|
|
// Replace entry from the same source + state, or keep alongside other sources for lingering pulse
|
|
const idx = this.entries.findIndex(
|
|
e => e.stateName === stateName && e.sourceUnitId === sourceUnitId && e.slvl === slvl,
|
|
)
|
|
if (idx !== -1) {
|
|
this.entries[idx] = newEntry
|
|
} else {
|
|
this.entries.push(newEntry)
|
|
}
|
|
} else {
|
|
const idx = this.entries.findIndex(e => e.stateName === stateName)
|
|
if (idx !== -1) {
|
|
if (this.entries[idx]!.slvl > slvl && expireFrame > 0) {
|
|
// Higher level buff remains active
|
|
return {
|
|
applied: false,
|
|
stateName,
|
|
stateId,
|
|
activeSlvl: this.entries[idx]!.slvl,
|
|
}
|
|
}
|
|
this.entries[idx] = newEntry
|
|
} else {
|
|
this.entries.push(newEntry)
|
|
}
|
|
}
|
|
|
|
this.statList.addOrUpdateModifier({
|
|
id: entryId,
|
|
stateName,
|
|
stateId,
|
|
sourceUnitId,
|
|
sourceSkillId,
|
|
slvl,
|
|
expireFrame,
|
|
isAura,
|
|
isCurse: curseType > 0,
|
|
curseType,
|
|
stats: params.stats ?? {},
|
|
})
|
|
|
|
const maxActiveSlvl = Math.max(
|
|
...this.entries.filter(e => e.stateName === stateName).map(e => e.slvl),
|
|
slvl,
|
|
)
|
|
|
|
return {
|
|
applied: true,
|
|
stateName,
|
|
stateId,
|
|
...(evictedState !== undefined ? { evictedState } : {}),
|
|
activeSlvl: maxActiveSlvl,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a specific state by name or ID.
|
|
*/
|
|
removeState(stateNameOrId: string | number): void {
|
|
const targetName =
|
|
typeof stateNameOrId === 'string'
|
|
? stateNameOrId.trim().toLowerCase().replace(/^state_/, '')
|
|
: undefined
|
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
|
const e = this.entries[i]!
|
|
if (
|
|
(typeof stateNameOrId === 'number' && e.stateId === stateNameOrId) ||
|
|
(targetName !== undefined && e.stateName === targetName)
|
|
) {
|
|
this.entries.splice(i, 1)
|
|
}
|
|
}
|
|
this.statList.removeModifiersByState(targetName ?? stateNameOrId)
|
|
}
|
|
|
|
/**
|
|
* Advance clock to `currentFrame` and expire any state whose `expireFrame <= currentFrame`.
|
|
*/
|
|
tick(currentFrame: number): string[] {
|
|
const expiredStates: string[] = []
|
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
|
const e = this.entries[i]!
|
|
if (e.expireFrame > 0 && e.expireFrame <= currentFrame) {
|
|
expiredStates.push(e.stateName)
|
|
this.entries.splice(i, 1)
|
|
}
|
|
}
|
|
this.statList.expireAtFrame(currentFrame)
|
|
return expiredStates
|
|
}
|
|
|
|
hasState(stateNameOrId: string | number): boolean {
|
|
if (typeof stateNameOrId === 'number') {
|
|
return this.entries.some(e => e.stateId === stateNameOrId)
|
|
}
|
|
const clean = stateNameOrId.trim().toLowerCase().replace(/^state_/, '')
|
|
return this.entries.some(e => e.stateName === clean)
|
|
}
|
|
|
|
getActiveStateNames(): string[] {
|
|
return Array.from(new Set(this.entries.map(e => `STATE_${e.stateName.toUpperCase()}`)))
|
|
}
|
|
|
|
getActiveOverlays(): string[] {
|
|
const set = new Set<string>()
|
|
for (const e of this.entries) {
|
|
for (const ov of e.overlays) {
|
|
set.add(ov)
|
|
}
|
|
}
|
|
return Array.from(set)
|
|
}
|
|
|
|
getEffectiveAuraSlvl(stateName: string): number {
|
|
const clean = stateName.trim().toLowerCase().replace(/^state_/, '')
|
|
let best = 0
|
|
for (const e of this.entries) {
|
|
if (e.stateName === clean && e.slvl > best) {
|
|
best = e.slvl
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/**
|
|
* Trigger reactive `auraeventfunc` handlers (`Chilling/Frozen/Shiver Armor`,
|
|
* `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`,
|
|
* `Clay/Blood Golem`) for a combat event.
|
|
*/
|
|
triggerReactiveEvents(params: {
|
|
event: 'hitbymissile' | 'attackedinmelee' | 'damagedinmelee' | 'damagedbymissile' | 'absorbdamage' | 'domeleedamage'
|
|
incomingPhys256?: number
|
|
incomingElem256?: number
|
|
attackerStateBus?: StateBus
|
|
ownerUnitStatList?: UnitStatList
|
|
}): ReactiveEventOutcome {
|
|
let remPhys256 = params.incomingPhys256 ?? 0
|
|
let remElem256 = params.incomingElem256 ?? 0
|
|
let absorbedPhys256 = 0
|
|
let absorbedElem256 = 0
|
|
let manaDrained256 = 0
|
|
let reflectedPhys256 = 0
|
|
let attackerHealed256 = 0
|
|
let ownerHealed256 = 0
|
|
const spawnedMissiles: string[] = []
|
|
const attackerStatesApplied: string[] = []
|
|
let attackerColdDamage256 = 0
|
|
|
|
for (const entry of [...this.entries]) {
|
|
const stateName = entry.stateName
|
|
|
|
// 1. Energy Shield (`state = energyshield` or `auraeventfunc = 24`) on `absorbdamage`
|
|
if (
|
|
params.event === 'absorbdamage' &&
|
|
(stateName === 'energyshield' || entry.reactiveEvents?.some(r => r.funcId === 24))
|
|
) {
|
|
const totalIncoming256 = remPhys256 + remElem256
|
|
if (totalIncoming256 > 0) {
|
|
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? (20 + Math.min(75, entry.slvl * 3)))
|
|
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
|
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis
|
|
const manaRatio16 = Math.max(12, 32 - tkBlvl)
|
|
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
|
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
|
const currentMana256 = this.statList.getMana256()
|
|
|
|
if (currentMana256 >= requiredMana256) {
|
|
this.statList.setMana256(currentMana256 - requiredMana256)
|
|
manaDrained256 += requiredMana256
|
|
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
|
const elemPart = Math.trunc((remElem256 * absorbPct) / 100)
|
|
absorbedPhys256 += physPart
|
|
absorbedElem256 += elemPart
|
|
remPhys256 -= physPart
|
|
remElem256 -= elemPart
|
|
} else {
|
|
// Partial absorb and Energy Shield collapses
|
|
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
|
this.statList.setMana256(0)
|
|
manaDrained256 += currentMana256
|
|
const physPart = Math.min(remPhys256, actualRedirect256)
|
|
const elemPart = Math.min(remElem256, actualRedirect256 - physPart)
|
|
absorbedPhys256 += physPart
|
|
absorbedElem256 += elemPart
|
|
remPhys256 -= physPart
|
|
remElem256 -= elemPart
|
|
this.removeState('energyshield')
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Bone Armor (`state = bonearmor` or `auraeventfunc = 22`) absorbs physical damage
|
|
if (
|
|
(params.event === 'damagedinmelee' || params.event === 'absorbdamage') &&
|
|
stateName === 'bonearmor' &&
|
|
remPhys256 > 0
|
|
) {
|
|
const shieldPool256 = (entry.stats['damagearmor'] ?? (entry.slvl * 15)) * FIXED_ONE
|
|
const absorb = Math.min(remPhys256, shieldPool256)
|
|
absorbedPhys256 += absorb
|
|
remPhys256 -= absorb
|
|
const newPool = Math.floor((shieldPool256 - absorb) / FIXED_ONE)
|
|
if (newPool <= 0) {
|
|
this.removeState('bonearmor')
|
|
} else {
|
|
this.applyState({
|
|
stateNameOrId: 'bonearmor',
|
|
slvl: entry.slvl,
|
|
stats: { ...entry.stats, damagearmor: newPool },
|
|
})
|
|
}
|
|
}
|
|
|
|
// 3. Cyclone Armor (`state = cyclonearmor` or `auraeventfunc = 25`) absorbs elemental damage
|
|
if (
|
|
params.event === 'absorbdamage' &&
|
|
stateName === 'cyclonearmor' &&
|
|
remElem256 > 0
|
|
) {
|
|
const shieldPool256 = (entry.stats['magicarmor'] ?? (40 + entry.slvl * 15)) * FIXED_ONE
|
|
const absorb = Math.min(remElem256, shieldPool256)
|
|
absorbedElem256 += absorb
|
|
remElem256 -= absorb
|
|
const newPool = Math.floor((shieldPool256 - absorb) / FIXED_ONE)
|
|
if (newPool <= 0) {
|
|
this.removeState('cyclonearmor')
|
|
} else {
|
|
this.applyState({
|
|
stateNameOrId: 'cyclonearmor',
|
|
slvl: entry.slvl,
|
|
stats: { ...entry.stats, magicarmor: newPool },
|
|
})
|
|
}
|
|
}
|
|
|
|
// 4. Chilling Armor (`state = chillingarmor`, `auraeventfunc = 1`) on `hitbymissile`
|
|
if (params.event === 'hitbymissile' && stateName === 'chillingarmor') {
|
|
spawnedMissiles.push('chillingarmorbolt')
|
|
}
|
|
|
|
// 5. Frozen Armor (`state = frozenarmor`, `auraeventfunc = 2`) on `attackedinmelee`
|
|
if (params.event === 'attackedinmelee' && stateName === 'frozenarmor') {
|
|
attackerStatesApplied.push('freeze')
|
|
params.attackerStateBus?.applyState({
|
|
stateNameOrId: 'freeze',
|
|
slvl: entry.slvl,
|
|
durationFrames: 30 + entry.slvl * 3,
|
|
})
|
|
}
|
|
|
|
// 6. Shiver Armor (`state = shiverarmor`, `auraeventfunc = 3`) on `attackedinmelee`
|
|
if (params.event === 'attackedinmelee' && stateName === 'shiverarmor') {
|
|
attackerStatesApplied.push('cold')
|
|
attackerColdDamage256 += (6 + entry.slvl * 4) * FIXED_ONE
|
|
params.attackerStateBus?.applyState({
|
|
stateNameOrId: 'cold',
|
|
slvl: entry.slvl,
|
|
durationFrames: 100,
|
|
})
|
|
}
|
|
|
|
// 7. Iron Maiden (`state = ironmaiden`, `auraeventfunc = 4`) on `damagedinmelee`
|
|
if (params.event === 'damagedinmelee' && stateName === 'ironmaiden' && remPhys256 > 0) {
|
|
const reflectPct = entry.stats['thorns_percent'] ?? (175 + entry.slvl * 25)
|
|
reflectedPhys256 += Math.trunc((remPhys256 * reflectPct) / 100)
|
|
}
|
|
|
|
// 8. Life Tap (`state = lifetap`, `auraeventfunc = 5`) on `damagedinmelee` / `damagedbymissile`
|
|
if (
|
|
(params.event === 'damagedinmelee' || params.event === 'damagedbymissile') &&
|
|
stateName === 'lifetap' &&
|
|
remPhys256 > 0
|
|
) {
|
|
attackerHealed256 += Math.trunc(remPhys256 / 2)
|
|
}
|
|
|
|
// 9. Blood Golem (`state = bloodgolem`, `auraeventfunc = 23/26`)
|
|
// CRITICAL 1.13c RULE (`patch.txt` line 124): Blood Golem no longer has negative shared life
|
|
// when taking damage (`damagedinmelee` does NOT drain owner HP!), while positive life steal
|
|
// on `domeleedamage` heals the Necromancer (`30%`).
|
|
if (params.event === 'domeleedamage' && stateName === 'bloodgolem' && remPhys256 > 0) {
|
|
const heal = Math.trunc((remPhys256 * 30) / 100)
|
|
ownerHealed256 += heal
|
|
if (params.ownerUnitStatList) {
|
|
params.ownerUnitStatList.setHp256(params.ownerUnitStatList.getHp256() + heal)
|
|
}
|
|
}
|
|
|
|
// 10. Clay Golem (`state = claygolem`, `auraeventfunc = 27`) slows enemy on melee contact
|
|
if (params.event === 'attackedinmelee' && stateName === 'claygolem') {
|
|
attackerStatesApplied.push('slow')
|
|
params.attackerStateBus?.applyState({
|
|
stateNameOrId: 'slow',
|
|
slvl: entry.slvl,
|
|
durationFrames: 200,
|
|
stats: { item_slow: Math.min(75, 15 + entry.slvl * 3) },
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
absorbedPhys256,
|
|
absorbedElem256,
|
|
manaDrained256,
|
|
reflectedPhys256,
|
|
attackerHealed256,
|
|
ownerHealed256,
|
|
spawnedMissiles,
|
|
attackerStatesApplied,
|
|
attackerColdDamage256,
|
|
}
|
|
}
|
|
}
|