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

630 lines
20 KiB
TypeScript

import { create } from "zustand";
import {
MANASTORM_MAX_RESURRECTION_CHARGES,
drawManastormStage,
getManastormCatalog,
manastormAffixes,
manastormAvailableLoadoutIds,
manastormCheckpointForLevel,
manastormCacheChance,
manastormGroupModifier,
isManastormSpecialLevel,
manastormMessage,
manastormModeId,
manastormReward,
manastormRuntimeScaling,
normalizeManastormPartySize,
type ManastormEncounterDefinition,
type ManastormPartySize,
type ManastormRewardState,
type ManastormRewardCacheState,
type ManastormRunState,
} from "./manastorm";
import {
createManastormClientOverlayState,
withManastormNextWaveCountdown,
withManastormSafetyBubble,
type ManastormClientOverlayState,
type ManastormSafetyBubbleUpdate,
} from "./manastormClientOverlay";
import {
isManastormPortalCueId,
manastormCueForStatusTransition,
type ManastormClientCueRequest,
type ManastormPortalCueId,
} from "./manastormClientCues";
import {
resolveManastormActiveSpellLoadout,
validateManastormActiveSpellLoadout,
type ManastormActiveSpellLoadoutRequest,
} from "./manastormSpellLoadout";
export interface ManastormStartOptions {
readonly partySize?: number;
readonly startingLevel?: number;
readonly unlockedThroughLevel?: number;
readonly loadoutId?: string | null;
readonly activeSpellLoadout?: ManastormActiveSpellLoadoutRequest;
readonly modeId?: number;
readonly cacheMeter?: number;
readonly rewardCaches?: readonly ManastormRewardCacheState[];
readonly random?: () => number;
}
export interface ManastormClientRuntimeFields {
readonly clientOverlay: ManastormClientOverlayState;
readonly clientCue: ManastormClientCueRequest | null;
readonly clientCueRevision: number;
}
export interface ManastormState extends ManastormRunState, ManastormClientRuntimeFields {
startRun: (
randomOrOptions?: (() => number) | ManastormStartOptions,
) => ManastormEncounterDefinition;
enterStage: () => boolean;
observeTrash: (cleared: boolean) => void;
observeBoss: (dead: boolean, looted: boolean, random?: () => number) => void;
beginTransition: () => boolean;
prepareNextEncounter: (random?: () => number) => ManastormEncounterDefinition | null;
finishTransition: () => void;
useResurrectionCharge: () => boolean;
restartFromCheckpoint: (random?: () => number) => ManastormEncounterDefinition | null;
setPartySize: (partySize: number) => boolean;
setLoadout: (loadoutId: string | null) => boolean;
setActiveSpellLoadout: (selectedIds: readonly string[]) => boolean;
openRewardCache: (cacheId: string) => boolean;
setNextWaveCountdown: (seconds: number | null) => void;
setSafetyBubble: (update: ManastormSafetyBubbleUpdate) => void;
requestClientCue: (cueId: ManastormPortalCueId) => boolean;
clearClientCue: () => void;
failRun: () => boolean;
resetRun: () => void;
}
function safeMaximumLevel(): number {
return Math.max(1, Math.trunc(getManastormCatalog().maximumLevel ?? 16_384));
}
function runtimeFields(
level: number,
partySize: ManastormPartySize,
requestedLoadoutId?: string | null,
requestedModeId?: number,
unlockedThroughLevel = level,
) {
const catalog = getManastormCatalog();
const modeId = Math.trunc(requestedModeId ?? manastormModeId(catalog));
const availableLoadoutIds = manastormAvailableLoadoutIds(level, partySize, catalog);
const preferredLoadoutId = requestedLoadoutId === undefined
? catalog.defaultLoadoutId ?? null
: requestedLoadoutId;
const loadoutId = preferredLoadoutId && availableLoadoutIds.includes(preferredLoadoutId)
? preferredLoadoutId
: availableLoadoutIds[0] ?? null;
return {
catalogId: catalog.id ?? "manastorm-catalog",
modeId,
groupModifier: manastormGroupModifier(partySize, catalog, modeId),
scaling: manastormRuntimeScaling(level, partySize, catalog, modeId),
activeAffixes: manastormAffixes(level, catalog, modeId, unlockedThroughLevel),
availableLoadoutIds,
loadoutId,
message: manastormMessage(level, partySize, catalog, modeId),
};
}
const initialRunState = (
partySize: ManastormPartySize = 5,
transitionRevision = 0,
): ManastormRunState & ManastormClientRuntimeFields => {
const level = 1;
const runtime = runtimeFields(level, partySize);
return {
status: "idle",
phase: "idle",
level,
maximumLevel: safeMaximumLevel(),
unlockedThroughLevel: 1,
completed: 0,
checkpointLevel: 1,
milestone: false,
partySize,
maxResurrectionCharges: MANASTORM_MAX_RESURRECTION_CHARGES,
resurrectionCharges: MANASTORM_MAX_RESURRECTION_CHARGES,
currentEncounter: null,
encounterBag: [],
previousEncounterId: null,
transitionRevision,
...runtime,
pendingReward: null,
lastReward: null,
rewardCache: [],
cacheMeter: 0,
bonusCacheThreshold: 5,
currency: {},
activeSpellLoadout: resolveManastormActiveSpellLoadout(),
messageRevision: 0,
clientOverlay: createManastormClientOverlayState(),
clientCue: null,
clientCueRevision: 0,
};
};
function cueFieldsForStatusTransition(
state: ManastormClientRuntimeFields & Pick<ManastormRunState, "status">,
nextStatus: string,
): Partial<ManastormClientRuntimeFields> {
const cueId = manastormCueForStatusTransition(state.status, nextStatus);
if (!cueId) return {};
const revision = state.clientCueRevision + 1;
return {
clientCue: { cueId, revision },
clientCueRevision: revision,
};
}
function startOptions(
randomOrOptions?: (() => number) | ManastormStartOptions,
): Required<Pick<ManastormStartOptions, "partySize" | "startingLevel">>
& Omit<ManastormStartOptions, "partySize" | "startingLevel"> {
if (typeof randomOrOptions === "function") {
return {
partySize: 5,
startingLevel: 1,
random: randomOrOptions,
};
}
return {
...randomOrOptions,
partySize: randomOrOptions?.partySize ?? 5,
startingLevel: randomOrOptions?.startingLevel ?? 1,
};
}
const MAX_PERSISTED_CACHE_ID_LENGTH = 160;
function uniqueCacheInstanceId(
baseId: string,
caches: readonly ManastormRewardCacheState[],
): string {
const usedIds = new Set(caches.map(({ id }) => id));
if (!usedIds.has(baseId)) return baseId;
for (let instance = 2; ; instance += 1) {
const suffix = `:instance-${instance}`;
const candidate = `${baseId.slice(0, MAX_PERSISTED_CACHE_ID_LENGTH - suffix.length)}${suffix}`;
if (!usedIds.has(candidate)) return candidate;
}
}
function grantedRewardState(
state: ManastormRunState,
reward: ManastormRewardState,
random: () => number,
): Partial<ManastormRunState> {
const rewardCache = [...state.rewardCache];
const cacheChance = reward.cachePolicy === "accumulating-chance"
? manastormCacheChance(state.level, state.cacheMeter)
: 0;
const chanceAwarded = reward.cachePolicy === "accumulating-chance"
&& random() < cacheChance;
const cacheAwarded = reward.cachePolicy === "guaranteed" || chanceAwarded;
const badLuckAwarded = chanceAwarded && state.cacheMeter >= state.bonusCacheThreshold - 1;
const awardedCache = reward.cacheId && cacheAwarded
? [
{
id: uniqueCacheInstanceId(reward.id, rewardCache),
cacheId: badLuckAwarded ? "manastorm-bonus-cache" : reward.cacheId,
rewardId: reward.rewardId,
stageId: reward.stageId,
level: reward.level,
rewardMultiplier: reward.rewardMultiplier,
},
]
: [];
rewardCache.push(...awardedCache);
// The meter protects against consecutive cacheless clears. A normal cache
// satisfies the protection immediately, so it resets the streak without
// also producing the guaranteed bonus cache.
const accumulatedMeter = cacheAwarded
? 0
: state.cacheMeter + 1;
const bonusCacheEarned = reward.cachePolicy === "none"
&& accumulatedMeter >= state.bonusCacheThreshold;
const bonusCache = bonusCacheEarned
? [{
id: uniqueCacheInstanceId(`${reward.id}:bonus-cache`, rewardCache),
cacheId: "manastorm-bonus-cache",
rewardId: "bonus-cache-bad-luck-protection",
stageId: reward.stageId,
level: reward.level,
rewardMultiplier: reward.rewardMultiplier,
}]
: [];
rewardCache.push(...bonusCache);
return {
pendingReward: null,
lastReward: reward,
rewardCache,
cacheMeter: bonusCacheEarned ? 0 : accumulatedMeter,
currency: {
...state.currency,
[reward.currencyId]: (state.currency[reward.currencyId] ?? 0) + reward.currencyAmount,
},
};
}
export const useManastormStore = create<ManastormState>((set, get) => ({
...initialRunState(),
startRun: (randomOrOptions) => {
const options = startOptions(randomOrOptions);
const partySize = normalizeManastormPartySize(options.partySize);
const maximumLevel = safeMaximumLevel();
const level = Math.max(1, Math.min(maximumLevel, Math.trunc(options.startingLevel)));
const unlockedThroughLevel = Math.max(
level,
Math.min(
maximumLevel,
Math.trunc(options.unlockedThroughLevel ?? level),
),
);
const catalog = getManastormCatalog();
const modeId = Math.trunc(options.modeId ?? manastormModeId(catalog));
const draw = drawManastormStage(
level,
partySize,
[],
null,
options.random ?? Math.random,
catalog,
modeId,
unlockedThroughLevel,
);
const runtime = runtimeFields(
level,
partySize,
options.loadoutId,
modeId,
unlockedThroughLevel,
);
const activeSpellLoadout = resolveManastormActiveSpellLoadout(options.activeSpellLoadout);
set((state) => ({
...initialRunState(partySize, state.transitionRevision + 1),
status: "entering",
phase: "enter",
level,
maximumLevel,
unlockedThroughLevel,
completed: level - 1,
checkpointLevel: manastormCheckpointForLevel(level),
milestone: isManastormSpecialLevel(level),
currentEncounter: draw.encounter,
encounterBag: draw.remainingBag,
...runtime,
activeSpellLoadout,
cacheMeter: Math.max(0, Math.trunc(options.cacheMeter ?? 0)),
rewardCache: options.rewardCaches
? Object.freeze(options.rewardCaches.map((cache) => Object.freeze({ ...cache })))
: Object.freeze([]),
message: activeSpellLoadout.validationError
? `Local active-spell selection ignored: ${activeSpellLoadout.validationError}`
: runtime.message,
messageRevision: state.messageRevision + 1,
}));
return draw.encounter;
},
enterStage: () => {
const state = get();
if (state.status !== "entering" || !state.currentEncounter) return false;
set({
status: "clearing-trash",
phase: "trash",
message: `Break Chaotic Link by clearing guardians, or challenge empowered ${state.currentEncounter.bossName} now.`,
messageRevision: state.messageRevision + 1,
});
return true;
},
observeTrash: (cleared) => set((state) => {
if (state.status !== "clearing-trash" || !cleared || !state.currentEncounter) return state;
return {
status: "fighting",
phase: "boss",
message: `Defeat ${state.currentEncounter.bossName}.`,
messageRevision: state.messageRevision + 1,
};
}),
observeBoss: (dead, looted, random = Math.random) => set((state) => {
if (
state.status !== "fighting"
&& state.status !== "awaiting-loot"
) return state;
if (!dead || !state.currentEncounter) return state;
const pendingReward = state.pendingReward ?? manastormReward(
state.currentEncounter,
state.level,
state.partySize,
getManastormCatalog(),
state.modeId,
);
if (!looted) {
if (state.status === "awaiting-loot" && state.pendingReward === pendingReward) return state;
return {
status: "awaiting-loot",
phase: "loot",
pendingReward,
message: `Claim ${state.currentEncounter.bossName}'s reward.`,
messageRevision: state.messageRevision + 1,
};
}
const completed = Math.max(state.completed, state.level);
return {
status: "portal-ready",
phase: "portal",
completed,
checkpointLevel: isManastormSpecialLevel(state.level)
? state.level + 1
: state.checkpointLevel,
...grantedRewardState(state, pendingReward, random),
...cueFieldsForStatusTransition(state, "portal-ready"),
message: "The next Manastorm portal is open.",
messageRevision: state.messageRevision + 1,
};
}),
beginTransition: () => {
const state = get();
if (state.status !== "portal-ready") return false;
set({
status: "transitioning",
phase: "next",
clientOverlay: createManastormClientOverlayState(),
message: "The storm is selecting the next stage.",
messageRevision: state.messageRevision + 1,
});
return true;
},
prepareNextEncounter: (random = Math.random) => {
const state = get();
if (state.status !== "transitioning" || !state.currentEncounter) return null;
const completed = Math.max(state.completed, state.level);
const nextLevel = state.level + 1;
if (nextLevel > state.maximumLevel) {
set({
status: "completed",
phase: "complete",
completed,
previousEncounterId: state.currentEncounter.id,
currentEncounter: null,
encounterBag: [],
clientOverlay: createManastormClientOverlayState(),
clientCue: null,
message: "The final Manastorm collapses. Run complete.",
messageRevision: state.messageRevision + 1,
});
return null;
}
const catalog = getManastormCatalog();
const modeId = state.modeId;
const draw = drawManastormStage(
nextLevel,
state.partySize,
state.encounterBag,
state.currentEncounter.id,
random,
catalog,
modeId,
Math.max(state.unlockedThroughLevel, nextLevel),
);
const nextUnlockedThroughLevel = Math.max(state.unlockedThroughLevel, nextLevel);
const runtime = runtimeFields(
nextLevel,
state.partySize,
state.loadoutId,
modeId,
nextUnlockedThroughLevel,
);
set({
level: nextLevel,
unlockedThroughLevel: nextUnlockedThroughLevel,
completed,
checkpointLevel: manastormCheckpointForLevel(nextLevel),
milestone: isManastormSpecialLevel(nextLevel),
currentEncounter: draw.encounter,
encounterBag: draw.remainingBag,
previousEncounterId: state.currentEncounter.id,
transitionRevision: state.transitionRevision + 1,
pendingReward: null,
resurrectionCharges: state.maxResurrectionCharges,
clientOverlay: createManastormClientOverlayState(),
...runtime,
messageRevision: state.messageRevision + 1,
});
return draw.encounter;
},
finishTransition: () => set((state) => state.status === "transitioning"
? {
status: "entering",
phase: "enter",
message: state.message,
messageRevision: state.messageRevision + 1,
}
: state),
useResurrectionCharge: () => {
const state = get();
if (
state.status === "idle"
|| state.status === "failed"
|| state.status === "completed"
|| state.resurrectionCharges <= 0
) return false;
set({
resurrectionCharges: state.resurrectionCharges - 1,
message: `${state.resurrectionCharges - 1} shared resurrection charge${state.resurrectionCharges - 1 === 1 ? "" : "s"} remaining.`,
messageRevision: state.messageRevision + 1,
});
return true;
},
restartFromCheckpoint: (random = Math.random) => {
const state = get();
if (state.status !== "failed") return null;
const catalog = getManastormCatalog();
const level = state.checkpointLevel;
const modeId = state.modeId;
const draw = drawManastormStage(
level,
state.partySize,
[],
state.previousEncounterId,
random,
catalog,
modeId,
state.unlockedThroughLevel,
);
const runtime = runtimeFields(
level,
state.partySize,
state.loadoutId,
modeId,
state.unlockedThroughLevel,
);
set({
status: "entering",
phase: "enter",
level,
completed: level - 1,
milestone: isManastormSpecialLevel(level),
currentEncounter: draw.encounter,
encounterBag: draw.remainingBag,
resurrectionCharges: state.maxResurrectionCharges,
pendingReward: null,
clientOverlay: createManastormClientOverlayState(),
clientCue: null,
transitionRevision: state.transitionRevision + 1,
...runtime,
messageRevision: state.messageRevision + 1,
});
return draw.encounter;
},
setPartySize: (partySize) => {
const state = get();
if (state.status !== "idle" && state.status !== "failed" && state.status !== "completed") return false;
const normalized = normalizeManastormPartySize(partySize);
const runtime = runtimeFields(
state.level,
normalized,
state.loadoutId,
state.modeId,
state.unlockedThroughLevel,
);
set({
partySize: normalized,
...runtime,
messageRevision: state.messageRevision + 1,
});
return true;
},
setLoadout: (loadoutId) => {
const state = get();
if (loadoutId !== null && !state.availableLoadoutIds.includes(loadoutId)) return false;
set({
loadoutId,
message: loadoutId ? `Loadout selected: ${loadoutId}.` : "No Manastorm loadout selected.",
messageRevision: state.messageRevision + 1,
});
return true;
},
setActiveSpellLoadout: (selectedIds) => {
const state = get();
const validation = validateManastormActiveSpellLoadout(
selectedIds,
state.activeSpellLoadout.available,
state.activeSpellLoadout.maxSelections,
);
if (!validation.ok) return false;
set({
activeSpellLoadout: {
...state.activeSpellLoadout,
selectedIds: validation.selectedIds,
selected: validation.selected,
validationError: null,
},
message: validation.selectedIds.length
? `${validation.selectedIds.length} local active-spell selection${validation.selectedIds.length === 1 ? "" : "s"} prepared.`
: "No local active-spell selections prepared.",
messageRevision: state.messageRevision + 1,
});
return true;
},
openRewardCache: (cacheId) => {
const state = get();
const cacheIndex = state.rewardCache.findIndex((candidate) => candidate.id === cacheId);
const cache = state.rewardCache[cacheIndex];
if (!cache) return false;
const multiplier = Math.max(
0,
Number.isFinite(cache.rewardMultiplier) ? cache.rewardMultiplier ?? 1 : 1,
);
set({
rewardCache: state.rewardCache.filter((_, index) => index !== cacheIndex),
currency: {
...state.currency,
bedlamBullions: (state.currency.bedlamBullions ?? 0) + Math.max(1, Math.round(multiplier)),
bonzoBolts: (state.currency.bonzoBolts ?? 0)
+ Math.max(1, Math.round(Math.ceil(cache.level / 5) * multiplier)),
},
message: "Manastorm cache opened.",
messageRevision: state.messageRevision + 1,
});
return true;
},
setNextWaveCountdown: (seconds) => set((state) => ({
clientOverlay: withManastormNextWaveCountdown(state.clientOverlay, seconds),
})),
setSafetyBubble: (update) => set((state) => ({
clientOverlay: withManastormSafetyBubble(state.clientOverlay, update),
})),
requestClientCue: (cueId) => {
if (!isManastormPortalCueId(cueId)) return false;
set((state) => {
const revision = state.clientCueRevision + 1;
return {
clientCue: { cueId, revision },
clientCueRevision: revision,
};
});
return true;
},
clearClientCue: () => set({ clientCue: null }),
failRun: () => {
const state = get();
if (state.status === "idle" || state.status === "failed" || state.status === "completed") return false;
set({
status: "failed",
phase: "failed",
clientOverlay: createManastormClientOverlayState(),
clientCue: null,
message: `Run failed. Checkpoint ${state.checkpointLevel} is available.`,
messageRevision: state.messageRevision + 1,
});
return true;
},
resetRun: () => set((state) => initialRunState(state.partySize, state.transitionRevision + 1)),
}));