forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user