377 lines
19 KiB
TypeScript
377 lines
19 KiB
TypeScript
import type { RomNativeNpcSpellBinding } from "./romNativeCombatResolution";
|
|
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
|
|
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
|
|
import { matchesNativeTrainingGear } from "./romNativeCombatAmount";
|
|
|
|
type SourceComponent = Readonly<Record<string, unknown>>;
|
|
|
|
export interface RomNativeEnemyTargetAbsorbInputs {
|
|
/** Fin.AB, added to signed damage before the source Abr rate. */
|
|
readonly physicalFlatAbsorb: number;
|
|
/** Fin.Abr, the native rate subtracted from one in CalWeaponDMG. */
|
|
readonly physicalAbsorbRate: number;
|
|
/** Fin.MAbr, the native rate subtracted from one in CalMagicDMG. */
|
|
readonly magicalAbsorbRate: number;
|
|
/** WearEqType_AEMagicDefRate BodyRate; used only for non-target magical rows. */
|
|
readonly aoeMagicDefenseRate: number;
|
|
readonly provenance: readonly string[];
|
|
}
|
|
|
|
export type RomNativeEnemyTargetAbsorbInputResult =
|
|
| { readonly status: "supported"; readonly value: RomNativeEnemyTargetAbsorbInputs }
|
|
| { readonly status: "unsupported"; readonly reason: string };
|
|
|
|
/**
|
|
* The canonical source training set has neutral AB/Abr/MAbr and AoE magic-defense
|
|
* channels. Require both the source-gear match and an explicit aura-neutrality
|
|
* assertion; shared WoW armor is never used as a substitute for those channels.
|
|
*/
|
|
export function romNativeTrainingEnemyAbsorbInputs(
|
|
stats: RomNativeDerivedStats,
|
|
nativeAbsorbAurasVerifiedNeutral: boolean,
|
|
): RomNativeEnemyTargetAbsorbInputResult {
|
|
if (!nativeAbsorbAurasVerifiedNeutral) {
|
|
return { status: "unsupported", reason: "native-target-absorb-aura-state-unverified" };
|
|
}
|
|
if (!matchesNativeTrainingGear(stats, stats.sourceInputs.level)) {
|
|
return { status: "unsupported", reason: "native-target-training-gear-profile-unverified" };
|
|
}
|
|
return Object.freeze({
|
|
status: "supported",
|
|
value: Object.freeze({
|
|
physicalFlatAbsorb: 0,
|
|
physicalAbsorbRate: 0,
|
|
magicalAbsorbRate: 0,
|
|
aoeMagicDefenseRate: 1,
|
|
provenance: Object.freeze([
|
|
"RoleValueData::Init_BaseEQValue zeroes Body and initializes every BodyRate to 1",
|
|
"all 999 selected native training items/affixes have fully resolved channels and none supplies AB/Abr/MAbr/AEMagicDefRate",
|
|
"caller-verified no native absorb-channel aura is active",
|
|
]),
|
|
}),
|
|
});
|
|
}
|
|
|
|
export interface RomNativeEnemyDirectDamageInput {
|
|
readonly npcId: number;
|
|
/** Runtime NPC level used only by CalType 2 source DLvRate; MagicLv stays on the source binding. */
|
|
readonly npcLevel?: number;
|
|
readonly sourceSkillId: number;
|
|
readonly componentId: number;
|
|
/** Required only by CalType 8; source uses the recipient's Fin.MaxHP. */
|
|
readonly targetMaxHp?: number;
|
|
readonly targetCount?: number;
|
|
/** Ordinal in the final selected-recipient order used by MagicProcess. */
|
|
readonly targetOrdinal?: number;
|
|
/** Explicit source target Fin.AB/Abr/MAbr and AoE magic-defense BodyRate. */
|
|
readonly targetAbsorb?: RomNativeEnemyTargetAbsorbInputs;
|
|
/** NPC WearEqType_AEMagicPowerRate; required for non-target magical packets. */
|
|
readonly sourceAoeMagicPowerRate?: number;
|
|
/** GetValue(FixType) only for nonzero fixed terms outside the known None type. */
|
|
readonly fixedAttributeValue?: number;
|
|
}
|
|
|
|
export interface RomNativeEnemyDirectDamageSourceStages {
|
|
readonly bindingIdentityVerified: true;
|
|
readonly npcMagicLevelIncluded: true;
|
|
readonly formulaPowerApplied: true;
|
|
readonly sourceLevelDamageRate: "applied" | "not-applicable";
|
|
readonly sourceNpcDamageRate: "applied" | "not-applicable";
|
|
readonly sourceMagicPowerRate: "applied" | "not-applicable";
|
|
readonly sourceTypedResistance: "applied" | "not-applicable";
|
|
readonly targetFormulaAbsorption: "applied" | "pending-after-combat-table" | "bypassed";
|
|
readonly sourceAoeMagicRates: "applied" | "not-applicable";
|
|
readonly sourceMultiTargetFactor: "applied" | "pending-after-target-formula";
|
|
readonly sourceOrdinalPowerRate: number;
|
|
readonly sourceShareDamageRate: number;
|
|
readonly authoredEncounterDamageMultiplier: "not-applied";
|
|
readonly genericAuras: "not-applied";
|
|
readonly ordinaryShieldAbsorption: "not-applied";
|
|
}
|
|
|
|
export interface RomNativeEnemyDirectDamageValue {
|
|
readonly npcId: number;
|
|
readonly sourceSkillId: number;
|
|
readonly componentId: number;
|
|
readonly calculationType: 2 | 4 | 8;
|
|
readonly magicLevel: number;
|
|
/** Positive amount suitable as native base damage before authored encounter scaling. */
|
|
readonly amount: number;
|
|
/** Signed damage after the source-owned formula absorption when that stage is before hit resolution. */
|
|
readonly signedPower: number;
|
|
readonly formulaMitigation: "weapon-absorb" | "magic-absorb" | "bypassed";
|
|
readonly nativeDamageBudgetScale: "skip-authored-native-damage-budget";
|
|
readonly sourceHitEffectType: number;
|
|
readonly componentEffectType: number;
|
|
readonly magicType: number;
|
|
readonly sourceMultiTargetFactor: number;
|
|
readonly resolverDamageChannel: "none" | "physical";
|
|
readonly resolverCriticalKind?: "physical";
|
|
readonly resolverRules: Readonly<{
|
|
canCrit: boolean;
|
|
canBlock: boolean;
|
|
canParry: boolean;
|
|
ignoreArmor: boolean;
|
|
}>;
|
|
/** True means the formula has resolved or deliberately omitted these source power stages. */
|
|
readonly sourcePowerStagesAlreadyApplied: boolean;
|
|
readonly sourceStages: RomNativeEnemyDirectDamageSourceStages;
|
|
readonly authoredBudgetPolicy: "apply-imported-attack-damageMultiplier; skip-native-damage-budget-scale-for-source-formula; keep-authored-encounter-health";
|
|
}
|
|
|
|
export type RomNativeEnemyDirectDamageResult =
|
|
| { readonly status: "supported"; readonly value: RomNativeEnemyDirectDamageValue }
|
|
| { readonly status: "unsupported"; readonly reason: string };
|
|
|
|
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
const numeric = (source: SourceComponent, key: string): number | undefined => {
|
|
const value = source[key];
|
|
return finite(value) ? value : undefined;
|
|
};
|
|
const flag = (source: SourceComponent, key: string): boolean | undefined => {
|
|
const value = source[key];
|
|
return typeof value === "boolean" ? value : undefined;
|
|
};
|
|
const unsupported = (reason: string): RomNativeEnemyDirectDamageResult => ({ status: "unsupported", reason });
|
|
|
|
function sourceMagicLevel(binding: RomNativeNpcSpellBinding): number | undefined {
|
|
return Number.isSafeInteger(binding.magicLevel) && binding.magicLevel >= 0 ? binding.magicLevel : undefined;
|
|
}
|
|
|
|
function targetAbsorbedPower(power: number, kind: "weapon" | "magic", input: RomNativeEnemyTargetAbsorbInputs): number {
|
|
if (power >= 0) return power;
|
|
const value = kind === "weapon"
|
|
? (power + input.physicalFlatAbsorb) * (1 - input.physicalAbsorbRate)
|
|
: power * (1 - input.magicalAbsorbRate);
|
|
return Math.min(-1, Math.trunc(value));
|
|
}
|
|
|
|
function targetHpPercentPower(
|
|
powerPercent: number,
|
|
targetMaxHp: number,
|
|
kind: "weapon" | "magic",
|
|
input: RomNativeEnemyTargetAbsorbInputs | undefined,
|
|
bypassAbsorb: boolean,
|
|
): number | undefined {
|
|
const rawPower = targetMaxHp * powerPercent;
|
|
if (bypassAbsorb) return rawPower / 100;
|
|
if (!input) return undefined;
|
|
if (kind === "weapon") {
|
|
if (![input.physicalFlatAbsorb, input.physicalAbsorbRate].every(finite)) return undefined;
|
|
const absorbed = Math.min(-1, Math.trunc((rawPower + input.physicalFlatAbsorb) * (1 - input.physicalAbsorbRate)));
|
|
return absorbed / 100;
|
|
}
|
|
if (!finite(input.magicalAbsorbRate)) return undefined;
|
|
const absorbed = Math.min(-1, Math.trunc(rawPower * (1 - input.magicalAbsorbRate)));
|
|
return absorbed / 100;
|
|
}
|
|
|
|
function bindingFor(input: RomNativeEnemyDirectDamageInput): {
|
|
readonly binding: RomNativeNpcSpellBinding;
|
|
readonly component: SourceComponent;
|
|
readonly magicLevel: number;
|
|
} | RomNativeEnemyDirectDamageResult {
|
|
if (![input.npcId, input.sourceSkillId, input.componentId].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
|
return unsupported("native-enemy-source-identity-invalid");
|
|
}
|
|
if (input.npcLevel !== undefined && (!Number.isSafeInteger(input.npcLevel) || input.npcLevel < 1)) {
|
|
return unsupported("native-enemy-runtime-level-invalid");
|
|
}
|
|
const npc = romNativeNpcCombatant(input.npcId, input.npcLevel);
|
|
if (!npc) return unsupported("native-enemy-npc-binding-missing");
|
|
const binding = npc.spellBindings.find((entry) => entry.sourceSkillId === input.sourceSkillId);
|
|
if (!binding) return unsupported("native-enemy-skill-binding-missing");
|
|
const component = binding.components.find((entry) => numeric(entry, "componentId") === input.componentId);
|
|
if (!component) return unsupported("native-enemy-component-binding-missing");
|
|
const magicLevel = sourceMagicLevel(binding);
|
|
if (magicLevel === undefined) return unsupported("native-enemy-magic-level-missing");
|
|
if (!binding.magicCollect || ![0, 1].includes(binding.magicCollect.effectType)) {
|
|
return unsupported("native-enemy-collect-hit-binding-incomplete");
|
|
}
|
|
if (numeric(component, "magicFunc") !== 0 || numeric(component, "attackType") !== 0) {
|
|
return unsupported("native-enemy-component-is-not-direct-hp-damage");
|
|
}
|
|
return { binding, component, magicLevel };
|
|
}
|
|
|
|
function baseValue(
|
|
input: RomNativeEnemyDirectDamageInput,
|
|
formula: 2 | 4 | 8,
|
|
magicLevel: number,
|
|
amount: number,
|
|
signedPower: number,
|
|
formulaMitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"],
|
|
resolverDamageChannel: RomNativeEnemyDirectDamageValue["resolverDamageChannel"],
|
|
sourceHitEffectType: number,
|
|
componentEffectType: number,
|
|
magicType: number,
|
|
sourceMultiTargetFactor: number,
|
|
sourceOrdinalPowerRate: number,
|
|
sourceShareDamageRate: number,
|
|
stages: RomNativeEnemyDirectDamageSourceStages,
|
|
options: { readonly sourcePowerStagesAlreadyApplied: boolean; readonly crit?: "physical" },
|
|
): RomNativeEnemyDirectDamageResult {
|
|
if (![amount, signedPower].every(Number.isFinite) || amount < 0) return unsupported("native-enemy-formula-produced-invalid-damage");
|
|
return Object.freeze({
|
|
status: "supported",
|
|
value: Object.freeze({
|
|
npcId: input.npcId,
|
|
sourceSkillId: input.sourceSkillId,
|
|
componentId: input.componentId,
|
|
calculationType: formula,
|
|
magicLevel,
|
|
amount,
|
|
signedPower,
|
|
formulaMitigation,
|
|
nativeDamageBudgetScale: "skip-authored-native-damage-budget",
|
|
sourceHitEffectType,
|
|
componentEffectType,
|
|
magicType,
|
|
sourceMultiTargetFactor,
|
|
resolverDamageChannel,
|
|
...(options.crit ? { resolverCriticalKind: options.crit } : {}),
|
|
resolverRules: Object.freeze({
|
|
canCrit: formula === 2,
|
|
canBlock: formula === 2,
|
|
canParry: formula === 2,
|
|
ignoreArmor: formula === 2,
|
|
}),
|
|
sourcePowerStagesAlreadyApplied: options.sourcePowerStagesAlreadyApplied,
|
|
sourceStages: stages,
|
|
authoredBudgetPolicy: "apply-imported-attack-damageMultiplier; skip-native-damage-budget-scale-for-source-formula; keep-authored-encounter-health",
|
|
}),
|
|
});
|
|
}
|
|
|
|
const sourceStages = (
|
|
values: Omit<RomNativeEnemyDirectDamageSourceStages, "bindingIdentityVerified" | "npcMagicLevelIncluded" | "formulaPowerApplied" | "authoredEncounterDamageMultiplier" | "genericAuras" | "ordinaryShieldAbsorption">,
|
|
): RomNativeEnemyDirectDamageSourceStages => Object.freeze({
|
|
bindingIdentityVerified: true,
|
|
npcMagicLevelIncluded: true,
|
|
formulaPowerApplied: true,
|
|
authoredEncounterDamageMultiplier: "not-applied",
|
|
genericAuras: "not-applied",
|
|
ordinaryShieldAbsorption: "not-applied",
|
|
...values,
|
|
});
|
|
|
|
/** Resolve an exact imported NPC spell component; no skill/name/Day-of-Rain inference is used. */
|
|
export function resolveRomNativeEnemyDirectDamage(
|
|
input: RomNativeEnemyDirectDamageInput,
|
|
): RomNativeEnemyDirectDamageResult {
|
|
const resolved = bindingFor(input);
|
|
if ("status" in resolved) return resolved;
|
|
const { binding, component, magicLevel } = resolved;
|
|
const calculationType = numeric(component, "calculationType");
|
|
const damagePower = numeric(component, "damagePower");
|
|
const damagePowerSkillLevelArg = numeric(component, "damagePowerSkillLevelArg");
|
|
const fixedValue = numeric(component, "fixedValue");
|
|
const fixedType = numeric(component, "fixedType");
|
|
const fixedDamageSkillLevelArg = numeric(component, "fixedDamageSkillLevelArg");
|
|
const ignoreMagicAbsorb = flag(component, "ignoreMagicAbsorb");
|
|
const isFixDotPower = flag(component, "isFixDotPower");
|
|
const collect = binding.magicCollect;
|
|
const rangeType = numeric(collect as unknown as SourceComponent, "rangeType");
|
|
const decreaseArg = numeric(collect as unknown as SourceComponent, "decreaseArg");
|
|
const isShareDamage = flag(collect as unknown as SourceComponent, "isShareDamage");
|
|
const magicType = numeric(component, "magicType");
|
|
const componentEffectType = numeric(component, "effectType");
|
|
if (calculationType === undefined || damagePower === undefined || damagePowerSkillLevelArg === undefined
|
|
|| fixedValue === undefined || fixedType === undefined || fixedDamageSkillLevelArg === undefined
|
|
|| rangeType === undefined || decreaseArg === undefined || magicType === undefined
|
|
|| componentEffectType === undefined || ignoreMagicAbsorb === undefined
|
|
|| isFixDotPower === undefined || isShareDamage === undefined) {
|
|
return unsupported("native-enemy-component-source-fields-incomplete");
|
|
}
|
|
if (![2, 4, 8].includes(calculationType)) return unsupported("native-enemy-calculation-type-unsupported");
|
|
if ((calculationType === 2 || calculationType === 4)
|
|
&& fixedType !== 0 && fixedValue !== 0 && !finite(input.fixedAttributeValue)) {
|
|
return unsupported("native-enemy-fixed-attribute-input-required");
|
|
}
|
|
if (input.fixedAttributeValue !== undefined && !finite(input.fixedAttributeValue)) {
|
|
return unsupported("native-enemy-fixed-attribute-input-invalid");
|
|
}
|
|
const fixedAttributeValue = fixedType === 0 ? 1 : (input.fixedAttributeValue ?? 0);
|
|
const orgPower = damagePower * (1 + damagePowerSkillLevelArg * magicLevel / 100);
|
|
const fixedPower = fixedValue * fixedAttributeValue * (1 + magicLevel * fixedDamageSkillLevelArg / 100);
|
|
if (!Number.isFinite(orgPower) || !Number.isFinite(fixedPower)) return unsupported("native-enemy-component-power-invalid");
|
|
const targetCount = input.targetCount ?? 1;
|
|
const targetOrdinal = input.targetOrdinal ?? 0;
|
|
if (!Number.isInteger(targetCount) || targetCount < 1 || !Number.isInteger(targetOrdinal)
|
|
|| targetOrdinal < 0 || targetOrdinal >= targetCount) return unsupported("native-enemy-target-order-invalid");
|
|
const shareRate = Math.max(decreaseArg / 100, 1 / targetCount);
|
|
const ordinalPowerRate = (100 - decreaseArg) / 100;
|
|
const ordinalRate = isShareDamage ? 1 : ordinalPowerRate ** targetOrdinal;
|
|
|
|
if (calculationType === 4) {
|
|
let power = orgPower * ordinalRate + fixedPower;
|
|
if (power >= 0) return unsupported("native-enemy-fixed-formula-is-not-negative-direct-damage");
|
|
let mitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"] = "bypassed";
|
|
if (!ignoreMagicAbsorb) {
|
|
if (!input.targetAbsorb || ![input.targetAbsorb.magicalAbsorbRate, input.targetAbsorb.aoeMagicDefenseRate].every(finite)) {
|
|
return unsupported("native-enemy-target-magic-absorb-input-required");
|
|
}
|
|
power = targetAbsorbedPower(power, "magic", input.targetAbsorb);
|
|
mitigation = "magic-absorb";
|
|
}
|
|
if (rangeType !== 0) {
|
|
if (!input.targetAbsorb || !finite(input.targetAbsorb.aoeMagicDefenseRate)
|
|
|| !finite(input.sourceAoeMagicPowerRate)) return unsupported("native-enemy-source-aoe-magic-rates-required");
|
|
power *= input.sourceAoeMagicPowerRate * input.targetAbsorb.aoeMagicDefenseRate;
|
|
}
|
|
const afterShare = power * (isShareDamage ? shareRate : 1);
|
|
const signedPower = Math.trunc(-afterShare) * -1;
|
|
return baseValue(input, 4, magicLevel, Math.max(0, -signedPower), signedPower, mitigation, "none",
|
|
binding.magicCollect.effectType, componentEffectType, magicType,
|
|
isShareDamage ? shareRate : ordinalRate, ordinalRate, isShareDamage ? shareRate : 1,
|
|
sourceStages({ sourceLevelDamageRate: "not-applicable", sourceNpcDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
|
|
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: mitigation === "bypassed" ? "bypassed" : "applied",
|
|
sourceAoeMagicRates: rangeType === 0 ? "not-applicable" : "applied", sourceMultiTargetFactor: "applied",
|
|
sourceOrdinalPowerRate: ordinalRate, sourceShareDamageRate: isShareDamage ? shareRate : 1 }),
|
|
{ sourcePowerStagesAlreadyApplied: true });
|
|
}
|
|
|
|
if (calculationType === 8) {
|
|
if (orgPower >= 0) return unsupported("native-enemy-component-is-not-negative-direct-damage");
|
|
if (!finite(input.targetMaxHp) || (input.targetMaxHp as number) <= 0) {
|
|
return unsupported("native-enemy-target-max-hp-required");
|
|
}
|
|
const percentPower = orgPower * ordinalRate;
|
|
if (percentPower >= 0) return unsupported("native-enemy-percent-formula-is-not-negative-direct-damage");
|
|
let mitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"] = "bypassed";
|
|
const absorbBypass = ignoreMagicAbsorb || isFixDotPower;
|
|
const effectType = componentEffectType;
|
|
if (effectType !== 0 && effectType !== 1) return unsupported("native-enemy-percent-effect-type-unsupported");
|
|
const absorbKind = effectType === 1 ? "weapon" : "magic";
|
|
const power = targetHpPercentPower(percentPower, input.targetMaxHp as number, absorbKind, input.targetAbsorb, absorbBypass);
|
|
if (power === undefined) return unsupported(absorbBypass ? "native-enemy-percent-formula-invalid" : "native-enemy-target-absorb-input-required");
|
|
if (!absorbBypass) mitigation = absorbKind === "weapon" ? "weapon-absorb" : "magic-absorb";
|
|
let adjustedPower = power;
|
|
// FixDotPower takes the source's early raw-MaxHP path and skips the AoE
|
|
// BodyRate multiplication below the ordinary CalMagicDMG branch.
|
|
if (absorbKind === "magic" && rangeType !== 0 && !isFixDotPower) {
|
|
if (!input.targetAbsorb || !finite(input.targetAbsorb.aoeMagicDefenseRate)
|
|
|| !finite(input.sourceAoeMagicPowerRate)) return unsupported("native-enemy-source-aoe-magic-rates-required");
|
|
adjustedPower *= input.sourceAoeMagicPowerRate * input.targetAbsorb.aoeMagicDefenseRate;
|
|
}
|
|
const integerDamage = Math.trunc(-adjustedPower);
|
|
const sourceShareDamageRate = isShareDamage ? shareRate : 1;
|
|
const signedPower = -(integerDamage * sourceShareDamageRate);
|
|
return baseValue(input, 8, magicLevel, Math.max(0, -signedPower), signedPower, mitigation, "none",
|
|
binding.magicCollect.effectType, effectType, magicType,
|
|
isShareDamage ? shareRate : ordinalRate, ordinalRate, sourceShareDamageRate,
|
|
sourceStages({ sourceLevelDamageRate: "not-applicable", sourceNpcDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
|
|
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: mitigation === "bypassed" ? "bypassed" : "applied",
|
|
sourceAoeMagicRates: rangeType === 0 || absorbKind === "weapon" || isFixDotPower ? "not-applicable" : "applied",
|
|
sourceMultiTargetFactor: "applied", sourceOrdinalPowerRate: ordinalRate,
|
|
sourceShareDamageRate }),
|
|
{ sourcePowerStagesAlreadyApplied: true });
|
|
}
|
|
|
|
// Throw's source target CalWeaponDMG stage runs after the shared critical,
|
|
// block, and parry table. The runtime currently has no completion hook for
|
|
// that exact order, and the imported NPC catalog has no bound CalType 2 row.
|
|
// Do not return an apparently final amount while absorption/share are pending.
|
|
return unsupported("native-enemy-throw-post-table-absorption-runtime-unwired");
|
|
}
|