578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
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<ManastormAffixRuntimeState>(() => initialState());
|
|
|
|
export interface ManastormAffixImplementation {
|
|
readonly fidelity: ManastormAffixFidelity;
|
|
readonly substitution?: string;
|
|
readonly mechanic: string;
|
|
}
|
|
|
|
const AFFIX_MECHANICS: Readonly<Record<string, ManastormAffixImplementation>> = 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<Record<string, readonly [number, number, number, number]>> = {
|
|
"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);
|
|
}
|