161 lines
8.7 KiB
TypeScript
161 lines
8.7 KiB
TypeScript
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 };
|
||
}
|