189 lines
7.3 KiB
TypeScript
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,
|
|
});
|
|
}
|