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