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>; 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>; 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>; 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(MANASTORM_PARTY_SIZES); const modeIdSet = new Set(MANASTORM_MODE_IDS); function isRecord(value: unknown): value is Record { 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(); 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(); 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(); 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> { const record = isRecord(value) ? value : {}; return Object.freeze(Object.fromEntries(MANASTORM_PARTY_SIZES.map((partySize) => [ partySize, normalizePartyProgress(record[String(partySize)]), ])) as Record); } 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> { return Object.freeze({ [MANASTORM_LEVELING_MODE_ID]: leveling, [MANASTORM_ENDGAME_MODE_ID]: endgame, }); } function unlockedContentAcrossModes( modes: Readonly>, ): readonly string[] { return normalizeIds(MANASTORM_MODE_IDS.flatMap((modeId) => modes[modeId].unlockedContent)); } function frozenProgress(progress: { modes: Readonly>; 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 : {}; 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(); 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, ): 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); return frozenProgress({ modes: frozenModes(leveling, Object.freeze({ ...endgame, partySizes })), currencies: current.currencies, loadout: current.loadout, endgameHandoffApplied: true, }); }