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>>; } export interface BalanceTuningPolicy { readonly schemaVersion: 1; readonly abilityOverrides: Readonly>; readonly talentOverrides: Readonly>; } 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; const result: Record = {}; 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; const effectScalePercent = bounded(source.effectScalePercent, 0, 1_000); const sourceModifiers = source.modifiersPerRank && typeof source.modifiersPerRank === "object" ? source.modifiersPerRank as Record : {}; const modifiersPerRank: Partial> = {}; 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; const sourceAbilities = source.abilityOverrides && typeof source.abilityOverrides === "object" ? source.abilityOverrides as Record : {}; const sourceTalents = source.talentOverrides && typeof source.talentOverrides === "object" ? source.talentOverrides as Record : {}; 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>(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).basePointsPerDose ?? 0) * percent / 100, attackPowerCoefficientPerDose: Number((effect.poison as Record).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, damagePercent) as unknown as AbilityEffect; } else if (["heal", "hot", "shield"].includes(effect.kind)) { tuned = tuneAmount(effect as unknown as Record, 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 = (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 = (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; }