forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
fdb6739f8d
commit
f90ba7d989
41 files changed
+1671
-118
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user