Files
healer-man/src/game/romScoutRogueOverrides.ts
T

662 lines
29 KiB
TypeScript

import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
import {
ROM_ROGUE_COERCE_TAG,
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_OFF_HAND_MASTERY_TAG,
ROM_ROGUE_TREASURE_HUNTER_TAG,
romRogueCoerceLowBlowDamageMultiplier,
} from "./romRogueMechanics";
import { ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG as ROM_CHAOS_DAGGER_TAG } from "./romRogueChaosDagger";
import {
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID,
} from "./romRogueChampionNerveGearRuntime";
export const ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG = ROM_CHAOS_DAGGER_TAG;
/** @deprecated Use ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG for current CoA behavior. */
export const ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG = ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG;
export const ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG = "rom-rogue-priest-sinister-methods";
export const ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY = "rom-rogue-priest-sinister-methods";
export const ROM_SCOUT_PRIEST_SNIPER_TAG = "rom-scout-priest-sniper";
export const ROM_SCOUT_MAGE_WIND_RETURN_TAG = "rom-scout-mage-wind-return";
export const ROM_SCOUT_PRIEST_REVIVAL_ENGRAVING_TAG = "rom-scout-priest-revival-engraving";
export const ROM_SCOUT_WARDEN_FOREST_GUIDANCE_TAG = "rom-scout-warden-forest-guidance";
const ROM_SCOUT_KNIGHT_ENHANCED_ARMOR_PERFECTION_TAG = "rom-scout-knight-enhanced-armor-perfection";
// Local RuneWaker component 501823 sets Enhanced Armor's duration to 600s.
const ROM_SCOUT_KNIGHT_ENHANCED_ARMOR_DURATION_MS = 600_000;
/** RuneWaker itemobject/shop rows supply Poison Powder to the Scout/Rogue loadout. */
export const ROM_SCOUT_ROGUE_POISON_POWDER_SUPPLY = Object.freeze({
itemId: 200563,
name: "Poison Powder",
quantity: "unlimited",
} as const);
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
}
function scaleDamageEffects(effects: readonly AbilityEffect[], factor: number): readonly AbilityEffect[] {
return effects.map((effect) => effect.kind === "damage" || effect.kind === "dot" ? {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
...(effect.kind !== "damage" || effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }),
} : effect);
}
function taggedPassive(
sourceId: number,
name: string,
tag: string,
proc?: AuraProcDefinition,
): AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-pair-passive-${sourceId}`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
...(proc ? { procs: [proc] } : {}),
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
function taggedRoguePassive(sourceId: number, name: string, tag: string): AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-aura:${sourceId}:rogue-passive`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
function customProc(
sourceId: number,
trigger: "hit" | "crit",
actionId: string,
options: {
readonly chance?: number;
readonly internalCooldownMs?: number;
readonly data?: Readonly<Record<string, string | number | boolean>>;
} = {},
): AuraProcDefinition {
return {
id: `rom-pair-proc-${sourceId}`,
triggers: trigger === "hit" ? ["hit", "crit"] : trigger,
chance: options.chance ?? 1,
...(options.internalCooldownMs ? { internalCooldownMs: options.internalCooldownMs } : {}),
action: { kind: "custom", id: actionId, ...(options.data ? { data: options.data } : {}) },
};
}
function timedCasterAura(
sourceId: number,
name: string,
durationMs: number,
modifiers: readonly AuraModifier[],
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-pair-buff-${sourceId}`,
name,
disposition: "buff",
durationMs,
modifiers,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
};
}
/**
* Source-specific translations for the accepted Scout paired-class
* mechanics. Passive tags and custom proc IDs are consumed by combat-store
* transitions; they intentionally do not apply unrelated generic stat buffs.
*/
export function overrideRomScoutRogueEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 497206: { // Dodge arms the usable spell; its child applies the three-second concealment.
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null;
const concealment = row.source?.components?.find((component) => component.id === 502597);
const durationMs = Math.max(1_000, Math.round((concealment?.effectTime ?? 3) * 1_000));
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-rogue:instant-shadow",
name: "Instant Shadow",
disposition: "buff",
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["stealth"],
},
sourceEffectType: row.source?.sourceEffectType ?? -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 502597,
}];
}
case 490504:
return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG);
case 493019:
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-scout") return null;
// Current CoA Coerce directly increases Low Blow damage and reduces its
// Energy cost. Its older RuneWaker below-30% temporary buff is obsolete.
return taggedRoguePassive(493019, "Coerce", ROM_ROGUE_COERCE_TAG);
case 490316:
return taggedRoguePassive(490316, "Treasure Hunter", ROM_ROGUE_TREASURE_HUNTER_TAG);
case 490143:
// Keep the source modifiers scoped to actual off-hand attacks. A generic
// melee aura would incorrectly buff the main hand as well.
return taggedRoguePassive(490143, "Off Hand Mastery", ROM_ROGUE_OFF_HAND_MASTERY_TAG);
case 493020: { // Successful Low Blow/Blind Spot hits trigger the local 10s crit buff.
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-priest") return null;
const buff = row.source?.components?.find((component) => component.id === 503265);
const criticalModifier = buff?.abilityModifiers?.find((modifier) => modifier.type === 18);
const cooldown = row.source?.components?.find((component) => component.id === 503300);
const durationMs = Math.max(0, Math.round((buff?.effectTime ?? 0) * 1_000));
const cooldownMs = Math.max(0, Math.round((cooldown?.effectTime ?? 0) * 1_000));
const proc = criticalModifier && durationMs > 0 && cooldownMs > 0
? customProc(493020, "hit", "rom-sinister-methods", {
data: { criticalStrike: criticalModifier.value, durationMs, cooldownMs },
})
: undefined;
return taggedPassive(493020, "Sinister Methods", ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG, proc);
}
case ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID:
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-champion") return null;
// The timed child only triggers after an elite skill use. Keep a learned
// marker, not its generic aura, so party passive loading cannot grant the
// attack-speed/dodge payload on an unrelated cast.
return taggedPassive(
ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID,
"Nerve Gear",
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
);
case 491178:
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null;
// Current CoA reuses this exact Rogue/Mage ID as passive Chaos Dagger.
// Keep a marker for learned-passive consumers such as Day of Rain; do
// not register the incompatible RuneWaker Throw child or local aura.
return taggedRoguePassive(491178, "Chaos Dagger", ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG);
case 491552:
if (row.classId !== "rom-rogue") return null;
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-491552"] },
} : effect);
case 490445: // Bow/crossbow accuracy must not grant melee hit; keep its range scope explicit.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 54)
.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-hit"
? { ...modifier, stat: "ranged-hit" }
: modifier),
},
} : effect);
case 490505: // Bow and crossbow each carry an equivalent +11% attack-speed modifier in the source.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 70);
case 490400: // Piercing Arrow: one projectile, then a line hit behind its target.
return firstDamage(translated);
case 490424: { // Combo Shot: three impacts of the same source shot.
const hit = firstDamage(translated)[0];
return hit ? [hit, hit, hit] : translated;
}
case 490457: { // Reflected Shot: two jumps, each retaining half the prior hit.
const hit = firstDamage(translated)[0];
return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated;
}
case 498803: // Soul Stab's first hit is unconditional; its second hit requires Weakened.
return translated.filter((effect) => effect.kind === "damage");
case 490354: // Vanish is an aggro/stealth transition, not a self-heal.
return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } }
: effect);
case 491493: { // Weak Spot's buff is component 501895, not its permanent hit modifier.
const source = row.source?.components?.find((component) => component.id === 501895);
const critical = source?.abilityModifiers?.find((modifier) => modifier.type === 18)?.value ?? 20;
const durationMs = Math.max(1_000, Math.round((source?.effectTime ?? 15) * 1_000));
return [
...firstDamage(translated),
timedCasterAura(491493, "Weak Spot critical strike", durationMs, [
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: critical },
]),
];
}
case 492342:
return taggedPassive(492342, "Lasso Master", "rom-lasso-master");
case 492916:
return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot",
customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 }));
case 493006:
return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting",
customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 }));
case 491489:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-warrior") return null;
return taggedPassive(491489, "Enhanced Throat Attack", "rom-enhanced-throat-attack",
customProc(491489, "hit", "rom-enhanced-throat-attack"));
case 491532:
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-scout") return null;
return taggedPassive(491532, "Slit Throat", "rom-slit-throat",
customProc(491532, "hit", "rom-slit-throat"));
case 492344:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-priest") return null;
return taggedPassive(492344, "Sniper", ROM_SCOUT_PRIEST_SNIPER_TAG);
case 492346:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-knight") return null;
return taggedPassive(492346, "Enhanced Armor Perfection", ROM_SCOUT_KNIGHT_ENHANCED_ARMOR_PERFECTION_TAG);
case 493007:
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
case 493253:
return taggedPassive(493253, "Poisonous Spit", "rom-poisonous-spit",
customProc(493253, "hit", "rom-poisonous-spit", { internalCooldownMs: 20_000 }));
case 499548:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-mage") return null;
// Component 623070/623083 describes the six-second buff and HoT earned
// after skill 499549 expires. That referenced skill has no local player
// row, so do not grant its child payload when the passive is learned.
return taggedPassive(499548, "Wind Return", ROM_SCOUT_MAGE_WIND_RETURN_TAG);
case 499552:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-priest") return null;
// Component 623087 is a pet-stat aura triggered after skill 490443 hits.
// The trigger has no local player row and pet stats are not modeled, so
// keep only the learned marker instead of applying it to the Scout.
return taggedPassive(499552, "Revival Engraving", ROM_SCOUT_PRIEST_REVIVAL_ENGRAVING_TAG);
case 499575:
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-warden") return null;
// Component 623117 belongs on the target hit by skill 499576. That
// player-facing skill has no local generated row, so keep only the
// learned marker instead of applying its timed vulnerability to the
// passive owner at combat initialization.
return taggedPassive(499575, "Forest Guidance", ROM_SCOUT_WARDEN_FOREST_GUIDANCE_TAG);
case 499534:
return taggedPassive(499534, "Master Reflected Shot", "rom-master-reflected-shot");
case 499536:
return taggedPassive(499536, "Meridian Attack", "rom-meridian-attack");
case 491526:
return taggedPassive(491526, "Throwing Mastery", "rom-throwing-mastery");
case 491527: // The bonus applies to one-hand axes only, never all melee damage.
return taggedPassive(491527, "One-Hand Axe Mastery", "rom-one-hand-axe-mastery");
case 492349:
return taggedPassive(492349, "Whirlwind Mastery", "rom-whirlwind-mastery");
case 491339: // A negative RuneWaker value means reduced received healing.
return translated.map((effect) => effect.kind === "apply-aura"
? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "healing"
&& modifier.direction === "taken"
? { ...modifier, value: -Math.abs(modifier.value) }
: modifier),
},
}
: effect);
case 492625: // Source attack component 620287 must not make its victim immune to physical damage.
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
case 492920: { // Poison Spray spreads Infection's ten-second poison to nearby enemies.
const hit = firstDamage(translated)[0];
const poison: AbilityEffect = {
kind: "dot",
coefficient: 0,
basePoints: 12,
pointsPerLevel: 4.2,
sourceLevel: 1,
ticks: 5,
intervalMs: 2_000,
tag: "rom-poisonous-infection",
};
return hit ? [hit, poison] : [poison];
}
case 493017: // Decay's empty indefinite state is not its timed critical buff.
return translated.filter((effect) => effect.kind !== "apply-aura"
|| effect.aura.modifiers?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length);
case 493257:
return taggedPassive(493257, "Reclaim Energy", "rom-reclaim-energy",
customProc(493257, "hit", "rom-reclaim-energy", { chance: 0.25 }));
case 499476:
return taggedPassive(499476, "Toxic Splash", "rom-toxic-splash",
// 493296 replaces Infection and can generate 20 rage on a successful hit.
customProc(499476, "hit", "rom-infection-rage", { chance: 0.25 }));
case 499480:
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique");
default:
return null;
}
}
/** Select source-conditional pair effects after the target's existing states are known. */
export function romScoutRogueCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[],
targetTags: readonly string[],
): readonly AbilityEffect[] {
const passives = new Set(passiveTags);
const target = new Set(targetTags);
const coerceDamageMultiplier = romRogueCoerceLowBlowDamageMultiplier({
classId: ability.classId,
abilitySource: ability.source,
abilitySpellId: ability.dbcSpellId,
passiveTags,
});
if (coerceDamageMultiplier !== 1) return scaleDamageEffects(effects, coerceDamageMultiplier);
if (ability.dbcSpellId === 490152 && passives.has(ROM_SCOUT_KNIGHT_ENHANCED_ARMOR_PERFECTION_TAG)) {
// Component 501823 is the source's rank payload: Enhanced Armor lasts 600s.
return effects.map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, durationMs: ROM_SCOUT_KNIGHT_ENHANCED_ARMOR_DURATION_MS } }
: effect);
}
if (ability.dbcSpellId === 490457) {
// Replacement 499535 / component 623051 raises ranged DPS from 0.75 to 0.85.
const masteryFactor = passives.has("rom-master-reflected-shot") ? 0.85 / 0.75 : 1;
return effects.map((effect) => effect.kind === "damage" ? {
...effect,
coefficient: effect.coefficient * masteryFactor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * masteryFactor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * masteryFactor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * masteryFactor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * masteryFactor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * masteryFactor }),
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
} : effect);
}
if (ability.dbcSpellId === 498803) {
let seenDamage = 0;
const weakened = target.has("rom-weakened");
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
seenDamage += 1;
return seenDamage === 1 || weakened;
});
}
if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) {
// 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s.
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster"
? {
...effect,
aura: {
...effect.aura,
durationMs: 10_000,
modifiers: [
{ kind: "damage" as const, direction: "dealt" as const, school: "physical" as const, operation: "percent" as const, value: -0.30 },
{ kind: "damage" as const, direction: "dealt" as const, attackKind: "spell" as const, operation: "percent" as const, value: -0.30 },
],
},
} : effect);
}
if (ability.dbcSpellId === 492920 && passives.has("rom-toxic-splash")) {
// 499477 replaces Poison Spray. 493296 is its additional infection hit;
// 622992 is the six-second poison it spreads to nearby targets.
return [
...effects.filter((effect) => effect.kind !== "dot"),
// 620287: 0.85 main-hand DPS + 0.20 Dexterity, then +7.5% skill-level DPS.
{ kind: "damage", coefficient: 1.11375, coefficientPerLevel: 0.06375, sourceLevel: 1 },
{ kind: "dot", coefficient: 0, basePoints: 16.2, pointsPerLevel: 4.2,
sourceLevel: 1, ticks: 3, intervalMs: 2_000, tag: "rom-622992" },
];
}
if (ability.dbcSpellId === 490133 && passives.has("rom-poison-battle-technique")) {
// 499481/620286 raises Whirlwind's main-hand DPS coefficient 0.70 -> 0.75.
return effects.map((effect) => effect.kind === "damage" ? {
...effect,
coefficient: effect.coefficient * 0.75 / 0.70,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 0.75 / 0.70 }),
} : effect);
}
if (ability.dbcSpellId === 490454 && passives.has("rom-lasso-master")) {
return [...effects, {
kind: "status",
status: "disarm",
durationMs: 10_000,
maxTargets: 1,
breakOnDamage: true,
}];
}
if (ability.dbcSpellId === 490309) {
return [
...effects,
...(passives.has("rom-throwing-mastery")
? [{ kind: "status" as const, status: "slow" as const, durationMs: 5_000, magnitude: 0.4 }]
: []),
{ kind: "cooldown-reset", mode: "reduce", abilityName: "Day of Rain", amountMs: 1_000 },
];
}
if (ability.dbcSpellId === 492621) {
const hasSourcePoison = target.has("rom-deadly-poison") || target.has("rom-501690");
let seenHits = 0;
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
seenHits += 1;
return seenHits === 1 || (seenHits === 2 && hasSourcePoison);
});
}
return effects;
}
/** Day of Rain has an exact 50% miss chance while the Rogue/Mage Chaos Dagger passive is learned. */
export function romRogueMageDayOfRainBaseMissPercent(
classId: string,
secondaryClassId: string | null | undefined,
abilitySpellId: number | undefined,
activeAuraTags: readonly string[],
attackerHitPercent: number,
): number | null {
if (classId !== "rom-rogue"
|| secondaryClassId !== "rom-mage"
|| abilitySpellId !== 491183
|| !activeAuraTags.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG)) return null;
const hitPercent = Number.isFinite(attackerHitPercent)
? Math.max(0, Math.min(100, attackerHitPercent))
: 0;
// combatAttackTable subtracts attacker hit rating from base miss chance.
// Offset it here so the tooltip's 50% rate remains exact at any hit rating.
return 50 + hitPercent;
}
/** 499481/622993 adds a hit only to targets already carrying 622992's poison. */
export function romScoutRogueWhirlwindBonusEffect(
ability: AbilityDefinition,
passiveTags: readonly string[],
targetTags: readonly string[],
): Extract<AbilityEffect, { kind: "damage" }> | null {
if (ability.dbcSpellId !== 490133 || !passiveTags.includes("rom-poison-battle-technique")
|| !targetTags.includes("rom-622992")) return null;
return { kind: "damage", coefficient: 0.50, coefficientPerLevel: 0.03, sourceLevel: 1 };
}
export type RomScoutRogueProcActionSpec =
| {
readonly kind: "aura";
readonly recipient: "caster" | "target";
readonly aura: AuraDefinition;
readonly provenance: "source" | "hybrid-estimate";
readonly cooldownMs?: number;
}
| {
readonly kind: "dot";
readonly recipient: "target";
readonly effect: Extract<AbilityEffect, { kind: "dot" }>;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "resource";
readonly recipient: "caster";
readonly resource: "energy" | "rage";
readonly amount: number;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "damage";
readonly recipient: "target";
readonly effect: Extract<AbilityEffect, { kind: "damage" }>;
readonly provenance: "source" | "hybrid-estimate";
}
| {
readonly kind: "status";
readonly recipient: "target";
readonly status: "silence";
readonly durationMs: number;
readonly provenance: "source";
};
/**
* A pure result for combat-store proc execution. `triggeringSourceSkillId` is
* the RuneWaker skill ID of the attack that generated the hit/crit event.
* The level-60/70 replacement skills 499477 and 499481 are represented by
* their learned passive tags and base-ID cast upgrades, not custom procs.
*/
export function romScoutRogueProcActionSpec(
actionId: string,
triggeringSourceSkillId: number,
_casterPoisonous: boolean,
data: Readonly<Record<string, string | number | boolean>> = {},
): RomScoutRogueProcActionSpec | null {
switch (actionId) {
case "rom-enhanced-throat-attack":
case "rom-slit-throat":
if (triggeringSourceSkillId !== 490428) return null;
return { kind: "status", recipient: "target", status: "silence", durationMs: 4_000, provenance: "source" };
case "rom-sinister-methods": {
if (![490323, 490311].includes(triggeringSourceSkillId)) return null;
const criticalStrike = data.criticalStrike;
const durationMs = data.durationMs;
const cooldownMs = data.cooldownMs;
if (typeof criticalStrike !== "number" || !Number.isFinite(criticalStrike)
|| typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0
|| typeof cooldownMs !== "number" || !Number.isFinite(cooldownMs) || cooldownMs <= 0) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-pair-sinister-methods-critical",
name: "Sinister Methods",
disposition: "buff",
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: criticalStrike }],
},
cooldownMs,
provenance: "source",
};
}
case "rom-accurate-targeting": {
if (triggeringSourceSkillId !== 490423) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-scout-accurate-targeting",
name: "Accurate Targeting",
disposition: "buff",
durationMs: 15_000, // Source component 503255.
modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }],
},
provenance: "source",
};
}
case "rom-exploiting-shot":
if (triggeringSourceSkillId !== 490423) return null;
return {
kind: "aura",
recipient: "target",
aura: {
id: "rom-pair-exploiting-shot-vulnerability",
name: "Exploiting Shot",
disposition: "debuff",
durationMs: 12_000, // Source component 503169.
maxStacks: 5, // Explicit hybrid cap; source tooltip references a cap without a numeric value.
stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.02 }],
},
provenance: "hybrid-estimate",
};
case "rom-deadly-counterattack":
if (triggeringSourceSkillId !== 490400) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-pair-deadly-counterattack-crit",
name: "Deadly Counterattack",
disposition: "buff",
durationMs: 5_000, // Hybrid estimate; source specifies a 20s internal cooldown, not buff duration.
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
},
provenance: "hybrid-estimate",
};
case "rom-poisonous-spit":
// Poison Powder (item 200563) is supplied as an unlimited bag reagent by
// the Scout/Rogue loadout model. Do not use Rogue's separate Poison
// Bottle aura (490329) as a proxy for carrying this item.
if (ROM_SCOUT_ROGUE_POISON_POWDER_SUPPLY.quantity !== "unlimited"
|| ![490457, 490424].includes(triggeringSourceSkillId)) return null;
return {
kind: "dot",
recipient: "target",
effect: {
kind: "dot",
coefficient: 0,
basePoints: 7.1,
pointsPerLevel: 2.1,
sourceLevel: 1,
ticks: 4,
intervalMs: 2_000,
tag: "rom-poisonous-spit",
},
provenance: "source", // 503522; Poison Powder is a carried item, not Rogue's Poison Bottle buff.
};
case "rom-reclaim-energy":
if (triggeringSourceSkillId === 490306) return {
kind: "resource", recipient: "caster", resource: "energy", amount: 15, provenance: "hybrid-estimate",
};
if (triggeringSourceSkillId === 492625) return {
kind: "resource", recipient: "caster", resource: "rage", amount: 10, provenance: "hybrid-estimate",
};
return null;
case "rom-infection-rage":
return triggeringSourceSkillId === 492625
? { kind: "resource", recipient: "caster", resource: "rage", amount: 20, provenance: "hybrid-estimate" }
: null;
case "rom-toxic-splash": // 499477 is applied as an upgrade of base 492920.
case "rom-poison-battle-technique": // 499481 is applied as an upgrade of base 490133.
return null;
default:
return null;
}
}