forked from phenom/healer-man
666 lines
23 KiB
TypeScript
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,
|
|
});
|
|
}
|