forked from phenom/healer-man
309 lines
11 KiB
TypeScript
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;
|
|
}
|