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) { 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>>(); 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> | undefined; if (!referenceGear) return false; referenceGearCache.set(key, referenceGear); } const currentGear = caster.sourceInputs.gear as Readonly>; 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; }