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; 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): "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): 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>)[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>)[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["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["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 = {}, ): 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); const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => ( effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect )); // 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 === 490304 || row.sourceSkillId === 490434) { return translated.map((effect) => effect.kind === "apply-aura" ? { ...effect, aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] }, } : effect); } 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 === 490347) { return [{ kind: "status", status: "sleep", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }]; } if (row.sourceSkillId === 490454) { return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }]; } if (row.sourceSkillId === 490438) { return [{ kind: "status", status: "stun", durationMs: 10_000, 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) { const aura = translated.find((effect) => effect.kind === "apply-aura"); return aura?.kind === "apply-aura" ? [{ ...aura, aura: { ...aura.aura, durationMs: 900_000, 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) 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): 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(); 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> = 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, ); 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);