Files
healer-man/src/game/dungeonBossScaling.ts
T
2026-10-04 13:13:35 -04:00

161 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}