429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
|
import type { AuraProcDefinition } from "./combatAuras";
|
|
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
|
|
|
export const ROM_KNIGHT_DIVINE_PUNISHMENT_TAG = "rom-divine-punishment";
|
|
export const ROM_KNIGHT_GODS_VENGEANCE_TAG = "rom-gods-vengeance";
|
|
export const ROM_KNIGHT_SHIELD_OF_VALOR_TAG = "rom-shield-of-valor";
|
|
export const ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG = "rom-compassion-of-holy-light";
|
|
export const ROM_KNIGHT_HOLY_ILLUMINATION_TAG = "rom-holy-illumination";
|
|
export const ROM_KNIGHT_MASTER_OF_PARRY_TAG = "rom-knight-master-of-parry";
|
|
export const ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC = "rom-knight-master-of-parry-block";
|
|
export const ROM_KNIGHT_PANTHERS_BLESSING_TAG = "rom-knight-panthers-blessing";
|
|
export const ROM_KNIGHT_DEADLY_WHIRLWIND_TAG = "rom-deadly-whirlwind";
|
|
export const ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID = 1_493_719;
|
|
export const ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID = 490_175;
|
|
|
|
export interface RomKnightGodsVengeanceResetContext {
|
|
readonly characterClassId: string;
|
|
readonly secondaryClassId: string | null | undefined;
|
|
readonly ability: Pick<AbilityDefinition, "source" | "classId" | "secondaryClassId" | "dbcSpellId">;
|
|
readonly learnedPassiveSpellIds: readonly number[];
|
|
readonly shadowStepLearned: boolean;
|
|
readonly targetSealStacks: number;
|
|
readonly sameTarget: boolean;
|
|
readonly landed: boolean;
|
|
}
|
|
|
|
/** Current CoA God's Vengeance resets Shadow Step only for its linked Punishment hit at exactly four Seals. */
|
|
export function romKnightGodsVengeanceShouldResetShadowStep(
|
|
context: RomKnightGodsVengeanceResetContext,
|
|
): boolean {
|
|
return context.characterClassId === "rom-knight"
|
|
&& context.secondaryClassId === "rom-rogue"
|
|
&& context.ability.source === "runewaker"
|
|
&& context.ability.classId === "rom-knight"
|
|
&& context.ability.secondaryClassId === "rom-rogue"
|
|
&& context.ability.dbcSpellId === 499_909
|
|
&& context.learnedPassiveSpellIds.includes(499_934)
|
|
&& context.shadowStepLearned
|
|
&& context.targetSealStacks === 4
|
|
&& context.sameTarget
|
|
&& context.landed;
|
|
}
|
|
|
|
/** Shield of Valor reduces Holy Seal's remaining cooldown only on an active-aura parry. */
|
|
export function romKnightShieldOfValorCooldownReductionMs(auraActive: boolean, parried: boolean): number {
|
|
return auraActive && parried ? 1_000 : 0;
|
|
}
|
|
|
|
export interface RomKnightWarriorWhirlwindAdjustment {
|
|
readonly rageCostReduction: number;
|
|
readonly cooldownReductionMs: number;
|
|
}
|
|
|
|
/** RuneWaker Knight/Warrior passive 491639 modifies only Warrior Whirlwind. */
|
|
export function romKnightWarriorWhirlwindAdjustment(
|
|
characterClassId: string,
|
|
secondaryClassId: string | null | undefined,
|
|
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
|
learnedPassiveSpellIds: readonly number[],
|
|
): RomKnightWarriorWhirlwindAdjustment {
|
|
if (characterClassId !== "rom-knight"
|
|
|| secondaryClassId !== "rom-warrior"
|
|
|| ability.source !== "runewaker"
|
|
|| ability.classId !== "rom-warrior"
|
|
|| ability.dbcSpellId !== 490133
|
|
|| !learnedPassiveSpellIds.includes(491639)) {
|
|
return { rageCostReduction: 0, cooldownReductionMs: 0 };
|
|
}
|
|
|
|
// The local 491639 tooltip explicitly says 10 Rage and 20 seconds.
|
|
return { rageCostReduction: 10, cooldownReductionMs: 20_000 };
|
|
}
|
|
|
|
/** Current CoA changed God's Sword to passive, but the current passive payload is unpublished. */
|
|
export function romKnightRogueHolyStrikeCriticalRateIncreasePercent(
|
|
characterClassId: string,
|
|
secondaryClassId: string | null | undefined,
|
|
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
|
learnedPassiveSpellIds: readonly number[],
|
|
): number {
|
|
// The RuneWaker snapshot's 40% Holy Strike bonus conflicts with current CoA,
|
|
// which only says God's Sword became passive and does not publish its payload.
|
|
void characterClassId;
|
|
void secondaryClassId;
|
|
void ability;
|
|
void learnedPassiveSpellIds;
|
|
return 0;
|
|
}
|
|
|
|
function hasPassiveTag(
|
|
passiveTags: readonly string[] | ReadonlySet<string>,
|
|
tag: string,
|
|
): boolean {
|
|
return Array.isArray(passiveTags)
|
|
? passiveTags.includes(tag)
|
|
: (passiveTags as ReadonlySet<string>).has(tag);
|
|
}
|
|
|
|
/** CoA 6-1: Deadly Wound reduces healing received by 30%; player targets last 5s. */
|
|
export function romKnightDeadlyWhirlwindAura(targetIsPlayer: boolean): Extract<AbilityEffect, { kind: "apply-aura" }> {
|
|
return {
|
|
kind: "apply-aura",
|
|
recipient: "ability-target",
|
|
aura: {
|
|
id: "rom-knight-deadly-wound",
|
|
name: "Deadly Wound",
|
|
disposition: "debuff",
|
|
durationMs: targetIsPlayer ? 5_000 : 10_000,
|
|
dispelCategory: "magic",
|
|
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: -0.3 }],
|
|
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491_639,
|
|
};
|
|
}
|
|
|
|
/** CoA 10.6.1: Divine Punishment makes the target take 2% more damage per Seal for 10s. */
|
|
export function romKnightDivinePunishmentAuraForSealStacks(
|
|
sealStacks: number,
|
|
): Extract<AbilityEffect, { kind: "apply-aura" }> | null {
|
|
const stacks = Math.max(0, Math.min(4, Math.trunc(sealStacks)));
|
|
if (stacks <= 0) return null;
|
|
return {
|
|
kind: "apply-aura",
|
|
recipient: "ability-target",
|
|
aura: {
|
|
id: "rom-knight-divine-punishment-damage-taken",
|
|
name: "Divine Punishment",
|
|
disposition: "debuff",
|
|
durationMs: 10_000,
|
|
dispelCategory: "magic",
|
|
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: stacks * 0.02 }],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: stacks * 2,
|
|
triggerSpellId: 491_608,
|
|
};
|
|
}
|
|
|
|
/** Current CoA 11.1.5 cooldown rider, bridged by the exact Whirlwind Shield name. */
|
|
export function romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
|
characterClassId: string,
|
|
secondaryClassId: string | null | undefined,
|
|
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
|
learnedPassiveSpellIds: readonly number[],
|
|
): number {
|
|
return characterClassId === "rom-knight"
|
|
&& secondaryClassId === "rom-priest"
|
|
&& ability.source === "runewaker"
|
|
&& ability.classId === "rom-knight"
|
|
&& (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID
|
|
|| ability.dbcSpellId === ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID)
|
|
&& learnedPassiveSpellIds.includes(491_635)
|
|
? 1_000
|
|
: 0;
|
|
}
|
|
|
|
/** CoA 11.1.5 Compassion heal formula; called only after a Whirlwind Shield hit lands. */
|
|
export function romKnightCompassionWhirlwindShieldHealAmount(stamina: number): number {
|
|
return Math.max(0, Math.round(120 + Math.max(0, stamina) * 0.2));
|
|
}
|
|
|
|
const ROM_KNIGHT_MASTER_OF_PARRY_PROC: AuraProcDefinition = {
|
|
id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC,
|
|
triggers: "block",
|
|
chance: 1,
|
|
charges: 2,
|
|
action: { kind: "custom", id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC },
|
|
};
|
|
|
|
function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
|
return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-491608",
|
|
name: row.name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491608,
|
|
}];
|
|
}
|
|
|
|
function deadWhirlwindPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
|
return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-491639",
|
|
name: row.name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491_639,
|
|
}];
|
|
}
|
|
|
|
function compassionOfHolyLightPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
|
return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-491635",
|
|
name: "Compassion of Holy Light",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491_635,
|
|
}];
|
|
}
|
|
|
|
function holyIlluminationPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
|
return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-491632",
|
|
name: row.name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_HOLY_ILLUMINATION_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491_632,
|
|
}];
|
|
}
|
|
|
|
function panthersBlessingPassiveMarker(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
|
return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-493047",
|
|
name: row.name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_PANTHERS_BLESSING_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 493_047,
|
|
}];
|
|
}
|
|
|
|
/** Source-backed Knight passive and cast adjustments. */
|
|
export function overrideRomKnightEffects(
|
|
row: RomGeneratedAbilityLike,
|
|
translated: readonly AbilityEffect[],
|
|
): readonly AbilityEffect[] | null {
|
|
if (row.classId === "rom-knight"
|
|
&& row.secondaryClassId === "rom-rogue"
|
|
&& row.sourceSkillId === 493047) {
|
|
// Keep the stale RuneWaker shield-conditioned +2 Dodge child suppressed:
|
|
// its chance and required-buff gate are absent. The current CoA dodge
|
|
// restore is handled by romKnightPanthersBlessingRuntime.ts.
|
|
return panthersBlessingPassiveMarker(row);
|
|
}
|
|
if (row.classId === "rom-knight"
|
|
&& row.secondaryClassId === "rom-warrior"
|
|
&& row.sourceSkillId === 490088) {
|
|
// Fearless's local description promises Fear immunity, while its source
|
|
// component only exposes the +3% Strength and Stamina modifiers. The
|
|
// [SC_SKILLTIPS_FEAR] suffix is a tooltip category marker; discard the
|
|
// generic parser's false-positive Fear control and attach immunity to its
|
|
// actual stat buff rows.
|
|
return translated
|
|
.filter((effect) => !(effect.kind === "apply-aura" && effect.control?.kind === "fear"))
|
|
.map((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0 ? {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
controlImmunities: [...new Set([...(effect.aura.controlImmunities ?? []), "fear" as const])],
|
|
},
|
|
} : effect);
|
|
}
|
|
if (row.sourceSkillId === 492935) {
|
|
const durationMs = Math.max(0, ...(row.source?.components ?? []).map((component) => (
|
|
Math.max(0, Number(component.effectTime ?? 0)) * 1_000
|
|
)));
|
|
return translated.map((effect) => {
|
|
if (effect.kind !== "apply-aura") return effect;
|
|
if (effect.aura.modifiers?.some((modifier) => (
|
|
modifier.kind === "stat" && modifier.stat === "parry"
|
|
))) return {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_MASTER_OF_PARRY_TAG],
|
|
procs: [...(effect.aura.procs ?? []), ROM_KNIGHT_MASTER_OF_PARRY_PROC],
|
|
},
|
|
};
|
|
// Source component 503192 drives a periodic trigger for the same 15s
|
|
// effect. Keep that hidden trigger aura on the component's lifetime too.
|
|
if (effect.aura.procs?.some((proc) => proc.id.startsWith("rom-proc-trigger-492935-503192"))) return {
|
|
...effect,
|
|
aura: { ...effect.aura, durationMs: durationMs || effect.aura.durationMs },
|
|
};
|
|
return effect;
|
|
});
|
|
}
|
|
if (row.sourceSkillId === 491639 && row.passive) return deadWhirlwindPassive(row);
|
|
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
|
|
if (row.sourceSkillId === 499934 && row.passive) return [{
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-knight-passive-499934",
|
|
name: row.name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 499934,
|
|
}];
|
|
if (row.sourceSkillId === 491635 && row.passive) return compassionOfHolyLightPassive(row);
|
|
if (row.sourceSkillId === 491632 && row.passive) return holyIlluminationPassive(row);
|
|
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_SHIELD_OF_VALOR_TAG],
|
|
},
|
|
} : effect);
|
|
return null;
|
|
}
|
|
|
|
/** Apply Divine Punishment's extracted +1% only to its named source skill. */
|
|
export function romKnightCastEffects(
|
|
ability: AbilityDefinition,
|
|
effects: readonly AbilityEffect[],
|
|
passiveTags: readonly string[] | ReadonlySet<string> = [],
|
|
sealStacks = 0,
|
|
): readonly AbilityEffect[] {
|
|
if (ability.source !== "runewaker"
|
|
|| (ability.classId !== "rom-knight" && ability.dbcSpellId !== 490_133)) return effects;
|
|
|
|
let adjusted = effects;
|
|
if (ability.dbcSpellId === 490_151 && hasPassiveTag(passiveTags, ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)) {
|
|
adjusted = adjusted.map((effect) => {
|
|
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
|
return {
|
|
...effect,
|
|
coefficient: effect.coefficient * 1.285,
|
|
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.285 }),
|
|
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.285 }),
|
|
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.285 }),
|
|
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.285 }),
|
|
};
|
|
});
|
|
const punishmentAura = romKnightDivinePunishmentAuraForSealStacks(sealStacks);
|
|
if (punishmentAura) adjusted = [...adjusted, { ...punishmentAura, requiresLandedHit: true }];
|
|
}
|
|
|
|
if (ability.dbcSpellId === 490_133 && hasPassiveTag(passiveTags, ROM_KNIGHT_DEADLY_WHIRLWIND_TAG)) {
|
|
adjusted = [...adjusted, { ...romKnightDeadlyWhirlwindAura(false), requiresLandedHit: true }];
|
|
}
|
|
|
|
if (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID) {
|
|
adjusted = adjusted.map((effect) => effect.kind === "damage"
|
|
? {
|
|
...effect,
|
|
coefficient: 0,
|
|
basePoints: 0,
|
|
dieSides: 1,
|
|
weaponDpsCoefficient: 7.014,
|
|
school: "holy" as const,
|
|
}
|
|
: effect);
|
|
if (hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
|
|
adjusted = [...adjusted, {
|
|
kind: "heal",
|
|
coefficient: 0,
|
|
basePoints: 120,
|
|
dieSides: 1,
|
|
staminaCoefficient: 0.2,
|
|
scope: "party",
|
|
center: "caster",
|
|
radius: 100,
|
|
requiresLandedHit: true,
|
|
}];
|
|
}
|
|
if (hasPassiveTag(passiveTags, ROM_KNIGHT_HOLY_ILLUMINATION_TAG)
|
|
|| hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
|
|
adjusted = [...adjusted, {
|
|
kind: "apply-aura",
|
|
scope: "party",
|
|
radius: 100,
|
|
requiresLandedHit: true,
|
|
aura: {
|
|
id: "rom-knight-holy-illumination-proc",
|
|
name: "Holy Illumination",
|
|
disposition: "buff",
|
|
durationMs: 10_000,
|
|
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -0.15 }],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 491_632,
|
|
}];
|
|
}
|
|
}
|
|
return adjusted;
|
|
}
|