import { manastormGroupModifier, type ManastormCatalogLike, type ManastormRunState, } from "./manastorm"; import { addManastormCurrencies, applyManastormRewards, hasClearedManastormLevel, manastormModeProgress, normalizeManastormProgress, normalizeManastormModeId, recordManastormCheckpoint, setManastormCacheMeter, setManastormRewardCaches, type ManastormProgress, } from "./manastormProgress"; export type ManastormProgressRuntimeSnapshot = Pick< ManastormRunState, "status" | "completed" | "partySize" | "modeId" | "currency" | "cacheMeter" | "rewardCache" >; export interface ManastormProgressSyncResult { readonly progress: ManastormProgress; readonly changed: boolean; } function firstClearReward( catalog: ManastormCatalogLike, level: number, partySize: ManastormRunState["partySize"], modeId: number, ) { const definition = (catalog.rewards ?? []).find((reward) => ( reward.claimPolicy === "first-clear" && (!reward.modeIds?.length || reward.modeIds.includes(modeId)) && (reward.minimumLevel ?? 1) <= level && (reward.maximumLevel ?? Number.POSITIVE_INFINITY) >= level && (reward.minimumPartySize ?? 1) <= partySize && (reward.maximumPartySize ?? 5) >= partySize && (reward.milestoneLevel === undefined || reward.milestoneLevel === level) && (reward.milestoneEvery === undefined || level % Math.max(1, Math.trunc(reward.milestoneEvery)) === 0) )); if (!definition) return null; const group = manastormGroupModifier(partySize, catalog, modeId); const amount = Math.max(0, Math.round(( (definition.currencyBase ?? 0) + (definition.currencyPerLevel ?? 0) * Math.max(0, level - 1) ) * group.rewardMultiplier)); return { id: `manastorm:first-clear:${modeId}:${partySize}:${level}:${definition.id}`, ...(definition.currencyId === "bedlamBullions" ? { bedlamBullions: amount } : {}), ...(definition.currencyId === "bonzoBolts" ? { bonzoBolts: amount } : {}), ...(definition.cacheMeter ? { cacheMeter: definition.cacheMeter } : {}), }; } function sameCaches( left: ReturnType["rewardCaches"], right: ManastormRunState["rewardCache"], modeId: number, ): boolean { const safeModeId = normalizeManastormModeId(modeId); return left.length === right.length && left.every((cache, index) => { const candidate = right[index]; return Boolean(candidate) && cache.modeId === safeModeId && cache.id === candidate.id && cache.cacheId === candidate.cacheId && cache.rewardId === candidate.rewardId && cache.stageId === candidate.stageId && cache.level === candidate.level && cache.rewardMultiplier === (candidate.rewardMultiplier ?? 1); }); } /** * Pure save-boundary reducer shared by React and headless integration tests. * Runtime currency values are run-local totals, so only positive deltas are * added; checkpoints, first-clear unlocks, cache meter, and unopened caches * are copied into durable profile progress. */ export function synchronizeManastormProgress( progressValue: ManastormProgress, previous: ManastormProgressRuntimeSnapshot, state: ManastormProgressRuntimeSnapshot, catalog: ManastormCatalogLike, ): ManastormProgressSyncResult { const original = normalizeManastormProgress(progressValue); let progress = original; if (state.completed > previous.completed) { const isFirstClear = !hasClearedManastormLevel( original, state.partySize, state.completed, state.modeId, ); progress = recordManastormCheckpoint( progress, state.partySize, state.completed, state.completed % 5 === 0 ? state.completed + 1 : undefined, state.modeId, ); if (isFirstClear) { const reward = firstClearReward( catalog, state.completed, state.partySize, state.modeId, ); if (reward) progress = applyManastormRewards(progress, reward, state.modeId); } const unlockedContent = (catalog.messages ?? []) .filter((message) => ( message.modeId === state.modeId && message.progressionValue === state.completed )) .map((message) => `manastorm:unlock:${message.progressionValue}:${message.groupId ?? 0}:${message.title}`); if (unlockedContent.length) { progress = applyManastormRewards(progress, { id: `manastorm:unlocks:${state.modeId}:${state.partySize}:${state.completed}`, unlockedContent, }, state.modeId); } } const bedlamDelta = Math.max( 0, (state.currency.bedlamBullions ?? 0) - (previous.currency.bedlamBullions ?? 0), ); const bonzoDelta = Math.max( 0, (state.currency.bonzoBolts ?? 0) - (previous.currency.bonzoBolts ?? 0), ); if (bedlamDelta || bonzoDelta) { progress = addManastormCurrencies(progress, { bedlamBullions: bedlamDelta, bonzoBolts: bonzoDelta, }); } if (state.status !== "idle" && state.cacheMeter !== previous.cacheMeter) { progress = setManastormCacheMeter(progress, state.cacheMeter, state.modeId); } // resetRun publishes an idle state before the game surface unmounts. That is // not a cache-open action and must never erase unopened durable caches. const modeProgress = manastormModeProgress(progress, state.modeId); if ( state.status !== "idle" && !sameCaches(modeProgress.rewardCaches, state.rewardCache, state.modeId) ) { progress = setManastormRewardCaches(progress, state.rewardCache, state.modeId); } return Object.freeze({ progress, changed: JSON.stringify(progress) !== JSON.stringify(original), }); }