Files
healer-man/src/game/manastormSession.ts
T
2026-08-18 16:22:19 -04:00

309 lines
11 KiB
TypeScript

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