Files
healer-man/src/game/manastorm.ts
T
2026-08-18 16:22:19 -04:00

1055 lines
39 KiB
TypeScript

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<Record<string, number>>;
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<Record<string, number>> | 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<Record<string, Partial<ManastormEncounterDefinition>>> = 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<Record<string, number>>;
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<number, ManastormCatalogModifierLike[]>
>();
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);
}