import { create } from "zustand"; import type { MobStatusKind } from "./abilityCatalog"; import { useCombatStore } from "./combatStore"; import type { ManastormAffixState, ManastormEncounterDefinition, } from "./manastorm"; import { useManastormStore } from "./manastormStore"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; export type ManastormAffixFidelity = | "native" | "partial" | "substitution" | "observable-only"; export interface ManastormAffixProvenance { readonly source: "ascension-client-dbc"; readonly description: string; readonly spellId?: number; readonly sourceRecordIds?: readonly number[]; readonly sourceReportId?: string; readonly sourceSpellDbcSha256?: string; readonly fidelity: ManastormAffixFidelity; readonly substitution?: string; } export interface ManastormAffixAssignment { readonly runtimeId: string; readonly affixId: string; readonly trigger: "passive" | "on-death"; readonly provenance: ManastormAffixProvenance; } export interface ManastormAffixEffect { readonly id: string; readonly affixId: string; readonly sourceRuntimeId: string; readonly mechanic: string; readonly startsAt: number; readonly nextTickAt: number; readonly intervalMs: number; readonly remainingTicks: number; readonly magnitude: number; readonly state: "scheduled" | "active" | "completed"; readonly provenance: ManastormAffixProvenance; } export interface ManastormAffixEvent { readonly id: number; readonly stageKey: string; readonly affixId: string; readonly sourceRuntimeId: string; readonly trigger: "passive" | "timed" | "on-death"; readonly mechanic: string; readonly occurredAt: number; readonly targetIds: readonly string[]; readonly amount?: number; readonly provenance: ManastormAffixProvenance; } interface ManastormAffixRuntimeState { readonly stageKey: string | null; readonly assignments: readonly ManastormAffixAssignment[]; readonly effects: readonly ManastormAffixEffect[]; readonly events: readonly ManastormAffixEvent[]; readonly handledDeathIds: readonly string[]; readonly initializedPassiveIds: readonly string[]; readonly eventRevision: number; } const initialState = (): ManastormAffixRuntimeState => ({ stageKey: null, assignments: [], effects: [], events: [], handledDeathIds: [], initializedPassiveIds: [], eventRevision: 0, }); export const useManastormAffixStore = create(() => initialState()); export interface ManastormAffixImplementation { readonly fidelity: ManastormAffixFidelity; readonly substitution?: string; readonly mechanic: string; } const AFFIX_MECHANICS: Readonly> = Object.freeze({ "manastorm-affix-manabomb": { mechanic: "resource-drain", fidelity: "partial", substitution: "Drains player resource globally; enemy resource pools and blast-radius filtering are unavailable.", }, "manastorm-affix-lightningrod": { mechanic: "periodic-lightning", fidelity: "substitution", substitution: "Uses stage-wide periodic nature damage in place of spatial lightning orbs.", }, "manastorm-affix-rampaging": { mechanic: "rampaging-damage", fidelity: "substitution", substitution: "Uses a reviewed 10% increase every 5 seconds; unresolved DBC tooltip tokens do not expose the original magnitude or cadence.", }, "manastorm-affix-aethyricwinds": { mechanic: "haste-zone", fidelity: "partial", substitution: "Applies six seconds of stage-wide 20% melee haste to living enemies; player, companion, movement, ranged, spell, and spatial haste are unavailable.", }, "manastorm-affix-coldsnap": { mechanic: "cold-pulse", fidelity: "substitution", substitution: "Uses stage-wide frost damage; spatial orb movement and slows are not simulated.", }, "manastorm-affix-tribalfury": { mechanic: "ally-damage-aura", fidelity: "substitution", substitution: "Applies to all living stage enemies because mob-to-mob proximity is not exposed.", }, "manastorm-affix-vortex": { mechanic: "delayed-grip", fidelity: "substitution", substitution: "After three seconds, uses a stage-wide 5% Arcane pulse on the party and roots surviving enemies for two seconds; forced movement and 15-yard filtering are unavailable.", }, "manastorm-affix-stalwart": { mechanic: "ally-protection", fidelity: "substitution", substitution: "Uses bonus maximum health in place of nearby-ally damage reduction.", }, "manastorm-affix-fungal": { mechanic: "fungal-pulses", fidelity: "substitution", substitution: "Uses stage-wide nature pulses in place of positioned mushroom hazards.", }, "manastorm-affix-wildmagics": { mechanic: "wild-healing", fidelity: "substitution", substitution: "Uses stage-wide periodic healing because area-radius queries are unavailable.", }, "manastorm-affix-runesofpower": { mechanic: "power-rune", fidelity: "substitution", substitution: "Uses a stage-wide 10% heal for living actors plus a lasting 10% enemy damage increase; positioned, temporary player damage and healing amplification are unavailable.", }, "manastorm-affix-reflecting": { mechanic: "reflecting-shield", fidelity: "substitution", substitution: "Uses a deterministic 4% max-health Arcane retaliation pulse every eight seconds while the source lives; per-attack reflection and enemy shield state are unavailable.", }, "manastorm-affix-bonestorm": { mechanic: "bone-storm", fidelity: "substitution", substitution: "Uses stage-wide physical pulses in place of a pursuing spatial hazard.", }, "manastorm-affix-overwhelmingenergy": { mechanic: "arcane-missiles", fidelity: "substitution", substitution: "Uses deterministic periodic hero damage without portal geometry or line-of-sight checks.", }, }); function stableHash(value: string): number { let hash = 2_166_136_261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } return hash >>> 0; } export function manastormAffixImplementation(affixId: string): ManastormAffixImplementation { return AFFIX_MECHANICS[affixId] ?? { mechanic: "unknown-affix", fidelity: "observable-only", substitution: "No reviewed gameplay mapping exists for this imported modifier.", }; } export function manastormAffixProvenance(affix: ManastormAffixState): ManastormAffixProvenance { const mechanic = manastormAffixImplementation(affix.id); return Object.freeze({ source: "ascension-client-dbc" as const, description: affix.description, ...(affix.spellId !== undefined ? { spellId: affix.spellId } : {}), ...(affix.sourceRecordIds ? { sourceRecordIds: affix.sourceRecordIds } : {}), ...(affix.sourceReportId ? { sourceReportId: affix.sourceReportId } : {}), ...(affix.sourceSpellDbcSha256 ? { sourceSpellDbcSha256: affix.sourceSpellDbcSha256 } : {}), fidelity: mechanic.fidelity, ...(mechanic.substitution ? { substitution: mechanic.substitution } : {}), }); } export function assignManastormAffixes( encounter: ManastormEncounterDefinition, affixes: readonly ManastormAffixState[], level: number, ): readonly ManastormAffixAssignment[] { if (!affixes.length) return []; const deterministicPool = [...affixes].sort((left, right) => left.id.localeCompare(right.id)); const runtimeIds = [...(encounter.trashRuntimeIds ?? []), encounter.bossRuntimeId] .filter((id, index, ids) => Boolean(id) && ids.indexOf(id) === index) .sort(); return runtimeIds.map((runtimeId) => { const affix = deterministicPool[ stableHash(`${encounter.id}:${level}:${runtimeId}`) % deterministicPool.length ]; return Object.freeze({ runtimeId, affixId: affix.id, trigger: affix.trigger ?? "passive", provenance: manastormAffixProvenance(affix), }); }); } function appendEvent( stageKey: string, assignment: ManastormAffixAssignment, trigger: ManastormAffixEvent["trigger"], mechanic: string, occurredAt: number, targetIds: readonly string[], amount?: number, ): void { useManastormAffixStore.setState((state) => { const eventRevision = state.eventRevision + 1; const event: ManastormAffixEvent = Object.freeze({ id: eventRevision, stageKey, affixId: assignment.affixId, sourceRuntimeId: assignment.runtimeId, trigger, mechanic, occurredAt, targetIds: Object.freeze([...targetIds]), ...(amount !== undefined ? { amount } : {}), provenance: assignment.provenance, }); return { events: [...state.events.slice(-99), event], eventRevision, }; }); } function livingStageMobIds(): readonly string[] { const combat = useCombatStore.getState(); const encounter = useManastormStore.getState().currentEncounter; if (!encounter) return []; return [...(encounter.trashRuntimeIds ?? []), encounter.bossRuntimeId] .filter((runtimeId) => Boolean(combat.mobs[runtimeId] && !combat.mobs[runtimeId].dead)); } function applyDamagePulse(percent: number, now: number, school: "arcane" | "frost" | "nature" | "physical"): readonly string[] { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const targetIds: string[] = []; if (combat.health > 0) { combat.damagePlayer(Math.max(1, Math.round(combat.maxHealth * percent)), school, combat.level); targetIds.push("player"); } for (const member of party.members) { if (member.health <= 0) continue; party.damageMember(member.id, Math.max(1, Math.round(member.maxHealth * percent)), school, combat.level); targetIds.push(member.id); } for (const runtimeId of livingStageMobIds()) { const mob = useCombatStore.getState().mobs[runtimeId]; if (!mob) continue; useCombatStore.getState().damageMob( runtimeId, Math.max(1, Math.round(mob.maxHealth * percent)), now, undefined, school, ); targetIds.push(runtimeId); } return targetIds; } function applyHealingPulse(percent: number): readonly string[] { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const targetIds: string[] = []; if (combat.health > 0 && combat.health < combat.maxHealth) { combat.healPlayer(Math.max(1, Math.round(combat.maxHealth * percent))); targetIds.push("player"); } for (const member of party.members) { if (member.health <= 0 || member.health >= member.maxHealth) continue; party.healMember(member.id, Math.max(1, Math.round(member.maxHealth * percent))); targetIds.push(member.id); } const mobIds = livingStageMobIds(); useCombatStore.setState((state) => ({ mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [ id, mobIds.includes(id) ? { ...mob, health: Math.min(mob.maxHealth, mob.health + Math.max(1, Math.round(mob.maxHealth * percent))) } : mob, ])), })); targetIds.push(...mobIds); return targetIds; } function applyPartyDamagePulse( percent: number, school: "arcane" | "frost" | "nature" | "physical", ): readonly string[] { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const targetIds: string[] = []; if (combat.health > 0) { combat.damagePlayer(Math.max(1, Math.round(combat.maxHealth * percent)), school, combat.level); targetIds.push("player"); } for (const member of party.members) { if (member.health <= 0) continue; party.damageMember(member.id, Math.max(1, Math.round(member.maxHealth * percent)), school, combat.level); targetIds.push(member.id); } return targetIds; } function applyLivingMobStatus( kind: MobStatusKind, sourceAbilityId: string, now: number, durationMs: number, magnitude?: number, ): readonly string[] { const targetIds = livingStageMobIds(); useCombatStore.setState((state) => ({ mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [ id, targetIds.includes(id) ? { ...mob, statuses: [ ...mob.statuses.filter((status) => ( status.kind !== kind || status.sourceAbilityId !== sourceAbilityId )), { kind, sourceAbilityId, endsAt: now + durationMs, ...(magnitude === undefined ? {} : { magnitude }), }, ], } : mob, ])), })); return targetIds; } function empowerLivingStageMobs(percent: number): readonly string[] { const targetIds = livingStageMobIds(); for (const runtimeId of targetIds) { const mob = useCombatStore.getState().mobs[runtimeId]; if (!mob) continue; useCombatStore.getState().registerMob(runtimeId, { name: mob.name, damageMultiplier: mob.damageMultiplier * (1 + percent), }); } return targetIds; } function scheduleEffect( assignment: ManastormAffixAssignment, mechanic: string, startsAt: number, intervalMs: number, remainingTicks: number, magnitude: number, ): void { const id = `${assignment.runtimeId}:${assignment.affixId}:${startsAt}:${mechanic}`; useManastormAffixStore.setState((state) => ({ effects: [...state.effects, Object.freeze({ id, affixId: assignment.affixId, sourceRuntimeId: assignment.runtimeId, mechanic, startsAt, nextTickAt: startsAt, intervalMs, remainingTicks, magnitude, state: "scheduled" as const, provenance: assignment.provenance, })], })); } function applyPassive(stageKey: string, assignment: ManastormAffixAssignment, now: number): void { const mechanic = manastormAffixImplementation(assignment.affixId).mechanic; const combat = useCombatStore.getState(); const targetIds = livingStageMobIds().filter((runtimeId) => runtimeId !== assignment.runtimeId); if (mechanic === "rampaging-damage") { scheduleEffect(assignment, mechanic, now + 5_000, 5_000, Number.POSITIVE_INFINITY, 0.1); } else if (mechanic === "wild-healing") { scheduleEffect(assignment, mechanic, now + 6_000, 6_000, Number.POSITIVE_INFINITY, 0.05); } else if (mechanic === "reflecting-shield") { scheduleEffect(assignment, mechanic, now + 8_000, 8_000, Number.POSITIVE_INFINITY, 0.04); } else if (mechanic === "ally-damage-aura") { for (const runtimeId of targetIds) { const mob = useCombatStore.getState().mobs[runtimeId]; if (mob) combat.registerMob(runtimeId, { name: mob.name, damageMultiplier: mob.damageMultiplier * 1.15, }); } } else if (mechanic === "ally-protection") { useCombatStore.setState((state) => ({ mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => { if (!targetIds.includes(id)) return [id, mob]; const maxHealth = Math.max(mob.maxHealth + 1, Math.round(mob.maxHealth * 1.2)); return [id, { ...mob, maxHealth, health: mob.health + (maxHealth - mob.maxHealth) }]; })), })); } appendEvent(stageKey, assignment, "passive", mechanic, now, targetIds); } function handleDeath(stageKey: string, assignment: ManastormAffixAssignment, now: number): void { const mechanic = manastormAffixImplementation(assignment.affixId).mechanic; if (mechanic === "resource-drain") { const combat = useCombatStore.getState(); const amount = Math.max(1, Math.round(combat.maxResource * 0.2)); useCombatStore.setState({ resource: Math.max(0, combat.resource - amount) }); appendEvent(stageKey, assignment, "on-death", mechanic, now, ["player"], amount); return; } const timings: Readonly> = { "periodic-lightning": [now, 2_000, 3, 0.06], "cold-pulse": [now, 0, 1, 0.06], "delayed-grip": [now + 3_000, 0, 1, 0.05], "fungal-pulses": [now + 1_000, 2_000, 3, 0.04], "power-rune": [now, 0, 1, 0.1], "bone-storm": [now, 1_500, 3, 0.05], "arcane-missiles": [now + 1_000, 1_000, 4, 0.04], "haste-zone": [now, 0, 1, 0.2], }; const timing = timings[mechanic] ?? [now, 0, 1, 0]; scheduleEffect(assignment, mechanic, timing[0], timing[1], timing[2], timing[3]); appendEvent(stageKey, assignment, "on-death", mechanic, now, []); } function executeEffect(stageKey: string, effect: ManastormAffixEffect, now: number): readonly string[] { if (effect.mechanic === "wild-healing") return applyHealingPulse(effect.magnitude); if (effect.mechanic === "rampaging-damage") { const mob = useCombatStore.getState().mobs[effect.sourceRuntimeId]; if (!mob || mob.dead) return []; useCombatStore.getState().registerMob(effect.sourceRuntimeId, { name: mob.name, damageMultiplier: mob.damageMultiplier * (1 + effect.magnitude), }); return [effect.sourceRuntimeId]; } if (effect.mechanic === "periodic-lightning") return applyDamagePulse(effect.magnitude, now, "nature"); if (effect.mechanic === "cold-pulse") return applyDamagePulse(effect.magnitude, now, "frost"); if (effect.mechanic === "fungal-pulses") return applyDamagePulse(effect.magnitude, now, "nature"); if (effect.mechanic === "bone-storm") return applyDamagePulse(effect.magnitude, now, "physical"); if (effect.mechanic === "haste-zone") { return applyLivingMobStatus( "attack-speed", effect.affixId, now, 6_000, -effect.magnitude, ); } if (effect.mechanic === "delayed-grip") { const partyTargets = applyPartyDamagePulse(effect.magnitude, "arcane"); const mobTargets = applyLivingMobStatus("root", effect.affixId, now, 2_000); return [...new Set([...partyTargets, ...mobTargets])]; } if (effect.mechanic === "power-rune") { const healedTargets = applyHealingPulse(effect.magnitude); const empoweredTargets = empowerLivingStageMobs(effect.magnitude); return [...new Set([...healedTargets, ...empoweredTargets])]; } if (effect.mechanic === "reflecting-shield") { const source = useCombatStore.getState().mobs[effect.sourceRuntimeId]; return source && !source.dead ? applyPartyDamagePulse(effect.magnitude, "arcane") : []; } if (effect.mechanic === "arcane-missiles") { const combat = useCombatStore.getState(); if (combat.health <= 0) return []; combat.damagePlayer(Math.max(1, Math.round(combat.maxHealth * effect.magnitude)), "arcane", combat.level); return ["player"]; } return []; } function tickEffects(stageKey: string, now: number): void { const state = useManastormAffixStore.getState(); const due = state.effects.filter((effect) => effect.state !== "completed" && now >= effect.nextTickAt); for (const effect of due) { const assignment = state.assignments.find((candidate) => ( candidate.runtimeId === effect.sourceRuntimeId && candidate.affixId === effect.affixId )); if (!assignment) continue; const targets = executeEffect(stageKey, effect, now); appendEvent(stageKey, assignment, "timed", effect.mechanic, now, targets, effect.magnitude); useManastormAffixStore.setState((current) => ({ effects: current.effects.map((candidate) => { if (candidate.id !== effect.id) return candidate; const remainingTicks = Number.isFinite(candidate.remainingTicks) ? candidate.remainingTicks - 1 : candidate.remainingTicks; return { ...candidate, remainingTicks, nextTickAt: candidate.intervalMs > 0 ? now + candidate.intervalMs : now, state: remainingTicks <= 0 ? "completed" : "active", }; }), })); } } export function resetManastormAffixRuntime(): void { useManastormAffixStore.setState(initialState()); } export function synchronizeManastormAffixes(now = Date.now()): void { const game = useGameStore.getState(); if (game.gameMode !== "manastorm" || game.paused) return; const manastorm = useManastormStore.getState(); const encounter = manastorm.currentEncounter; if (!encounter || manastorm.status === "idle" || manastorm.status === "failed" || manastorm.status === "completed") { return; } const stageKey = `${manastorm.catalogId}:${manastorm.transitionRevision}:${game.sessionRevision}:${manastorm.level}:${encounter.id}`; let runtime = useManastormAffixStore.getState(); if (runtime.stageKey !== stageKey) { useManastormAffixStore.setState({ ...initialState(), stageKey, assignments: assignManastormAffixes(encounter, manastorm.activeAffixes, manastorm.level), }); runtime = useManastormAffixStore.getState(); } const mobs = useCombatStore.getState().mobs; for (const assignment of runtime.assignments) { if (!mobs[assignment.runtimeId]) continue; if (assignment.trigger === "passive" && !runtime.initializedPassiveIds.includes(assignment.runtimeId)) { applyPassive(stageKey, assignment, now); useManastormAffixStore.setState((state) => ({ initializedPassiveIds: [...state.initializedPassiveIds, assignment.runtimeId], })); } } runtime = useManastormAffixStore.getState(); for (const assignment of runtime.assignments) { if ( assignment.trigger !== "on-death" || runtime.handledDeathIds.includes(assignment.runtimeId) || !useCombatStore.getState().mobs[assignment.runtimeId]?.dead ) continue; handleDeath(stageKey, assignment, now); useManastormAffixStore.setState((state) => ({ handledDeathIds: [...state.handledDeathIds, assignment.runtimeId], })); } tickEffects(stageKey, now); }