1733 lines
88 KiB
TypeScript
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);
|