1055 lines
39 KiB
TypeScript
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);
|
|
}
|