import { activateManastormStage } from "./manastormStageLoader"; import { useCombatStore } from "./combatStore"; import { synchronizeManastormAffixes } from "./manastormAffixRuntime"; import { useManastormStore } from "./manastormStore"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; import { manastormChaoticLinkState, type ManastormChaoticLinkState, } from "./manastormChaoticLink"; import type { MobCombatState } from "./combatStore"; export type ManastormResurrectionTarget = | { readonly kind: "player" } | { readonly kind: "companion"; readonly memberId: string }; export type ManastormDownResolution = "ignored" | "revived" | "downed" | "failed"; interface ChaoticLinkBossBaseline { readonly stageKey: string; baseMaxHealth: number; baseAttackDamage: number; baseHealthMultiplier: number; baseDamageMultiplier: number; appliedMaxHealth: number; appliedAttackDamage: number; appliedHealthMultiplier: number; appliedDamageMultiplier: number; } let chaoticLinkBossBaseline: ChaoticLinkBossBaseline | null = null; function sameNumber(left: number, right: number): boolean { return Math.abs(left - right) <= 1e-9; } function currentLinkBaseline( stageKey: string, boss: MobCombatState, ): ChaoticLinkBossBaseline { if (chaoticLinkBossBaseline?.stageKey === stageKey) { const baseline = chaoticLinkBossBaseline; // Preserve unrelated runtime buffs applied after Chaotic Link. The affix // runtime, for example, can increase a boss multiplier independently. if (!sameNumber(boss.damageMultiplier, baseline.appliedDamageMultiplier)) { const ratio = boss.damageMultiplier / Math.max(0.01, baseline.appliedDamageMultiplier); baseline.baseDamageMultiplier *= ratio; baseline.baseAttackDamage = Math.max(1, Math.round(baseline.baseAttackDamage * ratio)); } if (boss.maxHealth !== baseline.appliedMaxHealth) { const ratio = boss.maxHealth / Math.max(1, baseline.appliedMaxHealth); baseline.baseMaxHealth = Math.max(1, Math.round(baseline.baseMaxHealth * ratio)); baseline.baseHealthMultiplier *= ratio; } return baseline; } chaoticLinkBossBaseline = { stageKey, baseMaxHealth: boss.maxHealth, baseAttackDamage: boss.attackDamage, baseHealthMultiplier: boss.healthMultiplier, baseDamageMultiplier: boss.damageMultiplier, appliedMaxHealth: boss.maxHealth, appliedAttackDamage: boss.attackDamage, appliedHealthMultiplier: boss.healthMultiplier, appliedDamageMultiplier: boss.damageMultiplier, }; return chaoticLinkBossBaseline; } /** * Applies Ascension's published Chaotic Link stack tuning to the active boss. * The baseline is scoped to one stage revision, so ordinary dungeons and later * Manastorm stages cannot inherit this floor's aura. */ export function synchronizeManastormChaoticLink(): ManastormChaoticLinkState | null { const manastorm = useManastormStore.getState(); const encounter = manastorm.currentEncounter; if (!encounter) return null; const combat = useCombatStore.getState(); const link = manastormChaoticLinkState(encounter, combat.mobs); const boss = combat.mobs[encounter.bossRuntimeId]; if (!boss || boss.dead) return link; const stageKey = `${manastorm.catalogId}:${manastorm.transitionRevision}:${encounter.id}:${encounter.bossRuntimeId}`; const baseline = currentLinkBaseline(stageKey, boss); const maxHealth = Math.max( 1, Math.round(baseline.baseMaxHealth * link.bossHealthMultiplier), ); const attackDamage = Math.max( 1, Math.round(baseline.baseAttackDamage * link.bossDamageMultiplier), ); const healthMultiplier = baseline.baseHealthMultiplier * link.bossHealthMultiplier; const damageMultiplier = baseline.baseDamageMultiplier * link.bossDamageMultiplier; if ( boss.maxHealth === maxHealth && boss.attackDamage === attackDamage && sameNumber(boss.healthMultiplier, healthMultiplier) && sameNumber(boss.damageMultiplier, damageMultiplier) ) { return link; } const healthRatio = boss.health / Math.max(1, boss.maxHealth); const health = Math.max(1, Math.min(maxHealth, Math.round(maxHealth * healthRatio))); baseline.appliedMaxHealth = maxHealth; baseline.appliedAttackDamage = attackDamage; baseline.appliedHealthMultiplier = healthMultiplier; baseline.appliedDamageMultiplier = damageMultiplier; useCombatStore.setState((state) => { const current = state.mobs[encounter.bossRuntimeId]; if (!current || current.dead) return state; return { mobs: { ...state.mobs, [encounter.bossRuntimeId]: { ...current, health, maxHealth, healthMultiplier, damageMultiplier, attackDamage, }, }, }; }); return link; } /** Keeps total group size (player plus companions) within the selected 1-5 contract. */ export function synchronizeManastormPartySize(): void { const partySize = useManastormStore.getState().partySize; const companionCount = Math.max(0, partySize - 1); const party = usePartyStore.getState(); if (party.members.length <= companionCount) return; const members = party.members.slice(0, companionCount); const memberIds = new Set(members.map((member) => member.id)); usePartyStore.setState({ members, selectedMemberId: party.selectedMemberId && memberIds.has(party.selectedMemberId) ? party.selectedMemberId : null, activeMobId: null, objectiveBossId: null, memberTargetIds: Object.fromEntries( Object.entries(party.memberTargetIds).filter(([memberId]) => memberIds.has(memberId)), ), }); } function activateCurrentStage(preserveCharacter: boolean): boolean { const encounter = useManastormStore.getState().currentEncounter; if (!encounter) return false; const result = activateManastormStage( encounter, undefined, preserveCharacter, useGameStore.getState().gmTestMode, ); if (result.activated) synchronizeManastormPartySize(); return result.activated; } /** * Reconciles the staged Manastorm flow with authoritative combat state. * Keeping this outside React makes the same rules usable by the bridge and * by headless integration tests. */ export function synchronizeManastormCombat(): void { synchronizeManastormAffixes(); const manastorm = useManastormStore.getState(); const encounter = manastorm.currentEncounter; if (!encounter) return; const link = synchronizeManastormChaoticLink(); const mobs = useCombatStore.getState().mobs; if (manastorm.status === "clearing-trash") { const boss = mobs[encounter.bossRuntimeId]; // Skipping guardians is a legal high-risk route. If the strengthened boss // dies first, advance the objective without requiring obsolete trash. if (boss?.dead) { manastorm.observeTrash(true); useManastormStore.getState().observeBoss(boss.dead, boss.looted); return; } // Around half of the linked creatures remove every aura stack. Remaining // guardians stay in the encounter and can still be killed for safety. manastorm.observeTrash(link?.broken ?? false); return; } if (manastorm.status === "fighting" || manastorm.status === "awaiting-loot") { const boss = mobs[encounter.bossRuntimeId]; if (boss) manastorm.observeBoss(boss.dead, boss.looted); } } export function advanceManastormSession(random: () => number = Math.random): boolean { const manastorm = useManastormStore.getState(); if (!manastorm.beginTransition()) return false; const encounter = useManastormStore.getState().prepareNextEncounter(random); if (!encounter) { if (useManastormStore.getState().status === "completed") return true; useManastormStore.getState().failRun(); return false; } if (!activateCurrentStage(true)) { useManastormStore.getState().failRun(); return false; } useManastormStore.getState().finishTransition(); return true; } function activeCompanions() { const companionCount = Math.max(0, useManastormStore.getState().partySize - 1); return usePartyStore.getState().members.slice(0, companionCount); } function firstFallenActor(): ManastormResurrectionTarget | null { if (useCombatStore.getState().health <= 0) return { kind: "player" }; const companion = activeCompanions().find((member) => member.health <= 0); return companion ? { kind: "companion", memberId: companion.id } : null; } function targetIsFallen(target: ManastormResurrectionTarget): boolean { if (target.kind === "player") return useCombatStore.getState().health <= 0; return Boolean(activeCompanions().find( (member) => member.id === target.memberId && member.health <= 0, )); } function reviveTarget(target: ManastormResurrectionTarget): boolean { if (target.kind === "player") { const combat = useCombatStore.getState(); if (combat.health > 0 || combat.maxHealth <= 0) return false; combat.revivePlayer(1); return useCombatStore.getState().health > 0; } let revived = false; usePartyStore.setState((state) => ({ members: state.members.map((member) => { if (member.id !== target.memberId || member.health > 0 || member.maxHealth <= 0) { return member; } revived = true; return { ...member, health: member.maxHealth, status: state.command === "stop" ? "holding" : "ready", nextActionAt: 0, }; }), })); return revived; } /** * Spends one of the five floor-local shared charges to revive exactly one * fallen actor. Encounter, mob, position, resource, and other party state stay * intact. */ export function resurrectManastormSession( requestedTarget?: ManastormResurrectionTarget, ): boolean { const manastorm = useManastormStore.getState(); const target = requestedTarget ?? firstFallenActor(); if (!manastorm.currentEncounter || !target || !targetIsFallen(target)) return false; if (!manastorm.useResurrectionCharge()) return false; return reviveTarget(target); } export function manastormPartyIsDefeated(): boolean { return useCombatStore.getState().health <= 0 && activeCompanions().every((member) => member.health <= 0); } /** * Resolves a newly downed actor without spending a shared resurrection on the * player's behalf. A charge can be used explicitly from the defeated prompt; * only a full-party defeat with no remaining charge fails the run. */ export function resolveManastormActorDown( target: ManastormResurrectionTarget, ): ManastormDownResolution { if (!targetIsFallen(target)) return "ignored"; if (!manastormPartyIsDefeated()) return "downed"; if (useManastormStore.getState().resurrectionCharges > 0) return "downed"; useManastormStore.getState().failRun(); return "failed"; } export function restartManastormAtCheckpoint(random: () => number = Math.random): boolean { const encounter = useManastormStore.getState().restartFromCheckpoint(random); if (!encounter) return false; if (!activateCurrentStage(true)) { useManastormStore.getState().failRun(); return false; } return true; }