Files
healer-man/src/game/runewakerCombatFormula.ts
T
2026-09-08 10:40:26 -04:00

189 lines
7.3 KiB
TypeScript

/**
* Portable evidence attached to an imported RuneWaker damage mechanic.
*
* `runtimeBaselineMultiplier` is deliberately named as an adapter value: it
* is not presented as a source coefficient when the original combat inputs
* are unavailable in HealerMan.
*/
export interface RunewakerDamageFormulaSource {
readonly schemaVersion: 1;
readonly spellId: number;
readonly componentId: number;
readonly spellLevel: number;
readonly calculationPath: "physical" | "magic" | "unknown";
readonly powerKind: "source-ratio" | "source-points" | "dot-only" | "unresolved";
readonly damagePower: number;
readonly fixedValue: number;
readonly dotBase: number;
readonly runtimeBaselineMultiplier: number;
readonly provenance: {
readonly sourceFile: string;
readonly physicalFunctionLine: number;
readonly magicFunctionLine: number;
readonly magicObjectDatabase: string;
};
}
export interface RunewakerNativeResolvedPowerInput {
/** The already-derived `Power` argument passed to CalPhyDamage/CalMagicDamage. */
readonly power: number;
readonly fixedValue: number;
readonly fixedAttributeValue: number;
readonly fixedDamageSkillLevelPercent: number;
readonly spellLevel: number;
readonly attackerPower: number;
readonly defenderPower: number;
readonly criticalMultiplier: number;
readonly levelRate: number;
readonly resistanceRate: number;
readonly pvpOrNpcDamageRate: number;
readonly areaPowerRate: number;
readonly areaDefenseRate: number;
readonly attackTargetRate: number;
readonly attackerOriginDefenseRate: number;
readonly sharedDamageRate: number;
}
export interface RunewakerDamageAdapterInput {
readonly formula: RunewakerDamageFormulaSource;
/** HealerMan's level-scaled enemy attack budget before an ability multiplier. */
readonly baselineDamage: number;
}
export interface RunewakerDamageAdapterResult {
readonly amount: number;
readonly fidelity: "source-ratio-adapter" | "scaled-point-adapter" | "unsupported";
readonly appliedMultiplier: number;
readonly mappedInputs: readonly string[];
readonly unavailableSourceInputs: readonly string[];
}
const MISSING_NATIVE_INPUTS = Object.freeze([
"RuneWaker ATK or MATK",
"RuneWaker DEF or MDEF",
"RuneWaker weapon or magic damage",
"RuneWaker FixType attribute value",
"RuneWaker level-difference rate table",
"RuneWaker resistance calculation",
"RuneWaker critical-power modifiers",
"RuneWaker global NPC/PvP/AoE/target damage rates",
] as const);
function finite(value: number, fallback = 0): number {
return Number.isFinite(value) ? value : fallback;
}
function nonNegative(value: number): number {
return Math.max(0, finite(value));
}
/** Exact ATK/DEF or MATK/MDEF ratio shared by the preserved source functions. */
export function runewakerAttackDefenseRate(attackerPower: number, defenderPower: number): number {
const attack = nonNegative(attackerPower) + 100;
const defense = nonNegative(defenderPower) + 200;
return Math.max(0.2, 1 + (attack - defense) / Math.max(attack, defense));
}
/** Exact source expression for Atk.DmgPower and its per-skill-level argument. */
export function runewakerOrgPower(
damagePower: number,
skillLevel: number,
damagePowerSkillLevelPercent: number,
): number {
const power = finite(damagePower);
return power + power * finite(damagePowerSkillLevelPercent) * nonNegative(skillLevel) / 100;
}
/**
* Evaluates the source-shaped tail of CalPhyDamage/CalMagicDamage when every
* native-only input is explicitly supplied. No global rate silently defaults
* to one here, which prevents an "exact" result from hiding missing data.
*/
export function resolveRunewakerNativeResolvedPower(
input: RunewakerNativeResolvedPowerInput,
): number {
let power = finite(input.power)
+ finite(input.fixedValue)
* finite(input.fixedAttributeValue)
* (1 + nonNegative(input.spellLevel) * finite(input.fixedDamageSkillLevelPercent) / 100);
if (power >= 0) return 0;
power *= nonNegative(input.levelRate);
power *= nonNegative(input.resistanceRate);
power *= nonNegative(input.pvpOrNpcDamageRate);
power *= nonNegative(input.criticalMultiplier);
power *= runewakerAttackDefenseRate(input.attackerPower, input.defenderPower);
power *= nonNegative(input.areaPowerRate);
power *= nonNegative(input.areaDefenseRate);
power *= nonNegative(input.attackTargetRate);
power *= nonNegative(input.attackerOriginDefenseRate);
power *= nonNegative(input.sharedDamageRate);
return Math.max(0, Math.trunc(-power));
}
export function normalizeRunewakerDamageFormula(
value: RunewakerDamageFormulaSource | undefined,
): RunewakerDamageFormulaSource | undefined {
if (!value || value.schemaVersion !== 1) return undefined;
if (!Number.isInteger(value.spellId) || value.spellId <= 0) return undefined;
if (!Number.isInteger(value.componentId) || value.componentId <= 0) return undefined;
const calculationPath = ["physical", "magic", "unknown"].includes(value.calculationPath)
? value.calculationPath
: "unknown";
const powerKind = ["source-ratio", "source-points", "dot-only", "unresolved"].includes(value.powerKind)
? value.powerKind
: "unresolved";
return Object.freeze({
schemaVersion: 1,
spellId: value.spellId,
componentId: value.componentId,
spellLevel: Math.max(0, Math.trunc(finite(value.spellLevel))),
calculationPath,
powerKind,
damagePower: finite(value.damagePower),
fixedValue: finite(value.fixedValue),
dotBase: finite(value.dotBase),
runtimeBaselineMultiplier: Math.min(4, nonNegative(value.runtimeBaselineMultiplier)),
provenance: Object.freeze({ ...value.provenance }),
});
}
/**
* Maps imported source evidence to HealerMan's scaled combat budget. Ratio-like
* source powers remain ratios. Point-valued powers use the separately labeled
* runtime adapter multiplier because the native stat universe is unavailable.
* WotLK-shaped hit, armor, resistance, crit, and aura processing happens later
* in the existing combat-result pipeline and is not misreported as RuneWaker.
*/
export function adaptRunewakerEnemyDamage(
input: RunewakerDamageAdapterInput,
): RunewakerDamageAdapterResult {
const formula = normalizeRunewakerDamageFormula(input.formula);
const baselineDamage = nonNegative(input.baselineDamage);
if (!formula || formula.powerKind === "unresolved" || formula.runtimeBaselineMultiplier <= 0) {
return Object.freeze({
amount: 0,
fidelity: "unsupported",
appliedMultiplier: 0,
mappedInputs: Object.freeze(["No executable source damage family"]),
unavailableSourceInputs: MISSING_NATIVE_INPUTS,
});
}
const sourceRatio = formula.powerKind === "source-ratio"
? Math.min(4, Math.max(0.2, Math.abs(formula.damagePower)))
: null;
const appliedMultiplier = sourceRatio ?? formula.runtimeBaselineMultiplier;
return Object.freeze({
amount: Math.max(0, Math.round(baselineDamage * appliedMultiplier)),
fidelity: sourceRatio === null ? "scaled-point-adapter" : "source-ratio-adapter",
appliedMultiplier,
mappedInputs: Object.freeze([
"HealerMan level-scaled enemy baseline damage",
sourceRatio === null
? "Explicit runtimeBaselineMultiplier for source point values"
: "Absolute RuneWaker Atk.DmgPower ratio",
"HealerMan combat-result mitigation after this adapter",
]),
unavailableSourceInputs: MISSING_NATIVE_INPUTS,
});
}