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

666 lines
23 KiB
TypeScript

export const MANASTORM_PROGRESS_VERSION = 2 as const;
export const MANASTORM_LEVELING_MODE_ID = 0 as const;
export const MANASTORM_ENDGAME_MODE_ID = 2 as const;
export const MANASTORM_ENDGAME_HANDOFF_LEVEL = 101 as const;
export const MANASTORM_MODE_IDS = Object.freeze([
MANASTORM_LEVELING_MODE_ID,
MANASTORM_ENDGAME_MODE_ID,
] as const);
export const MANASTORM_PARTY_SIZES = Object.freeze([1, 2, 3, 4, 5] as const);
export type ManastormModeId = (typeof MANASTORM_MODE_IDS)[number];
export type ManastormPartySize = (typeof MANASTORM_PARTY_SIZES)[number];
export interface ManastormPartyProgress {
readonly highestLevel: number;
readonly checkpointLevel: number;
readonly firstClearLevels: readonly number[];
}
export interface ManastormCurrencies {
readonly bedlamBullions: number;
readonly bonzoBolts: number;
}
export interface PersistedManastormRewardCache {
readonly id: string;
readonly modeId: ManastormModeId;
readonly cacheId: string;
readonly rewardId: string;
readonly stageId: string;
readonly level: number;
readonly rewardMultiplier: number;
}
export interface ManastormRewardCacheInput {
readonly id: string;
readonly modeId?: number;
readonly cacheId: string;
readonly rewardId: string;
readonly stageId: string;
readonly level: number;
readonly rewardMultiplier?: number;
}
export interface ManastormModeProgress {
readonly partySizes: Readonly<Record<ManastormPartySize, ManastormPartyProgress>>;
readonly unlockedContent: readonly string[];
readonly cacheMeter: number;
readonly rewardCaches: readonly PersistedManastormRewardCache[];
readonly claimedRewards: readonly string[];
}
export interface ManastormProgress {
readonly version: typeof MANASTORM_PROGRESS_VERSION;
readonly modes: Readonly<Record<ManastormModeId, ManastormModeProgress>>;
readonly currencies: ManastormCurrencies;
readonly loadout: readonly string[];
readonly endgameHandoffApplied: boolean;
/**
* Mode-0 compatibility views. These properties are intentionally
* non-enumerable, so v2 saves serialize only the authoritative `modes`
* object while older call sites continue to read leveling progress.
*/
readonly partySizes: Readonly<Record<ManastormPartySize, ManastormPartyProgress>>;
readonly unlockedContent: readonly string[];
readonly cacheMeter: number;
readonly rewardCaches: readonly PersistedManastormRewardCache[];
readonly claimedRewards: readonly string[];
}
export interface ManastormRewardGrant {
readonly id: string;
readonly bedlamBullions?: number;
readonly bonzoBolts?: number;
readonly cacheMeter?: number;
readonly unlockedContent?: readonly string[];
}
export interface ManastormFailureResolution {
readonly progress: ManastormProgress;
readonly modeId: ManastormModeId;
readonly partySize: ManastormPartySize;
readonly failedLevel: number;
readonly resumeLevel: number;
}
export interface ManastormLoadoutValidation {
readonly ok: boolean;
readonly loadout: readonly string[];
readonly error?: string;
}
export interface ManastormLoadoutUpdate extends ManastormLoadoutValidation {
readonly progress: ManastormProgress;
}
const MAX_LEVEL = 1_000_000;
const MAX_ID_LENGTH = 160;
const MAX_FIRST_CLEAR_RECORDS = 10_000;
const MAX_CONTENT_RECORDS = 10_000;
const MAX_REWARD_CACHES = 1_000;
const MAX_CURRENCY = Number.MAX_SAFE_INTEGER;
const partySizeSet = new Set<number>(MANASTORM_PARTY_SIZES);
const modeIdSet = new Set<number>(MANASTORM_MODE_IDS);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function safeInteger(value: unknown, maximum = MAX_CURRENCY): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(maximum, Math.trunc(value)))
: 0;
}
function normalizedId(value: unknown): string | null {
if (typeof value !== "string") return null;
const id = value.trim();
return id && id.length <= MAX_ID_LENGTH ? id : null;
}
function normalizedRewardMultiplier(value: unknown): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(1_000, value))
: 1;
}
function normalizeIds(value: unknown, maximum = MAX_CONTENT_RECORDS): readonly string[] {
if (!Array.isArray(value)) return Object.freeze([]);
const ids: string[] = [];
const seen = new Set<string>();
const scanLimit = Math.min(value.length, maximum * 4);
for (let index = 0; index < scanLimit; index += 1) {
const candidate = value[index];
const id = normalizedId(candidate);
if (!id || seen.has(id)) continue;
ids.push(id);
seen.add(id);
if (ids.length >= maximum) break;
}
return Object.freeze(ids);
}
function normalizeRewardCaches(
value: unknown,
modeId: ManastormModeId,
): readonly PersistedManastormRewardCache[] {
if (!Array.isArray(value)) return Object.freeze([]);
const caches: PersistedManastormRewardCache[] = [];
const seen = new Set<string>();
for (const candidate of value.slice(0, MAX_REWARD_CACHES * 4)) {
if (!isRecord(candidate)) continue;
const id = normalizedId(candidate.id);
const cacheId = normalizedId(candidate.cacheId);
const rewardId = normalizedId(candidate.rewardId);
const stageId = normalizedId(candidate.stageId);
const level = safeInteger(candidate.level, MAX_LEVEL);
if (!id || !cacheId || !rewardId || !stageId || level <= 0 || seen.has(id)) continue;
seen.add(id);
caches.push(Object.freeze({
id,
modeId,
cacheId,
rewardId,
stageId,
level,
rewardMultiplier: normalizedRewardMultiplier(candidate.rewardMultiplier),
}));
if (caches.length >= MAX_REWARD_CACHES) break;
}
return Object.freeze(caches);
}
function emptyPartyProgress(): ManastormPartyProgress {
return Object.freeze({
highestLevel: 0,
checkpointLevel: 1,
firstClearLevels: Object.freeze([]),
});
}
function normalizeFirstClearLevels(value: unknown): readonly number[] {
if (!Array.isArray(value)) return Object.freeze([]);
const levels = new Set<number>();
const scanLimit = Math.min(value.length, MAX_FIRST_CLEAR_RECORDS * 4);
for (let index = 0; index < scanLimit; index += 1) {
const candidate = value[index];
const level = safeInteger(candidate, MAX_LEVEL);
if (level > 0) levels.add(level);
if (levels.size >= MAX_FIRST_CLEAR_RECORDS) break;
}
return Object.freeze([...levels].sort((left, right) => left - right));
}
function normalizePartyProgress(value: unknown): ManastormPartyProgress {
if (!isRecord(value)) return emptyPartyProgress();
const firstClearLevels = normalizeFirstClearLevels(value.firstClearLevels);
const highestLevel = Math.max(
safeInteger(value.highestLevel, MAX_LEVEL),
firstClearLevels.at(-1) ?? 0,
);
return Object.freeze({
highestLevel,
checkpointLevel: Math.max(
1,
Math.min(safeInteger(value.checkpointLevel, MAX_LEVEL) || 1, highestLevel + 1),
),
firstClearLevels,
});
}
function normalizedPartySizes(
value: unknown,
): Readonly<Record<ManastormPartySize, ManastormPartyProgress>> {
const record = isRecord(value) ? value : {};
return Object.freeze(Object.fromEntries(MANASTORM_PARTY_SIZES.map((partySize) => [
partySize,
normalizePartyProgress(record[String(partySize)]),
])) as Record<ManastormPartySize, ManastormPartyProgress>);
}
function emptyModeProgress(modeId: ManastormModeId): ManastormModeProgress {
return Object.freeze({
partySizes: normalizedPartySizes(null),
unlockedContent: Object.freeze([]),
cacheMeter: 0,
rewardCaches: normalizeRewardCaches(null, modeId),
claimedRewards: Object.freeze([]),
});
}
function normalizeModeProgress(
value: unknown,
modeId: ManastormModeId,
): ManastormModeProgress {
if (!isRecord(value)) return emptyModeProgress(modeId);
return Object.freeze({
partySizes: normalizedPartySizes(value.partySizes),
unlockedContent: normalizeIds(value.unlockedContent),
cacheMeter: safeInteger(value.cacheMeter),
rewardCaches: normalizeRewardCaches(value.rewardCaches, modeId),
claimedRewards: normalizeIds(value.claimedRewards),
});
}
function frozenModes(
leveling: ManastormModeProgress,
endgame: ManastormModeProgress,
): Readonly<Record<ManastormModeId, ManastormModeProgress>> {
return Object.freeze({
[MANASTORM_LEVELING_MODE_ID]: leveling,
[MANASTORM_ENDGAME_MODE_ID]: endgame,
});
}
function unlockedContentAcrossModes(
modes: Readonly<Record<ManastormModeId, ManastormModeProgress>>,
): readonly string[] {
return normalizeIds(MANASTORM_MODE_IDS.flatMap((modeId) => modes[modeId].unlockedContent));
}
function frozenProgress(progress: {
modes: Readonly<Record<ManastormModeId, ManastormModeProgress>>;
currencies: ManastormCurrencies;
loadout: readonly string[];
endgameHandoffApplied: boolean;
}): ManastormProgress {
const leveling = progress.modes[MANASTORM_LEVELING_MODE_ID];
const result = {
version: MANASTORM_PROGRESS_VERSION,
modes: progress.modes,
currencies: Object.freeze({ ...progress.currencies }),
loadout: progress.loadout,
endgameHandoffApplied: progress.endgameHandoffApplied,
} as ManastormProgress;
Object.defineProperties(result, {
partySizes: { value: leveling.partySizes, enumerable: false },
unlockedContent: { value: leveling.unlockedContent, enumerable: false },
cacheMeter: { value: leveling.cacheMeter, enumerable: false },
rewardCaches: { value: leveling.rewardCaches, enumerable: false },
claimedRewards: { value: leveling.claimedRewards, enumerable: false },
});
return Object.freeze(result);
}
function progressWithMode(
progress: ManastormProgress,
modeId: ManastormModeId,
modeProgress: ManastormModeProgress,
): ManastormProgress {
return frozenProgress({
modes: frozenModes(
modeId === MANASTORM_LEVELING_MODE_ID
? modeProgress
: progress.modes[MANASTORM_LEVELING_MODE_ID],
modeId === MANASTORM_ENDGAME_MODE_ID
? modeProgress
: progress.modes[MANASTORM_ENDGAME_MODE_ID],
),
currencies: progress.currencies,
loadout: progress.loadout,
endgameHandoffApplied: progress.endgameHandoffApplied,
});
}
export function isManastormModeId(value: unknown): value is ManastormModeId {
return typeof value === "number" && Number.isInteger(value) && modeIdSet.has(value);
}
export function normalizeManastormModeId(value: unknown): ManastormModeId {
return isManastormModeId(value) ? value : MANASTORM_LEVELING_MODE_ID;
}
export function isManastormPartySize(value: unknown): value is ManastormPartySize {
return typeof value === "number" && Number.isInteger(value) && partySizeSet.has(value);
}
export function createEmptyManastormProgress(): ManastormProgress {
const modes = frozenModes(
emptyModeProgress(MANASTORM_LEVELING_MODE_ID),
emptyModeProgress(MANASTORM_ENDGAME_MODE_ID),
);
return frozenProgress({
modes,
currencies: { bedlamBullions: 0, bonzoBolts: 0 },
loadout: Object.freeze([]),
endgameHandoffApplied: false,
});
}
/**
* Treats every persisted field as untrusted. A v1/single-mode record migrates
* into mode 0, while mode 2 starts empty and waits for the explicit cap
* handoff. Current v2 records always treat `modes` as authoritative.
*/
export function normalizeManastormProgress(value: unknown): ManastormProgress {
if (!isRecord(value)) return createEmptyManastormProgress();
const currencies = isRecord(value.currencies) ? value.currencies : {};
const isV2 = value.version === MANASTORM_PROGRESS_VERSION && isRecord(value.modes);
const rawModes = isV2 ? value.modes as Record<string, unknown> : {};
const modes = isV2
? frozenModes(
normalizeModeProgress(rawModes[String(MANASTORM_LEVELING_MODE_ID)], MANASTORM_LEVELING_MODE_ID),
normalizeModeProgress(rawModes[String(MANASTORM_ENDGAME_MODE_ID)], MANASTORM_ENDGAME_MODE_ID),
)
: frozenModes(
normalizeModeProgress(value, MANASTORM_LEVELING_MODE_ID),
emptyModeProgress(MANASTORM_ENDGAME_MODE_ID),
);
const unlocked = new Set(unlockedContentAcrossModes(modes));
const loadout = normalizeIds(value.loadout).filter((id) => unlocked.has(id));
return frozenProgress({
modes,
currencies: {
bedlamBullions: safeInteger(currencies.bedlamBullions),
bonzoBolts: safeInteger(currencies.bonzoBolts),
},
loadout: Object.freeze(loadout),
endgameHandoffApplied: isV2 && value.endgameHandoffApplied === true,
});
}
export function manastormModeProgress(
progress: ManastormProgress,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormModeProgress {
const current = normalizeManastormProgress(progress);
return current.modes[normalizeManastormModeId(modeId)];
}
export function manastormPartyProgress(
progress: ManastormProgress,
partySize: ManastormPartySize,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormPartyProgress {
return manastormModeProgress(progress, modeId).partySizes[partySize];
}
export function hasClearedManastormLevel(
progress: ManastormProgress,
partySize: ManastormPartySize,
level: number,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): boolean {
const safeLevel = safeInteger(level, MAX_LEVEL);
return safeLevel > 0
&& manastormPartyProgress(progress, partySize, modeId).firstClearLevels.includes(safeLevel);
}
export function hasClaimedManastormReward(
progress: ManastormProgress,
rewardId: string,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): boolean {
const id = normalizedId(rewardId);
if (!id) return false;
return manastormModeProgress(progress, modeId).claimedRewards.includes(id);
}
/**
* Records a successful level without mutating the previous save. Checkpoints
* and highest clears only move forward; a successful level is recorded once.
*/
export function recordManastormCheckpoint(
progress: ManastormProgress,
partySize: ManastormPartySize,
completedLevel: number,
checkpointLevel?: number,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
const completed = safeInteger(completedLevel, MAX_LEVEL);
if (!isManastormPartySize(partySize) || completed <= 0) return current;
const safeModeId = normalizeManastormModeId(modeId);
const currentMode = current.modes[safeModeId];
const previous = currentMode.partySizes[partySize];
const highestLevel = Math.max(previous.highestLevel, completed);
const requestedCheckpoint = checkpointLevel === undefined
? previous.checkpointLevel
: safeInteger(checkpointLevel, MAX_LEVEL);
const nextCheckpoint = Math.max(
previous.checkpointLevel,
Math.min(requestedCheckpoint, highestLevel + 1),
);
const firstClearLevels = previous.firstClearLevels.includes(completed)
? previous.firstClearLevels
: Object.freeze([...previous.firstClearLevels, completed].sort((left, right) => left - right));
return progressWithMode(current, safeModeId, Object.freeze({
...currentMode,
partySizes: Object.freeze({
...currentMode.partySizes,
[partySize]: Object.freeze({
highestLevel,
checkpointLevel: nextCheckpoint,
firstClearLevels,
}),
}),
}));
}
/**
* Resolves a failed run to the saved checkpoint. Historical clears remain
* intact, so failure cannot erase first-clear or currency progress.
*/
export function recordManastormFailure(
progress: ManastormProgress,
partySize: ManastormPartySize,
failedLevel: number,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormFailureResolution {
const current = normalizeManastormProgress(progress);
const safeModeId = normalizeManastormModeId(modeId);
const safePartySize = isManastormPartySize(partySize) ? partySize : 1;
const failed = Math.max(1, safeInteger(failedLevel, MAX_LEVEL));
const checkpoint = current.modes[safeModeId].partySizes[safePartySize].checkpointLevel;
return Object.freeze({
progress: current,
modeId: safeModeId,
partySize: safePartySize,
failedLevel: failed,
resumeLevel: Math.max(1, Math.min(failed, checkpoint || 1)),
});
}
export function applyManastormRewards(
progress: ManastormProgress,
reward: ManastormRewardGrant,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
const safeModeId = normalizeManastormModeId(modeId);
const currentMode = current.modes[safeModeId];
const rewardId = normalizedId(reward?.id);
if (!rewardId || currentMode.claimedRewards.includes(rewardId)) return current;
const unlockedContent = normalizeIds([
...currentMode.unlockedContent,
...(Array.isArray(reward.unlockedContent) ? reward.unlockedContent : []),
]);
const withModeReward = progressWithMode(current, safeModeId, Object.freeze({
...currentMode,
unlockedContent,
cacheMeter: Math.min(
MAX_CURRENCY,
currentMode.cacheMeter + safeInteger(reward.cacheMeter),
),
claimedRewards: Object.freeze([...currentMode.claimedRewards, rewardId]),
}));
return frozenProgress({
modes: withModeReward.modes,
currencies: {
bedlamBullions: Math.min(
MAX_CURRENCY,
current.currencies.bedlamBullions + safeInteger(reward.bedlamBullions),
),
bonzoBolts: Math.min(
MAX_CURRENCY,
current.currencies.bonzoBolts + safeInteger(reward.bonzoBolts),
),
},
loadout: withModeReward.loadout,
endgameHandoffApplied: withModeReward.endgameHandoffApplied,
});
}
/** Adds client-grounded selectable content without presenting it as a reward. */
export function mergeManastormUnlockedContent(
progress: ManastormProgress,
contentIds: readonly string[],
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
const safeModeId = normalizeManastormModeId(modeId);
const currentMode = current.modes[safeModeId];
return progressWithMode(current, safeModeId, Object.freeze({
...currentMode,
unlockedContent: normalizeIds([...currentMode.unlockedContent, ...contentIds]),
}));
}
export function validateManastormLoadout(
value: unknown,
unlockedContent: readonly string[],
maxSlots = unlockedContent.length,
): ManastormLoadoutValidation {
if (!Array.isArray(value)) {
return Object.freeze({ ok: false, loadout: Object.freeze([]), error: "Loadout must be a list." });
}
const slotLimit = Number.isFinite(maxSlots)
? Math.max(0, Math.trunc(maxSlots))
: unlockedContent.length;
if (value.length > slotLimit) {
return Object.freeze({
ok: false,
loadout: Object.freeze([]),
error: `Loadout cannot contain more than ${slotLimit} selections.`,
});
}
const unlocked = new Set(normalizeIds(unlockedContent));
const loadout: string[] = [];
const seen = new Set<string>();
for (const candidate of value) {
const id = normalizedId(candidate);
if (!id) {
return Object.freeze({ ok: false, loadout: Object.freeze([]), error: "Loadout contains an invalid content ID." });
}
if (seen.has(id)) {
return Object.freeze({ ok: false, loadout: Object.freeze([]), error: `${id} is selected more than once.` });
}
if (!unlocked.has(id)) {
return Object.freeze({ ok: false, loadout: Object.freeze([]), error: `${id} has not been unlocked.` });
}
seen.add(id);
loadout.push(id);
}
return Object.freeze({ ok: true, loadout: Object.freeze(loadout) });
}
export function setManastormLoadout(
progress: ManastormProgress,
loadout: unknown,
maxSlots?: number,
): ManastormLoadoutUpdate {
const current = normalizeManastormProgress(progress);
const validation = validateManastormLoadout(
loadout,
unlockedContentAcrossModes(current.modes),
maxSlots,
);
if (!validation.ok) return Object.freeze({ ...validation, progress: current });
const next = frozenProgress({
modes: current.modes,
currencies: current.currencies,
loadout: validation.loadout,
endgameHandoffApplied: current.endgameHandoffApplied,
});
return Object.freeze({ ...validation, progress: next });
}
export function setManastormCacheMeter(
progress: ManastormProgress,
cacheMeter: number,
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
const safeModeId = normalizeManastormModeId(modeId);
return progressWithMode(current, safeModeId, Object.freeze({
...current.modes[safeModeId],
cacheMeter: safeInteger(cacheMeter),
}));
}
export function setManastormRewardCaches(
progress: ManastormProgress,
rewardCaches: readonly ManastormRewardCacheInput[],
modeId: number = MANASTORM_LEVELING_MODE_ID,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
const safeModeId = normalizeManastormModeId(modeId);
return progressWithMode(current, safeModeId, Object.freeze({
...current.modes[safeModeId],
rewardCaches: normalizeRewardCaches(rewardCaches, safeModeId),
}));
}
export function addManastormCurrencies(
progress: ManastormProgress,
currencies: Partial<ManastormCurrencies>,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
return frozenProgress({
modes: current.modes,
currencies: {
bedlamBullions: Math.min(
MAX_CURRENCY,
current.currencies.bedlamBullions + safeInteger(currencies.bedlamBullions),
),
bonzoBolts: Math.min(
MAX_CURRENCY,
current.currencies.bonzoBolts + safeInteger(currencies.bonzoBolts),
),
},
loadout: current.loadout,
endgameHandoffApplied: current.endgameHandoffApplied,
});
}
/**
* One-time character-cap transition. Each qualifying leveling checkpoint is
* copied into the matching end-game party-size record. Derived highest level
* is seeded only far enough to keep the checkpoint valid; no first-clear,
* unlock, cache, or claimed-reward history crosses modes.
*/
export function applyManastormEndgameHandoff(
progress: ManastormProgress,
minimumCheckpointLevel: number = MANASTORM_ENDGAME_HANDOFF_LEVEL,
): ManastormProgress {
const current = normalizeManastormProgress(progress);
if (current.endgameHandoffApplied) return current;
const minimum = Math.max(
MANASTORM_ENDGAME_HANDOFF_LEVEL,
safeInteger(minimumCheckpointLevel, MAX_LEVEL),
);
const leveling = current.modes[MANASTORM_LEVELING_MODE_ID];
const endgame = current.modes[MANASTORM_ENDGAME_MODE_ID];
const partySizes = Object.freeze(Object.fromEntries(MANASTORM_PARTY_SIZES.map((partySize) => {
const sourceCheckpoint = leveling.partySizes[partySize].checkpointLevel;
const target = endgame.partySizes[partySize];
if (sourceCheckpoint < minimum || sourceCheckpoint <= target.checkpointLevel) {
return [partySize, target];
}
return [partySize, Object.freeze({
highestLevel: Math.max(target.highestLevel, sourceCheckpoint - 1),
checkpointLevel: sourceCheckpoint,
firstClearLevels: target.firstClearLevels,
})];
})) as Record<ManastormPartySize, ManastormPartyProgress>);
return frozenProgress({
modes: frozenModes(leveling, Object.freeze({ ...endgame, partySizes })),
currencies: current.currencies,
loadout: current.loadout,
endgameHandoffApplied: true,
});
}