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

162 lines
5.5 KiB
TypeScript

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<typeof manastormModeProgress>["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),
});
}