Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

+699
View File
@@ -0,0 +1,699 @@
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,
});
}