/** * 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, }); }