Files
healer-man/src/game/romAbilityCatalog.ts
T
2026-09-17 09:13:15 -04:00

639 lines
30 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";
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 } : {}),
}));
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">;
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";
}
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}`;
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;
return {
kind: semantic.kind,
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
basePoints,
pointsPerLevel,
sourceLevel: 1,
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
} : {}),
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
? { percentMaxHealth: semantic.percentMaxHealth }
: {}),
};
}
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)));
return {
kind: semantic.kind,
coefficient: 0,
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
sourceLevel: 1,
ticks,
intervalMs,
...(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 = /\b(?:all )?party members?\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 } : {}), 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, 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 resourceType = 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: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
};
return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", 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 specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
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 AbilityEffect[] {
const specialized = specializedEffects(row);
if (specialized) return specialized;
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
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);
// 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), ...oneOutput("dot", false), { kind: "combo", amount: 1 },
];
if (row.sourceSkillId === 490149) return [
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: 4 },
];
if (row.sourceSkillId === 490150) return [...translated, { kind: "combo", amount: 2 }];
if (row.sourceSkillId === 490151) return [
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: -5 },
];
if (row.sourceSkillId === 491169) {
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
}
return translated;
}
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|friendly targets within|targets within (?:a )?range)\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range || 8));
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
const rankCosts = costs(row.costs);
return {
rank: row.rank,
spellId: row.sourceSkillId,
level: row.level,
description: row.description,
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
cooldownMs: row.cooldownMs,
gcdMs: 1_500,
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
costs: rankCosts,
effects: effects({
...row,
target: row.target ?? parent.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 abilityCosts = costs(row.costs);
return {
id: row.id,
classId,
...(secondaryClassId ? { secondaryClassId } : {}),
dbcSpellId: row.sourceSkillId,
name: row.name,
unlockLevel: row.unlockLevel,
icon: row.icon,
sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: row.description,
target: row.target,
range: { min: 0, max: row.range },
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
cooldownMs: row.cooldownMs,
gcdMs: 1_500,
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: abilityCosts,
effects: effects(row),
passive: row.passive,
source: "runewaker",
sourceUnlockLevel: row.unlockLevel,
ranks: row.ranks.map((entry) => rank(entry, row)),
romSkillGroup: row.group,
};
}
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
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 });
}
}
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
const owner = triggeredComponentOwners.get(spellId);
if (!owner) return null;
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
if (!parentAbility) return null;
const triggeredEffects = 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);