Files
healer-man/src/game/romNativeDamage.ts
T
2026-10-04 13:13:35 -04:00

700 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import attackCatalog from "./generated/romNativeHolyHealBindings.generated.json";
import nativeDamageProfiles from "./generated/romNativeDamageTrainingProfiles.generated.json";
import nativeStatRules from "./generated/romNativeStatRules.generated.json";
import { romNativeGearReference } from "./romNativeGear";
import type { RomClassId } from "../app/characterCatalog";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
export type RomNativeDamageCalculationType = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export type RomNativeDamageWeaponSlot = "main-hand" | "off-hand" | "ranged";
export interface RomNativeAttackComponentDescriptor {
readonly abilityId: string;
readonly classId: RomClassId;
readonly group: string;
readonly rank: number;
readonly sourceSkillId: number;
readonly configuredCollectMagicLevel: number;
/** Raw MagicCollect value used directly by damage formulas after the source min-one rule. */
readonly spellTime: number;
readonly rangeType: number;
readonly decreaseArg: number;
readonly isIgnoreDmgEffect: boolean;
readonly isFixDmgEffect: boolean;
readonly isShareDamage: boolean;
readonly componentId: number;
readonly magicFunc: number;
readonly magicType: number;
readonly effectType: number;
readonly attackType: number;
readonly calculationType: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedDamageSkillLevelArg: number;
readonly randomRange: number;
readonly criticalRate: number;
readonly ignoreMagicAbsorb: boolean;
}
export interface RomNativeDamageTrainingWeapon {
readonly slot: RomNativeDamageWeaponSlot;
readonly sourceItemIds: readonly number[];
/** Native weapon main/ranged/off-hand damage before class/BodyRate formulas. */
readonly sourceDamage: number;
/** Raw source attack speed in tenths, consumed by RoleAttr as its base speed unit. */
readonly speedTenths: number;
}
export interface RomNativeTrainingWeaponProfile {
readonly status: "supported";
readonly classId: RomClassId;
readonly secondaryClassId: RomClassId | null;
readonly level: number;
readonly mainHand: RomNativeDamageTrainingWeapon;
readonly offHand?: RomNativeDamageTrainingWeapon;
readonly ranged?: RomNativeDamageTrainingWeapon;
/** RoleAttr::_DMG-equivalent remainder after removing training weapon DMG from the common sidecar. */
readonly residualFlatDamage: number;
readonly expectedGearPhysicalDamage: number;
readonly expectedGearMagicalDamage: number;
readonly expectedGearMagicalDamagePoint: number;
readonly trainingWeaponSlotDamage: number;
readonly sourceSemantics: "runewaker-training-weapon-profile-bridge";
}
export type RomNativeDamageUnsupportedReason =
| "training-gear-profile-not-confirmed"
| "class-pair-outside-active-five"
| "native-gear-damage-does-not-match-training-profile"
| "source-weapon-profile-missing"
| "source-and-native-weapon-damage-disagree"
| "source-fix-coefficient-missing"
| "attack-component-not-found"
| "attack-component-is-not-direct-hp"
| "attack-calculation-type-unsupported"
| "fixed-damage-type-unsupported"
| "training-weapon-profile-unsupported"
| "runtime-magic-level-invalid"
| "target-count-invalid"
| "target-ordinal-invalid"
| "magic-school-rate-required"
| "required-training-weapon-missing"
| "invalid-source-formula-input";
export type RomNativeDamageResult<T> =
| { readonly status: "supported"; readonly value: T }
| { readonly status: "unsupported"; readonly reason: RomNativeDamageUnsupportedReason };
interface RawAttackComponent {
readonly componentId: number;
readonly magicFunc: number;
readonly magicType: number;
readonly effectType: number;
readonly attackType: number;
readonly calculationType: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedDamageSkillLevelArg: number;
readonly randomRange: number;
readonly criticalRate: number;
readonly ignoreMagicAbsorb: boolean;
}
interface RawAttackRank {
readonly rank: number | null;
readonly sourceSkillId: number;
readonly configuredCollectMagicLevel: number;
readonly spellTimeSeconds: number;
readonly rangeType: number;
readonly decreaseArg: number;
readonly isIgnoreDmgEffect: boolean;
readonly isFixDmgEffect: boolean;
readonly isShareDamage: boolean;
readonly componentIds: readonly RawAttackComponent[];
}
interface RawAttackBinding {
readonly abilityId: string;
readonly classId: RomClassId;
readonly group: string;
readonly ranks: readonly RawAttackRank[];
}
interface RawWeaponProfileRow {
readonly level: number;
readonly sourceItemIds: readonly number[];
readonly weaponDamage: number;
readonly magicalWeaponDamage: number;
readonly speedTenths: number | null;
readonly shieldDefense?: number;
}
type RawDamageProfileCatalog = {
readonly profiles: Readonly<Record<string, Readonly<Record<string, readonly RawWeaponProfileRow[]>>>>;
};
type RawStatRules = {
readonly classRows: Readonly<Record<string, { readonly args: { readonly damageRate: number } }>>;
readonly playerFix: {
readonly secondHandDamageCoefficient: number;
readonly magicDamageCoefficient: number;
readonly magicMagicalDamageCoefficient: number;
};
};
const attackBindings = (attackCatalog as unknown as { readonly attackBindings: readonly RawAttackBinding[] }).attackBindings;
const damageProfiles = nativeDamageProfiles as unknown as RawDamageProfileCatalog;
/** Raw BaseEQ.Def[Shield] curve; excludes fixed WearEqType_DEF enchantments. */
export function romNativeTrainingShieldDefense(classId: string, level: number): number | undefined {
return damageProfiles.profiles[classId]?.["off-hand"]?.[Math.min(79, Math.max(1, Math.trunc(level))) - 1]?.shieldDefense;
}
const statRules = nativeStatRules as unknown as RawStatRules;
const CLASS_PAIRS: Readonly<Record<string, readonly string[]>> = Object.freeze({
"rom-knight": ["rom-scout"],
"rom-priest": ["rom-knight"],
"rom-rogue": ["rom-warrior"],
"rom-scout": ["rom-rogue"],
"rom-mage": ["rom-warlock"],
});
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function near(left: number, right: number): boolean {
return Math.abs(left - right) <= 1e-5;
}
function weaponRow(classId: RomClassId, slot: RomNativeDamageWeaponSlot, level: number): RawWeaponProfileRow | undefined {
const curve = damageProfiles.profiles[classId]?.[slot];
return curve?.[Math.max(0, Math.min(78, Math.trunc(level) - 1))];
}
function isTrainingWeaponActive(
classId: RomClassId,
secondaryClassId: RomClassId | null,
slot: RomNativeDamageWeaponSlot,
level: number,
): boolean {
if (slot === "main-hand") return true;
if (slot === "off-hand") {
return (classId === "rom-rogue" && level >= 16)
|| (classId === "rom-scout" && secondaryClassId === "rom-rogue" && level >= 16);
}
if (slot === "ranged") {
return classId === "rom-scout"
|| (classId === "rom-knight" && secondaryClassId === "rom-scout" && level >= 10)
|| (classId === "rom-rogue" && level >= 12);
}
return false;
}
function expectedGearDamage(classId: RomClassId, secondaryClassId: RomClassId | null, level: number): {
readonly physicalDamage: number;
readonly magicalDamage: number;
readonly magicalDamagePoint: number;
} {
const catalogClass = damageProfiles.profiles[classId];
if (!catalogClass) return { physicalDamage: Number.NaN, magicalDamage: Number.NaN, magicalDamagePoint: Number.NaN };
const total = { physicalDamage: 0, magicalDamage: 0, magicalDamagePoint: 0 };
for (const slot of Object.keys(catalogClass)) {
if ((slot === "off-hand" || slot === "ranged")
&& !isTrainingWeaponActive(classId, secondaryClassId, slot as RomNativeDamageWeaponSlot, level)) continue;
const gear = romNativeGearReference(classId, slot as Parameters<typeof romNativeGearReference>[1], level);
total.physicalDamage += gear?.physicalDamage ?? 0;
total.magicalDamage += gear?.magicalDamage ?? 0;
total.magicalDamagePoint += gear?.magicalDamagePoint ?? 0;
}
return total;
}
function resolveWeapon(
classId: RomClassId,
secondaryClassId: RomClassId | null,
slot: RomNativeDamageWeaponSlot,
level: number,
): RomNativeDamageResult<RomNativeDamageTrainingWeapon | undefined> {
if (!isTrainingWeaponActive(classId, secondaryClassId, slot, level)) return { status: "supported", value: undefined };
const row = weaponRow(classId, slot, level);
const native = romNativeGearReference(classId, slot, level);
if (!row || !native || row.speedTenths === null || row.weaponDamage <= 0) {
return slot === "main-hand"
? { status: "unsupported", reason: "source-weapon-profile-missing" }
: { status: "supported", value: undefined };
}
if (!near(row.weaponDamage, native.physicalDamage ?? 0)) {
return { status: "unsupported", reason: "source-and-native-weapon-damage-disagree" };
}
return {
status: "supported",
value: Object.freeze({
slot,
sourceItemIds: row.sourceItemIds,
sourceDamage: row.weaponDamage,
speedTenths: row.speedTenths,
}),
};
}
/**
* Reverses the RoM training-weapon stat bridge for the five explicit active class pairs.
* The caller must first compare the complete native gear payload with automatic training gear;
* source gear stats contain no item provenance or weapon speed. This function also checks the
* aggregate type-25 DMG channel against the compact native training curve. Custom/unverified
* equipment must pass `trainingGearProfileMatches: false` and remains unsupported.
*/
export function resolveRomNativeTrainingWeaponProfile(
stats: RomNativeDerivedStats,
evidence: { readonly trainingGearProfileMatches: boolean },
): RomNativeDamageResult<RomNativeTrainingWeaponProfile> {
if (!evidence.trainingGearProfileMatches) {
return { status: "unsupported", reason: "training-gear-profile-not-confirmed" };
}
const classId = stats.sourceInputs.classId;
const secondaryClassId = stats.sourceInputs.secondaryClassId;
const level = Math.max(1, Math.min(80, Math.trunc(stats.sourceInputs.level)));
const primaryOnlyBeforeSecondaryUnlock = secondaryClassId === null && level < 10 && CLASS_PAIRS[classId] !== undefined;
if (!primaryOnlyBeforeSecondaryUnlock && (!secondaryClassId || !CLASS_PAIRS[classId]?.includes(secondaryClassId))) {
return { status: "unsupported", reason: "class-pair-outside-active-five" };
}
const expectedGear = expectedGearDamage(classId, secondaryClassId, level);
if (!Object.values(expectedGear).every(finite)
|| !near(stats.sourceInputs.gear.physicalDamage ?? 0, expectedGear.physicalDamage)
|| !near(stats.sourceInputs.gear.magicalDamage ?? 0, expectedGear.magicalDamage)
|| !near(stats.sourceInputs.gear.magicalDamagePoint ?? 0, expectedGear.magicalDamagePoint)) {
return { status: "unsupported", reason: "native-gear-damage-does-not-match-training-profile" };
}
const main = resolveWeapon(classId, secondaryClassId, "main-hand", level);
const off = resolveWeapon(classId, secondaryClassId, "off-hand", level);
const ranged = resolveWeapon(classId, secondaryClassId, "ranged", level);
for (const result of [main, off, ranged]) if (result.status === "unsupported") return result;
if (main.status !== "supported" || !main.value) {
return { status: "unsupported", reason: "source-weapon-profile-missing" };
}
const weapons = [main.value, ...(off.status === "supported" && off.value ? [off.value] : []), ...(ranged.status === "supported" && ranged.value ? [ranged.value] : [])];
const trainingWeaponSlotDamage = weapons.reduce((sum, weapon) => sum + weapon.sourceDamage, 0);
const classDamageRate = statRules.classRows[classId]?.args.damageRate;
if (!finite(classDamageRate)) return { status: "unsupported", reason: "source-fix-coefficient-missing" };
const residualFlatDamage = Math.max(0,
stats.physicalDamage - trainingWeaponSlotDamage * classDamageRate * stats.rateFactors.physicalDamage);
return {
status: "supported",
value: Object.freeze({
status: "supported",
classId,
secondaryClassId,
level,
mainHand: main.value,
...(off.status === "supported" && off.value ? { offHand: off.value } : {}),
...(ranged.status === "supported" && ranged.value ? { ranged: ranged.value } : {}),
residualFlatDamage,
expectedGearPhysicalDamage: expectedGear.physicalDamage,
expectedGearMagicalDamage: expectedGear.magicalDamage,
expectedGearMagicalDamagePoint: expectedGear.magicalDamagePoint,
trainingWeaponSlotDamage,
sourceSemantics: "runewaker-training-weapon-profile-bridge",
}),
};
}
export function findRomNativeAttackComponent(input: {
readonly abilityId: string;
readonly sourceSkillId: number;
readonly rank: number;
readonly componentId: number;
}): RomNativeDamageResult<RomNativeAttackComponentDescriptor> {
const ability = attackBindings.find((candidate) => candidate.abilityId === input.abilityId);
const rank = ability?.ranks.find((candidate) => candidate.sourceSkillId === input.sourceSkillId && candidate.rank === input.rank);
const component = rank?.componentIds.find((candidate) => candidate.componentId === input.componentId);
if (!ability || !rank || !component) return { status: "unsupported", reason: "attack-component-not-found" };
return {
status: "supported",
value: Object.freeze({
abilityId: ability.abilityId,
classId: ability.classId,
group: ability.group,
rank: input.rank,
sourceSkillId: rank.sourceSkillId,
configuredCollectMagicLevel: rank.configuredCollectMagicLevel,
spellTime: rank.spellTimeSeconds,
rangeType: rank.rangeType,
decreaseArg: rank.decreaseArg,
isIgnoreDmgEffect: rank.isIgnoreDmgEffect,
isFixDmgEffect: rank.isFixDmgEffect,
isShareDamage: rank.isShareDamage,
componentId: component.componentId,
magicFunc: component.magicFunc,
magicType: component.magicType,
effectType: component.effectType,
attackType: component.attackType,
calculationType: component.calculationType,
damagePower: component.damagePower,
damagePowerSkillLevelArg: component.damagePowerSkillLevelArg,
fixedValue: component.fixedValue,
fixedType: component.fixedType,
fixedDamageSkillLevelArg: component.fixedDamageSkillLevelArg,
randomRange: component.randomRange,
criticalRate: component.criticalRate,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
}),
};
}
export interface RomNativeDamageCalculation {
readonly abilityId: string;
readonly sourceSkillId: number;
readonly rank: number;
readonly componentId: number;
readonly calculationType: number;
readonly ownerKind: "player" | "npc";
readonly magicLevel: number;
/** Signed source power formula before the downstream Attack.FixValue contribution. */
readonly sourcePower: number;
/** Attack.FixValue * GetValue(Attack.FixType) * (1 + MagicLv * FixDmg_SkillLVArg / 100). */
readonly sourceFixedContribution: number;
/** Positive magnitude after the source target-count/decrease factor, before target resolution. */
readonly damageBeforeTargetResolver: number;
readonly targetPowerMultiplier: number;
readonly rangePowerMultiplier: number;
readonly ignoreMagicAbsorb: boolean;
readonly sourceRandomRange: number;
readonly sourceCriticalRate: number;
readonly magicType: number;
readonly sourceSemantics: "runewaker-magicprocess-direct-hp-outgoing-power";
}
export interface RomNativeAutoAttackProfile {
readonly slot: RomNativeDamageWeaponSlot;
readonly sourceDamageChannel: "Main_DMG" | "Sec_DMG" | "Bow_DMG";
/** RoleAttr-derived weapon damage input before MainAtkDamage target/armor/random resolution. */
readonly damage: number;
readonly attackSpeed: number;
/** Runewaker attack-speed source units are converted by MagicProcess scheduling at ×100 ms. */
readonly intervalMs: number;
readonly physicalAttack: number;
readonly sourceItemIds: readonly number[];
readonly sourceSemantics: "runewaker-roleattr-auto-weapon-channel";
}
export interface RomNativeWeaponRates {
/** Known weapon-type/all-weapon multiplier beyond the native DMG_Per BodyRate. */
readonly mainDamage?: number;
readonly offHandDamage?: number;
readonly rangedDamage?: number;
readonly mainAttackSpeed?: number;
readonly offHandAttackSpeed?: number;
readonly rangedAttackSpeed?: number;
}
/** Exact native channels; prior-hit values are signed source HP deltas. */
export interface RomNativeSpecialDamageInputs {
readonly throwDamage?: number;
readonly lastSkillHitPoint?: number;
readonly lastPhysicalHitPoint?: number;
/** Raw BaseEQ.Def[EM_Armor_Shield], before BodyRate or aggregate DEF. */
readonly shieldDefense?: number;
}
function profileMatchesStats(stats: RomNativeDerivedStats, profile: RomNativeTrainingWeaponProfile): boolean {
return profile.status === "supported"
&& profile.classId === stats.sourceInputs.classId
&& profile.secondaryClassId === stats.sourceInputs.secondaryClassId
&& profile.level === stats.sourceInputs.level;
}
/**
* Resolves the Runewaker outgoing Main_DMG, Sec_DMG, or Bow_DMG channel and attack interval.
* This is not the completed auto-hit formula: MainAtkDamage/SecAtkDamage still apply source
* weapon variance, target armor absorption, and the attacker-vs-target ATK/DEF ratio.
*/
export function resolveRomNativeAutoAttackProfile(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly slot: RomNativeDamageWeaponSlot;
readonly weaponRates?: RomNativeWeaponRates;
}): RomNativeDamageResult<RomNativeAutoAttackProfile> {
const { stats, trainingWeapons, slot } = input;
if (!profileMatchesStats(stats, trainingWeapons)) return { status: "unsupported", reason: "training-weapon-profile-unsupported" };
const weapon = slot === "main-hand" ? trainingWeapons.mainHand
: slot === "off-hand" ? trainingWeapons.offHand
: trainingWeapons.ranged;
if (!weapon) return { status: "unsupported", reason: "required-training-weapon-missing" };
const rate = slot === "main-hand" ? input.weaponRates?.mainDamage ?? 1
: slot === "off-hand" ? input.weaponRates?.offHandDamage ?? 1
: input.weaponRates?.rangedDamage ?? 1;
const speedRate = slot === "main-hand" ? input.weaponRates?.mainAttackSpeed ?? 1
: slot === "off-hand" ? input.weaponRates?.offHandAttackSpeed ?? 1
: input.weaponRates?.rangedAttackSpeed ?? 1;
if (![rate, speedRate].every(finite) || rate < 0 || speedRate < 0) {
return { status: "unsupported", reason: "invalid-source-formula-input" };
}
let damage: number;
let attackSpeed: number;
let sourceDamageChannel: RomNativeAutoAttackProfile["sourceDamageChannel"];
if (slot === "main-hand") {
damage = Math.max(1,
(trainingWeapons.residualFlatDamage + weapon.sourceDamage) * stats.rateFactors.physicalDamage * rate);
attackSpeed = Math.max(5, weapon.speedTenths * speedRate);
sourceDamageChannel = "Main_DMG";
} else if (slot === "off-hand") {
damage = Math.max(0,
(trainingWeapons.residualFlatDamage + weapon.sourceDamage)
* stats.rateFactors.physicalDamage * statRules.playerFix.secondHandDamageCoefficient * rate);
attackSpeed = Math.max(5, weapon.speedTenths * speedRate);
sourceDamageChannel = "Sec_DMG";
} else {
damage = Math.max(0,
trainingWeapons.residualFlatDamage + weapon.sourceDamage * stats.rateFactors.physicalDamage * rate);
attackSpeed = weapon.speedTenths * speedRate;
if (attackSpeed <= 0) return { status: "unsupported", reason: "invalid-source-formula-input" };
sourceDamageChannel = "Bow_DMG";
}
return {
status: "supported",
value: Object.freeze({
slot,
sourceDamageChannel,
damage,
attackSpeed,
intervalMs: attackSpeed * 100,
physicalAttack: stats.physicalAttack,
sourceItemIds: weapon.sourceItemIds,
sourceSemantics: "runewaker-roleattr-auto-weapon-channel",
}),
};
}
/**
* Resolves MagicAttackCalENUM's verified GetValue overload. Special channels
* require explicit source inputs; aggregate DEF and unsigned damage are invalid substitutes.
*/
function resolveFixedDamageStat(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly component: RomNativeAttackComponentDescriptor;
readonly weaponRates?: RomNativeWeaponRates;
readonly specialInputs?: RomNativeSpecialDamageInputs;
}): RomNativeDamageResult<number> {
switch (input.component.fixedType) {
case 0: // EM_MagicAttackCal_None
return { status: "supported", value: 1 };
case 1: // EM_MagicAttackCal_MPow
return { status: "supported", value: input.stats.magicPowerRate };
case 2: { // EM_MagicAttackCal_Weapon
const profile = resolveRomNativeAutoAttackProfile({
stats: input.stats, trainingWeapons: input.trainingWeapons, slot: "main-hand", weaponRates: input.weaponRates,
});
return profile.status === "supported" ? { status: "supported", value: profile.value.damage } : profile;
}
case 3: { // EM_MagicAttackCal_Shoot
const profile = resolveRomNativeAutoAttackProfile({
stats: input.stats, trainingWeapons: input.trainingWeapons, slot: "ranged", weaponRates: input.weaponRates,
});
return profile.status === "supported" ? { status: "supported", value: profile.value.damage } : profile;
}
case 4:
case 10:
case 11:
case 12: {
const value = input.component.fixedType === 4 ? input.specialInputs?.throwDamage
: input.component.fixedType === 10 ? input.specialInputs?.lastSkillHitPoint
: input.component.fixedType === 11 ? input.specialInputs?.lastPhysicalHitPoint
: input.specialInputs?.shieldDefense;
return value !== undefined && finite(value)
? { status: "supported", value }
: { status: "unsupported", reason: "fixed-damage-type-unsupported" };
}
case 5: // EM_MagicAttackCal_INT
return { status: "supported", value: input.stats.attributes.intellect };
case 6: // EM_MagicAttackCal_STR
return { status: "supported", value: input.stats.attributes.strength };
case 7: // EM_MagicAttackCal_AGI
return { status: "supported", value: input.stats.attributes.agility };
case 8: // EM_MagicAttackCal_STA
return { status: "supported", value: input.stats.attributes.stamina };
case 9: // EM_MagicAttackCal_MND
return { status: "supported", value: input.stats.attributes.wisdom };
default:
return { status: "unsupported", reason: "fixed-damage-type-unsupported" };
}
}
function targetPowerMultiplier(component: RomNativeAttackComponentDescriptor, targetCount: number, targetOrdinal: number): number {
if (component.isShareDamage) return Math.max(component.decreaseArg / 100, 1 / targetCount);
return Math.pow(1 - component.decreaseArg / 100, targetOrdinal);
}
/**
* Source-backed MagicProcess outgoing-power branches used by the five RoM training pairs.
* Typed Attack.FixType additions supported by the audited RoleData overload are included; an
* unported nonzero type returns an explicit unsupported result. This deliberately stops before
* target armor/MDEF, hit, crit, variance, and auras owned by another resolver. Callers must bypass
* common gear scaling and apply the returned target power exactly once in their target resolver.
*/
export function calculateRomNativeDamagePower(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly component: RomNativeAttackComponentDescriptor;
/** Runtime MagicProc.MagicLv. Never inferred from unlock level or catalog rank. */
readonly magicLevel: number;
readonly ownerKind: "player" | "npc";
readonly magicSchoolRate?: number;
readonly targetCount?: number;
readonly targetOrdinal?: number;
readonly weaponRates?: RomNativeWeaponRates;
readonly specialInputs?: RomNativeSpecialDamageInputs;
}): RomNativeDamageResult<RomNativeDamageCalculation> {
const { stats, component, trainingWeapons } = input;
if (!profileMatchesStats(stats, trainingWeapons)) return { status: "unsupported", reason: "training-weapon-profile-unsupported" };
if (!finite(input.magicLevel) || input.magicLevel < 0) return { status: "unsupported", reason: "runtime-magic-level-invalid" };
if (component.magicFunc !== 0 || component.attackType !== 0 || component.damagePower >= 0) {
return { status: "unsupported", reason: "attack-component-is-not-direct-hp" };
}
if (![0, 1, 3, 9].includes(component.calculationType)) {
return { status: "unsupported", reason: "attack-calculation-type-unsupported" };
}
const magicLevel = input.magicLevel;
const targetCount = input.targetCount ?? 1;
const targetOrdinal = input.targetOrdinal ?? 0;
if (!Number.isInteger(targetCount) || targetCount < 1) return { status: "unsupported", reason: "target-count-invalid" };
if (!Number.isInteger(targetOrdinal) || targetOrdinal < 0 || targetOrdinal >= targetCount) {
return { status: "unsupported", reason: "target-ordinal-invalid" };
}
const fix = statRules.playerFix;
const powerRate = component.rangeType === 0 ? 1 : 0.6;
const spellTime = component.isFixDmgEffect || component.spellTime < 1 ? 1 : component.spellTime;
const orgPower = component.damagePower
+ component.damagePower * component.damagePowerSkillLevelArg * magicLevel / 100;
const mainDamageRate = input.weaponRates?.mainDamage ?? 1;
const rangedDamageRate = input.weaponRates?.rangedDamage ?? 1;
const mainAttackSpeedRate = input.weaponRates?.mainAttackSpeed ?? 1;
const rangedAttackSpeedRate = input.weaponRates?.rangedAttackSpeed ?? 1;
if (![powerRate, spellTime, orgPower, mainDamageRate, rangedDamageRate, mainAttackSpeedRate, rangedAttackSpeedRate].every(finite)) {
return { status: "unsupported", reason: "invalid-source-formula-input" };
}
let purePower: number;
let rangePowerMultiplier = 1;
if (component.calculationType === 0 || component.calculationType === 9) {
const mainDamage = Math.max(1,
(trainingWeapons.residualFlatDamage + trainingWeapons.mainHand.sourceDamage)
* stats.rateFactors.physicalDamage * mainDamageRate);
if (component.calculationType === 0) {
const mainAttackSpeed = Math.max(5, trainingWeapons.mainHand.speedTenths * mainAttackSpeedRate);
const dps = mainDamage * 10 * spellTime / mainAttackSpeed;
purePower = component.isIgnoreDmgEffect ? orgPower : orgPower * dps;
if (purePower < 0) purePower -= dps * fix.magicDamageCoefficient * powerRate;
} else {
purePower = orgPower;
if (purePower < 0 && !component.isIgnoreDmgEffect) {
const spellMultiplier = component.isFixDmgEffect ? 1 : spellTime;
purePower -= mainDamage * fix.magicDamageCoefficient * powerRate * spellMultiplier;
}
}
if (purePower < 0 && component.calculationType !== 0 && component.isIgnoreDmgEffect) {
// Physics_Point's source ignore flag removes its Main_DMG additive branch entirely.
}
if (purePower < 0 && component.calculationType === 0 && component.effectType === 0) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
purePower *= schoolRate;
}
if (purePower < 0 && component.calculationType === 9 && component.effectType === 0) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
purePower *= schoolRate;
}
} else if (component.calculationType === 1) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
const exRate = input.ownerKind === "player" ? 0.6 + Math.abs(magicLevel) * 0.01 : 1;
purePower = orgPower;
if (purePower < 0 && !component.isIgnoreDmgEffect) {
const spellMultiplier = component.isFixDmgEffect ? 1 : spellTime;
purePower -= exRate * stats.magicalDamage * fix.magicMagicalDamageCoefficient * powerRate * spellMultiplier;
}
purePower *= schoolRate;
if (orgPower < 0) {
purePower -= stats.magicalDamagePoint;
if (purePower > -1) purePower = -1;
}
} else {
const ranged = trainingWeapons.ranged;
if (!ranged) return { status: "unsupported", reason: "required-training-weapon-missing" };
const rangedDamage = Math.max(0,
trainingWeapons.residualFlatDamage + ranged.sourceDamage * stats.rateFactors.physicalDamage * rangedDamageRate);
const rangedAttackSpeed = Math.max(5, ranged.speedTenths * rangedAttackSpeedRate);
purePower = orgPower * (rangedDamage * 10 / rangedAttackSpeed);
if (purePower < 0) purePower -= rangedDamage * fix.magicDamageCoefficient * powerRate;
}
if (!finite(purePower)) return { status: "unsupported", reason: "invalid-source-formula-input" };
let sourceFixedContribution = 0;
if (component.fixedValue !== 0) {
const fixedStat = resolveFixedDamageStat({
stats, trainingWeapons, component, weaponRates: input.weaponRates, specialInputs: input.specialInputs,
});
if (fixedStat.status !== "supported") return fixedStat;
sourceFixedContribution = component.fixedValue * fixedStat.value
* (1 + magicLevel * component.fixedDamageSkillLevelArg / 100);
}
if (!finite(sourceFixedContribution)) return { status: "unsupported", reason: "invalid-source-formula-input" };
const targetMultiplier = targetPowerMultiplier(component, targetCount, targetOrdinal);
rangePowerMultiplier = powerRate;
// For non-shared targets MagicProcess attenuates PurePower in its target loop,
// then CalPhyDamage/CalMagicDamage adds the typed fixed term per target. Shared
// damage passes FinFixPowerRate into that later target resolver, scaling both.
const signedPower = component.isShareDamage
? (purePower + sourceFixedContribution) * targetMultiplier
: purePower * targetMultiplier + sourceFixedContribution;
return {
status: "supported",
value: Object.freeze({
abilityId: component.abilityId,
sourceSkillId: component.sourceSkillId,
rank: component.rank,
componentId: component.componentId,
calculationType: component.calculationType,
ownerKind: input.ownerKind,
magicLevel,
sourcePower: purePower,
sourceFixedContribution,
damageBeforeTargetResolver: Math.max(0, -signedPower),
targetPowerMultiplier: targetMultiplier,
rangePowerMultiplier,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
sourceRandomRange: component.randomRange,
sourceCriticalRate: component.criticalRate,
magicType: component.magicType,
sourceSemantics: "runewaker-magicprocess-direct-hp-outgoing-power",
}),
};
}
function resolveMagicSchoolRate(baseMagicPowerRate: number, magicType: number, schoolRate?: number): number | null {
if (magicType >= 0 && magicType <= 5) {
return finite(schoolRate) ? baseMagicPowerRate * schoolRate : null;
}
return baseMagicPowerRate;
}
/** Source FIX rows are retained for targeted formula tests and callers that need exact raw coefficients. */
export function getRomNativeDamageFixCoefficients(): {
readonly secondHandDamage: number;
readonly physicalMagicPowerCoefficient: number;
readonly magicalMagicPowerCoefficient: number;
} | null {
const fix = statRules.playerFix;
if (![fix.secondHandDamageCoefficient, fix.magicDamageCoefficient, fix.magicMagicalDamageCoefficient].every(finite)) return null;
return Object.freeze({
secondHandDamage: fix.secondHandDamageCoefficient,
physicalMagicPowerCoefficient: fix.magicDamageCoefficient,
magicalMagicPowerCoefficient: fix.magicMagicalDamageCoefficient,
});
}