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 = | { 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>>>; }; type RawStatRules = { readonly classRows: Readonly>; 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> = 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[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 { 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 { 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 { 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 { 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 { 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 { 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, }); }