Files
healer-man/src/game/romAbilityCatalog.ts
T
2026-10-06 22:25:34 -04:00

1733 lines
88 KiB
TypeScript

import { ROM_CLASS_IDS, type RomClassId } from "../app/characterCatalog";
import rawCatalog from "./romPlayerCatalog.generated.json";
import type {
AbilityCost,
AbilityDefinition,
AbilityEffect,
AbilityRankDefinition,
ResourceType,
} from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, DamageSchool, ProcTrigger } from "./combatAuras";
import {
translateRomAbilitySemantics,
type RomAbilitySemantic,
type RomGeneratedAbilityLike,
type RomSourceComponent,
} from "./romAbilitySemantics";
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomKnightEffects } from "./romKnightOverrides";
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { overrideRomMageEffects } from "./romMageOverrides";
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
import { overrideRomDruidEffects } from "./romDruidOverrides";
import { romPriestDescription, romPriestName } from "./romPriestTooltips";
import { overrideRomPriestMageEffects } from "./romPriestMageRuntime";
import { romPriestHolyBlessingEffects } from "./romPriestHolyBlessingRuntime";
import {
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
} from "./romPriestChampionBeaconRuntime";
import { overrideRomPriestWarlockEffects } from "./romPriestWarlockOverrides";
import { overrideRomChampionEffects } from "./romChampionOverrides";
import { ROM_WARDEN_BRIAR_SHIELD_TAG } from "./romWardenOverrides";
import { romWarriorCoaAdditionalAbilities } from "./romWarriorCoaCatalog";
import { romWarriorDruidRageOfCalmnessCatalogOverride } from "./romWarriorDruidRageOfCalmness";
import { romScoutPriestScreenAttackCatalogOverride } from "./romScoutPriestScreenAttack";
import { romScoutPriestDrumsOfWarCatalogOverride } from "./romScoutPriestOverrides";
import { romPriestScoutCuringShotCatalogOverride } from "./romPriestScoutCuringShot";
import { romWarlockWarriorEnragedCoaAdditionalAbilities } from "./romWarlockWarriorEnragedCoaMechanics";
import { ROM_SCOUT_BLOOD_ARROW_AURA_TAG } from "./romBloodArrow";
import {
ROM_SCOUT_AUTO_SHOT_ABILITY_ID,
ROM_SCOUT_AUTO_SHOT_MAX_RANGE,
ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID,
} from "./romScoutAutoShot";
import {
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST,
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION,
} from "./romWarlockMageSoulBrandSting";
import {
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST,
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION,
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS,
isRomWarlockPriestSoulEnlightenment,
} from "./romWarlockPriestSoulEnlightenment";
import {
ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION,
ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION,
ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION,
romDruidRogueSinisterRequestEffects,
isRomDruidRoguePair,
} from "./romDruidRogueCurrentCoa";
import {
isRomDruidMagePair,
ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
} from "./romDruidMageInspirationRuntime";
import {
isRomMageDruidPair,
ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
} from "./romMageDruidGreenGuardianRuntime";
import {
ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION,
ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID,
ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION,
ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
romRogueEnergizedHandsApplyEffect,
} from "./romRogueEnergizedHandsRuntime";
import {
ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION,
ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID,
} from "./romRogueChaosDagger";
import {
ROM_PRIEST_HOLY_CANDLE_COST,
ROM_PRIEST_HOLY_CANDLE_DESCRIPTION,
romPriestHolyCandleEffects,
} from "./romPriestHolyCandleRuntime";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
interface GeneratedCost {
readonly resource: string;
readonly amount: number;
readonly percentage?: boolean;
}
interface GeneratedEffect {
readonly kind: string;
readonly coefficient?: number;
readonly creatureId?: number;
readonly ticks?: number;
readonly intervalMs?: number;
readonly durationMs?: number;
}
interface GeneratedRank {
readonly rank: number;
readonly level: number;
readonly sourceSkillId: number;
readonly name: string;
readonly description: string;
readonly target: "hostile" | "friendly" | "self";
readonly castTimeMs: number;
readonly cooldownMs: number;
readonly costs: readonly GeneratedCost[];
readonly effect: GeneratedEffect;
readonly passive: boolean;
readonly source?: RomGeneratedAbilityLike["source"];
}
interface GeneratedSkill {
readonly id: string;
readonly classId: string;
readonly secondaryClassId?: string;
readonly group: RomSkillGroup;
readonly unlockLevel: number;
readonly sourceSkillId: number;
readonly name: string;
readonly description: string;
readonly target: "hostile" | "friendly" | "self";
readonly range: number;
readonly castTimeMs: number;
readonly cooldownMs: number;
readonly costs: readonly GeneratedCost[];
readonly effect: GeneratedEffect;
readonly passive: boolean;
readonly ranks: readonly GeneratedRank[];
readonly icon: string;
readonly iconResolution: "native-interface-fdb";
readonly source?: RomGeneratedAbilityLike["source"];
}
const generated = rawCatalog.skills as unknown as readonly GeneratedSkill[];
function resource(value: string): ResourceType {
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
? value as ResourceType
: `custom:${slug(value)}`;
}
function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
return rows.map((row) => ({
resource: resource(row.resource),
amount: Math.max(0, row.amount),
...(row.percentage ? { percentage: true } : {}),
}));
}
function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "costs">): readonly AbilityCost[] {
if (row.sourceSkillId === 490274) return [];
if (row.sourceSkillId === 490275) return [{ resource: "mana", amount: 120 }];
if (row.sourceSkillId === 490276) return [{
resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana",
}];
if (row.sourceSkillId === 498612) return [{ resource: "rage", amount: 15 }];
if (row.sourceSkillId === 498642) return [{ resource: "rage", amount: 75, percentage: false }];
let resolved = costs(row.costs);
if (row.sourceSkillId === 490136) {
resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
}
if (![493536, 494217].includes(row.sourceSkillId) || resolved.some((entry) => entry.resource === "nature-power")) return resolved;
return [...resolved, { resource: "nature-power", amount: 1 }];
}
function cooldownMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "cooldownMs">): number {
if (row.sourceSkillId === 490274) return 0;
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return 18_000;
if (row.sourceSkillId === 498612) return 12_000;
if (row.sourceSkillId === 498642) return 1_000;
// The local snapshot omitted Prayer of Tribulation's timing. CoA's current
// skill list identifies this same Priest/Warlock spell (498770) as a 1.5s
// channel with a 5s cooldown; its healing and buff payload still come from
// the local RuneWaker tooltip/components below.
if (row.sourceSkillId === 498770) return 5_000;
// CoA's current Knight/Shaman Whirlwind Shield is 5s before Compassion.
if (row.sourceSkillId === 490175) return 5_000;
return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs;
}
function castTimeMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): number {
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return 0;
if (row.sourceSkillId === 498770) return 1_500;
if (row.sourceSkillId === 491164) return 4_000;
return row.castTimeMs;
}
function castModeFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): AbilityDefinition["castMode"] {
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return "instant";
return row.sourceSkillId === 491164 || row.sourceSkillId === 498770
? "channel"
: row.castTimeMs > 0 ? "cast" : "instant";
}
function currentWarlockCoaDescription(sourceSkillId: number, fallback: string): string {
// Current CoA Warrior/Warlock skill list: https://chroniclesofarcadia.com/en/wiki/skills/list/1-9
// These two local passive rows describe obsolete cooldown-reset children;
// the current page instead gives Speed Slash's Rage discount and Excellent
// Combat Training's physical-damage bonus.
const descriptions: Readonly<Record<number, string>> = {
499827: "Passive: Increases Slash damage by an unspecified amount, which is not modeled. Reduces Slash's Rage cost by 10.",
499806: "Passive: Grants an additional 20% Physical Damage.",
498642: "Converts 75 Rage into 6 Psi. 1-second cooldown.",
498641: "Restores HP for up to 6 friendly targets in range. Also provides 1 Psi.",
498634: "When casting Psychic Arrows, Warp Charge, Weakening Weave Curse, Slash, Soul Trauma, Puzzlement, Heart Collection Strike, or Surge of Malice, there is a chance to trigger Enraged automatically. Enraged also recovers additional Focus. Current CoA does not publish the proc chance, trigger timing, or extra Focus amount, so these effects are not modeled.",
498633: "When Psychic Arrows hits, gain 12% healing power and 12% Wisdom for 20 seconds. Warp Charge heals a friendly target instead of dealing damage. The simulator does not model ally-target redirection to that ally's hostile target.",
498643: "Rising Tide becomes instant with a 5-second cooldown and restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
498648: "Urgent Heal restores 1 Psi. Defense Net's effect time is reduced by 1 second. Ring of Comfort's reset chance is not specified and is not modeled.",
498668: "Passively increases magical damage and healing by 16%.",
499856: "Inflicts [497959], [497960], and [497964] on the target. Increases Magical Attack Power by 18% and Magical Critical Bonus by 25% for 30 seconds, and activates Healing Fortune, increasing healing by 12% for 20 seconds. Current CoA lists a 10% MP cost plus 15 Focus and range 150; RuneWaker supplies 15 Focus and range 22.5. The simulator retains those local cost and range values because RoM Warlock has no modeled Mana pool and no verified range-unit conversion.",
499868: "Passive: magical damage skills call an Ethereal Hammer for 187.5% main-hand weapon DPS against area targets or an Ethereal Sword for 1,050% main-hand weapon DPS against a single target. The source calls this Physical Dark damage, which the combat model cannot represent as a composite damage type, so this rider is not yet applied. The additional main-hand damage for Slash and Consistent Heart Strike is unspecified and is not modeled.",
499870: "Increases Breathing Control's Focus recovery and grants an extra 15% HP. Current CoA does not publish the Focus amount or proc chance, so these riders are not modeled.",
};
return descriptions[sourceSkillId] ?? fallback;
}
const ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION = "Reworks Shadow Step (490344): the cast deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. This replaces Shadow Step's teleport; the simulator does not model the behind-target positioning.";
const ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION = "Instantly moves behind the target. In the Warlock/Rogue build with Shadow Slice (498678), it instead deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. The simulator cannot model the behind-target positioning.";
function currentPriestCoaDescription(sourceSkillId: number, fallback: string): string {
if (sourceSkillId === 490304) {
return ROM_PRIEST_HOLY_CANDLE_DESCRIPTION;
}
if (sourceSkillId === 498620) {
return "Creates a 30-second Beacon on one friendly party member. Each direct healing cast restores 770 HP to that member. CoA publishes 770 at skill level +105 (the maximum); the simulator applies this value at every unlocked rank because lower skill ranks are not modeled separately. HoT ticks and channel pulses are outside the simulator's direct-cast trigger boundary.";
}
if (sourceSkillId === 492933) {
return "Passive: Increases Wisdom by 12%.";
}
if (sourceSkillId === 498612) {
return "Inflicts 975.1% CPS Light Damage to nearby targets around you and reduces Blessing of Humility's cooldown by 15 seconds. Cost: 15 Rage. Radius: 80. Cooldown: 12 seconds.";
}
return romPriestDescription(sourceSkillId, fallback);
}
function currentMageCoaDescription(sourceSkillId: number, fallback: string): string {
if (sourceSkillId === 490248) {
return "Increases Fire resistance by 10 points and reduces received magical damage by 11.5% within radius 100. CoA lists +230 Fire resistance at skill level 105, but the simulator retains RuneWaker's local +10 because it has no source-backed scaling curve or skill-level state. Costs 120 MP; range 150; instant. CoA omits a duration, so this catalog retains RuneWaker's 10-minute duration.";
}
return fallback;
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">
& Partial<Pick<GeneratedSkill, "classId" | "secondaryClassId">>;
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "effect";
}
function durationMs(seconds: number): number {
return Math.max(0, Math.round(seconds * 1_000));
}
function combatSchool(sourceSchool: string): DamageSchool | undefined {
if (sourceSchool === "physical") return "physical";
if (sourceSchool === "fire") return "fire";
if (sourceSchool === "water") return "frost";
if (sourceSchool === "earth" || sourceSchool === "wind") return "nature";
if (sourceSchool === "light") return "holy";
if (sourceSchool === "dark") return "shadow";
if (sourceSchool === "magic" || sourceSchool === "untyped" || sourceSchool === "tooltip-defined") return "arcane";
return undefined;
}
function knownResource(value: string): ResourceType | undefined {
if (value === "mp") return "mana";
if (value === "nature's power") return "nature-power";
if (value === "psi point" || value === "psi points") return "psi";
if (value === "soul point" || value === "soul points") return "soul-power";
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
? value as ResourceType
: value ? `custom:${slug(value)}` : undefined;
}
function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySemantic, { kind: "aura" | "absorb" | "proc" }>): "ability-target" | "caster" {
if (row.target === "self" || row.passive || semantic.kind === "proc") return "caster";
if (semantic.kind === "aura" && semantic.value !== null && semantic.value >= 0 && /\b(?:increases? your|you gain|your .+ increases?)\b/i.test(row.description)) {
return "caster";
}
return "ability-target";
}
function abilityTarget(row: Pick<GeneratedSemanticRow, "sourceSkillId" | "target">): GeneratedSemanticRow["target"] {
if (row.sourceSkillId === 490274 || row.sourceSkillId === 498612) return "self";
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return "hostile";
if ([490290, 490491, 492636, 493531, 494021, 494027, 498641].includes(row.sourceSkillId)) return "friendly";
// RuneWaker's tooltip calls this a four-pulse heal on a friendly target;
// sourceTarget 7 is stale/conflicting and was incorrectly making it hostile.
if (row.sourceSkillId === 498770) return "friendly";
if (row.sourceSkillId === 490239) return "hostile";
if (row.sourceSkillId === 494217) return "self";
return row.target;
}
const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"];
function percent(value: number): number {
return value / 100;
}
function magicDamageTaken(value: number): readonly AuraModifier[] {
return MAGIC_SCHOOLS.map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value,
}));
}
function auraModifiers(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>): readonly AuraModifier[] {
const value = semantic.value ?? 0;
if (semantic.effect === "damage-immunity") {
return [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }];
}
if (semantic.effect === "physical-damage-immunity") {
return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 }];
}
if (semantic.effect === "magic-damage-immunity") {
return magicDamageTaken(-1);
}
if (semantic.effect === "poison-immunity") {
return [{ kind: "stat", stat: "poison-immunity", operation: "flat", value: 1 }];
}
if (semantic.effect === "maximum-mana") {
return [{ kind: "resource", resource: "mana", property: "maximum", operation: "flat", value }];
}
if (semantic.effect === "mana-regeneration") {
return [{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value }];
}
if (semantic.effect === "health-regeneration") {
return [{ kind: "stat", stat: "health-regeneration", operation: "flat", value }];
}
const type = semantic.modifierType;
if (type === null) return semantic.value === null
? []
: [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
if (type >= 2 && type <= 22) {
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
}
if (type === 23) return [{ kind: "stat", stat: "attack-speed", operation: "flat", value: -value }];
if (type === 24) return [{ kind: "stat", stat: "movement-speed", operation: "percent", value: percent(value) }];
if (type === 25) return [
{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "flat", value },
{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "flat", value },
];
if (type >= 26 && type <= 32) {
const school = ({ 27: "nature", 28: "frost", 29: "fire", 30: "nature", 31: "holy", 32: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "flat", value }];
}
if (type === 33) return [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -percent(value) }];
if (type === 37 || (type >= 56 && type <= 67) || type === 173) {
return [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: percent(value) }];
}
if (type >= 38 && type <= 43) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
if (type === 44) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
if (type >= 45 && type <= 50) {
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 45];
return [{ kind: "damage", direction: "dealt", school, operation: "percent", value: percent(value) }];
}
if (type === 51) return [{ kind: "stat", stat: "spell-haste", operation: "flat", value: -value }];
if (type >= 52 && type <= 55) return [{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "percent", value: percent(value) }];
if (type >= 68 && type <= 71) return [{ kind: "stat", stat: "ranged-haste", operation: "flat", value: -value }];
if (type >= 72 && type <= 83) return [{ kind: "stat", stat: "melee-haste", operation: "flat", value: -value }];
if (type === 134) return [{ kind: "stat", stat: "attack-power", operation: "percent", value: percent(value) }];
if (type === 135) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
if (type === 138) return [{ kind: "stat", stat: "threat", operation: "percent", value: percent(value) }];
if (type >= 139 && type <= 141) {
const resource = (["rage", "focus", "energy"] as const)[type - 139];
return [{ kind: "resource", resource, property: "regeneration", operation: "percent", value: percent(value) }];
}
if (type === 142) return magicDamageTaken(-percent(value));
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -percent(value) }];
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: percent(value) }];
if (type === 148) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
if (type === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", value }];
if (type >= 161 && type <= 166) {
const stat = (["strength", "stamina", "intellect", "wisdom", "dexterity", "all-primary-attributes"] as const)[type - 161];
return [{ kind: "stat", stat, operation: "percent", value: percent(value) }];
}
if (type === 167) return [{ kind: "stat", stat: "maximum-health", operation: "percent", value: percent(value) }];
if (type === 168) return [{ kind: "resource", resource: "mana", property: "maximum", operation: "percent", value: percent(value) }];
if (type === 170) return [{ kind: "stat", stat: "magical-defense", operation: "percent", value: percent(value) }];
if (type === 171) return [{ kind: "stat", stat: "magical-attack", operation: "percent", value: percent(value) }];
if (type === 184 || type === 185 || type === 186 || type === 187 || type === 188 || type === 189) {
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 184];
return [{ kind: "damage", direction: "dealt", school, operation: "flat", value }];
}
if (type === 191) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
if (type === 192 || type === 208) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
if (type === 195 || type === 199) return [{ kind: "stat", stat: "magical-hit", operation: "flat", value }];
if (type === 197) return [{ kind: "stat", stat: "physical-hit", operation: "flat", value }];
if (type === 198) return [{ kind: "stat", stat: "dodge", operation: "flat", value }];
if (type === 204 || type === 205) return [{ kind: "damage", direction: "dealt", operation: "percent", value: percent(value) }];
if (type === 206 || type === 207 || type === 209) return [{ kind: "damage", direction: "taken", operation: "percent", value: percent(value) }];
if (type >= 214 && type <= 216) {
const resource = (["rage", "energy", "focus"] as const)[type - 214];
return [{ kind: "resource", resource, property: "cost", operation: "percent", value: -percent(value) }];
}
if (type >= 220 && type <= 226) {
const school = ({ 221: "nature", 222: "frost", 223: "fire", 224: "nature", 225: "holy", 226: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "percent", value: percent(value) }];
}
if (type === 227) return [{ kind: "stat", stat: "parry", operation: "percent", value: percent(value) }];
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
}
function controlKind(effect: string): Extract<AbilityEffect, { kind: "apply-aura" }>["control"] {
if (["blind", "charm", "confuse", "disarm", "fear", "freeze", "knockdown", "pacify", "root", "silence", "sleep", "stun"].includes(effect)) {
return { kind: effect as "blind" | "charm" | "confuse" | "disarm" | "fear" | "freeze" | "knockdown" | "pacify" | "root" | "silence" | "sleep" | "stun" };
}
return undefined;
}
function procTrigger(trigger: Extract<RomAbilitySemantic, { kind: "proc" }>["trigger"]): ProcTrigger {
if (trigger === "on-attack" || trigger === "on-hit") return "hit";
if (trigger === "physical-rebound" || trigger === "magic-rebound") return "damage-taken";
if (trigger === "on-death") return "kill";
return "periodic";
}
function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, index: number): AbilityEffect {
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
const nativeSource = ["damage", "heal", "dot", "hot"].includes(semantic.kind)
&& semantic.sourceComponentIds.length === 1
? { romNativeSource: {
skillId: row.sourceSkillId,
componentId: semantic.sourceComponentIds[0],
kind: semantic.kind as "damage" | "heal" | "dot" | "hot",
} }
: {};
if (semantic.kind === "damage" || semantic.kind === "heal") {
const rawPower = Math.max(0, semantic.power);
const rawFixed = Math.max(0, semantic.fixedValue);
const coefficientCalculation = semantic.calculationType === null
? rawPower > 0 && rawPower <= 4
: [0, 2, 3, 10].includes(semantic.calculationType);
const powerCoefficient = coefficientCalculation ? rawPower : 0;
const fixedCoefficient = semantic.fixedType > 0 ? rawFixed : 0;
const coefficientBase = powerCoefficient + fixedCoefficient;
const coefficientPerLevel = powerCoefficient * Math.max(0, semantic.powerPerSkillLevel) / 100
+ fixedCoefficient * Math.max(0, semantic.fixedPerSkillLevel) / 100;
const coefficient = coefficientBase + coefficientPerLevel;
const sourcePoints = coefficientCalculation ? 0 : rawPower;
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
const basePoints = sourcePoints + pointsPerLevel;
const amount = {
...nativeSource,
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
basePoints,
pointsPerLevel,
sourceLevel: 1,
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
} : {}),
};
if (semantic.kind === "damage") return {
kind: "damage", ...amount,
...(combatSchool(semantic.school) ? { school: combatSchool(semantic.school) } : {}),
};
return {
kind: "heal", ...amount,
...(semantic.percentMaxHealth !== undefined ? { percentMaxHealth: semantic.percentMaxHealth } : {}),
...(/\b(?:all teammates|(?:all|every) party members?)\b/i.test(row.description)
? { scope: "party" as const } : {}),
};
}
if (semantic.kind === "dot" || semantic.kind === "hot") {
const intervalMs = durationMs(semantic.intervalSeconds || 2);
const ticks = Math.max(1, Math.round((semantic.durationSeconds || semantic.intervalSeconds || 2) / (semantic.intervalSeconds || 2)));
const percentHealthHot = semantic.kind === "hot" && semantic.resource === "health-percent";
return {
kind: semantic.kind,
...nativeSource,
coefficient: 0,
...(semantic.kind === "dot" && combatSchool(semantic.school)
? { school: combatSchool(semantic.school) }
: {}),
basePoints: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
pointsPerLevel: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
sourceLevel: 1,
ticks,
intervalMs,
...(percentHealthHot ? { percentMaxHealth: Math.max(0, semantic.basePerTick) / 100 } : {}),
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
} as AbilityEffect;
}
if (semantic.kind === "taunt") return { kind: "taunt", durationMs: 3_000, ...(semantic.scope === "area" ? { maxTargets: 8 } : {}) };
if (semantic.kind === "dispel") return {
kind: "dispel",
mode: semantic.mode === "steal" ? "steal" : semantic.polarity === "harmful" ? "cleanse" : "purge",
relationship: semantic.polarity === "harmful" ? "friendly" : "hostile",
categories: semantic.ailment && semantic.ailment !== "all"
? [semantic.ailment]
: ["magic", "curse", "poison", "disease", "enrage"],
maxCount: semantic.polarity === "all" ? 99 : 1,
...(semantic.scope ? { scope: semantic.scope } : {}),
};
if (semantic.kind === "control") {
const control = controlKind(semantic.effect);
if (!control) return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM control: ${semantic.effect}` };
const aura: AuraDefinition = {
id: `rom-control-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: "debuff",
durationMs: Math.max(1_000, durationMs(semantic.durationSeconds)),
dispelCategory: "magic",
};
return { kind: "apply-aura", aura, control, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
}
if (semantic.kind === "interrupt") return { kind: "interrupt", lockoutMs: Math.max(1_000, durationMs(semantic.lockoutSeconds || 4)) };
if (semantic.kind === "aura") {
const recipient = auraRecipient(row, semantic);
const modifiers = auraModifiers(semantic);
const partyScope = [490248, 491335, 491644, 494027, 498201, 498202, 498219, 498649, 498719]
.includes(row.sourceSkillId)
|| (row.sourceSkillId === 498722 && semantic.sourceComponentIds.includes(621463))
|| /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const aura: AuraDefinition = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: recipient === "caster" || row.target !== "hostile" ? "buff" : "debuff",
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
dispelCategory: "magic",
...(modifiers.length ? { modifiers } : {}),
...(semantic.effect === "poison-immunity"
? { utilityTags: ["poison-immunity"] }
: semantic.effect === "stealth"
? { utilityTags: ["stealth"] }
: {}),
};
return {
kind: "apply-aura", aura, recipient,
...(partyScope ? { scope: "party" as const } : {}),
...(row.sourceSkillId === 498719 ? { excludeCaster: true } : {}),
sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0,
};
}
if (semantic.kind === "absorb") {
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM absorb (${semantic.basis}, ${semantic.school})` };
}
const recipient = auraRecipient(row, semantic);
const aura: AuraDefinition = {
id: `rom-absorb-${effectId}`,
name: row.name,
disposition: "buff",
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
dispelCategory: "magic",
absorbs: [{ id: `rom-absorb-pool-${effectId}`, amount: Math.max(0, semantic.amount + semantic.perSkillLevel), school: combatSchool(semantic.school) }],
};
return {
kind: "apply-aura", aura, recipient,
...([491513, 494027, 498201].includes(row.sourceSkillId) ? { scope: "party" as const } : {}),
sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0,
};
}
if (semantic.kind === "proc") {
const recipient = auraRecipient(row, semantic);
const chance = semantic.rate === null ? 1 : Math.max(0, Math.min(1, semantic.rate > 1 ? semantic.rate / 100 : semantic.rate));
const aura: AuraDefinition = {
id: `rom-proc-${effectId}`,
name: row.name,
disposition: "buff",
durationMs: null,
procs: [{
id: `rom-proc-trigger-${effectId}`,
triggers: procTrigger(semantic.trigger),
chance,
...(semantic.intervalSeconds && semantic.intervalSeconds > 0 ? { internalCooldownMs: durationMs(semantic.intervalSeconds) } : {}),
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: semantic.triggerSkillId, rank: semantic.rank ?? 0 } },
}],
};
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: semantic.triggerSkillId };
}
if (semantic.kind === "resource") {
const manaPercentRegen = semantic.resource === "mana-percent"
&& semantic.intervalSeconds !== null
&& semantic.intervalSeconds > 0;
const resourceType = manaPercentRegen ? "mana" : knownResource(semantic.resource);
if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) {
const amountPerSecond = semantic.amount / semantic.intervalSeconds;
const aura: AuraDefinition = {
id: `rom-resource-over-time-${effectId}`,
name: row.name,
disposition: semantic.operation === "drain" ? "debuff" : "buff",
durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
modifiers: manaPercentRegen
? [{
kind: "stat",
stat: "mana-regeneration-max-percent-per-second",
operation: "flat",
value: (semantic.operation === "drain" ? -amountPerSecond : amountPerSecond) / 100,
}]
: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
};
return {
kind: "apply-aura",
aura,
recipient: row.sourceSkillId === 498529 || row.target === "self" ? "caster" : "ability-target",
...(row.sourceSkillId === 491644 ? { scope: "party" as const } : {}),
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: semantic.amount,
triggerSpellId: 0,
};
}
if (semantic.operation === "drain") return {
kind: "resource-drain",
amount: semantic.amount,
burn: false,
...(semantic.percentage ? { percentage: true } : {}),
...(resourceType ? { resource: resourceType } : {}),
};
return { kind: "resource", amount: semantic.amount, ...(resourceType ? { resource: resourceType } : {}) };
}
if (semantic.kind === "summon") return { kind: "summon", creatureId: semantic.creatureId, coefficient: Math.max(0, semantic.ownerPowerRate), durationMs: durationMs(semantic.lifetimeSeconds || 15) };
if (semantic.kind === "resurrection") return { kind: "resurrection", percentMaxHealth: .35 };
if (semantic.kind === "movement") {
if (semantic.effect === "pull" || semantic.effect === "forced-movement") return { kind: "pull", toward: "caster" };
if (semantic.effect === "charge") return { kind: "movement", movement: "charge", toward: "target" };
if (semantic.effect === "teleport") return { kind: "movement", movement: "teleport", toward: "destination" };
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: semantic.distance ?? 0, triggerSpellId: 0, label: `RoM movement: ${semantic.effect}` };
}
if (semantic.kind === "cooldown-reset") return { kind: "cooldown-reset", mode: semantic.mode, ...(semantic.abilityName ? { abilityName: semantic.abilityName } : {}), ...(semantic.seconds !== null ? { amountMs: durationMs(semantic.seconds) } : {}) };
if (semantic.kind === "trigger-spell") return { kind: "trigger-spell", spellId: semantic.spellId };
if (semantic.kind === "pet") return { kind: "pet", action: semantic.action };
if (semantic.kind === "custom") {
if (semantic.mechanicId === "threat-reset") {
return { kind: "threat", mode: "percent", amount: -100 };
}
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM ${semantic.mechanicId}: ${semantic.summary}` };
}
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM source mechanic` };
}
function stateAuraEffect(
row: GeneratedSemanticRow,
stateId: string,
name: string,
duration: number | null,
options: Pick<AuraDefinition, "maxStacks" | "stackBehavior" | "procs"> = {},
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-state-${stateId}`,
name,
disposition: "buff",
durationMs: duration,
utilityTags: [`rom-${stateId}`],
...options,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: row.sourceSkillId,
};
}
function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name: string, duration: number): AbilityEffect {
return {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: `rom-state-${stateId}`,
name,
disposition: "debuff",
durationMs: duration,
utilityTags: [`rom-${stateId}`],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: row.sourceSkillId,
};
}
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
if (row.classId === "rom-rogue" && row.secondaryClassId === "rom-warden" && row.sourceSkillId === 499544) {
// The 900-second state owns Revival's 499545 on-hit child. The hit-time
// runtime supplies its sourced stack and physical-dodge components.
return [stateAuraEffect(row, "warden-revival-state", row.name, 900_000)];
}
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior" && row.sourceSkillId === 499955) {
// Briar Technique's modifiers are applied by its named source skills on a
// landed hit; it is a marker here, not a permanent stat aura.
return [stateAuraEffect(row, "druid-warrior-briar-technique-learned", row.name, null)];
}
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior"
&& [494016, 494216].includes(row.sourceSkillId)) {
// The local trigger component 623080 is duplicated onto its two existing
// trigger skills; the hit-time runtime owns its stack application.
return translateRomAbilitySemantics(row)
.map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => effect.kind !== "apply-aura"
|| !effect.aura.id.includes("-623080-"));
}
if (row.sourceSkillId === 493642) {
// RuneWaker defines a 900-second +5% DEF self aura. Its separate
// component 503959 is handled on a qualifying physical hit. Do not treat
// it as damage dealt when Briar Shield is cast.
const defense = translateRomAbilitySemantics(row)
.filter((semantic): semantic is Extract<RomAbilitySemantic, { kind: "aura" }> => (
semantic.kind === "aura" && semantic.modifierType === 135
))
.map((semantic, index) => semanticEffect(row, semantic, index))
.find((effect) => effect.kind === "apply-aura");
if (!defense || defense.kind !== "apply-aura") return [];
return [{
...defense,
aura: {
...defense.aura,
id: "rom-briar-shield",
name: row.name,
disposition: "buff",
utilityTags: [...(defense.aura.utilityTags ?? []), ROM_WARDEN_BRIAR_SHIELD_TAG],
},
}];
}
if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494207) {
// Tough Oak's 1-second cooldown rider is handled per qualifying cast. Its
// +5% pet defense rider has no pet-stat representation in this runtime.
return [stateAuraEffect(row, "warden-tough-oak", row.name, null)];
}
if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494011) {
// Oak Blessing's local 620679 effect is conditional on Heart of the Oak;
// its buff and periodic heal are added when that parent skill is cast.
return [stateAuraEffect(row, "warden-oak-blessing", row.name, null)];
}
if (row.classId === "rom-warden" && row.sourceSkillId === 494610) {
// This passive is a trigger marker; its source damage reduction is applied
// to Frantic Briar's target, not permanently to the Warden.
return [stateAuraEffect(row, "will-attack", row.name, null)];
}
if (row.classId === "rom-warden" && row.sourceSkillId === 493344) {
return translateRomAbilitySemantics(row).map((semantic, index) => {
const effect = semanticEffect(row, semantic, index);
return effect.kind === "summon"
? { ...effect, durationMs: Number.MAX_SAFE_INTEGER }
: effect;
});
}
if (row.classId === "rom-warden" && row.sourceSkillId === 491668) {
let keptAbsorb = false;
return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return true;
if (keptAbsorb) return false;
keptAbsorb = true;
return true;
})
.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
recipient: "caster" as const,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((absorb) => ({
...absorb,
amount: 0,
hitCharges: 2,
})),
},
}
: effect);
}
if (row.classId === "rom-champion" && row.sourceSkillId === 498564) {
return [
...translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)),
{ kind: "pull", toward: "caster" },
];
}
if (row.sourceSkillId === 494611) {
const modifiers = [...new Map(translateRomAbilitySemantics(row)
.filter((semantic): semantic is Extract<RomAbilitySemantic, { kind: "aura" }> => semantic.kind === "aura")
.flatMap(auraModifiers)
.map((modifier) => [JSON.stringify(modifier), modifier] as const)).values()];
if (!modifiers.length) return null;
const aura: AuraDefinition = {
id: "rom-coat-of-arms",
name: row.name,
disposition: "buff",
durationMs: durationMs(Math.max(0, ...(row.source?.components ?? []).map((component) => Number(component.effectTime ?? 0)))),
dispelCategory: "magic",
modifiers,
};
const apply = (recipient: "caster" | "ability-target"): AbilityEffect => ({
kind: "apply-aura",
aura,
recipient,
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
});
return [apply("caster"), apply("ability-target")];
}
if (row.sourceSkillId === 491173) {
return [stateAuraEffect(row, "cripple-joint", row.name, null, {
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
})];
}
if (row.sourceSkillId === 491351) {
return [{
kind: "apply-aura",
aura: {
id: "rom-free-will-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 0),
controlImmunities: ["root", "slow"],
},
recipient: "ability-target",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498570) {
return [{
kind: "apply-aura",
aura: {
id: "rom-overrule-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 3),
controlImmunities: ["fear", "confuse", "sleep", "stun", "root", "slow"],
},
recipient: "caster",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498744) {
return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }];
}
if (row.sourceSkillId === 498758) {
return [
{ kind: "cooldown-reset", mode: "reset", abilityName: "Intensification" },
{ kind: "cooldown-reset", mode: "reset", abilityName: "Energy Well" },
];
}
if (row.sourceSkillId === 498759) {
// Its own cooldown resets only after the two hits leave the target alive.
return translateRomAbilitySemantics(row)
.map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => effect.kind === "damage");
}
if (row.sourceSkillId === 491163) {
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
procs: [{
id: "rom-frost-arrow-wind-arrow",
triggers: "hit",
chance: 0.5,
action: { kind: "custom", id: "rom-frost-arrow" },
}],
})];
}
if (row.sourceSkillId === 491171) {
return [stateAuraEffect(row, "static-field-charge", "Static Field Charge", null)];
}
if (row.sourceSkillId === 494038) {
return [stateAuraEffect(row, "entling-offering", "Entling Offering", 900_000, {
procs: [{
id: "rom-entling-offering-shot",
triggers: "hit",
chance: 1,
action: { kind: "custom", id: "rom-entling-offering" },
}],
})];
}
if (row.sourceSkillId === 495296) {
return [stateAuraEffect(row, "holy-salvation-candle", "Holy Salvation Candle", 20_000)];
}
if (row.sourceSkillId === 498571) {
return [stateAuraEffect(row, "remodeled-body", "Remodeled Body", 8_000)];
}
if (row.sourceSkillId === 499542) {
return [{
kind: "utility",
action: "class-script",
effectType: -1,
auraType: -1,
miscValue: 499542,
triggerSpellId: 623078,
durationMs: 0,
}];
}
return null;
}
function effects(row: GeneratedSemanticRow & { readonly range?: number }): readonly AbilityEffect[] {
if (isRomDruidRoguePair(row) && row.sourceSkillId === 494023) {
return romDruidRogueSinisterRequestEffects();
}
if (row.classId === "rom-priest") {
const holyBlessing = romPriestHolyBlessingEffects(row);
if (holyBlessing) return holyBlessing;
if (row.sourceSkillId === 498620) {
return [{
kind: "apply-aura",
aura: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
recipient: "ability-target",
sourceEffectType: 1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 498620,
}];
}
if (row.sourceSkillId === 490275) {
const silence: AbilityEffect = {
kind: "apply-aura",
aura: {
id: "rom-priest-vow-of-silence",
name: "Vow of Silence",
disposition: "debuff",
durationMs: 8_400,
dispelCategory: "magic",
},
recipient: "ability-target",
control: { kind: "silence" },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 490275,
};
return [silence, { kind: "interrupt", lockoutMs: 8_400 }];
}
if (row.sourceSkillId === 490276) {
return [
{
kind: "damage",
coefficient: 0,
basePoints: 1_655,
pointsPerLevel: 0,
sourceLevel: 1,
school: "holy",
},
{
kind: "apply-aura",
aura: {
id: "rom-priest-holy-void",
name: "Voidal Infection",
disposition: "debuff",
durationMs: 20_000,
dispelCategory: "magic",
modifiers: [
{ kind: "stat", stat: "physical-defense", operation: "percent", value: -0.188 },
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 },
],
},
recipient: "ability-target",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 490276,
},
];
}
if (row.sourceSkillId === 498612) {
return [
{ kind: "damage", coefficient: 9.751, school: "holy" },
{
kind: "cooldown-reset",
mode: "reduce",
abilityName: "Blessing of Humility",
amountMs: 15_000,
},
];
}
}
const specialized = specializedEffects(row);
if (specialized) return specialized;
const semantics = translateRomAbilitySemantics(row);
const groupHealRadius = row.sourceSkillId === 490281
? areaRadius({ ...row, range: row.range ?? 0 }) : undefined;
const translated = semantics.map((semantic, index) => semanticEffect(row, semantic, index))
.map((effect) => row.sourceSkillId === 490281 && groupHealRadius !== undefined
&& effect.kind === "heal" && effect.scope === "party"
? { ...effect, radius: groupHealRadius }
: effect)
.map((effect) => row.sourceSkillId === 493642 && effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
},
} : effect);
const holyCandle = romPriestHolyCandleEffects(row, translated);
if (holyCandle) return holyCandle;
const priestMageOverride = overrideRomPriestMageEffects(row, translated);
if (priestMageOverride) return priestMageOverride;
const championOverride = overrideRomChampionEffects(row, translated);
if (championOverride) return championOverride;
const druidOverride = row.classId === "rom-druid" || row.sourceSkillId === 494331
? overrideRomDruidEffects(row, translated)
: null;
if (druidOverride) return druidOverride;
if (row.sourceSkillId === 498825) return [
...translated,
{ kind: "cooldown-reset", mode: "reset", abilityName: "Lightning" },
{ kind: "cooldown-reset", mode: "reset", abilityName: "Discharge" },
];
if (row.sourceSkillId === 491606 || row.sourceSkillId === 490078) {
const damageComponentId = row.sourceSkillId === 491606 ? 502050 : 502102;
const triggerComponentId = row.sourceSkillId === 491606 ? 502049 : 502103;
const triggerComponent = row.source?.components?.find((component) => component.id === triggerComponentId);
const intervalSeconds = triggerComponent?.triggers?.onTimeSeconds ?? (row.sourceSkillId === 491606 ? 2 : 3);
const durationSeconds = triggerComponent?.effectTime ?? (row.sourceSkillId === 491606 ? 6 : 18);
const ticks = Math.max(1, Math.round(durationSeconds / intervalSeconds));
const damageSemanticIndex = semantics.findIndex((semantic) => semantic.kind === "damage"
&& semantic.sourceComponentIds.includes(damageComponentId));
const damageSemantic = semantics[damageSemanticIndex];
const damageEffect = damageSemantic?.kind === "damage"
? semanticEffect(row, damageSemantic, damageSemanticIndex)
: null;
const periodicDamage: AbilityEffect | null = damageEffect?.kind === "damage" && row.sourceSkillId === 491606
? {
kind: "dot",
coefficient: damageEffect.coefficient,
...(damageEffect.school ? { school: damageEffect.school } : {}),
...(damageEffect.basePoints === undefined ? {} : { basePoints: damageEffect.basePoints }),
...(damageEffect.dieSides === undefined ? {} : { dieSides: damageEffect.dieSides }),
...(damageEffect.pointsPerLevel === undefined ? {} : { pointsPerLevel: damageEffect.pointsPerLevel }),
...(damageEffect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: damageEffect.coefficientPerLevel }),
...(damageEffect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: damageEffect.bonusPowerCoefficient }),
...(damageEffect.sourceLevel === undefined ? {} : { sourceLevel: damageEffect.sourceLevel }),
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-holy-light-domain",
}
: row.sourceSkillId === 490078
? {
kind: "dot",
coefficient: 0,
school: "holy",
basePoints: 0,
pointsPerLevel: 0,
sourceLevel: 1,
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-war-prayer-seal",
}
: null;
if (!periodicDamage) return translated;
const output: AbilityEffect[] = [periodicDamage];
if (row.sourceSkillId === 490078 && damageEffect?.kind === "damage") output.unshift(damageEffect);
if (row.sourceSkillId === 491606) {
semantics.forEach((semantic, index) => {
if (!semantic.sourceComponentIds.includes(502051)) return;
output.push(semanticEffect(row, semantic, index));
});
}
return output;
}
if (row.sourceSkillId === 491133) {
const vulnerableDuration = row.source?.components?.find((component) => component.id === 501502)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "vulnerable", "Vulnerable", durationMs(vulnerableDuration))];
}
if (row.sourceSkillId === 491136) {
const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))];
}
const mageOverride = overrideRomMageEffects(row, translated);
if (mageOverride) return mageOverride;
const pairOverride = overrideRomKnightEffects(row, translated)
?? overrideRomWarriorEffects(row, translated)
?? overrideRomPriestKnightEffects(row, translated)
?? overrideRomPriestWarlockEffects(row, translated)
?? overrideRomScoutRogueEffects(row, translated)
?? overrideRomMageWarlockEffects(row, translated)
?? overrideRomWarlockEffects(row, translated);
if (pairOverride) return pairOverride;
const outputStrength = (effect: AbilityEffect): number => (
"coefficient" in effect ? effect.coefficient : 0
) + ("coefficientPerLevel" in effect ? effect.coefficientPerLevel ?? 0 : 0)
+ ("basePoints" in effect ? effect.basePoints ?? 0 : 0)
+ ("pointsPerLevel" in effect ? effect.pointsPerLevel ?? 0 : 0);
const oneOutput = (kind: "damage" | "dot", strongest: boolean): readonly AbilityEffect[] => translated
.filter((effect) => effect.kind === kind)
.sort((left, right) => (strongest ? -1 : 1) * (outputStrength(left) - outputStrength(right)))
.slice(0, 1);
const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => (
effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect
));
if (row.sourceSkillId === 494025) {
return oneOutput("damage", true).map((effect) => effect.kind === "damage"
? { ...effect, falloffPerJump: 0.7 }
: effect);
}
if (row.sourceSkillId === 499558) {
const shot = semantics.find((semantic): semantic is Extract<RomAbilitySemantic, { kind: "damage" }> => (
semantic.kind === "damage" && semantic.sourceComponentIds.includes(620194)
));
const healSource = semantics.find((semantic) => semantic.sourceComponentIds.includes(501888));
const output: AbilityEffect[] = [];
if (shot) {
const shotEffect = semanticEffect(row, shot, semantics.indexOf(shot));
if (shotEffect.kind === "damage") output.push(shotEffect);
}
if (healSource && (healSource.kind === "damage" || healSource.kind === "heal")) {
const healSemantic: RomAbilitySemantic = { ...healSource, kind: "heal" };
const healEffect = semanticEffect({ ...row, target: "friendly" }, healSemantic, semantics.indexOf(healSource));
if (healEffect.kind === "heal") output.push({
...healEffect,
recipient: "ability-target",
scope: "party",
maximumTargets: 1,
selection: "lowest-max-health",
});
}
return output;
}
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warden" && row.sourceSkillId === 494323) {
return translated.filter((effect) => effect.kind === "heal").slice(0, 2).map((effect) => effect.kind === "heal"
? { ...effect, falloffPerJump: 0.5 }
: effect);
}
// Source order is the Nature's Power enhanced HoT (504529), then the
// unconditional baseline HoT (620446). Runtime selects exactly one.
if (row.sourceSkillId === 494021) return translated.filter((effect) => effect.kind === "hot").slice(0, 2);
// These source rows carry alternate/duplicate payloads, while their player
// tooltips specify a single projectile hit. The first attack component is
// the primary shot; executing every component also applies unrelated Shot
// stat modifiers and doubles Throw damage.
if (row.sourceSkillId === 490309 || row.sourceSkillId === 490423) {
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
}
if (row.sourceSkillId === 490313) return translated.filter((effect) => effect.kind === "damage");
// The source rows include auxiliary spell records alongside the tooltip's
// single hit or periodic heal. They are alternatives, not extra casts.
if (row.sourceSkillId === 491150 || row.sourceSkillId === 490204) {
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
}
if (row.sourceSkillId === 490252 || row.sourceSkillId === 490244) {
const hit = translated.find((effect) => effect.kind === "damage");
const hitCount = row.sourceSkillId === 490252 ? 5 : 3;
return [
...(hit ? Array.from({ length: hitCount }, () => hit) : []),
...(row.sourceSkillId === 490244
? translated.filter((effect) => effect.kind === "apply-aura")
: []),
];
}
if (row.sourceSkillId === 490256) {
return translated.filter((effect) => effect.kind === "damage");
}
if (row.sourceSkillId === 490269) {
return translated.filter((effect) => effect.kind === "hot").slice(0, 1);
}
if (row.sourceSkillId === 490434) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), ROM_SCOUT_BLOOD_ARROW_AURA_TAG] },
} : effect);
}
if (row.sourceSkillId === 498770) {
const heal = translated.find((effect) => effect.kind === "heal");
const buffs = translated.filter((effect) => effect.kind === "apply-aura");
if (!heal) return translated;
const buff = buffs[0];
const defenseModifiers = buffs.flatMap((effect) => effect.kind === "apply-aura"
? effect.aura.modifiers ?? []
: []);
// Local text says four heals; preserve that count as four separate channel
// payloads so the runtime emits one equal portion on each of four pulses.
return [
...Array.from({ length: 4 }, () => heal.kind === "heal"
? { ...heal, recipient: "ability-target" as const }
: heal),
...(buff?.kind === "apply-aura" ? [{
...buff,
aura: { ...buff.aura, modifiers: defenseModifiers },
}] : []),
];
}
if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) {
return translated.filter((effect) => effect.kind === "apply-aura");
}
if (row.sourceSkillId === 490420) {
return translated.filter((effect) => effect.kind === "damage"
|| (effect.kind === "apply-aura" && !effect.control));
}
if (row.sourceSkillId === 490242) {
return translated.filter((effect) => effect.kind === "damage"
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
}
if (row.sourceSkillId === 490344) {
return [{ kind: "movement", movement: "teleport", toward: "target" }];
}
if (row.sourceSkillId === 490311) {
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
}
if (row.sourceSkillId === 490192) {
return translated.filter((effect) => effect.kind === "apply-aura").map((effect) => (
effect.kind === "apply-aura" && effect.aura.procs?.length ? {
...effect,
aura: {
...effect.aura,
durationMs: 30_000,
procs: effect.aura.procs.map((proc) => ({
...proc,
action: { kind: "custom" as const, id: "rom-shield-discipline-reflect" },
})),
},
} : effect
));
}
if (row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID && row.classId === "rom-rogue") {
return [romRogueEnergizedHandsApplyEffect()];
}
if (row.sourceSkillId === 490454) {
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490438) {
// Neck Strike has a six-second NPC stun (component 500877); the separate
// 503863 component supplies the reduced three-second player duration.
const durationMsForTarget = durationMs(row.source?.components?.find((component) => component.id === 500877)?.effectTime ?? 6);
return [{ kind: "status", status: "stun", durationMs: durationMsForTarget, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 491172) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490337) {
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot"
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
}
if (row.sourceSkillId === 490507) {
const aura = translated.find((effect) => effect.kind === "apply-aura");
return aura?.kind === "apply-aura" ? [{
...aura,
aura: { ...aura.aura, utilityTags: ["next-offensive-critical", "rom-premeditation"] },
}] : translated;
}
if (row.sourceSkillId === 490354) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] },
} : effect);
}
if (row.sourceSkillId === 490329 && row.classId === "rom-rogue") {
const aura = translated.find((effect) => effect.kind === "apply-aura");
return aura?.kind === "apply-aura" ? [{
...aura,
aura: {
...aura.aura,
durationMs: 900_000,
exclusiveGroup: ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
utilityTags: ["rom-poisonous"],
},
}] : translated;
}
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
// Model it through the combat engine's combo authority so both players and
// companions build seals before Punishment instead of casting the payoff on
// an unsealed target. Source components contain one alternative payload per
// seal count; executing every branch at once greatly overstates the skills.
if (row.sourceSkillId === 490148) return [
...oneOutput("damage", true), ...holySealDot(false), { kind: "combo", amount: 1 },
];
if (row.sourceSkillId === 490149) return [
...oneOutput("damage", true), ...holySealDot(true), { kind: "combo", amount: 4 },
];
if (row.sourceSkillId === 490150) return [
{ kind: "movement", movement: "charge", toward: "target" },
...oneOutput("damage", true),
...translated.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun").slice(0, 1),
...holySealDot(false),
{ kind: "combo", amount: 2 },
];
if (row.sourceSkillId === 490151 || row.sourceSkillId === 499909) return [
...oneOutput("damage", true), { kind: "combo", amount: -5 },
];
if (row.sourceSkillId === 491169) {
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
}
return translated;
}
function areaRadius(row: Pick<GeneratedSkill, "sourceSkillId" | "description" | "range">): number | undefined {
// Rune Overload's local tooltip says only "immediate vicinity". Its
// hash-matched source has no numeric radius, so do not apply the generic
// heuristic's 10-yard fallback to this self-targeted ability.
if (row.sourceSkillId === 498572) return undefined;
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
if (!/\b(?:multiple targets|multiple enemies|all targets|all enemies|nearby targets|nearby party members|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) (?:within (?:a )?range|in a range|in range))\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius)\s+(?:of\s+)?(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10));
}
function sourceTargeting(sourceSkillId: number, classId?: string, secondaryClassId?: string): AbilityDefinition["targeting"] | undefined {
if (sourceSkillId === ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID) {
return { shape: "unit", requiresLineOfSight: true, requiresFacing: true, attackArcRadians: Math.PI };
}
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494321) return { shape: "circle", radius: 8 };
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return { shape: "circle", radius: 15 };
if (sourceSkillId === 498612) return { shape: "circle", radius: 80 };
// Current CoA Fire Ward (490248) is a radius-100 raid aura. RoM's local
// runtime uses one tenth of the published distance units, matching range 150
// to 15 for this same catalog row.
if (sourceSkillId === 490248) return { shape: "circle", radius: 10 };
// CoA's current Whirlwind Shield (1493719) is a 100-radius multi-target cast;
// map the exact-name local RuneWaker row to that current shape.
if (sourceSkillId === 490175 || sourceSkillId === 1_493_719) return { shape: "circle", radius: 100 };
if (sourceSkillId === 490311 || sourceSkillId === 490337) return { shape: "unit", requiresBehindTarget: true };
if (sourceSkillId === 490239) return { shape: "line", radius: 12 };
if (sourceSkillId === 498201 || sourceSkillId === 498202) return { shape: "circle", radius: 7.5 };
if (sourceSkillId === 490136) return { shape: "circle", radius: 10 };
if (sourceSkillId === 491513) return { shape: "circle", radius: 5, maximumTargets: 6 };
if (sourceSkillId === 492636) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494021) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494026) return { shape: "circle", radius: 12 };
if (sourceSkillId === 494027) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 494217) return { shape: "circle", radius: 5 };
if (sourceSkillId === 494025) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 494323) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 499936) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498641) return { shape: "circle", radius: 20, maximumTargets: 6 };
if (sourceSkillId === 493540) return { shape: "line" };
if (sourceSkillId === 498658) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 498721) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498534) return { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 };
if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 };
if (sourceSkillId === 497959 || sourceSkillId === 497966) return { shape: "circle", radius: 6, maximumTargets: 3 };
return undefined;
}
function sourceRange(sourceSkillId: number, fallback: number, classId?: string, secondaryClassId?: string): number {
if (sourceSkillId === 490274) return 1;
if (classId === "rom-priest" && secondaryClassId === undefined && sourceSkillId === 490304) return 1;
if (sourceSkillId === 490275 || sourceSkillId === 490276 || sourceSkillId === 498612) return 150;
if (isRomDruidRoguePair({ classId, secondaryClassId }) && [494024, 494321].includes(sourceSkillId)) return 15;
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return 5;
// RoM tooltip ranges are expressed in decimeters; Phoenix's source row is
// self-targeted, so use its described 120-unit path length for line casting.
return sourceSkillId === 490239 ? 12 : fallback;
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
const currentDruidRogue = isRomDruidRoguePair(parent);
const currentScoutAutoShot = parent.id === ROM_SCOUT_AUTO_SHOT_ABILITY_ID
&& row.sourceSkillId === ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID
&& parent.classId === "rom-scout" && parent.secondaryClassId === undefined;
const currentDruidMageInspiration = isRomDruidMagePair(parent) && row.sourceSkillId === 494322;
const currentMageDruidGreenGuardian = isRomMageDruidPair(parent)
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
const currentCoaHolyCandle = parent.classId === "rom-priest"
&& parent.secondaryClassId === undefined
&& row.sourceSkillId === 490304;
const currentCoaRogueEnergizedHands = parent.classId === "rom-rogue"
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
const currentCoaRoguePoisonousHands = parent.classId === "rom-rogue" && row.sourceSkillId === 490329;
const currentCoaRogueMageChaosDagger = parent.classId === "rom-rogue"
&& parent.secondaryClassId === "rom-mage"
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-mage";
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
sourceSkillId: row.sourceSkillId,
});
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172
&& parent.classId === "rom-knight" && parent.secondaryClassId === undefined;
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
&& parent.classId === "rom-knight" && parent.secondaryClassId === "rom-rogue";
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|| currentDruidMageInspiration;
const passiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger;
const rankCosts = currentMageDruidGreenGuardian ? []
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
: currentCoaRogueEnergizedHands || currentCoaRogueMageChaosDagger ? []
: currentDruidRogue && row.sourceSkillId === 494321
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
: currentDruidRogue && row.sourceSkillId === 494024
? [{ resource: "energy" as const, amount: 25 }]
: currentDruidRogue && row.sourceSkillId === 494023
? []
: currentCoaSoulBrandSting
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
: currentCoaPassive ? [] : costsFor(row);
const target = abilityTarget({ ...row, target: row.target ?? parent.target });
const priestRankName = parent.classId === "rom-priest" ? romPriestName(row.sourceSkillId, row.name) : row.name;
const currentClassDescription = currentMageDruidGreenGuardian
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
: currentDruidMageInspiration
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
: currentCoaRogueEnergizedHands
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
: currentCoaRoguePoisonousHands
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
: currentCoaRogueMageChaosDagger
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
: parent.classId === "rom-priest"
? currentPriestCoaDescription(row.sourceSkillId, row.description)
: parent.classId === "rom-mage"
? currentMageCoaDescription(row.sourceSkillId, row.description)
: currentDruidRogue && row.sourceSkillId === 494321
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494024
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494023
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
: row.description;
const currentWarlockRogueShadowSlice = row.sourceSkillId === 498678
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-rogue";
const rankDescription = currentScoutAutoShot
? "Activates Auto Shot. Each physical shot deals 80% ranged weapon DPS at Physical Attack Speed. Use this ability again to stop Auto Shot."
: currentCoaSoulBrandSting
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
: currentCoaSoulEnlightenment
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
: currentWarlockRogueShadowSlice
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
: row.sourceSkillId === 490344 && parent.classId === "rom-rogue"
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
: currentWarlockCoaDescription(row.sourceSkillId, currentClassDescription);
return {
rank: row.rank,
spellId: row.sourceSkillId,
level: currentMageDruidGreenGuardian ? 25 : row.level,
// AbilityRankDefinition has no name field, so retain the canonical CoA
// name in the rank copy when a local row was renamed.
description: parent.classId === "rom-priest" && priestRankName !== row.name
&& !rankDescription.toLowerCase().startsWith(priestRankName.toLowerCase())
? `${priestRankName}: ${rankDescription}`
: rankDescription,
castMode: passiveMode || currentMageDruidGreenGuardian ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
castTimeMs: passiveMode || currentMageDruidGreenGuardian ? 0 : currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 1_000
: currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
cooldownMs: passiveMode || currentMageDruidGreenGuardian || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 0
: currentDruidRogue && row.sourceSkillId === 494321 ? 0
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
: currentCoaHolyCandle ? 30_000
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
costs: rankCosts,
effects: currentCoaPassive ? [] : effects({
...row,
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
target,
source: row.source?.components?.length ? row.source : parent.source,
}),
};
}
function ability(row: GeneratedSkill): AbilityDefinition {
if (!ROM_CLASS_IDS.includes(row.classId as RomClassId)) throw new Error(`Generated RuneWaker class is invalid: ${row.classId}`);
const classId = row.classId as RomClassId;
const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId)
? row.secondaryClassId as RomClassId
: undefined;
const currentDruidRogue = isRomDruidRoguePair({ classId, secondaryClassId });
const currentScoutAutoShot = row.sourceSkillId === ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID
&& classId === "rom-scout" && secondaryClassId === undefined;
const currentMageDruidGreenGuardian = isRomMageDruidPair({ classId, secondaryClassId })
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
const currentDruidMageInspiration = isRomDruidMagePair({ classId, secondaryClassId })
&& row.sourceSkillId === 494322;
const currentCoaHolyCandle = classId === "rom-priest"
&& secondaryClassId === undefined
&& row.sourceSkillId === 490304;
const currentCoaRogueEnergizedHands = classId === "rom-rogue"
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
const currentCoaRoguePoisonousHands = classId === "rom-rogue" && row.sourceSkillId === 490329;
const currentCoaRogueMageChaosDagger = classId === "rom-rogue"
&& secondaryClassId === "rom-mage"
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
const currentCoaCompassion = row.sourceSkillId === 491_635;
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172 && classId === "rom-knight";
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
&& classId === "rom-knight" && secondaryClassId === "rom-rogue";
const currentCoaPsychicWarrior = row.sourceSkillId === 499868
&& classId === "rom-warlock" && secondaryClassId === "rom-warrior";
const currentCoaShadowSlice = row.sourceSkillId === 498678
&& classId === "rom-warlock" && secondaryClassId === "rom-rogue";
const currentCoaShadowStep = row.sourceSkillId === 490344 && classId === "rom-rogue";
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
&& classId === "rom-warlock" && secondaryClassId === "rom-mage";
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
classId,
secondaryClassId,
sourceSkillId: row.sourceSkillId,
});
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|| currentCoaPsychicWarrior || currentCoaShadowSlice || currentDruidMageInspiration;
const currentCoaPassiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger || currentMageDruidGreenGuardian;
const abilityCosts = currentMageDruidGreenGuardian ? []
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
: currentCoaRogueEnergizedHands ? []
: currentDruidRogue && row.sourceSkillId === 494321
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
: currentDruidRogue && row.sourceSkillId === 494024
? [{ resource: "energy" as const, amount: 25 }]
: currentDruidRogue && row.sourceSkillId === 494023
? []
: currentCoaSoulBrandSting
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
: costsFor(row);
const target = abilityTarget(row);
const currentKnightName = currentCoaRogueEnergizedHands
? "Energized Hands"
: currentCoaRoguePoisonousHands
? "Poisonous Hands"
: currentCoaRogueMageChaosDagger
? "Chaos Dagger"
: row.sourceSkillId === 491_635
? "Compassion of Holy Light"
: currentCoaPsychicWarrior
? "Psychic Warrior"
: currentDruidRogue && row.sourceSkillId === 494023
? "Sinister Request"
: row.name;
const currentAbilityName = currentCoaShadowSlice
? "Shadow Slice"
: classId === "rom-priest"
? romPriestName(row.sourceSkillId, currentKnightName)
: currentKnightName;
const currentKnightDescription = currentMageDruidGreenGuardian
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
: currentDruidMageInspiration
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
: currentCoaRogueEnergizedHands
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
: currentCoaRoguePoisonousHands
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
: currentCoaRogueMageChaosDagger
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
: currentCoaShadowSlice
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
: currentCoaShadowStep
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494321
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494024
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494023
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
: row.sourceSkillId === 491_639
? "Increases aggro generated by Whirlwind and reduces its Rage cost and cooldown. On successful hits, Whirlwind inflicts Deadly Wound, reducing the target's received healing by 30% for 10 seconds (5 seconds against players)."
: row.sourceSkillId === 491_608
? "Increases Punishment damage by 28.5%. When Punishment hits, the target takes 2% more damage for every Holy Seal stack it has for 10 seconds."
: row.sourceSkillId === 491_635
? "Current CoA lists this reused passive under Knight/Shaman, while the local generated row is Knight/Priest and this simulator has no Shaman class entry. The exact-ID local passive is bridged to Whirlwind Shield (CoA skill 1493719): a successful hit heals nearby allies for 120 + 0.2 x STA and triggers Holy Illumination; cooldown becomes 4 seconds instead of 5. CoA also scales its shield-defense damage term to 5x, but this simulator has no shield-defense stat, so that damage term is not modeled."
: currentCoaHolyPowerExplosion
? "Passive: increases Aggro generated by Knight abilities by 198%."
: currentCoaLeopardInstinct
? "Passive: each hit received adds one stack, up to 10. Each stack grants 3% Dodge and 3% Magic Resistance Rate; each Dodge removes one stack. This simulator applies Dodge only because it has no Magic Resistance Rate consumer."
: currentWarlockCoaDescription(row.sourceSkillId, row.description);
const currentWarriorCoaDescription = row.sourceSkillId === 493248
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue"
? "Passive: gives the Warrior/Rogue CoA Probing Attack (493283) a 30% chance on a landed hit to restore 25 Energy and 50 Rage."
: currentKnightDescription;
const currentDescription = currentCoaSoulBrandSting
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
: currentCoaSoulEnlightenment
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
: classId === "rom-priest"
? currentWarlockCoaDescription(row.sourceSkillId, currentPriestCoaDescription(row.sourceSkillId, currentKnightDescription))
: classId === "rom-mage"
? currentMageCoaDescription(row.sourceSkillId, currentWarriorCoaDescription)
: currentScoutAutoShot
? "Activates Auto Shot. Each physical shot deals 80% ranged weapon DPS at Physical Attack Speed. Use this ability again to stop Auto Shot."
: currentWarriorCoaDescription;
const currentCoaDescriptionOverride = currentMageDruidGreenGuardian || currentCoaPassive || currentCoaShadowStep || currentCoaSoulBrandSting || currentCoaSoulEnlightenment
|| currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands || currentCoaRogueMageChaosDagger
|| (currentDruidRogue && [494321, 494024, 494023].includes(row.sourceSkillId)) || (row.sourceSkillId === 493248
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue")
|| (row.sourceSkillId === 490248 && classId === "rom-mage")
|| currentScoutAutoShot;
const currentRanks = row.ranks.map((entry) => rank(entry, row)).map((entry) => ({
...entry,
...(currentCoaDescriptionOverride ? { description: currentDescription } : {}),
...(currentDruidRogue && row.sourceSkillId === 494023
? { effects: romDruidRogueSinisterRequestEffects() }
: {}),
}));
return {
id: row.id,
classId,
...(secondaryClassId ? { secondaryClassId } : {}),
dbcSpellId: row.sourceSkillId,
name: currentAbilityName,
unlockLevel: currentMageDruidGreenGuardian ? 25 : row.unlockLevel,
icon: row.icon,
sigil: currentAbilityName.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: currentDescription,
target: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? "self" : target,
range: { min: 0, max: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands
? 0 : currentScoutAutoShot
? ROM_SCOUT_AUTO_SHOT_MAX_RANGE
: sourceRange(row.sourceSkillId, row.range, classId, secondaryClassId) },
...(currentCoaSoulEnlightenment
? { radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS }
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(currentCoaSoulEnlightenment
? { targeting: { shape: "circle" as const, radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS } }
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)
? { targeting: sourceTargeting(row.sourceSkillId, classId, secondaryClassId) } : {}),
castMode: currentCoaPassiveMode ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
castTimeMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
cooldownMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 0
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
: currentCoaHolyCandle ? 30_000
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
cost: currentCoaPassiveMode ? { resource: "mana", amount: 0 } : abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: currentCoaPassiveMode ? [] : abilityCosts,
effects: currentCoaPassive ? [] : effects({ ...row, target, classId, secondaryClassId }),
passive: currentMageDruidGreenGuardian ? false : currentCoaPassiveMode || row.sourceSkillId === 490274 ? true : row.passive,
source: "runewaker",
...(currentScoutAutoShot ? { sourceAttackKind: "ranged" as const } : {}),
...(row.sourceSkillId === 490148 ? { sourceAttackKind: "melee" as const } : {}),
sourceUnlockLevel: row.unlockLevel,
ranks: currentRanks,
romSkillGroup: currentMageDruidGreenGuardian ? "elite" : row.group,
};
}
const generatedAbilities = generated.map(ability)
.map(romWarriorDruidRageOfCalmnessCatalogOverride)
.map(romScoutPriestScreenAttackCatalogOverride)
.map(romScoutPriestDrumsOfWarCatalogOverride)
.map(romPriestScoutCuringShotCatalogOverride);
export const ROM_ALL_ABILITIES = Object.freeze([
...generatedAbilities,
...romPriestKnightAdditionalAbilities(generatedAbilities),
...romWarriorCoaAdditionalAbilities(generatedAbilities),
...romWarlockWarriorEnragedCoaAdditionalAbilities(generatedAbilities),
]);
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
const triggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
for (const parent of generated) {
const componentSets = [parent.source?.components ?? [], ...parent.ranks.map((entry) => entry.source?.components ?? [])];
for (const components of componentSets) for (const component of components) {
if (!triggeredComponentOwners.has(component.id)) triggeredComponentOwners.set(component.id, { parent, component });
}
}
// Rune Overload's generated player row contains the hidden MagicCollect child
// ID on component 621241's one-second on-time trigger. The child itself is not
// a learned/player row, but its tooltip-referenced damage MagicObject (621243)
// is retained in the hash-matched parent source payload. Alias the child spell
// ID to that existing payload so the generic triggered-spell resolver can
// expand it without inventing a radius or target cap.
const runeOverloadParent = generated.find((entry) => entry.sourceSkillId === 498572);
const runeOverloadChildPayload = runeOverloadParent?.source?.components?.find((component) => component.id === 621243);
const hiddenTriggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
if (runeOverloadParent && runeOverloadChildPayload) {
hiddenTriggeredComponentOwners.set(498574, { parent: runeOverloadParent, component: runeOverloadChildPayload });
}
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
const owner = hiddenTriggeredComponentOwners.get(spellId) ?? triggeredComponentOwners.get(spellId);
if (!owner) return null;
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
if (!parentAbility) return null;
const triggeredEffects = spellId === 498574
? [{ kind: "damage", coefficient: 0.6, school: "physical" } satisfies AbilityEffect]
: effects({
sourceSkillId: spellId,
name: `${owner.parent.name} trigger`,
description: "",
target: owner.parent.target,
passive: false,
source: { componentIds: [spellId], components: [owner.component] },
}).filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported");
if (!triggeredEffects.length) return null;
return {
...parentAbility,
id: `rom-triggered-${spellId}`,
dbcSpellId: spellId,
name: `${owner.parent.name} trigger`,
passive: false,
effects: triggeredEffects,
ranks: undefined,
};
}
export const ROM_ORDINARY_ABILITIES_BY_CLASS: Readonly<Record<RomClassId, readonly AbilityDefinition[]>> = Object.freeze(
Object.fromEntries((rawCatalog.canonicalClasses as readonly { id: RomClassId }[]).map(({ id }) => [
id,
Object.freeze(ROM_ALL_ABILITIES.filter((entry) => entry.classId === id && !entry.secondaryClassId)),
])) as Record<RomClassId, readonly AbilityDefinition[]>,
);
export const ROM_ELITE_ABILITIES = Object.freeze(ROM_ALL_ABILITIES.filter((entry) => entry.secondaryClassId));
export const RUNEWAKER_PLAYER_SOURCE_HASHES = Object.freeze(rawCatalog.source.hashes);