Files
healer-man/src/game/bossPressure.ts
T
2026-09-16 14:30:58 -04:00

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));
}