152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
|
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
|
import type { DungeonDefinition } from "./dungeonTypes";
|
|
import type {
|
|
EnemyAbilityEffect,
|
|
EnemyAttackDefinition,
|
|
EnemyAtomicAbilityEffect,
|
|
} from "./enemyAttacks";
|
|
import type { PopulationEntityDefinition } from "./mobPopulation";
|
|
|
|
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
|
|
export const BOSS_PRESSURE_PARTY_SIZE = 5;
|
|
|
|
export interface BossAttackPressureEstimate {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly spellId?: number;
|
|
readonly target: EnemyAttackDefinition["target"];
|
|
readonly school: EnemyAttackDefinition["school"];
|
|
readonly expectedCasts: number;
|
|
readonly targetsPerCast: number;
|
|
readonly damagePerTarget: number;
|
|
readonly partyDamage: number;
|
|
}
|
|
|
|
export interface BossPartyPressureEstimate {
|
|
readonly key: string;
|
|
readonly dungeonId: string;
|
|
readonly dungeonTitle: string;
|
|
readonly boss: string;
|
|
readonly serverEntry: number | null;
|
|
readonly playerLevel: number;
|
|
readonly enemyLevel: number;
|
|
readonly windowSeconds: number;
|
|
readonly attackDamage: number;
|
|
readonly partyDamage: number;
|
|
readonly partyDamagePerSecond: number;
|
|
readonly tankDamage: number;
|
|
readonly randomTargetDamage: number;
|
|
readonly groupwideDamage: number;
|
|
readonly attacks: readonly BossAttackPressureEstimate[];
|
|
}
|
|
|
|
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
|
|
if (!range) return fallback;
|
|
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
|
|
}
|
|
|
|
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
|
|
const initial = midpoint(attack.initialCooldownMs, 450);
|
|
if (windowMs < initial) return 0;
|
|
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
|
|
const casts = 1 + (windowMs - initial) / repeat;
|
|
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
|
|
}
|
|
|
|
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
|
|
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
|
|
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
|
|
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
|
|
return 0;
|
|
}
|
|
|
|
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
|
|
if (!effect) return Math.max(0, attack.damageMultiplier);
|
|
if (effect.kind === "composite") {
|
|
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
|
|
}
|
|
return atomicDamageMultiplier(effect);
|
|
}
|
|
|
|
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
|
|
if (attack.target === "nearby-party") return partySize;
|
|
if (attack.target === "primary" || attack.target === "random-party") return 1;
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
|
|
* live attack definitions and source creature stats, but deliberately excludes
|
|
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
|
|
* phase uptime. Those omissions keep the audit deterministic and make inflated
|
|
* schedules or accidental party-wide targeting obvious.
|
|
*/
|
|
export function estimateBossPartyPressure(
|
|
dungeon: DungeonDefinition,
|
|
entityId: string,
|
|
playerLevel = 80,
|
|
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
|
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
|
): BossPartyPressureEstimate {
|
|
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
|
|
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
|
|
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
|
|
const serverEntry = entity.combat?.serverEntry ?? null;
|
|
const baseDamage = serverEntry
|
|
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
|
|
: undefined;
|
|
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
|
|
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
|
|
const casts = expectedCasts(attack, windowMs);
|
|
const targets = targetsPerCast(attack, partySize);
|
|
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
|
|
return {
|
|
id: attack.id,
|
|
name: attack.name,
|
|
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
|
target: attack.target,
|
|
school: attack.school,
|
|
expectedCasts: casts,
|
|
targetsPerCast: targets,
|
|
damagePerTarget: perTarget,
|
|
partyDamage: casts * targets * perTarget,
|
|
};
|
|
});
|
|
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
|
|
.filter((attack) => attack.target === target)
|
|
.reduce((total, attack) => total + attack.partyDamage, 0);
|
|
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
|
|
return {
|
|
key: `${dungeon.id}/${entityId}`,
|
|
dungeonId: dungeon.id,
|
|
dungeonTitle: dungeon.title,
|
|
boss: entity.name,
|
|
serverEntry,
|
|
playerLevel,
|
|
enemyLevel,
|
|
windowSeconds: windowMs / 1_000,
|
|
attackDamage,
|
|
partyDamage,
|
|
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
|
|
tankDamage: sum("primary"),
|
|
randomTargetDamage: sum("random-party"),
|
|
groupwideDamage: sum("nearby-party"),
|
|
attacks,
|
|
};
|
|
}
|
|
|
|
export function rankBossPartyPressure(
|
|
definitions: readonly DungeonDefinition[],
|
|
playerLevel = 80,
|
|
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
|
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
|
): readonly BossPartyPressureEstimate[] {
|
|
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
|
|
.filter(([, entity]) => entity.kind === "boss")
|
|
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
|
|
.sort((left, right) => right.partyDamage - left.partyDamage
|
|
|| left.boss.localeCompare(right.boss)
|
|
|| left.dungeonId.localeCompare(right.dungeonId));
|
|
}
|