/** * 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> 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> 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() 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, } } }