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, nextStatus: string, ): Partial { 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> & Omit { 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 { 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((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)), }));