forked from phenom/healer-man
700 lines
32 KiB
TypeScript
700 lines
32 KiB
TypeScript
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,
|
||
});
|
||
}
|