Update Healer Man server
This commit is contained in:
1 parent
fdb6739f8d
commit
f90ba7d989
41 files changed
+1671
-118
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
|
||||
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -890,6 +891,9 @@ const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefin
|
||||
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
|
||||
...CLASSIC_CORE_ABILITIES,
|
||||
};
|
||||
const sourceAbilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
|
||||
...CLASSIC_CORE_ABILITIES,
|
||||
};
|
||||
const abilityCatalog: Record<string, AbilityDefinition> = {};
|
||||
let romEliteAbilities: readonly AbilityDefinition[] = [];
|
||||
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
|
||||
@@ -904,8 +908,28 @@ export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, Ability
|
||||
|
||||
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
|
||||
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
|
||||
abilitiesByClass[classId] = abilities;
|
||||
for (const ability of abilities) abilityCatalog[ability.id] = ability;
|
||||
sourceAbilitiesByClass[classId] = abilities;
|
||||
const policy = currentBalanceTuningPolicy();
|
||||
const tuned = abilities.map((ability) => applyAbilityBalanceTuning(ability, policy));
|
||||
abilitiesByClass[classId] = tuned;
|
||||
for (const ability of tuned) abilityCatalog[ability.id] = ability;
|
||||
}
|
||||
|
||||
/** Rebuilds the live catalog after a local balance policy is published. */
|
||||
export function refreshAbilityBalanceTuning(): void {
|
||||
const policy = currentBalanceTuningPolicy();
|
||||
for (const [classId, source] of Object.entries(sourceAbilitiesByClass)) {
|
||||
if (!source) continue;
|
||||
for (const previous of abilitiesByClass[classId as ClassId] ?? []) delete abilityCatalog[previous.id];
|
||||
const tuned = source.map((ability) => applyAbilityBalanceTuning(ability, policy));
|
||||
abilitiesByClass[classId as ClassId] = tuned;
|
||||
for (const ability of tuned) abilityCatalog[ability.id] = ability;
|
||||
}
|
||||
}
|
||||
|
||||
/** Untuned source definitions used by the GM balance editor. */
|
||||
export function sourceAbilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
|
||||
return sourceAbilitiesByClass[classId] ?? [];
|
||||
}
|
||||
|
||||
for (const classId of BASE_CLASS_IDS) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import {
|
||||
applyAbilityBalanceTuning,
|
||||
currentBalanceTuningPolicy,
|
||||
loadBalanceTuningPolicy,
|
||||
normalizeBalanceTuningPolicy,
|
||||
resetBalanceTuningCache,
|
||||
saveBalanceTuningPolicy,
|
||||
} from "./balanceTuning";
|
||||
|
||||
const ability: AbilityDefinition = {
|
||||
id: "test-fireball",
|
||||
classId: "mage",
|
||||
dbcSpellId: 1,
|
||||
name: "Test Fireball",
|
||||
unlockLevel: 1,
|
||||
icon: "",
|
||||
sigil: "TF",
|
||||
description: "Test",
|
||||
target: "hostile",
|
||||
range: { min: 0, max: 30 },
|
||||
radius: 8,
|
||||
castMode: "cast",
|
||||
castTimeMs: 2_000,
|
||||
cooldownMs: 10_000,
|
||||
gcdMs: 1_500,
|
||||
cost: { resource: "mana", amount: 20 },
|
||||
effects: [
|
||||
{ kind: "damage", coefficient: 2, basePoints: 100 },
|
||||
{ kind: "dot", coefficient: 0.5, ticks: 4, intervalMs: 1_000 },
|
||||
{ kind: "utility", action: "class-script", effectType: 1, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: -1 },
|
||||
],
|
||||
};
|
||||
|
||||
function memoryStorage(initial: string | null = null) {
|
||||
let value = initial;
|
||||
return {
|
||||
getItem: () => value,
|
||||
setItem: (_key: string, next: string) => { value = next; },
|
||||
removeItem: () => { value = null; },
|
||||
value: () => value,
|
||||
};
|
||||
}
|
||||
|
||||
describe("local ability and talent balance tuning", () => {
|
||||
afterEach(() => resetBalanceTuningCache());
|
||||
|
||||
it("normalizes unsafe values and drops default-only overrides", () => {
|
||||
expect(normalizeBalanceTuningPolicy({
|
||||
abilityOverrides: {
|
||||
default: { cooldownPercent: 100 },
|
||||
tuned: { cooldownPercent: -50, damagePercent: 9_999 },
|
||||
},
|
||||
talentOverrides: {
|
||||
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
|
||||
},
|
||||
})).toEqual({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: { tuned: { cooldownPercent: 0, damagePercent: 1_000 } },
|
||||
talentOverrides: {
|
||||
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies cooldown, cast, cost, damage, duration, and radius tuning", () => {
|
||||
const tuned = applyAbilityBalanceTuning(ability, normalizeBalanceTuningPolicy({
|
||||
abilityOverrides: {
|
||||
[ability.id]: {
|
||||
cooldownPercent: 50,
|
||||
castTimePercent: 0,
|
||||
resourceCostPercent: 75,
|
||||
damagePercent: 125,
|
||||
durationPercent: 150,
|
||||
radiusPercent: 200,
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(tuned).toMatchObject({
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 5_000,
|
||||
cost: { amount: 15 },
|
||||
radius: 16,
|
||||
});
|
||||
expect(tuned.effects[0]).toMatchObject({ coefficient: 2.5, basePoints: 125 });
|
||||
expect(tuned.effects[1]).toMatchObject({ coefficient: 0.625, ticks: 6 });
|
||||
expect(tuned.effects[2]).toMatchObject({ durationMs: -1 });
|
||||
});
|
||||
|
||||
it("persists valid policy data and falls back after corrupt storage", () => {
|
||||
const storage = memoryStorage();
|
||||
saveBalanceTuningPolicy({ abilityOverrides: { x: { cooldownPercent: 80 } } }, storage);
|
||||
expect(JSON.parse(storage.value()!)).toMatchObject({ abilityOverrides: { x: { cooldownPercent: 80 } } });
|
||||
expect(currentBalanceTuningPolicy().abilityOverrides.x.cooldownPercent).toBe(80);
|
||||
expect(loadBalanceTuningPolicy(memoryStorage("not-json"))).toEqual({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: {},
|
||||
talentOverrides: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import type {
|
||||
AbilityDefinition,
|
||||
AbilityEffect,
|
||||
AbilityRankDefinition,
|
||||
} from "./abilityCatalog";
|
||||
|
||||
export const BALANCE_TUNING_STORAGE_KEY = "healer-man:balance-tuning:v1";
|
||||
export const BALANCE_TUNING_EVENT = "healer-man:balance-tuning-changed";
|
||||
|
||||
export const TALENT_BALANCE_MODIFIER_KINDS = [
|
||||
"damage-percent",
|
||||
"critical-damage-percent",
|
||||
"healing-percent",
|
||||
"damage-taken-percent",
|
||||
"max-health-percent",
|
||||
"max-resource-percent",
|
||||
"resource-regeneration-percent",
|
||||
"resource-cost-percent",
|
||||
"critical-chance-percent",
|
||||
"haste-percent",
|
||||
"cast-time-percent",
|
||||
"cooldown-percent",
|
||||
"armor-percent",
|
||||
"attribute-percent",
|
||||
"range-percent",
|
||||
"duration-percent",
|
||||
"threat-percent",
|
||||
] as const;
|
||||
|
||||
export type TalentBalanceModifierKind = (typeof TALENT_BALANCE_MODIFIER_KINDS)[number];
|
||||
|
||||
export interface AbilityBalanceOverride {
|
||||
readonly cooldownPercent?: number;
|
||||
readonly castTimePercent?: number;
|
||||
readonly resourceCostPercent?: number;
|
||||
readonly damagePercent?: number;
|
||||
readonly healingPercent?: number;
|
||||
readonly durationPercent?: number;
|
||||
readonly radiusPercent?: number;
|
||||
}
|
||||
|
||||
export interface TalentBalanceOverride {
|
||||
/** Scales every supported passive modifier parsed from the talent tooltip. */
|
||||
readonly effectScalePercent?: number;
|
||||
/** Signed percentage-point adjustments applied per learned rank. */
|
||||
readonly modifiersPerRank?: Readonly<Partial<Record<TalentBalanceModifierKind, number>>>;
|
||||
}
|
||||
|
||||
export interface BalanceTuningPolicy {
|
||||
readonly schemaVersion: 1;
|
||||
readonly abilityOverrides: Readonly<Record<string, AbilityBalanceOverride>>;
|
||||
readonly talentOverrides: Readonly<Record<string, TalentBalanceOverride>>;
|
||||
}
|
||||
|
||||
interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: BalanceTuningPolicy = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: Object.freeze({}),
|
||||
talentOverrides: Object.freeze({}),
|
||||
});
|
||||
|
||||
const ABILITY_FIELDS = [
|
||||
"cooldownPercent",
|
||||
"castTimePercent",
|
||||
"resourceCostPercent",
|
||||
"damagePercent",
|
||||
"healingPercent",
|
||||
"durationPercent",
|
||||
"radiusPercent",
|
||||
] as const;
|
||||
|
||||
let cachedPolicy: BalanceTuningPolicy | null = null;
|
||||
|
||||
function browserStorage(): StorageLike | null {
|
||||
try {
|
||||
return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bounded(value: unknown, minimum: number, maximum: number): number | undefined {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return undefined;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(numeric * 100) / 100));
|
||||
}
|
||||
|
||||
function normalizeAbilityOverride(value: unknown): AbilityBalanceOverride | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const source = value as Record<string, unknown>;
|
||||
const result: Record<string, number> = {};
|
||||
for (const field of ABILITY_FIELDS) {
|
||||
const normalized = bounded(source[field], 0, 1_000);
|
||||
if (normalized !== undefined && normalized !== 100) result[field] = normalized;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function normalizeTalentOverride(value: unknown): TalentBalanceOverride | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const source = value as Record<string, unknown>;
|
||||
const effectScalePercent = bounded(source.effectScalePercent, 0, 1_000);
|
||||
const sourceModifiers = source.modifiersPerRank && typeof source.modifiersPerRank === "object"
|
||||
? source.modifiersPerRank as Record<string, unknown>
|
||||
: {};
|
||||
const modifiersPerRank: Partial<Record<TalentBalanceModifierKind, number>> = {};
|
||||
for (const kind of TALENT_BALANCE_MODIFIER_KINDS) {
|
||||
const normalized = bounded(sourceModifiers[kind], -1_000, 1_000);
|
||||
if (normalized !== undefined && normalized !== 0) modifiersPerRank[kind] = normalized;
|
||||
}
|
||||
const result: TalentBalanceOverride = {
|
||||
...(effectScalePercent !== undefined && effectScalePercent !== 100 ? { effectScalePercent } : {}),
|
||||
...(Object.keys(modifiersPerRank).length ? { modifiersPerRank } : {}),
|
||||
};
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
export function normalizeBalanceTuningPolicy(value: unknown): BalanceTuningPolicy {
|
||||
if (!value || typeof value !== "object") return DEFAULT_POLICY;
|
||||
const source = value as Record<string, unknown>;
|
||||
const sourceAbilities = source.abilityOverrides && typeof source.abilityOverrides === "object"
|
||||
? source.abilityOverrides as Record<string, unknown>
|
||||
: {};
|
||||
const sourceTalents = source.talentOverrides && typeof source.talentOverrides === "object"
|
||||
? source.talentOverrides as Record<string, unknown>
|
||||
: {};
|
||||
const abilityOverrides = Object.fromEntries(Object.entries(sourceAbilities).flatMap(([id, override]) => {
|
||||
const normalized = normalizeAbilityOverride(override);
|
||||
return normalized ? [[id, normalized]] : [];
|
||||
}));
|
||||
const talentOverrides = Object.fromEntries(Object.entries(sourceTalents).flatMap(([id, override]) => {
|
||||
const normalized = normalizeTalentOverride(override);
|
||||
return normalized ? [[id, normalized]] : [];
|
||||
}));
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
abilityOverrides: Object.freeze(abilityOverrides),
|
||||
talentOverrides: Object.freeze(talentOverrides),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
|
||||
if (!storage) return DEFAULT_POLICY;
|
||||
try {
|
||||
const serialized = storage.getItem(BALANCE_TUNING_STORAGE_KEY);
|
||||
return serialized ? normalizeBalanceTuningPolicy(JSON.parse(serialized)) : DEFAULT_POLICY;
|
||||
} catch {
|
||||
return DEFAULT_POLICY;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentBalanceTuningPolicy(): BalanceTuningPolicy {
|
||||
cachedPolicy ??= loadBalanceTuningPolicy();
|
||||
return cachedPolicy;
|
||||
}
|
||||
|
||||
export function saveBalanceTuningPolicy(
|
||||
value: unknown,
|
||||
storage: StorageLike | null = browserStorage(),
|
||||
): BalanceTuningPolicy {
|
||||
const policy = normalizeBalanceTuningPolicy(value);
|
||||
cachedPolicy = policy;
|
||||
try {
|
||||
if (storage) {
|
||||
const empty = !Object.keys(policy.abilityOverrides).length && !Object.keys(policy.talentOverrides).length;
|
||||
if (empty) storage.removeItem(BALANCE_TUNING_STORAGE_KEY);
|
||||
else storage.setItem(BALANCE_TUNING_STORAGE_KEY, JSON.stringify(policy));
|
||||
}
|
||||
} catch {
|
||||
// Runtime tuning still applies for this session when persistent storage is unavailable.
|
||||
}
|
||||
if (typeof globalThis.dispatchEvent === "function" && typeof globalThis.CustomEvent === "function") {
|
||||
globalThis.dispatchEvent(new CustomEvent(BALANCE_TUNING_EVENT, { detail: policy }));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
export function clearBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
|
||||
return saveBalanceTuningPolicy(DEFAULT_POLICY, storage);
|
||||
}
|
||||
|
||||
/** Test helper for isolating the module-level read cache without mutating browser storage. */
|
||||
export function resetBalanceTuningCache(): void {
|
||||
cachedPolicy = null;
|
||||
}
|
||||
|
||||
function scale(value: number, percent: number): number {
|
||||
return Math.max(0, Math.round(value * percent / 100));
|
||||
}
|
||||
|
||||
function scaleOptional(value: number | undefined, percent: number): number | undefined {
|
||||
return value === undefined ? undefined : value * percent / 100;
|
||||
}
|
||||
|
||||
function scaleDuration(value: number, percent: number): number {
|
||||
// Imported catalogs use negative durations as semantic sentinels. They are
|
||||
// not elapsed time and must survive unrelated tuning unchanged.
|
||||
return value < 0 ? value : scale(value, percent);
|
||||
}
|
||||
|
||||
function tuneAmount<T extends Record<string, unknown>>(effect: T, percent: number): T {
|
||||
return {
|
||||
...effect,
|
||||
...(typeof effect.coefficient === "number" ? { coefficient: effect.coefficient * percent / 100 } : {}),
|
||||
...(typeof effect.baseCoefficient === "number" ? { baseCoefficient: effect.baseCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.perComboCoefficient === "number" ? { perComboCoefficient: effect.perComboCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.basePoints === "number" ? { basePoints: effect.basePoints * percent / 100 } : {}),
|
||||
...(typeof effect.dieSides === "number" ? { dieSides: effect.dieSides * percent / 100 } : {}),
|
||||
...(typeof effect.pointsPerLevel === "number" ? { pointsPerLevel: effect.pointsPerLevel * percent / 100 } : {}),
|
||||
...(typeof effect.coefficientPerLevel === "number" ? { coefficientPerLevel: effect.coefficientPerLevel * percent / 100 } : {}),
|
||||
...(typeof effect.bonusPowerCoefficient === "number" ? { bonusPowerCoefficient: effect.bonusPowerCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.attackPowerCoefficient === "number" ? { attackPowerCoefficient: effect.attackPowerCoefficient * percent / 100 } : {}),
|
||||
...(typeof effect.spiritCoefficient === "number" ? { spiritCoefficient: effect.spiritCoefficient * percent / 100 } : {}),
|
||||
...(effect.poison && typeof effect.poison === "object" ? {
|
||||
poison: {
|
||||
...effect.poison,
|
||||
basePointsPerDose: Number((effect.poison as Record<string, unknown>).basePointsPerDose ?? 0) * percent / 100,
|
||||
attackPowerCoefficientPerDose: Number((effect.poison as Record<string, unknown>).attackPowerCoefficientPerDose ?? 0) * percent / 100,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function tuneEffect(
|
||||
effect: AbilityEffect,
|
||||
override: AbilityBalanceOverride,
|
||||
): AbilityEffect {
|
||||
const damagePercent = override.damagePercent ?? 100;
|
||||
const healingPercent = override.healingPercent ?? 100;
|
||||
const durationPercent = override.durationPercent ?? 100;
|
||||
const radiusPercent = override.radiusPercent ?? 100;
|
||||
let tuned: AbilityEffect = effect;
|
||||
if (["damage", "finisher-damage", "dot", "execute"].includes(effect.kind)) {
|
||||
tuned = tuneAmount(effect as unknown as Record<string, unknown>, damagePercent) as unknown as AbilityEffect;
|
||||
} else if (["heal", "hot", "shield"].includes(effect.kind)) {
|
||||
tuned = tuneAmount(effect as unknown as Record<string, unknown>, healingPercent) as unknown as AbilityEffect;
|
||||
} else if (effect.kind === "summon") {
|
||||
tuned = { ...effect, coefficient: effect.coefficient * damagePercent / 100 };
|
||||
}
|
||||
if (tuned.kind === "dot" || tuned.kind === "hot") {
|
||||
tuned = { ...tuned, ticks: Math.max(1, Math.round(tuned.ticks * durationPercent / 100)) };
|
||||
} else if (tuned.kind === "apply-aura") {
|
||||
tuned = {
|
||||
...tuned,
|
||||
aura: {
|
||||
...tuned.aura,
|
||||
durationMs: tuned.aura.durationMs === null ? null : scale(tuned.aura.durationMs, durationPercent),
|
||||
},
|
||||
};
|
||||
} else if ("durationMs" in tuned && typeof tuned.durationMs === "number") {
|
||||
tuned = { ...tuned, durationMs: scaleDuration(tuned.durationMs, durationPercent) } as AbilityEffect;
|
||||
}
|
||||
if ("radius" in tuned && typeof tuned.radius === "number") {
|
||||
tuned = { ...tuned, radius: tuned.radius * radiusPercent / 100 } as AbilityEffect;
|
||||
}
|
||||
return tuned;
|
||||
}
|
||||
|
||||
function tuneRank(rank: AbilityRankDefinition, override: AbilityBalanceOverride): AbilityRankDefinition {
|
||||
const castTimeMs = scale(rank.castTimeMs, override.castTimePercent ?? 100);
|
||||
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
|
||||
...cost,
|
||||
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
|
||||
});
|
||||
return {
|
||||
...rank,
|
||||
castMode: rank.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs,
|
||||
cooldownMs: scale(rank.cooldownMs, override.cooldownPercent ?? 100),
|
||||
cost: tuneCost(rank.cost),
|
||||
costs: rank.costs?.map(tuneCost),
|
||||
effects: rank.effects.map((effect) => tuneEffect(effect, override)),
|
||||
targeting: rank.targeting ? {
|
||||
...rank.targeting,
|
||||
radius: scaleOptional(rank.targeting.radius, override.radiusPercent ?? 100),
|
||||
jumpRange: scaleOptional(rank.targeting.jumpRange, override.radiusPercent ?? 100),
|
||||
groundRange: scaleOptional(rank.targeting.groundRange, override.radiusPercent ?? 100),
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAbilityBalanceTuning(
|
||||
ability: AbilityDefinition,
|
||||
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
|
||||
): AbilityDefinition {
|
||||
const override = policy.abilityOverrides[ability.id];
|
||||
if (!override) return ability;
|
||||
const castTimeMs = scale(ability.castTimeMs, override.castTimePercent ?? 100);
|
||||
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
|
||||
...cost,
|
||||
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
|
||||
});
|
||||
return {
|
||||
...ability,
|
||||
castMode: ability.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs,
|
||||
cooldownMs: scale(ability.cooldownMs, override.cooldownPercent ?? 100),
|
||||
cost: tuneCost(ability.cost),
|
||||
costs: ability.costs?.map(tuneCost),
|
||||
radius: scaleOptional(ability.radius, override.radiusPercent ?? 100),
|
||||
effects: ability.effects.map((effect) => tuneEffect(effect, override)),
|
||||
targeting: ability.targeting ? {
|
||||
...ability.targeting,
|
||||
radius: scaleOptional(ability.targeting.radius, override.radiusPercent ?? 100),
|
||||
jumpRange: scaleOptional(ability.targeting.jumpRange, override.radiusPercent ?? 100),
|
||||
groundRange: scaleOptional(ability.targeting.groundRange, override.radiusPercent ?? 100),
|
||||
} : undefined,
|
||||
ranks: ability.ranks?.map((rank) => tuneRank(rank, override)),
|
||||
};
|
||||
}
|
||||
|
||||
export function talentBalanceOverride(
|
||||
talentId: string,
|
||||
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
|
||||
): TalentBalanceOverride | null {
|
||||
return policy.talentOverrides[talentId] ?? null;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../../app/characterCatalog";
|
||||
import { partyRolesForClass } from "../partyRoles";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadCanonicalBenchmarkBuilds,
|
||||
@@ -9,16 +7,15 @@ import {
|
||||
} from "./benchmarkBuilds";
|
||||
|
||||
describe("canonical benchmark builds", () => {
|
||||
it("covers every declared class-role pair exactly once with legal ranks", async () => {
|
||||
it("defaults to the five Ready class/spec builds with legal ranks", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
const expectedPairs = CLASSES.flatMap((definition) => (
|
||||
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
|
||||
));
|
||||
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
|
||||
|
||||
expect(builds).toHaveLength(77);
|
||||
expect(new Set(actualPairs).size).toBe(builds.length);
|
||||
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
|
||||
expect(builds.map((build) => `${build.classId}:${build.orientation}:${build.role}`)).toEqual([
|
||||
"warrior:Protection:tank",
|
||||
"hunter:Marksmanship:damage",
|
||||
"rogue:Assassination:damage",
|
||||
"priest:Discipline:healer",
|
||||
"mage:Fire:damage",
|
||||
]);
|
||||
for (const build of builds) {
|
||||
expect(build.level).toBe(60);
|
||||
expect(build.gearPresetId).toBe("virtual-heirloom");
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
romGearProfile,
|
||||
specializationsForClass,
|
||||
type SpecializationDefinition,
|
||||
} from "../specializationCatalog";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
} from "../talentUiCatalog";
|
||||
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
|
||||
import {
|
||||
READY_COMPANION_SPECIALIZATION_IDS,
|
||||
readinessStatusFor,
|
||||
type CompanionSpecReadinessStatus,
|
||||
} from "../companionSpecReadiness";
|
||||
@@ -59,6 +61,7 @@ const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
|
||||
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
|
||||
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
|
||||
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
|
||||
const READY_SPECIALIZATION_ID_SET: ReadonlySet<string> = new Set(READY_COMPANION_SPECIALIZATION_IDS);
|
||||
|
||||
const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
|
||||
@@ -66,8 +69,8 @@ const WOW_ORIENTATION_BY_ROLE: Readonly<
|
||||
warrior: { tank: "Protection", damage: "Fury" },
|
||||
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
|
||||
hunter: { damage: "Marksmanship" },
|
||||
rogue: { damage: "Combat" },
|
||||
priest: { healer: "Holy", damage: "Shadow" },
|
||||
rogue: { damage: "Assassination" },
|
||||
priest: { healer: "Discipline", damage: "Shadow" },
|
||||
"death-knight": { tank: "Blood", damage: "Frost" },
|
||||
shaman: { healer: "Restoration", damage: "Elemental" },
|
||||
mage: { damage: "Fire" },
|
||||
@@ -555,14 +558,19 @@ export function selectableBenchmarkBuildCatalog(
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId))
|
||||
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
|
||||
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
|
||||
id: benchmarkBuildId(classId, role, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role,
|
||||
orientation: "Primary",
|
||||
level: safeLevel,
|
||||
}));
|
||||
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => {
|
||||
const specialization = romGearProfile(classId, role);
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role,
|
||||
orientation: "Primary",
|
||||
specializationId: specialization.id,
|
||||
readinessStatus: readinessStatusFor(specialization.id),
|
||||
level: safeLevel,
|
||||
};
|
||||
});
|
||||
return specializationsForClass(classId).flatMap((specialization) => {
|
||||
const profiles = isBaseClassId(classId)
|
||||
? runtimePartyTalentProfiles(classId, specialization.displayName)
|
||||
@@ -583,8 +591,8 @@ export function selectableBenchmarkBuildCatalog(
|
||||
classId,
|
||||
role: specialization.role,
|
||||
orientation: specialization.displayName,
|
||||
specializationId: isBaseClassId(classId) ? specialization.id : undefined,
|
||||
readinessStatus: isBaseClassId(classId) ? readinessStatusFor(specialization.id) : undefined,
|
||||
specializationId: specialization.id,
|
||||
readinessStatus: readinessStatusFor(specialization.id),
|
||||
level: safeLevel,
|
||||
characterName: talentProfile?.characterName,
|
||||
talentBuildId: talentProfile?.id,
|
||||
@@ -663,19 +671,25 @@ export async function loadSelectableBenchmarkBuilds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
* and partyRolesForClass declaration order.
|
||||
* Creates the five Ready benchmark builds by default. Supplying explicit class
|
||||
* IDs remains a targeted diagnostic and returns those classes' declared roles.
|
||||
* Output follows the canonical CLASSES and partyRolesForClass declaration order.
|
||||
*/
|
||||
export async function loadCanonicalBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const readyOnly = classIds === undefined;
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
.filter((classId) => {
|
||||
if (requested) return requested.has(classId);
|
||||
return isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => (
|
||||
READY_SPECIALIZATION_ID_SET.has(specialization.id)
|
||||
));
|
||||
});
|
||||
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
|
||||
@@ -690,6 +704,13 @@ export async function loadCanonicalBenchmarkBuilds(
|
||||
for (const classId of selectedClassIds) {
|
||||
for (const role of partyRolesForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
if (readyOnly) {
|
||||
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
|
||||
const specialization = specializationsForClass(classId).find((candidate) => (
|
||||
candidate.displayName === orientation
|
||||
));
|
||||
if (!specialization || !READY_SPECIALIZATION_ID_SET.has(specialization.id)) continue;
|
||||
}
|
||||
builds.push(wowBuild(classId, role, safeLevel));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = coaCatalogs.get(classId);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import {
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
@@ -483,6 +484,44 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().shield).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Inner Focus as an on-use, one-heal effect", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
||||
const ranks = { [talent.id]: 1 };
|
||||
useCombatStore.setState({
|
||||
talentRanks: ranks,
|
||||
talentModifiers: talentModifiersForRanks(ranks, "priest"),
|
||||
});
|
||||
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
|
||||
useCombatStore.getState().damagePlayer(500);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, mana: 0 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(innerFocus.id, undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
))).toBe(true);
|
||||
|
||||
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 3_000);
|
||||
expect(heal.ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
|
||||
const completion = useCombatStore.getState().activeCast!.completesAt;
|
||||
const spentAtBeforeCompletion = useCombatStore.getState().lastResourceSpentAt.mana;
|
||||
useCombatStore.getState().tick(0.1, completion);
|
||||
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(spentAtBeforeCompletion);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
))).toBe(false);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, mana: 0 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, completion + 2_000))
|
||||
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
||||
});
|
||||
|
||||
it("ticks damage over time and awards kill experience exactly once", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
|
||||
registerTarget("ooze", [10, 0, 0], 5, 100);
|
||||
|
||||
+35
-4
@@ -4626,6 +4626,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
||||
: null;
|
||||
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
||||
const innerFocusAura = activePlayerAuras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
));
|
||||
const consumesInnerFocus = Boolean(
|
||||
innerFocusAura
|
||||
&& ability?.name !== "Inner Focus"
|
||||
&& ability?.effects.some((effect) => effect.kind === "heal"),
|
||||
);
|
||||
const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag));
|
||||
const holyAuraFriendly = Boolean(
|
||||
ability?.source === "runewaker"
|
||||
@@ -4640,9 +4648,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly"
|
||||
: state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile"
|
||||
: ability?.target;
|
||||
const modifiers = state.talentModifiers;
|
||||
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||
const modifiers: readonly TalentModifier[] = consumesInnerFocus ? [
|
||||
...state.talentModifiers,
|
||||
{
|
||||
kind: "critical-chance-percent",
|
||||
amount: 25,
|
||||
unit: "percent",
|
||||
scope: "your healing spells",
|
||||
sourceTalentId: "talent-348:active",
|
||||
sourceTalentName: "Inner Focus",
|
||||
rank: 1,
|
||||
description: "The next direct or area heal costs no resource and gains 25 percentage points of critical chance.",
|
||||
},
|
||||
] : state.talentModifiers;
|
||||
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
||||
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||
@@ -4959,6 +4979,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId: state.raceId,
|
||||
secondaryClassId: state.secondaryClassId,
|
||||
};
|
||||
if (consumesInnerFocus && innerFocusAura) {
|
||||
work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId);
|
||||
}
|
||||
const castThreatSource = playerThreatSource(state, ability.id);
|
||||
|
||||
if (targeting?.shape === "ground" && groundPosition) {
|
||||
@@ -5194,7 +5217,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers),
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
||||
);
|
||||
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
|
||||
const criticalChance = Math.max(0, (
|
||||
casterStats.spellCritPercent
|
||||
+ applyTalentModifierValue(
|
||||
0,
|
||||
modifiers,
|
||||
"critical-chance-percent",
|
||||
abilityTalentContext(ability),
|
||||
)
|
||||
) / 100);
|
||||
let directHealing = 0;
|
||||
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
||||
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMPANION_READINESS_SPECIALIZATIONS,
|
||||
DEFAULT_COMPANION_SPEC_READINESS_POLICY,
|
||||
READY_COMPANION_SPECIALIZATION_IDS,
|
||||
WOW_COMPANION_SPECIALIZATIONS,
|
||||
@@ -25,6 +26,15 @@ describe("companion specialization readiness", () => {
|
||||
expect(readinessStatusFor("future:new-spec", normalized)).toBe("needs-testing");
|
||||
});
|
||||
|
||||
it("marks every CoA specialization and RoM class-role profile as Needs Testing", () => {
|
||||
const wowIds = new Set(WOW_COMPANION_SPECIALIZATIONS.map((definition) => definition.id));
|
||||
const nonWow = COMPANION_READINESS_SPECIALIZATIONS.filter((definition) => !wowIds.has(definition.id));
|
||||
expect(nonWow.length).toBeGreaterThan(0);
|
||||
expect(nonWow.every((definition) => readinessStatusFor(definition.id) === "needs-testing")).toBe(true);
|
||||
expect(nonWow.some((definition) => definition.classId === "barbarian")).toBe(true);
|
||||
expect(nonWow.some((definition) => definition.classId === "rom-warrior")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects policies without certified role coverage", () => {
|
||||
expect(() => normalizeCompanionSpecReadinessPolicy({
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BASE_CLASS_IDS } from "../app/characterCatalog";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
import { BASE_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { partyRolesForClass, type PartyRole } from "./partyRoles";
|
||||
import {
|
||||
SPECIALIZATION_DEFINITIONS,
|
||||
romGearProfile,
|
||||
type SpecializationDefinition,
|
||||
} from "./specializationCatalog";
|
||||
|
||||
@@ -35,6 +36,18 @@ export const WOW_COMPANION_SPECIALIZATIONS: readonly SpecializationDefinition[]
|
||||
SPECIALIZATION_DEFINITIONS.filter((definition) => CLASSIC_CLASS_IDS.has(definition.classId)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Complete certification catalog. CoA specializations and RoM class/role
|
||||
* profiles are visible as Needs Testing, but remain outside the publishable
|
||||
* WoW policy until their companion implementations enter certification.
|
||||
*/
|
||||
export const COMPANION_READINESS_SPECIALIZATIONS: readonly SpecializationDefinition[] = Object.freeze([
|
||||
...SPECIALIZATION_DEFINITIONS,
|
||||
...ROM_CLASS_IDS.flatMap((classId) => partyRolesForClass(classId).map((role) => (
|
||||
romGearProfile(classId, role)
|
||||
))),
|
||||
]);
|
||||
|
||||
const WOW_SPECIALIZATION_IDS = new Set(WOW_COMPANION_SPECIALIZATIONS.map((definition) => definition.id));
|
||||
|
||||
export const DEFAULT_COMPANION_SPEC_READINESS_POLICY: CompanionSpecReadinessPolicy = Object.freeze({
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ClassId } from "../app/characterCatalog";
|
||||
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
equipmentStatsForItems,
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentItems,
|
||||
resolvedEquipmentLoadout,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { itemStatsFor } from "./itemStats";
|
||||
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
|
||||
import { LOOT_CATALOG, scaledLootItemLevel, type LootCatalogItem } from "./lootCatalog";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "./specializationCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { normalizeInventory } from "./lootTypes";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
const templates = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
||||
|
||||
@@ -90,6 +92,23 @@ describe("specialization-aware heirlooms", () => {
|
||||
expect(combatStats.agility).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"priest:discipline",
|
||||
"hunter:marksmanship",
|
||||
"mage:fire",
|
||||
"rogue:assassination",
|
||||
])("turns %s heirloom crit and haste ratings into combat percentages", (specializationId) => {
|
||||
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === specializationId)!;
|
||||
const gear = equipmentStatsForItems(resolvedEquipmentItems({}, [], context(profile, 80)));
|
||||
const geared = deriveCharacterStats(profile.classId, 80, gear);
|
||||
const ungeared = deriveCharacterStats(profile.classId, 80, EMPTY_ITEM_STATS);
|
||||
expect(gear.criticalStrikeRating).toBeGreaterThan(0);
|
||||
expect(gear.hasteRating).toBeGreaterThan(0);
|
||||
expect(Math.max(geared.meleeCritPercent, geared.rangedCritPercent, geared.spellCritPercent))
|
||||
.toBeGreaterThan(Math.max(ungeared.meleeCritPercent, ungeared.rangedCritPercent, ungeared.spellCritPercent));
|
||||
expect(Math.max(geared.meleeHastePercent, geared.rangedHastePercent, geared.spellHastePercent)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([1, 40, 79])("keeps compatible same-level boss items below heirlooms at level %i", (level) => {
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(profile, level));
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { rotationProfileFor } from "./combatRotation";
|
||||
import { READY_COMPANION_SPECIALIZATION_IDS } from "./companionSpecReadiness";
|
||||
import { createDungeonParty } from "./partyStore";
|
||||
import { runtimeNormalizeTalentRanks } from "./talentRuntimeCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { specializationById } from "./specializationCatalog";
|
||||
|
||||
const READY_CASES = [
|
||||
["warrior:protection", "warrior", "Protection"],
|
||||
["priest:discipline", "priest", "Discipline"],
|
||||
["hunter:marksmanship", "hunter", "Marksmanship"],
|
||||
["mage:fire", "mage", "Fire"],
|
||||
["rogue:assassination", "rogue", "Assassination"],
|
||||
] as const;
|
||||
|
||||
describe("Ready-five certification scope", () => {
|
||||
it("keeps the canonical benchmark default limited to the five Ready specs", async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds();
|
||||
expect(builds.map((build) => `${build.classId}:${build.orientation.toLowerCase()}`))
|
||||
.toEqual(expect.arrayContaining([...READY_COMPANION_SPECIALIZATION_IDS]));
|
||||
expect(builds).toHaveLength(READY_COMPANION_SPECIALIZATION_IDS.length);
|
||||
});
|
||||
|
||||
it("has legal level-bracket talents and an executable rotation for every Ready spec", () => {
|
||||
for (const [specializationId, classId, specialization] of READY_CASES) {
|
||||
expect(specializationById(specializationId)).not.toBeNull();
|
||||
expect(rotationProfileFor(classId, specializationId, specialization)?.abilityPriority.length ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
for (const level of [1, 10, 20, 30, 40, 50, 60, 70, 80]) {
|
||||
const ranks = runtimePartyTalentRanksForLevel(classId, specialization, level);
|
||||
expect(runtimeNormalizeTalentRanks(classId, level, ranks)).toEqual(ranks);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("draws random dungeon companions only from the Ready set", () => {
|
||||
const ready = new Set<string>(READY_COMPANION_SPECIALIZATION_IDS);
|
||||
for (const playerRole of ["tank", "healer", "damage"] as const) {
|
||||
for (let seed = 0; seed < 12; seed += 1) {
|
||||
const party = createDungeonParty(`ready-five-${playerRole}-${seed}`, 80, playerRole);
|
||||
expect(party.every((member) => ready.has(member.specializationId ?? ""))).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,4 +74,18 @@ describe("talent runtime modifiers", () => {
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Arm of Thorim" })).toBe(100);
|
||||
expect(applyTalentModifierValue(2, modifiers, "critical-damage-percent", { abilityName: "Arm of Thorim" })).toBe(5);
|
||||
});
|
||||
|
||||
it("does not turn Inner Focus activation text into permanent spell modifiers", () => {
|
||||
const innerFocus = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
||||
const modifiers = talentModifiersForRanks({ [innerFocus.id]: 1 }, "priest");
|
||||
expect(modifiers).toEqual([]);
|
||||
expect(applyTalentModifierValue(2_500, modifiers, "cast-time-percent", {
|
||||
abilityName: "Greater Heal",
|
||||
tags: ["cast-time", "holy"],
|
||||
})).toBe(2_500);
|
||||
expect(applyTalentModifierValue(20, modifiers, "resource-cost-percent", {
|
||||
abilityName: "Flash Heal",
|
||||
tags: ["cast-time", "holy"],
|
||||
})).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
runtimeActiveTalentRankEffects as activeTalentRankEffects,
|
||||
type RuntimeTalentRanks as TalentRanks,
|
||||
} from "./talentRuntimeCatalog";
|
||||
import { talentBalanceOverride } from "./balanceTuning";
|
||||
|
||||
export type TalentModifierKind =
|
||||
| "damage-percent"
|
||||
@@ -40,6 +41,19 @@ export interface TalentModifier {
|
||||
|
||||
interface ParsedModifier extends Omit<TalentModifier, "sourceTalentId" | "sourceTalentName" | "rank" | "description"> {}
|
||||
|
||||
const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
"Inner Focus",
|
||||
]);
|
||||
|
||||
/**
|
||||
* These talents are modeled as on-use abilities and must never be interpreted
|
||||
* as always-on scalar bonuses merely because their activation tooltip contains
|
||||
* phrases such as "reduces cast time" or "reducing mana cost".
|
||||
*/
|
||||
export function talentUsesExplicitActivation(talentName: string): boolean {
|
||||
return EXPLICIT_ACTIVATION_TALENTS.has(talentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the concrete client tooltip into common simulation modifiers.
|
||||
* Exact DBC effects remain available through the full authoring catalog for
|
||||
@@ -128,13 +142,33 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
|
||||
}
|
||||
|
||||
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
|
||||
return activeTalentRankEffects(ranks, classId).flatMap((active) => parseTalentModifiers(active.description).map((modifier) => ({
|
||||
...modifier,
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
})));
|
||||
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
|
||||
if (talentUsesExplicitActivation(active.talentName)) return [];
|
||||
const override = talentBalanceOverride(active.talentId);
|
||||
const scale = (override?.effectScalePercent ?? 100) / 100;
|
||||
const parsed = parseTalentModifiers(active.description).map((modifier) => ({
|
||||
...modifier,
|
||||
amount: modifier.amount * scale,
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}));
|
||||
const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => {
|
||||
if (!Number.isFinite(amount) || amount === 0) return [];
|
||||
return [{
|
||||
kind: kind as TalentModifierKind,
|
||||
amount: amount * active.rank,
|
||||
unit: "percent" as const,
|
||||
scope: `Balance tuning supplement for ${active.talentName}`,
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}];
|
||||
});
|
||||
return [...parsed, ...supplemental];
|
||||
});
|
||||
}
|
||||
|
||||
export interface TalentModifierContext {
|
||||
|
||||
@@ -8,7 +8,7 @@ import generated from "./generated/talentRuntimeCatalog.json";
|
||||
|
||||
export type RuntimeTalentRanks = Readonly<Record<string, number>>;
|
||||
|
||||
interface RuntimeTalentTree {
|
||||
export interface RuntimeTalentTree {
|
||||
readonly id: string;
|
||||
readonly classId: ClassId;
|
||||
}
|
||||
@@ -43,6 +43,8 @@ interface RuntimeTalentSnapshot {
|
||||
}
|
||||
|
||||
const snapshot = generated as unknown as RuntimeTalentSnapshot;
|
||||
export const RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees;
|
||||
export const RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes;
|
||||
const treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))) as Readonly<Record<string, RuntimeTalentTree>>;
|
||||
const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly<Record<string, RuntimeTalentNode>>;
|
||||
const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [
|
||||
|
||||
@@ -11,7 +11,7 @@ export const HUD_ELEMENTS = {
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||||
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
|
||||
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache",
|
||||
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
|
||||
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
|
||||
} as const;
|
||||
|
||||
Reference in new issue
Block a user