forked from phenom/healer-man
181 lines
9.4 KiB
TypeScript
181 lines
9.4 KiB
TypeScript
import type { AbilityAmountScaling } from "./abilityCatalog";
|
|
import { clampPlayerLevel } from "./progression";
|
|
import { resolveRomNativeHolyHeal, resolveRomNativePeriodicHolyHeal, romNativeHolyHealSourceMetadata } from "./romNativeHealing";
|
|
import { resolveRomNativeTrainingWeaponProfile, findRomNativeAttackComponent, calculateRomNativeDamagePower, resolveRomNativeAutoAttackProfile, romNativeTrainingShieldDefense, type RomNativeDamageWeaponSlot } from "./romNativeDamage";
|
|
import { resolvedEquipmentStats } from "./heirloomEquipment";
|
|
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
|
import { nativePeriodicDamageSnapshot, romNativePeriodicDamageBinding,
|
|
type RomNativePeriodicDamageCombatantStats, type RomNativePeriodicDamageMagicResistance } from "./romNativePeriodicDamage";
|
|
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
|
|
|
|
/** Simulator policy: skills train with the character; there is no separate TP/skill-level progression yet. */
|
|
export const romNativeMagicLevel = (level: number): number => clampPlayerLevel(level);
|
|
|
|
const bindings = romNativeHolyHealSourceMetadata.attackBindings.flatMap((ability) => ability.ranks.flatMap((rank) =>
|
|
rank.componentIds.map((component) => ({ abilityId: ability.abilityId, rank, component })),
|
|
));
|
|
|
|
export function romNativeAmountBinding(source: NonNullable<AbilityAmountScaling["romNativeSource"]>) {
|
|
return bindings.find(({ rank, component }) => rank.sourceSkillId === source.skillId
|
|
&& component.componentId === source.componentId) ?? null;
|
|
}
|
|
|
|
/** Source caster power only. Recipient modifiers are resolved at each real healing target. */
|
|
export function nativeHolyHealCasterAmount(
|
|
scaling: AbilityAmountScaling,
|
|
level: number,
|
|
caster: RomNativeDerivedStats,
|
|
): number | null {
|
|
const source = scaling.romNativeSource;
|
|
if (!source || source.kind !== "heal") return null;
|
|
const binding = romNativeAmountBinding(source);
|
|
if (!binding || binding.component.calculationType !== 5) return null;
|
|
const { rank, component } = binding;
|
|
return Math.max(0, resolveRomNativeHolyHeal({
|
|
source: {
|
|
componentId: component.componentId,
|
|
sourceSkillId: rank.sourceSkillId,
|
|
damagePower: component.damagePower,
|
|
damagePowerSkillLevelArg: component.damagePowerSkillLevelArg,
|
|
magicLevel: romNativeMagicLevel(level),
|
|
spellTimeSeconds: rank.spellTimeSeconds,
|
|
isIgnoreDmgEffect: rank.isIgnoreDmgEffect,
|
|
isFixDmgEffect: rank.isFixDmgEffect,
|
|
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
|
|
randomRange: component.randomRange,
|
|
},
|
|
caster,
|
|
target: { healing: { ...caster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 } },
|
|
// Combat policy uses source mean variance; the common healing resolver handles crit separately.
|
|
randomUnit: 0.5,
|
|
}).amount);
|
|
}
|
|
|
|
export function nativeHolyHealTargetAmount(
|
|
amount: number,
|
|
scaling: AbilityAmountScaling,
|
|
target: RomNativeDerivedStats | undefined,
|
|
): number {
|
|
if (!target || !scaling.romNativeSource || scaling.romNativeSource.kind !== "heal") return amount;
|
|
const binding = romNativeAmountBinding(scaling.romNativeSource);
|
|
if (!binding || binding.component.calculationType !== 5 || binding.component.ignoreMagicAbsorb) return amount;
|
|
return Math.max(0, amount * (1 + target.healing.targetHealAbsorbRate / 100) + target.healing.targetHealAbsorb);
|
|
}
|
|
|
|
const referenceGearCache = new Map<string, Readonly<Record<string, number>>>();
|
|
|
|
export interface RomNativeDamageTargetContext {
|
|
readonly targetCount: number;
|
|
readonly targetOrdinal: number;
|
|
}
|
|
|
|
export function nativeDamageCasterAmount(
|
|
scaling: AbilityAmountScaling,
|
|
level: number,
|
|
caster: RomNativeDerivedStats,
|
|
targetContext?: RomNativeDamageTargetContext,
|
|
): number | null {
|
|
const source = scaling.romNativeSource;
|
|
if (!source || source.kind !== "damage") return null;
|
|
const binding = romNativeAmountBinding(source);
|
|
if (!binding) return null;
|
|
// The shared amount adapter runs before recipient enumeration. Avoid using
|
|
// first-target power for every recipient of a source share/decrease attack.
|
|
if (!targetContext && (binding.rank.isShareDamage || (binding.rank.rangeType !== 0 && binding.rank.decreaseArg !== 0))) return null;
|
|
const trainingGearProfileMatches = matchesNativeTrainingGear(caster, level);
|
|
const profile = resolveRomNativeTrainingWeaponProfile(caster, { trainingGearProfileMatches });
|
|
const component = findRomNativeAttackComponent({ abilityId: binding.abilityId,
|
|
sourceSkillId: source.skillId, componentId: source.componentId, rank: binding.rank.rank ?? 1 });
|
|
if (profile.status !== "supported" || component.status !== "supported") return null;
|
|
const result = calculateRomNativeDamagePower({ stats: caster, trainingWeapons: profile.value,
|
|
component: component.value, magicLevel: romNativeMagicLevel(level), ownerKind: "player",
|
|
// Native school modifiers remain on the shared outgoing-aura stage, once.
|
|
magicSchoolRate: 1, ...targetContext,
|
|
specialInputs: {
|
|
// Training profiles equip no throwing item. All-range BodyRate is neutral
|
|
// in this adapter; dealt-damage auras remain in the shared stage.
|
|
// residualFlatDamage is the source Fin.DMG bridge after weapon removal.
|
|
throwDamage: profile.value.residualFlatDamage * caster.rateFactors.physicalDamage,
|
|
// ShieldDef is Owner.BaseEQ's raw category sum, never aggregate DEF.
|
|
shieldDefense: caster.sourceInputs.classId === "rom-knight"
|
|
? romNativeTrainingShieldDefense("rom-knight", level) : 0,
|
|
} });
|
|
return result.status === "supported" ? result.value.damageBeforeTargetResolver : null;
|
|
}
|
|
|
|
const periodicBindings = romNativeHolyHealSourceMetadata.periodicHealBindings.flatMap((ability) =>
|
|
ability.ranks.flatMap((rank) => rank.componentIds.map((component) => ({ rank, component }))));
|
|
|
|
/** Source DotMagic stores a rank correction at application. Keep this amount frozen for the buff. */
|
|
export function nativePeriodicHealSnapshot(
|
|
scaling: AbilityAmountScaling,
|
|
level: number,
|
|
caster: RomNativeDerivedStats | undefined,
|
|
target?: RomNativeDerivedStats,
|
|
targetLevel = level,
|
|
) {
|
|
const source = scaling.romNativeSource;
|
|
if (!caster || !source || source.kind !== "hot") return undefined;
|
|
const binding = periodicBindings.find(({ rank, component }) => rank.sourceSkillId === source.skillId
|
|
&& component.componentId === source.componentId);
|
|
if (!binding) return undefined;
|
|
const { rank, component } = binding;
|
|
return resolveRomNativePeriodicHolyHeal({
|
|
source: { ...component, sourceSkillId: rank.sourceSkillId,
|
|
rawSpellTimeSeconds: rank.spellTimeSeconds, isFixDmgEffect: rank.isFixDmgEffect,
|
|
magicLevel: romNativeMagicLevel(level) },
|
|
caster: { ...caster, effectiveMagicPower: caster.magicPowerRate },
|
|
target: target ?? { healing: { ...caster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 } },
|
|
sourceIsPlayer: true, casterLevel: level, targetLevel,
|
|
});
|
|
}
|
|
|
|
export function matchesNativeTrainingGear(caster: RomNativeDerivedStats, level: number): boolean {
|
|
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === caster.sourceInputs.classId);
|
|
if (!identity) return false;
|
|
const key = `${identity.classId}:${level}:${caster.sourceInputs.secondaryClassId}`;
|
|
let referenceGear = referenceGearCache.get(key);
|
|
if (!referenceGear) {
|
|
referenceGear = resolvedEquipmentStats({}, [], {
|
|
ownerId: "native-source-reference", ownerKind: "player", classId: identity.classId,
|
|
secondaryClassId: caster.sourceInputs.secondaryClassId, specializationId: null, role: identity.role, level,
|
|
}).romNative as Readonly<Record<string, number>> | undefined;
|
|
if (!referenceGear) return false;
|
|
referenceGearCache.set(key, referenceGear);
|
|
}
|
|
const currentGear = caster.sourceInputs.gear as Readonly<Record<string, number>>;
|
|
return [...new Set([...Object.keys(referenceGear), ...Object.keys(currentGear)])]
|
|
.every((key) => Math.abs((referenceGear![key] ?? 0) - (currentGear[key] ?? 0)) < 0.00001);
|
|
}
|
|
|
|
export function nativeTrainingAutoAttackProfile(caster: RomNativeDerivedStats | undefined, slot: RomNativeDamageWeaponSlot) {
|
|
if (!caster) return undefined;
|
|
const profile = resolveRomNativeTrainingWeaponProfile(caster,
|
|
{ trainingGearProfileMatches: matchesNativeTrainingGear(caster, caster.sourceInputs.level) });
|
|
if (profile.status !== "supported") return undefined;
|
|
const result = resolveRomNativeAutoAttackProfile({ stats: caster, trainingWeapons: profile.value, slot });
|
|
return result.status === "supported" ? result.value : undefined;
|
|
}
|
|
|
|
/** Native outgoing DotMagic freezes its source rank and target defense at application. */
|
|
export function nativePeriodicDamageCasterSnapshot(
|
|
scaling: AbilityAmountScaling,
|
|
level: number,
|
|
caster: RomNativeDerivedStats | undefined,
|
|
target: RomNativePeriodicDamageCombatantStats | undefined,
|
|
targetLevel: number,
|
|
magicResistance?: RomNativePeriodicDamageMagicResistance,
|
|
effectiveMagicPower?: number,
|
|
) {
|
|
if (!caster || !target || scaling.romNativeSource?.kind !== "dot") return undefined;
|
|
const source = romNativePeriodicDamageBinding(scaling.romNativeSource);
|
|
if (!source) return undefined;
|
|
const mainHand = nativeTrainingAutoAttackProfile(caster, "main-hand");
|
|
const result = nativePeriodicDamageSnapshot({ source, caster, target,
|
|
sourceIsPlayer: true, magicLevel: romNativeMagicLevel(level), casterLevel: level, targetLevel,
|
|
mainDamage: mainHand?.damage, mainAttackSpeed: mainHand?.attackSpeed,
|
|
effectiveMagicPower, magicResistance,
|
|
});
|
|
return result.status === "supported" ? result.value : undefined;
|
|
}
|