import { creatureRuntimeBaseStats } from "./azerothCoreReference"; import { bossDifficultyMultipliers } from "./dungeonDifficulty"; import { dungeonEnemyHealthBaseline, dungeonPartyHealthScale, scaledDungeonEnemyLevel } from "./dungeonLevelScaling"; import { finalDungeonBoss } from "./dungeonRunStore"; import type { DungeonDefinition } from "./dungeonTypes"; import type { PopulationEntityDefinition } from "./mobPopulation"; import type { ContentCategoryId } from "../app/contentCategories"; import { isBiteSizedDungeonId } from "./dungeonContentSource"; import { resolveBossBalanceOverride } from "./bossBalanceOverrides"; import type { CharacterStatRuleset } from "./characterStatRuleset"; import { clampPlayerLevel } from "./progression"; // Simulator health budgets measured with the five native training builds and // active player rotations. Keep the legacy RoM/WoW-stat curve independent. const ROM_NATIVE_HEALTH_BANDS: readonly (readonly [number, number, number])[] = [ // character level, trash scale, boss scale [1, 0.4, 0.2], [10, 1.0, 0.6], [30, 1.1, 0.85], [60, 1.5, 1.5], [65, 1.8, 1.9], [66, 2.6, 2.8], [79, 4.5, 4.8], [80, 4.5, 4.8], ]; // Normal training-party point-damage budgets. Native HP/DEF grows faster than // the shared creature budget; exact source formulas bypass this adjustment. const ROM_NATIVE_DAMAGE_BANDS: readonly (readonly [number, number])[] = [ [1, 1], [10, 1], [30, 3], [60, 10], [65, 12], [66, 20], [79, 40], [80, 40], ]; // Legacy RoM-character parties still use their compatibility-stat output // while generated RoM NPCs use native snapshots. Retune only the boss-health // floor for that mixed mode; explicit native RoM health bands stay independent. const ROM_LEGACY_BOSS_HEALTH_SCALE: readonly (readonly [level: number, scale: number])[] = [ [1, 1], [7, 1], [8, 1.12], [70, 1.12], [80, 2.3], ]; const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [ // Calibrate the starter floors to the tested Ready five-member party while // retaining the existing 25/45-second mechanic windows. [2, 1_800], [4, 2_600], [9, 7_000], [21, 11_000], [41, 23_000], [51, 39_000], [80, 180_000], ]; const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [ [2, 900], [4, 1_300], [9, 7_000], [21, 11_000], [41, 23_000], // Isolated test fights are normalized to the certified companion curve. // Native expansion-2 template health grows much faster than the party's // observed level-80 DPS and previously pushed the average fight above 170s. [80, 44_000], ]; const WOW_BOSS_TEST_DAMAGE_SCALE: readonly (readonly [level: number, scale: number])[] = [ [1, 1], [50, 1], // Level-80 source creature weapon damage grows faster than the certified // healer's throughput. This keeps test-room pressure sustained without // turning roughly a third of catalog coverage runs into early wipes. [80, 0.75], ]; const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6; const FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER = 1.9; // A single boss follows one or two short packs. Measured Ready Priest party // damage changes sharply with unlocks, so use a small bracket curve to keep // these bosses near the existing 25–45 second mechanic window at levels 1–80. const BITE_SIZED_BOSS_HEALTH_BANDS: readonly (readonly [number, number, number])[] = [ [1, 2.2, 2.45], [9, 0.95, 1.1], [10, 0.65, 0.8], [20, 0.7, 0.85], [30, 0.82, 0.94], [40, 0.85, 1], [50, 0.7, 0.82], [55, 0.43, 0.53], [60, 0.4, 0.5], [65, 0.33, 0.4], [70, 0.35, 0.43], [80, 0.35, 0.43], ]; function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number { const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel); if (upperIndex <= 0) return anchors[0][1]; if (upperIndex < 0) return anchors[anchors.length - 1][1]; const [lowerLevel, lowerHealth] = anchors[upperIndex - 1]; const [upperLevel, upperHealth] = anchors[upperIndex]; const progress = (level - lowerLevel) / (upperLevel - lowerLevel); return lowerHealth + (upperHealth - lowerHealth) * progress; } function partyBossHealthFloor(level: number, characterSource: ContentCategoryId, testing: boolean): number { if (characterSource === "wow") return interpolatedFloor( level, testing ? WOW_BOSS_TEST_HEALTH_FLOOR : WOW_BOSS_HEALTH_FLOOR, ); const baseline = 1_000 + level * 1_200 + level * level * 22; // Each catalog is calibrated independently against its strongest canonical // class-role cohort so every fight retains the 25/45-second mechanic window. return baseline * 1.15 * dungeonPartyHealthScale(characterSource); } /** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition, instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number, characterSource: ContentCategoryId = "wow", statRuleset: CharacterStatRuleset = "wow") { const boss = entity.kind === "boss"; const testing = dungeon.id.startsWith("boss-testing:"); const level = scaledDungeonEnemyLevel(characterLevel, boss); const authoredHealth = entity.combat?.healthMultiplier ?? 1; const authoredDamage = entity.combat?.damageMultiplier ?? 1; const baseline = dungeonEnemyHealthBaseline(level, characterSource); // Isolated testing rooms retain a packaged source boss's native health so // developers can inspect it without changing campaign cross-catalog balance. const native = testing && entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null; const originalHealth = (native?.health ?? baseline) * authoredHealth; const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 }; const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; // Match durability to the character's game, including when crossing dungeon catalogs. const legacyRomCompatibilityBossScale = boss && characterSource === "rom" && statRuleset === "wow" ? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_LEGACY_BOSS_HEALTH_SCALE) : 1; const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing) * legacyRomCompatibilityBossScale; const biteSizedBoss = boss && !testing && isBiteSizedDungeonId(dungeon.id); const biteSizedFloorScale = biteSizedBoss ? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, floor]) => [levelAnchor, floor])) : 1; const biteSizedCeilingScale = biteSizedBoss ? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, , ceiling]) => [levelAnchor, ceiling])) : Number.POSITIVE_INFINITY; const finaleFloor = finalBoss && dungeon.finalBossDurability !== "standard"; const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : biteSizedFloorScale); // A test room's sole objective must not promote every boss to a campaign // finale. A reduced floor still prevents high-level source creatures with // tiny extrapolated level-two health from dying before mechanics can run. const testingBossFloor = partyHealthFloor * BOSS_TEST_HEALTH_FLOOR_MULTIPLIER; const testingBossCeiling = characterSource === "wow" ? partyHealthFloor * 0.9 * Math.max(1, Math.min(1.5, authoredHealth)) : Number.POSITIVE_INFINITY; const scaledHealth = boss ? testing ? Math.max(Math.min(originalHealth, testingBossCeiling), testingBossFloor) : biteSizedBoss ? Math.min(Math.max(originalHealth, bossFloor), partyHealthFloor * biteSizedCeilingScale) : Math.max(originalHealth, bossFloor) : originalHealth; const testingDamageScale = testing && characterSource === "wow" ? interpolatedFloor(level, WOW_BOSS_TEST_DAMAGE_SCALE) : 1; const bossOverride = boss ? resolveBossBalanceOverride(dungeon.id, entity.id, level) : { healthScale: 1, damageScale: 1 }; const nativeHealthScale = characterSource === "rom" && statRuleset === "rom" ? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_NATIVE_HEALTH_BANDS.map( ([anchor, trashScale, bossScale]) => [anchor, boss ? bossScale : trashScale])) : 1; const nativeDamageBudgetScale = characterSource === "rom" && statRuleset === "rom" ? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_NATIVE_DAMAGE_BANDS) : 1; return { level, maxHealth: scaledHealth * multipliers.health * bossOverride.healthScale * nativeHealthScale, healthMultiplier: authoredHealth * multipliers.health * bossOverride.healthScale * nativeHealthScale, damageMultiplier: authoredDamage * multipliers.damage * testingDamageScale * bossOverride.damageScale, nativeDamageBudgetScale }; }