Validate RoM primary five hybrid stats and pair mechanics

This commit is contained in:
phenom committed 2026-09-23 19:08:44 -04:00
1 parent 8e95480d4f
commit c8b3a3d95a
18 files changed
+2061 -48

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
}
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 customProc(
sourceId: number,
trigger: "hit" | "crit",
actionId: string,
options: { readonly chance?: number; readonly internalCooldownMs?: number } = {},
): 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 },
};
}
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/Rogue and Rogue/Warrior
* pairings. 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 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 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 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 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",
customProc(499476, "hit", "rom-toxic-splash"));
case 499480:
return taggedPassive(499480, "Poison Battle Technique", "rom-poison-battle-technique",
customProc(499480, "hit", "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);
if (ability.dbcSpellId === 490457) {
const masteryFactor = passives.has("rom-master-reflected-shot") ? 1.2 : 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 === 490454 && passives.has("rom-lasso-master")) {
return [...effects, {
kind: "status",
status: "disarm",
durationMs: 10_000,
maxTargets: 1,
breakOnDamage: true,
}];
}
if (ability.dbcSpellId === 490309 && passives.has("rom-throwing-mastery")) {
return [...effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.4 }];
}
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;
}
export type RomScoutRogueProcActionSpec =
| {
readonly kind: "aura";
readonly recipient: "caster" | "target";
readonly aura: AuraDefinition;
readonly provenance: "source" | "hybrid-estimate";
}
| {
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";
};
/**
* 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 absent from the
* playable source catalog; those unresolved actions deliberately return null.
*/
export function romScoutRogueProcActionSpec(
actionId: string,
triggeringSourceSkillId: number,
casterPoisonous: boolean,
): RomScoutRogueProcActionSpec | null {
switch (actionId) {
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":
if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
return {
kind: "dot",
recipient: "target",
effect: {
kind: "dot",
coefficient: 0,
basePoints: 16,
pointsPerLevel: 8,
sourceLevel: 1,
ticks: 5,
intervalMs: 2_000,
tag: "rom-poisonous-spit",
},
provenance: "hybrid-estimate", // 501690 poison scale is a proxy; Poison Powder payload is not in this catalog.
};
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-toxic-splash": // Requires absent replacement skill 499477 and triggered payload 493296.
case "rom-poison-battle-technique": // Requires absent replacement skill 499481.
return null;
default:
return null;
}
}