import type { DungeonId } from "./dungeonRegistry"; import type { MobVector3 } from "./mobPopulation"; import type { WorldSpawn } from "./dungeonTypes"; import type { ManastormActiveSpellLoadoutState } from "./manastormSpellLoadout"; export const MANASTORM_SCALING_PER_LEVEL = 0.03; export const MANASTORM_PORTAL_RADIUS = 2.35; export const MANASTORM_MAX_RESURRECTION_CHARGES = 5; export const MANASTORM_LEVELING_MODE_ID = 0; export const MANASTORM_ENDGAME_MODE_ID = 2; export const MANASTORM_ENDGAME_CHARACTER_LEVEL = 60; export const MANASTORM_PARTY_SIZES = [1, 2, 3, 4, 5] as const; /** First three examples; the milestone rule continues at every multiple of five. */ export const MANASTORM_SPECIAL_LEVELS = [5, 10, 15] as const; /** First three examples; checkpoints continue at 16, 21, and so on. */ export const MANASTORM_CHECKPOINT_LEVELS = [1, 6, 11] as const; export type ManastormPartySize = typeof MANASTORM_PARTY_SIZES[number]; export type ManastormSpecialLevel = number; export type ManastormCheckpointLevel = number; export type ManastormStageKind = "standard" | "milestone"; export type ManastormPhase = | "idle" | "enter" | "trash" | "boss" | "loot" | "portal" | "next" | "failed" | "complete"; export interface ManastormEncounterDefinition { readonly id: string; readonly encounterId?: number; readonly dungeonId: DungeonId; /** Authoritative package map identity; dungeonId may only select fallback scene geometry. */ readonly mapId?: number; /** Authoritative package location label shown while this stage is active. */ readonly location?: string; readonly assetPackageId?: string; readonly bossRuntimeId: string; readonly bossEntityId: string; readonly bossName: string; readonly bossPosition: MobVector3; readonly bossYaw?: number; readonly playerSpawn: WorldSpawn; readonly portalPosition: MobVector3; readonly trashRuntimeIds?: readonly string[]; readonly requiredGuardianKills?: number; readonly chaoticLinkEnabled?: boolean; readonly modeIds?: readonly number[]; readonly stageKind?: ManastormStageKind; readonly unlockLevel?: number; readonly milestoneLevel?: ManastormSpecialLevel; readonly minimumPartySize?: ManastormPartySize; readonly maximumPartySize?: ManastormPartySize; readonly rewardId?: string; /** Installed Manastorm.dbc field-8 scalar, resolved for this map/encounter binding. */ readonly mapRewardMultiplier?: number; } export type ManastormRunStatus = | "idle" | "entering" | "clearing-trash" | "fighting" | "awaiting-loot" | "portal-ready" | "transitioning" | "failed" | "completed"; export interface ManastormScaling { readonly statMultiplier: number; readonly bonusLootChance: number; /** Imported modifier slot 10; its XP meaning is an explicit client-data inference. */ readonly experienceMultiplier: number; } export interface ManastormGroupModifier { readonly partySize: ManastormPartySize; readonly enemyStatMultiplier: number; readonly rewardMultiplier: number; readonly sourceModeId?: number; readonly appliesToModeIds?: readonly number[]; } export interface ManastormAffixState { readonly id: string; readonly name: string; readonly description: string; readonly iconReference?: string; readonly rawFlag?: number; readonly trigger?: "passive" | "on-death"; readonly spellId?: number; readonly sourceRecordIds?: readonly number[]; readonly sourceReportId?: string; readonly sourceSpellDbcSha256?: string; } export interface ManastormRuntimeScaling extends ManastormScaling { readonly partySize: ManastormPartySize; readonly enemyStatMultiplier: number; readonly rewardMultiplier: number; } export interface ManastormRewardState { readonly id: string; readonly rewardId: string; readonly stageId: string; readonly level: number; readonly currencyId: string; readonly currencyAmount: number; readonly cacheId: string | null; readonly cachePolicy: "none" | "guaranteed" | "accumulating-chance"; readonly rewardMultiplier: number; } export interface ManastormRewardCacheState { readonly id: string; readonly cacheId: string; readonly rewardId: string; readonly stageId: string; readonly level: number; readonly rewardMultiplier?: number; } export interface ManastormRunState { readonly status: ManastormRunStatus; readonly phase: ManastormPhase; readonly level: number; readonly maximumLevel: number; readonly unlockedThroughLevel: number; readonly completed: number; readonly checkpointLevel: ManastormCheckpointLevel; readonly milestone: boolean; readonly partySize: ManastormPartySize; readonly maxResurrectionCharges: number; readonly resurrectionCharges: number; readonly currentEncounter: ManastormEncounterDefinition | null; readonly encounterBag: readonly string[]; readonly previousEncounterId: string | null; readonly transitionRevision: number; readonly catalogId: string; readonly modeId: number; readonly groupModifier: ManastormGroupModifier; readonly scaling: ManastormRuntimeScaling; readonly activeAffixes: readonly ManastormAffixState[]; readonly pendingReward: ManastormRewardState | null; readonly lastReward: ManastormRewardState | null; readonly rewardCache: readonly ManastormRewardCacheState[]; readonly cacheMeter: number; readonly bonusCacheThreshold: number; readonly currency: Readonly>; readonly availableLoadoutIds: readonly string[]; readonly loadoutId: string | null; readonly activeSpellLoadout: ManastormActiveSpellLoadoutState; readonly message: string; readonly messageRevision: number; } export interface ManastormDraw { readonly encounter: ManastormEncounterDefinition; readonly remainingBag: readonly string[]; } export type ManastormNumericValues = Readonly> | readonly number[]; export interface ManastormCatalogMapLike { readonly id: string; readonly mapId: number; readonly name: string; readonly directory?: string; readonly sourceRowIds?: readonly string[]; readonly encounterIds?: readonly string[]; } export interface ManastormCatalogEncounterLike { readonly id: string; readonly encounterId?: number; readonly mapId?: number; readonly location?: string; readonly mapIds?: readonly number[]; readonly modeIds?: readonly number[]; readonly sourceRowIds?: readonly string[]; readonly dungeonId?: DungeonId; readonly assetPackageId?: string; readonly bossRuntimeId?: string; readonly bossEntityId?: string; readonly bossName?: string; readonly bossPosition?: MobVector3; readonly bossYaw?: number; readonly playerSpawn?: WorldSpawn; readonly portalPosition?: MobVector3; readonly trashRuntimeIds?: readonly string[]; readonly requiredGuardianKills?: number; readonly chaoticLinkEnabled?: boolean; readonly stageKind?: ManastormStageKind; readonly unlockLevel?: number; readonly milestoneLevel?: ManastormSpecialLevel; readonly minimumPartySize?: ManastormPartySize; readonly maximumPartySize?: ManastormPartySize; readonly rewardId?: string; readonly mapRewardMultiplier?: number; } export interface ManastormCatalogSourceRowLike { readonly sourceRecordId: string; readonly mapId?: number; readonly mapCatalogId?: string; readonly modeId?: number; readonly encounterId?: number; readonly encounterCatalogId?: string; readonly scalars?: ManastormNumericValues; } export interface ManastormCatalogModifierLike { readonly id?: string; readonly modeId: number; readonly level: number; readonly tuning: ManastormNumericValues; readonly rawFlag?: number; readonly name?: string; readonly description?: string; readonly iconReference?: string; } export interface ManastormCatalogGroupModifierLike { readonly playerCount: number; readonly modeId: number; readonly multipliers: ManastormNumericValues; readonly sourceModeId?: number; readonly appliesToModeIds?: readonly number[]; } export interface ManastormCatalogMessageLike { readonly modeId: number; readonly progressionValue: number; readonly groupId?: number; readonly title: string; readonly description: string; readonly iconReference?: string; } export interface ManastormCatalogAffixLike { readonly id: string; readonly name: string; readonly description: string; readonly unlockLevel?: number; readonly levels?: readonly number[]; readonly everyLevels?: number; readonly modeIds?: readonly number[]; readonly iconReference?: string; readonly rawFlag?: number; readonly trigger?: "passive" | "on-death"; readonly spellId?: number; readonly sourceRecordIds?: readonly number[]; readonly sourceReportId?: string; readonly sourceSpellDbcSha256?: string; } export interface ManastormCatalogRewardLike { readonly id: string; readonly modeIds?: readonly number[]; readonly claimPolicy?: "repeatable" | "first-clear"; readonly minimumLevel?: number; readonly maximumLevel?: number; readonly milestoneLevel?: ManastormSpecialLevel; readonly milestoneEvery?: number; readonly minimumPartySize?: ManastormPartySize; readonly maximumPartySize?: ManastormPartySize; readonly currencyId?: string; readonly currencyBase?: number; readonly currencyPerLevel?: number; readonly cacheId?: string; readonly cachePolicy?: "guaranteed" | "accumulating-chance"; readonly cacheMeter?: number; readonly provenance?: { readonly fidelity: "authored-observable-cadence"; readonly source: string; }; } export interface ManastormCatalogLoadoutLike { readonly id: string; readonly name?: string; readonly unlockLevel?: number; readonly minimumPartySize?: ManastormPartySize; readonly maximumPartySize?: ManastormPartySize; } export interface ManastormCatalogLike { readonly id?: string; readonly modeId?: number; readonly modeIds?: readonly number[]; readonly maximumLevel?: number; readonly maps?: readonly ManastormCatalogMapLike[]; readonly encounters?: readonly ManastormCatalogEncounterLike[]; readonly stages?: readonly ManastormCatalogEncounterLike[]; readonly sourceRows?: readonly ManastormCatalogSourceRowLike[]; readonly modifiers?: readonly ManastormCatalogModifierLike[]; readonly groupModifiers?: readonly ManastormCatalogGroupModifierLike[]; readonly messages?: readonly ManastormCatalogMessageLike[]; readonly affixes?: readonly ManastormCatalogAffixLike[]; readonly rewards?: readonly ManastormCatalogRewardLike[]; readonly loadouts?: readonly ManastormCatalogLoadoutLike[]; readonly defaultLoadoutId?: string; readonly fallbackPolicy?: "legacy" | "none"; readonly runtimeOptions?: { readonly enabled?: boolean; readonly levelingEnabled?: boolean; readonly endgameEnabled?: boolean; readonly affixesEnabled?: boolean; readonly chaoticLinkEnabled?: boolean; readonly milestoneEncountersEnabled?: boolean; }; readonly scaling?: { readonly perLevel?: number; readonly bonusLootPerLevel?: number; readonly maximumBonusLootChance?: number; }; } const LEGACY_ENCOUNTER_DATA = [ { id: "deviate-faerie-dragon", dungeonId: "wailing-caverns", bossRuntimeId: "faerie-dragon-spawn", bossEntityId: "deviate-faerie-dragon", bossName: "Deviate Faerie Dragon", bossPosition: [-0.008, -67.031, -55.934], playerSpawn: { footPosition: [3.837, -67.114, -60.538], yaw: -0.696 }, portalPosition: [0.834, -66.629, -63.082], }, { id: "lady-anacondra", dungeonId: "wailing-caverns", bossRuntimeId: "lady-anacondra-spawn", bossEntityId: "lady-anacondra", bossName: "Lady Anacondra", bossPosition: [-15.34, -87.7, 297.18], playerSpawn: { footPosition: [-9.712, -87.198, 299.145], yaw: -1.903 }, portalPosition: [-8.479, -86.97, 295.38], }, { id: "kresh", dungeonId: "wailing-caverns", bossRuntimeId: "kresh-patrol:kresh-roaming", bossEntityId: "kresh", bossName: "Kresh", bossPosition: [64.41, -106.7, 319.08], playerSpawn: { footPosition: [64.406, -106.542, 325.079], yaw: Math.PI }, portalPosition: [68.375, -106.337, 325.089], }, { id: "lord-cobrahn", dungeonId: "wailing-caverns", bossRuntimeId: "lord-cobrahn-spawn", bossEntityId: "lord-cobrahn", bossName: "Lord Cobrahn", bossPosition: [151.14, -72.63, 414.37], playerSpawn: { footPosition: [145.372, -72.525, 416.02], yaw: 1.85 }, portalPosition: [146.472, -72.492, 419.866], }, { id: "lord-pythas", dungeonId: "wailing-caverns", bossRuntimeId: "lord-pythas-spawn", bossEntityId: "lord-pythas", bossName: "Lord Pythas", bossPosition: [-36.81, -79.5, -241.06], playerSpawn: { footPosition: [-30.931, -78.905, -241.746], yaw: -1.449 }, portalPosition: [-31.539, -78.481, -245.395], }, { id: "skum", dungeonId: "wailing-caverns", bossRuntimeId: "skum-spawn", bossEntityId: "skum", bossName: "Skum", bossPosition: [285.58, -69.19, -312.97], playerSpawn: { footPosition: [291.314, -68.36, -314.426], yaw: -1.326 }, portalPosition: [290.353, -68.444, -318.294], }, { id: "lord-serpentis", dungeonId: "wailing-caverns", bossRuntimeId: "lord-serpentis-spawn", bossEntityId: "lord-serpentis", bossName: "Lord Serpentis", bossPosition: [120.16, -28.58, -24.62], playerSpawn: { footPosition: [125.859, -28.731, -22.769], yaw: -1.882 }, portalPosition: [126.974, -29.673, -26.49], }, { id: "verdan-the-everliving", dungeonId: "wailing-caverns", bossRuntimeId: "verdan-spawn", bossEntityId: "verdan-the-everliving", bossName: "Verdan the Everliving", bossPosition: [81.86, -30.99, 32.26], playerSpawn: { footPosition: [86.891, -31.031, 28.991], yaw: -0.995 }, portalPosition: [84.712, -31.022, 25.637], }, { id: "mutanus-the-devourer", dungeonId: "wailing-caverns", bossRuntimeId: "mutanus-spawn", bossEntityId: "mutanus-the-devourer", bossName: "Mutanus the Devourer", bossPosition: [-151.27, -102.82, 252.26], playerSpawn: { footPosition: [-145.501, -102.059, 250.887], yaw: -1.333 }, portalPosition: [-146.793, -101.961, 247.24], }, ] as const; const FALLBACK_STAGE_RULES: Readonly>> = Object.freeze({ "deviate-faerie-dragon": { unlockLevel: 1 }, "lady-anacondra": { unlockLevel: 1 }, kresh: { unlockLevel: 1 }, "lord-cobrahn": { unlockLevel: 6 }, skum: { unlockLevel: 6 }, "lord-serpentis": { unlockLevel: 6 }, "lord-pythas": { stageKind: "milestone", milestoneLevel: 5, unlockLevel: 5 }, "verdan-the-everliving": { stageKind: "milestone", milestoneLevel: 10, unlockLevel: 10 }, "mutanus-the-devourer": { stageKind: "milestone", milestoneLevel: 15, unlockLevel: 15 }, }); export const MANASTORM_ENCOUNTERS: readonly ManastormEncounterDefinition[] = Object.freeze( LEGACY_ENCOUNTER_DATA.map((encounter) => ({ ...encounter, assetPackageId: "manastorm:assets:43", trashRuntimeIds: [], stageKind: "standard" as const, minimumPartySize: 1 as const, maximumPartySize: 5 as const, ...FALLBACK_STAGE_RULES[encounter.id], })), ); const FALLBACK_GROUP_MODIFIERS: readonly ManastormCatalogGroupModifierLike[] = [ { playerCount: 1, modeId: 0, multipliers: { enemyStatMultiplier: 0.72, rewardMultiplier: 1 } }, { playerCount: 2, modeId: 0, multipliers: { enemyStatMultiplier: 0.86, rewardMultiplier: 1.08 } }, { playerCount: 3, modeId: 0, multipliers: { enemyStatMultiplier: 1, rewardMultiplier: 1.16 } }, { playerCount: 4, modeId: 0, multipliers: { enemyStatMultiplier: 1.14, rewardMultiplier: 1.24 } }, { playerCount: 5, modeId: 0, multipliers: { enemyStatMultiplier: 1.28, rewardMultiplier: 1.32 } }, ]; export const LEGACY_MANASTORM_CATALOG: ManastormCatalogLike = Object.freeze({ id: "legacy-manastorm-fallback", modeId: 0, maximumLevel: 16_384, stages: MANASTORM_ENCOUNTERS, groupModifiers: FALLBACK_GROUP_MODIFIERS, affixes: [ { id: "unstable-magic", name: "Unstable Magic", description: "Arcane pressure intensifies throughout the stage.", levels: [3, 4, 8, 9, 13, 14] }, { id: "milestone-surge", name: "Milestone Surge", description: "The storm empowers this milestone encounter.", unlockLevel: 5, everyLevels: 5 }, ], rewards: [ { id: "stage-clear", minimumLevel: 1, maximumLevel: 16_384, currencyId: "bonzoBolts", currencyBase: 1, currencyPerLevel: 0.2 }, { id: "milestone-cache-repeating", minimumLevel: 5, milestoneEvery: 5, currencyId: "bedlamBullions", currencyBase: 5, currencyPerLevel: 0.5, cacheId: "manastorm-milestone-cache" }, { id: "milestone-cache", milestoneLevel: 5, currencyId: "bedlamBullions", currencyBase: 5, cacheId: "bronze-manastorm-cache" }, { id: "milestone-cache-greater", milestoneLevel: 10, currencyId: "bedlamBullions", currencyBase: 10, cacheId: "silver-manastorm-cache" }, { id: "milestone-cache-final", milestoneLevel: 15, currencyId: "bedlamBullions", currencyBase: 15, cacheId: "gold-manastorm-cache" }, ], loadouts: [ { id: "balanced", name: "Balanced", unlockLevel: 1 }, { id: "aggressive", name: "Aggressive", unlockLevel: 6 }, { id: "resilient", name: "Resilient", unlockLevel: 11 }, ], defaultLoadoutId: "balanced", messages: [ { modeId: 0, progressionValue: 1, title: "The storm opens", description: "Clear the guardians, defeat their master, claim the cache, and enter the portal." }, { modeId: 0, progressionValue: 5, title: "First milestone", description: "A special storm seals the end of this wing." }, { modeId: 0, progressionValue: 10, title: "Second milestone", description: "The Manastorm reaches a dangerous new intensity." }, { modeId: 0, progressionValue: 15, title: "Final milestone", description: "Break the heart of the storm." }, ], }); let configuredCatalog: ManastormCatalogLike | null = null; /** Small runtime seam used by the generated-catalog adapter. Passing null restores the safe fallback. */ export function setManastormCatalog(catalog: ManastormCatalogLike | null): void { configuredCatalog = catalog; } export function getManastormCatalog(): ManastormCatalogLike { return configuredCatalog ?? LEGACY_MANASTORM_CATALOG; } /** * Ascension chooses the progression mode from character state. The caller * supplies this game's current level cap so a run never changes mode midway. */ export function manastormModeForCharacterLevel( playerLevel: number, catalog: ManastormCatalogLike = getManastormCatalog(), ): number { const safePlayerLevel = Math.max( 1, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1), ); const availableModeIds = catalog.modeIds ?? [ ...new Set([ ...(catalog.modifiers ?? []).map((modifier) => modifier.modeId), ...(catalog.messages ?? []).map((message) => message.modeId), ...(catalog.stages ?? []).flatMap((stage) => stage.modeIds ?? []), ]), ]; if ( safePlayerLevel >= MANASTORM_ENDGAME_CHARACTER_LEVEL && availableModeIds.includes(MANASTORM_ENDGAME_MODE_ID) ) { return MANASTORM_ENDGAME_MODE_ID; } return manastormModeId(catalog); } export function normalizeManastormPartySize(value: number): ManastormPartySize { const normalized = Math.max(1, Math.min(5, Math.trunc(Number.isFinite(value) ? value : 5))); return normalized as ManastormPartySize; } export function manastormCheckpointForLevel(level: number): ManastormCheckpointLevel { const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); return Math.floor((safeLevel - 1) / 5) * 5 + 1; } export function isManastormSpecialLevel(level: number): boolean { const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); return safeLevel % 5 === 0; } function boundedRandom(random: () => number): number { const value = random(); if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(0.999999999999, value)); } function shuffleIds(ids: readonly string[], random: () => number, previousId: string | null): readonly string[] { const shuffled = [...ids].sort((left, right) => left.localeCompare(right)); for (let index = shuffled.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(boundedRandom(random) * (index + 1)); [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]; } if (previousId && shuffled.length > 1 && shuffled[0] === previousId) { [shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]; } return shuffled; } const encounterById = new Map(MANASTORM_ENCOUNTERS.map((encounter) => [encounter.id, encounter])); export function manastormEncounterById(id: string): ManastormEncounterDefinition | null { return runtimeStages(getManastormCatalog()).find((encounter) => encounter.id === id) ?? encounterById.get(id) ?? null; } export function shuffleManastormEncounterIds( random: () => number = Math.random, previousEncounterId: string | null = null, ): readonly string[] { return shuffleIds(MANASTORM_ENCOUNTERS.map((encounter) => encounter.id), random, previousEncounterId); } export function drawManastormEncounter( bag: readonly string[], previousEncounterId: string | null, random: () => number = Math.random, ): ManastormDraw { const available = bag.length ? [...bag] : [...shuffleManastormEncounterIds(random, previousEncounterId)]; const id = available.shift(); const encounter = id ? encounterById.get(id) ?? null : null; if (!encounter) throw new Error("The Manastorm encounter bag did not contain a valid boss."); return { encounter, remainingBag: available }; } function isRuntimeEncounter(value: ManastormCatalogEncounterLike): value is ManastormCatalogEncounterLike & ManastormEncounterDefinition { return Boolean( value.dungeonId && value.bossRuntimeId && value.bossEntityId && value.bossName && value.bossPosition?.length === 3 && value.playerSpawn?.footPosition.length === 3 && value.portalPosition?.length === 3, ); } export function runtimeStages(catalog: ManastormCatalogLike = getManastormCatalog()): readonly ManastormEncounterDefinition[] { const candidates = catalog.stages ?? catalog.encounters ?? []; const stages = candidates.filter(isRuntimeEncounter).map((stage) => ({ ...stage, trashRuntimeIds: stage.trashRuntimeIds ?? [], stageKind: stage.stageKind ?? (stage.milestoneLevel ? "milestone" : "standard"), unlockLevel: Math.max(1, Math.trunc(stage.unlockLevel ?? 1)), minimumPartySize: normalizeManastormPartySize(stage.minimumPartySize ?? 1), maximumPartySize: normalizeManastormPartySize(stage.maximumPartySize ?? 5), })); return stages.length || catalog.fallbackPolicy === "none" ? stages : MANASTORM_ENCOUNTERS; } export function drawManastormStage( level: number, partySize: number, bag: readonly string[], previousEncounterId: string | null, random: () => number = Math.random, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), unlockedThroughLevel = level, ): ManastormDraw { const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); const safeUnlockedThroughLevel = Math.max( safeLevel, Math.trunc(Number.isFinite(unlockedThroughLevel) ? unlockedThroughLevel : safeLevel), ); const safePartySize = normalizeManastormPartySize(partySize); const allStages = runtimeStages(catalog); const unlocked = allStages.filter((stage) => ( (stage.unlockLevel ?? 1) <= safeUnlockedThroughLevel && (!stage.modeIds?.length || stage.modeIds.includes(modeId)) && (stage.minimumPartySize ?? 1) <= safePartySize && (stage.maximumPartySize ?? 5) >= safePartySize )); const special = catalog.runtimeOptions?.milestoneEncountersEnabled !== false && isManastormSpecialLevel(safeLevel); const milestoneStages = unlocked.filter((stage) => stage.milestoneLevel === safeLevel); const milestonePool = unlocked.filter((stage) => ( stage.stageKind === "milestone" || stage.milestoneLevel !== undefined )); const standardStages = unlocked.filter((stage) => ( (stage.stageKind !== "milestone" && stage.milestoneLevel === undefined) || (stage.milestoneLevel !== undefined && safeUnlockedThroughLevel > stage.milestoneLevel) )); let eligible = special ? (milestoneStages.length ? milestoneStages : milestonePool) : standardStages; if (!eligible.length && catalog.fallbackPolicy !== "none") { eligible = MANASTORM_ENCOUNTERS.filter((stage) => ( (stage.unlockLevel ?? 1) <= safeLevel && (special ? stage.milestoneLevel === safeLevel || stage.stageKind === "milestone" : stage.stageKind !== "milestone" && !stage.milestoneLevel) )); } if (!eligible.length && catalog.fallbackPolicy !== "none") eligible = [MANASTORM_ENCOUNTERS[0]]; const byId = new Map(eligible.map((stage) => [stage.id, stage])); let available = bag.filter((id) => byId.has(id)); if (!available.length) { available = [...shuffleIds([...byId.keys()], random, previousEncounterId)]; } else if ( previousEncounterId && available[0] === previousEncounterId && byId.size > 1 ) { const alternate = [...byId.keys()].sort().find((id) => id !== previousEncounterId); available = alternate ? [alternate, ...available.filter((id) => id !== alternate)] : available; } const id = available.shift(); const encounter = id ? byId.get(id) : null; if (!encounter) throw new Error("The Manastorm stage selector found no unlocked encounter."); return { encounter, remainingBag: available }; } function numericValue( values: ManastormNumericValues | undefined, names: readonly string[], arrayIndex: number, fallback: number, ): number { if (!values) return fallback; if (Array.isArray(values)) { const value = values[arrayIndex]; return typeof value === "number" && Number.isFinite(value) ? value : fallback; } const record = values as Readonly>; for (const name of names) { const value = record[name]; if (typeof value === "number" && Number.isFinite(value)) return value; } return fallback; } export function manastormModeId(catalog: ManastormCatalogLike = getManastormCatalog()): number { return Math.trunc(catalog.modeId ?? catalog.modifiers?.[0]?.modeId ?? catalog.groupModifiers?.[0]?.modeId ?? catalog.messages?.[0]?.modeId ?? 0); } const modifierIndex = new WeakMap< ManastormCatalogLike, Map >(); function modifierForLevel( catalog: ManastormCatalogLike, modeId: number, level: number, ): ManastormCatalogModifierLike | null { let byMode = modifierIndex.get(catalog); if (!byMode) { byMode = new Map(); for (const modifier of catalog.modifiers ?? []) { const values = byMode.get(modifier.modeId) ?? []; values.push(modifier); byMode.set(modifier.modeId, values); } for (const values of byMode.values()) { values.sort((left, right) => left.level - right.level || (left.id ?? "").localeCompare(right.id ?? "")); } modifierIndex.set(catalog, byMode); } const values = byMode.get(modeId) ?? []; let low = 0; let high = values.length - 1; let match: ManastormCatalogModifierLike | null = null; while (low <= high) { const middle = (low + high) >> 1; const candidate = values[middle]; if (candidate.level <= level) { match = candidate; low = middle + 1; } else { high = middle - 1; } } return match; } export function manastormScaling( level: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): ManastormScaling { const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); const modifier = modifierForLevel(catalog, modeId, safeLevel); const perLevel = catalog.scaling?.perLevel ?? MANASTORM_SCALING_PER_LEVEL; const bonusPerLevel = catalog.scaling?.bonusLootPerLevel ?? perLevel; const increase = perLevel * (safeLevel - 1); const statMultiplier = numericValue( modifier?.tuning, ["statMultiplier", "enemyStatMultiplier", "creatureMultiplier"], 8, 1 + increase, ); // Imported modifier arrays use slots 8/9/10 for multiplier candidates; they // do not carry a semantic loot chance. Only named tuning may override loot. const bonusLootChance = Array.isArray(modifier?.tuning) ? bonusPerLevel * (safeLevel - 1) : numericValue( modifier?.tuning, ["bonusLootChance", "lootChance", "bonusRewardChance"], 0, bonusPerLevel * (safeLevel - 1), ); const experienceMultiplier = numericValue( modifier?.tuning, ["experienceMultiplier", "xpMultiplier", "experienceRewardMultiplier"], 10, 1 + increase, ); return { statMultiplier: Math.max(0.01, statMultiplier), bonusLootChance: Math.max(0, Math.min(catalog.scaling?.maximumBonusLootChance ?? 1, bonusLootChance)), experienceMultiplier: Math.max(0, experienceMultiplier), }; } export function manastormGroupModifier( partySize: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): ManastormGroupModifier { const safePartySize = normalizeManastormPartySize(partySize); const exact = catalog.groupModifiers?.find((modifier) => ( modifier.playerCount === safePartySize && modifier.modeId === modeId )); const sharedLevelingModifier = catalog.groupModifiers?.find((modifier) => ( modifier.playerCount === safePartySize && modifier.modeId === MANASTORM_LEVELING_MODE_ID )); const fallback = FALLBACK_GROUP_MODIFIERS.find((modifier) => modifier.playerCount === safePartySize)!; const source = exact ?? sharedLevelingModifier ?? fallback; return { partySize: safePartySize, enemyStatMultiplier: Math.max(0.01, numericValue( source.multipliers, ["enemyStatMultiplier", "statMultiplier", "creatureMultiplier"], 0, 1, )), rewardMultiplier: Math.max(0, numericValue( source.multipliers, ["rewardMultiplier", "lootMultiplier", "currencyMultiplier"], 1, 1, )), sourceModeId: source.sourceModeId ?? source.modeId, appliesToModeIds: source.appliesToModeIds ?? [source.modeId], }; } export function manastormRuntimeScaling( level: number, partySize: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): ManastormRuntimeScaling { const scaling = manastormScaling(level, catalog, modeId); const group = manastormGroupModifier(partySize, catalog, modeId); return { ...scaling, partySize: group.partySize, enemyStatMultiplier: scaling.statMultiplier * group.enemyStatMultiplier, rewardMultiplier: group.rewardMultiplier, }; } /** * Applies the imported modifier's third multiplier candidate to kill XP, then * uses the same group/map reward scalars as currencies and caches. The client * does not label slot 10, so this remains a documented data-shape inference. */ export function manastormExperienceReward( baseExperience: number, level: number, partySize: number, mapRewardMultiplier = 1, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): number { const base = Math.max(0, Number.isFinite(baseExperience) ? baseExperience : 0); const scaling = manastormScaling(level, catalog, modeId); const group = manastormGroupModifier(partySize, catalog, modeId); const mapMultiplier = Math.max( 0, Number.isFinite(mapRewardMultiplier) ? mapRewardMultiplier : 1, ); return Math.max( 0, Math.round(base * scaling.experienceMultiplier * group.rewardMultiplier * mapMultiplier), ); } export function manastormAffixes( level: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), unlockedThroughLevel = level, ): readonly ManastormAffixState[] { if (catalog.runtimeOptions?.affixesEnabled === false) return []; const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); const safeUnlockedThroughLevel = Math.max( safeLevel, Math.trunc(Number.isFinite(unlockedThroughLevel) ? unlockedThroughLevel : safeLevel), ); const eligible = (catalog.affixes ?? []) .filter((affix) => ( (affix.unlockLevel ?? 1) <= safeUnlockedThroughLevel && (!affix.modeIds?.length || affix.modeIds.includes(modeId)) )); const scheduled = eligible.filter((affix) => ( Boolean(affix.levels?.length) || Boolean(affix.everyLevels) )).filter((affix) => ( (!affix.levels?.length || affix.levels.includes(safeLevel)) && (!affix.everyLevels || safeLevel % Math.max(1, Math.trunc(affix.everyLevels)) === 0) )); const rotatingPool = eligible.filter((affix) => ( !affix.levels?.length && !affix.everyLevels )); const introduced = rotatingPool.filter((affix) => affix.unlockLevel === safeLevel); const selected = [ ...scheduled, ...(introduced.length ? introduced : rotatingPool.length ? [rotatingPool[(safeLevel - 1) % rotatingPool.length]] : []), ]; const explicit = selected .map((affix) => ({ id: affix.id, name: affix.name, description: affix.description, ...(affix.iconReference ? { iconReference: affix.iconReference } : {}), ...(affix.rawFlag !== undefined ? { rawFlag: affix.rawFlag } : {}), ...(affix.trigger ? { trigger: affix.trigger } : {}), ...(affix.spellId !== undefined ? { spellId: affix.spellId } : {}), ...(affix.sourceRecordIds ? { sourceRecordIds: affix.sourceRecordIds } : {}), ...(affix.sourceReportId ? { sourceReportId: affix.sourceReportId } : {}), ...(affix.sourceSpellDbcSha256 ? { sourceSpellDbcSha256: affix.sourceSpellDbcSha256 } : {}), })); if (explicit.length) return explicit; const modifier = modifierForLevel(catalog, modeId, safeLevel); if (!modifier?.rawFlag) return []; return [{ id: modifier.id ?? `manastorm:modifier:${modeId}:${modifier.level}`, name: modifier.name ?? `Storm modifier ${modifier.rawFlag}`, description: modifier.description ?? "A catalog-defined Manastorm modifier is active.", ...(modifier.iconReference ? { iconReference: modifier.iconReference } : {}), rawFlag: modifier.rawFlag, }]; } export function manastormAvailableLoadoutIds( level: number, partySize: number, catalog: ManastormCatalogLike = getManastormCatalog(), ): readonly string[] { const safePartySize = normalizeManastormPartySize(partySize); return (catalog.loadouts ?? []) .filter((loadout) => ( (loadout.unlockLevel ?? 1) <= level && (loadout.minimumPartySize ?? 1) <= safePartySize && (loadout.maximumPartySize ?? 5) >= safePartySize )) .map((loadout) => loadout.id) .filter((id, index, ids) => Boolean(id) && ids.indexOf(id) === index); } function rewardDefinition( encounter: ManastormEncounterDefinition, level: number, partySize: ManastormPartySize, catalog: ManastormCatalogLike, modeId: number, ): ManastormCatalogRewardLike | null { const rewards = catalog.rewards ?? LEGACY_MANASTORM_CATALOG.rewards ?? []; if (encounter.rewardId) { const exact = rewards.find((reward) => ( reward.id === encounter.rewardId && (reward.claimPolicy ?? "repeatable") === "repeatable" && (!reward.modeIds?.length || reward.modeIds.includes(modeId)) )); if (exact) return exact; } const matches = rewards.filter((reward) => ( (reward.claimPolicy ?? "repeatable") === "repeatable" && (!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) )); return matches.find((reward) => reward.milestoneLevel === level) ?? matches.find((reward) => reward.milestoneEvery !== undefined) ?? matches.find((reward) => reward.milestoneLevel === undefined) ?? null; } export function manastormReward( encounter: ManastormEncounterDefinition, level: number, partySize: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): ManastormRewardState { const safePartySize = normalizeManastormPartySize(partySize); const definition = rewardDefinition(encounter, level, safePartySize, catalog, modeId) ?? { id: "safe-fallback-reward", currencyId: "bonzoBolts", currencyBase: 1 }; const group = manastormGroupModifier(safePartySize, catalog, modeId); const rawCurrency = (definition.currencyBase ?? 0) + (definition.currencyPerLevel ?? 0) * Math.max(0, level - 1); const mapRewardMultiplier = Number.isFinite(encounter.mapRewardMultiplier) ? Math.max(0, encounter.mapRewardMultiplier ?? 1) : 1; const rewardMultiplier = group.rewardMultiplier * mapRewardMultiplier; return { id: `${encounter.id}:${level}:${definition.id}`, rewardId: definition.id, stageId: encounter.id, level, currencyId: definition.currencyId ?? "bonzoBolts", currencyAmount: Math.max( 0, Math.round(rawCurrency * rewardMultiplier), ), cacheId: definition.cacheId ?? null, cachePolicy: definition.cacheId ? definition.cachePolicy ?? "guaranteed" : "none", rewardMultiplier, }; } /** * Observable Chapter 2 behavior: cache chance rises after every miss and rises * faster at deeper levels. Ascension's exact server formula is private, so the * coefficients are a deterministic local balance substitution. */ export function manastormCacheChance(level: number, consecutiveMisses: number): number { const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); const misses = Math.max( 0, Math.trunc(Number.isFinite(consecutiveMisses) ? consecutiveMisses : 0), ); if (misses >= 4) return 1; return Math.min(0.95, 0.12 + Math.min(0.45, (safeLevel - 1) * 0.003) + misses * 0.18); } export function manastormMessage( level: number, _partySize: number, catalog: ManastormCatalogLike = getManastormCatalog(), modeId = manastormModeId(catalog), ): string { const messages = (catalog.messages ?? []) .filter((message) => ( message.modeId === modeId && message.progressionValue <= level )) .sort((left, right) => right.progressionValue - left.progressionValue || left.title.localeCompare(right.title)); const message = messages[0] ?? LEGACY_MANASTORM_CATALOG.messages?.[0] ?? { title: "Manastorm", description: "The storm shifts." }; return `${message.title}: ${message.description}`; } export function manastormActiveRuntimeIds( encounter: ManastormEncounterDefinition | null, phase: ManastormPhase, ): readonly string[] { if (!encounter) return []; if (phase === "enter" || phase === "trash") return encounter.trashRuntimeIds ?? []; if (phase === "boss" || phase === "loot" || phase === "portal") { return [...(encounter.trashRuntimeIds ?? []), encounter.bossRuntimeId]; } return []; } function distanceSquared(left: MobVector3, right: MobVector3): number { const dx = left[0] - right[0]; const dy = left[1] - right[1]; const dz = left[2] - right[2]; return dx * dx + dy * dy + dz * dz; } export function actorEnteredManastormPortal( portalPosition: MobVector3, actorPositions: readonly MobVector3[], radius = MANASTORM_PORTAL_RADIUS, ): boolean { const radiusSquared = Math.max(0, radius) ** 2; return actorPositions.some((position) => distanceSquared(portalPosition, position) <= radiusSquared); }