Files
healer-man/src/game/manastormAffixRuntime.ts
T
2026-08-14 15:56:39 -04:00

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);
}