diablo2-web/src/game/engine/state-bus.ts

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,
}
}
}