export type RomSemanticKind = | "damage" | "heal" | "dot" | "hot" | "taunt" | "dispel" | "control" | "interrupt" | "aura" | "absorb" | "proc" | "resource" | "summon" | "resurrection" | "movement" | "cooldown-reset" | "trigger-spell" | "pet" | "custom"; export type RomSemanticSource = "component" | "tooltip" | "combined" | "fallback"; export type RomAbilityTarget = "hostile" | "friendly" | "self"; export interface RomSourceAbilityModifier { readonly type: number; readonly value: number; } export interface RomSourceComponent { readonly id: number; readonly magicFunc: number; readonly magicType: number; readonly effectType: number; readonly effectTime?: number; readonly hateCost?: number; readonly settingFlags?: number; readonly specialActionFlags?: number; readonly assistType?: number; readonly effectFlagWords?: readonly number[]; readonly clearFlags?: number; readonly abilityModifiers?: readonly RomSourceAbilityModifier[]; readonly abilitySkillLevelArg?: number; readonly onAttackTrigger?: { readonly rate: number; readonly rank: number; readonly id: number; }; readonly triggers?: { readonly onTimeMagicId?: number; readonly onTimeSeconds?: number; readonly onHitMagicId?: number; readonly onBuffTimeoutMagicId?: number; readonly onAttackReboundMagicId?: number; readonly onMagicAttackReboundMagicId?: number; readonly onDeathMagicId?: number; }; readonly dot?: { readonly timeSeconds: number; readonly type: number; readonly base: number; readonly skillLevelArg: number; }; readonly attack?: { readonly type: number; readonly calculationType?: number; readonly damagePower: number; readonly damagePowerSkillLevelArg: number; readonly fixedValue: number; readonly fixedType: number; readonly fixedDamageSkillLevelArg: number; readonly randomRange: number; readonly criticalRate: number; readonly hateRate: number; }; readonly raise?: { readonly experiencePercent: number }; readonly summon?: { readonly creatureId: number; readonly level: number; readonly rangeLevel: number; readonly lifetimeSeconds: number; readonly skillLevelArg: number; readonly type: number; readonly groupId: number; readonly ownerPowerRate: number; }; readonly magicShield?: { readonly rawBaseWord: number; readonly type: number; readonly effect: number; readonly point: number; readonly skillLevelArg: number; }; } export interface RomGeneratedAbilityLike { readonly sourceSkillId?: number; readonly name: string; readonly description: string; readonly target?: RomAbilityTarget; readonly passive?: boolean; readonly source?: { readonly sourceEffectType?: number; readonly sourceTarget?: number; readonly componentIds?: readonly number[]; readonly components?: readonly RomSourceComponent[]; }; } interface RomSemanticBase { readonly kind: RomSemanticKind; readonly source: RomSemanticSource; readonly sourceComponentIds: readonly number[]; } export type RomAbilitySemantic = | (RomSemanticBase & { readonly kind: "damage" | "heal"; readonly school: string; readonly power: number; readonly powerPerSkillLevel: number; readonly fixedValue: number; readonly fixedType: number; readonly fixedPerSkillLevel: number; readonly calculationType: number | null; readonly percentMaxHealth?: number; }) | (RomSemanticBase & { readonly kind: "dot" | "hot"; readonly school: string; readonly resource: string; readonly intervalSeconds: number; readonly durationSeconds: number; readonly basePerTick: number; readonly perSkillLevel: number; }) | (RomSemanticBase & { readonly kind: "taunt"; readonly scope: "single" | "area"; readonly hateDelta: number }) | (RomSemanticBase & { readonly kind: "dispel"; readonly polarity: "harmful" | "beneficial" | "all"; readonly mode: "remove" | "steal"; readonly scope?: "target" | "party"; readonly ailment?: "magic" | "curse" | "poison" | "disease" | "enrage" | "all"; }) | (RomSemanticBase & { readonly kind: "control"; readonly effect: string; readonly durationSeconds: number }) | (RomSemanticBase & { readonly kind: "interrupt"; readonly lockoutSeconds: number }) | (RomSemanticBase & { readonly kind: "aura"; readonly effect: string; readonly value: number | null; readonly modifierType: number | null; readonly durationSeconds: number; }) | (RomSemanticBase & { readonly kind: "absorb"; readonly basis: "mana" | "hits" | "points" | "max-health-percent" | "healing-scaled-points"; readonly school: string; readonly amount: number; readonly perSkillLevel: number; readonly durationSeconds: number; }) | (RomSemanticBase & { readonly kind: "proc"; readonly trigger: "on-attack" | "periodic" | "on-hit" | "on-timeout" | "physical-rebound" | "magic-rebound" | "on-death"; readonly triggerSkillId: number; readonly rate: number | null; readonly rank: number | null; readonly intervalSeconds: number | null; }) | (RomSemanticBase & { readonly kind: "resource"; readonly resource: string; readonly operation: "restore" | "drain" | "modify"; readonly amount: number; readonly intervalSeconds: number | null; readonly durationSeconds?: number; readonly percentage?: boolean; }) | (RomSemanticBase & { readonly kind: "summon"; readonly creatureId: number; readonly summonType: number; readonly lifetimeSeconds: number; readonly ownerPowerRate: number; }) | (RomSemanticBase & { readonly kind: "resurrection"; readonly experiencePercent: number }) | (RomSemanticBase & { readonly kind: "movement"; readonly effect: string; readonly distance: number | null }) | (RomSemanticBase & { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName: string | null; readonly seconds: number | null; }) | (RomSemanticBase & { readonly kind: "trigger-spell"; readonly spellId: number }) | (RomSemanticBase & { readonly kind: "pet"; readonly action: "recall" | "resurrect" }) | (RomSemanticBase & { readonly kind: "custom"; readonly mechanicId: string; readonly summary: string; readonly unresolved: boolean; }); export interface RomSemanticCoverage { readonly abilities: number; readonly semantics: number; readonly customSemantics: number; readonly unresolvedAbilities: number; } const SETTING_MAGIC_SHIELD_BIT = 8; const SETTING_CANCEL_BAD_MAGIC_BIT = 23; const SPECIAL_ACTION_INTERRUPT_BIT = 2; const SPECIAL_ACTION_ERASE_HATE_BIT = 4; const MODIFIER_NAMES: Readonly> = Object.freeze({ 2: "strength", 3: "stamina", 4: "intellect", 5: "wisdom", 6: "dexterity", 7: "all-primary-attributes", 8: "maximum-health", 9: "maximum-mana", 10: "health-regeneration", 11: "mana-regeneration", 12: "physical-attack", 13: "physical-defense", 14: "magical-defense", 15: "magical-attack", 16: "physical-hit", 17: "dodge", 18: "physical-critical-strike", 19: "physical-critical-power", 20: "magical-critical-strike", 21: "magical-critical-power", 22: "parry", 23: "attack-speed", 24: "movement-speed", 25: "weapon-damage", 26: "all-resistance", 27: "earth-resistance", 28: "water-resistance", 29: "fire-resistance", 30: "wind-resistance", 31: "light-resistance", 32: "dark-resistance", 33: "mana-cost", 37: "off-hand-damage", 44: "all-magic-damage", 45: "earth-damage", 46: "water-damage", 47: "fire-damage", 48: "wind-damage", 49: "light-damage", 50: "dark-damage", 51: "spell-speed", 52: "ranged-damage", 56: "melee-damage", 68: "ranged-attack-speed", 72: "melee-attack-speed", 134: "physical-attack-percent", 135: "physical-defense-percent", 138: "threat-percent", 139: "rage-regeneration-percent", 140: "focus-regeneration-percent", 141: "energy-regeneration-percent", 142: "magic-damage-absorption-percent", 143: "physical-damage-absorption-percent", 144: "healing-absorption-percent", 148: "magical-damage-points", 149: "healing-power-percent", 150: "healing-points", 161: "strength-percent", 162: "stamina-percent", 163: "intellect-percent", 164: "wisdom-percent", 165: "dexterity-percent", 166: "all-primary-attributes-percent", 167: "maximum-health-percent", 168: "maximum-mana-percent", 170: "magical-defense-percent", 171: "magical-attack-percent", 173: "weapon-damage-percent", 191: "magical-damage", 192: "magical-damage-percent", 195: "magical-hit", 197: "physical-hit-percent", 198: "physical-dodge-percent", 199: "magical-hit-percent", 204: "player-damage-percent", 205: "npc-damage-percent", 206: "player-damage-taken-percent", 207: "npc-damage-taken-percent", 208: "area-magic-damage-percent", 209: "area-magic-damage-taken-percent", 214: "rage-cost-percent", 215: "energy-cost-percent", 216: "focus-cost-percent", 220: "all-resistance-percent", 221: "earth-resistance-percent", 222: "water-resistance-percent", 223: "fire-resistance-percent", 224: "wind-resistance-percent", 225: "light-resistance-percent", 226: "dark-resistance-percent", 227: "parry-percent", }); const MAGIC_SCHOOLS = ["earth", "water", "fire", "wind", "light", "dark"] as const; const SHIELD_SCHOOLS = ["all", "physical", "magic", "earth", "water", "fire", "wind", "light", "dark"] as const; const SHIELD_BASES = ["mana", "hits", "points", "max-health-percent", "healing-scaled-points"] as const; const DOT_RESOURCES = ["health", "mana", "rage", "focus", "energy", "health-percent", "mana-percent"] as const; function hasFlag(word: number | undefined, bit: number): boolean { return (((word ?? 0) >>> bit) & 1) === 1; } function finite(value: number | undefined): number { return Number.isFinite(value) ? Number(value) : 0; } function positiveDuration(component: RomSourceComponent): number { return Math.max(0, finite(component.effectTime)); } function componentSource(component: RomSourceComponent): Pick { return { source: "component", sourceComponentIds: Object.freeze([component.id]) }; } function tooltipSource(): Pick { return { source: "tooltip", sourceComponentIds: Object.freeze([]) }; } function schoolFor(component: RomSourceComponent): string { if (component.effectType === 1) return "physical"; return MAGIC_SCHOOLS[component.magicType] ?? "untyped"; } function resourceForType(type: number): string { return DOT_RESOURCES[type] ?? `source-resource-${type}`; } function slug(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown"; } function cleanTooltip(description: string): string { return description.replace(/\[SC_[^\]]+\]/gi, "").replace(/\s+/g, " ").trim(); } function firstNumber(text: string, pattern: RegExp): number | null { const match = text.match(pattern); return match ? Number(match[1]) : null; } function addIfMissing( rows: RomAbilitySemantic[], semantic: RomAbilitySemantic, discriminator = "", ): void { const duplicate = rows.some((row) => { if (row.kind !== semantic.kind) return false; if (row.kind === "control" && semantic.kind === "control") return row.effect === semantic.effect; if (row.kind === "proc" && semantic.kind === "proc") { return row.trigger === semantic.trigger && row.triggerSkillId === semantic.triggerSkillId; } if (row.kind === "aura" && semantic.kind === "aura") return row.effect === semantic.effect; if (row.kind === "resource" && semantic.kind === "resource") return row.resource === semantic.resource && row.operation === semantic.operation; if (row.kind === "custom" && semantic.kind === "custom") return row.mechanicId === semantic.mechanicId; return discriminator.length === 0; }); if (!duplicate) rows.push(semantic); } function componentControlSemantics(component: RomSourceComponent, rows: RomAbilitySemantic[]): void { const first = component.effectFlagWords?.[0] ?? 0; const second = component.effectFlagWords?.[1] ?? 0; const durationSeconds = positiveDuration(component); const controls: readonly [boolean, string][] = [ [hasFlag(first, 0) || hasFlag(second, 0), "silence"], [hasFlag(first, 1), "disarm"], [hasFlag(first, 6) || hasFlag(second, 8), "root"], [hasFlag(first, 11), "blind"], [hasFlag(first, 12), "knockdown"], [hasFlag(first, 16), "confuse"], [hasFlag(first, 17), "fear"], [hasFlag(first, 24), "stun"], [hasFlag(first, 25), "sleep"], [hasFlag(second, 2), "freeze"], ]; for (const [present, effect] of controls) { if (!present) continue; addIfMissing(rows, { kind: "control", effect, durationSeconds, ...componentSource(component), }); } if (hasFlag(first, 2) || hasFlag(first, 3)) { const effect = hasFlag(first, 2) && hasFlag(first, 3) ? "damage-immunity" : hasFlag(first, 2) ? "magic-damage-immunity" : "physical-damage-immunity"; addIfMissing(rows, { kind: "aura", effect, value: null, modifierType: null, durationSeconds, ...componentSource(component), }); } if (hasFlag(first, 7) || hasFlag(first, 9)) { addIfMissing(rows, { kind: "aura", effect: hasFlag(first, 9) ? "stealth" : "invisibility", value: null, modifierType: null, durationSeconds, ...componentSource(component), }); } } function componentProcSemantics(component: RomSourceComponent, rows: RomAbilitySemantic[]): void { const onAttack = component.onAttackTrigger; if (onAttack && onAttack.id > 0) { addIfMissing(rows, { kind: "proc", trigger: "on-attack", triggerSkillId: onAttack.id, rate: onAttack.rate, rank: onAttack.rank, intervalSeconds: null, ...componentSource(component), }); } const triggerRows = [ ["periodic", component.triggers?.onTimeMagicId, component.triggers?.onTimeSeconds], ["on-hit", component.triggers?.onHitMagicId, null], ["on-timeout", component.triggers?.onBuffTimeoutMagicId, null], ["physical-rebound", component.triggers?.onAttackReboundMagicId, null], ["magic-rebound", component.triggers?.onMagicAttackReboundMagicId, null], ["on-death", component.triggers?.onDeathMagicId, null], ] as const; for (const [trigger, id, intervalSeconds] of triggerRows) { if (!id || id <= 0) continue; addIfMissing(rows, { kind: "proc", trigger, triggerSkillId: id, rate: null, rank: null, intervalSeconds: intervalSeconds ?? null, ...componentSource(component), }); } } function componentSemantics(component: RomSourceComponent, target: RomAbilityTarget, rows: RomAbilitySemantic[]): void { componentControlSemantics(component, rows); componentProcSemantics(component, rows); if (hasFlag(component.specialActionFlags, SPECIAL_ACTION_INTERRUPT_BIT)) { addIfMissing(rows, { kind: "interrupt", lockoutSeconds: 0, ...componentSource(component), }); } if (hasFlag(component.settingFlags, SETTING_CANCEL_BAD_MAGIC_BIT)) { addIfMissing(rows, { kind: "dispel", polarity: "harmful", mode: "remove", ...componentSource(component), }); } if (component.magicFunc === 5) { addIfMissing(rows, { kind: "dispel", polarity: "beneficial", mode: "steal", ...componentSource(component), }); } if (component.magicFunc === 2) { addIfMissing(rows, { kind: "movement", effect: target === "hostile" ? "forced-movement" : "teleport", distance: null, ...componentSource(component), }); } if (component.magicFunc === 8) { addIfMissing(rows, { kind: "resurrection", experiencePercent: finite(component.raise?.experiencePercent), ...componentSource(component), }); } if (component.magicFunc === 3 || (component.summon?.creatureId ?? 0) > 0) { addIfMissing(rows, { kind: "summon", creatureId: finite(component.summon?.creatureId), summonType: finite(component.summon?.type), lifetimeSeconds: finite(component.summon?.lifetimeSeconds), ownerPowerRate: finite(component.summon?.ownerPowerRate), ...componentSource(component), }); } const shield = component.magicShield; if (hasFlag(component.settingFlags, SETTING_MAGIC_SHIELD_BIT) || (shield && (shield.point !== 0 || shield.skillLevelArg !== 0))) { addIfMissing(rows, { kind: "absorb", basis: SHIELD_BASES[shield?.type ?? 2] ?? "points", school: SHIELD_SCHOOLS[shield?.effect ?? 0] ?? "all", amount: finite(shield?.point), perSkillLevel: finite(shield?.skillLevelArg), durationSeconds: positiveDuration(component), ...componentSource(component), }); } const dot = component.dot; if (dot && dot.timeSeconds > 0 && dot.base !== 0) { const resource = resourceForType(dot.type); if (dot.type === 0 || dot.type === 5) { const healing = target !== "hostile" && dot.base > 0; addIfMissing(rows, { kind: healing ? "hot" : "dot", school: schoolFor(component), resource, intervalSeconds: dot.timeSeconds, durationSeconds: positiveDuration(component), basePerTick: Math.abs(dot.base), perSkillLevel: Math.abs(dot.skillLevelArg), ...componentSource(component), }, String(component.id)); } else { addIfMissing(rows, { kind: "resource", resource, operation: dot.base > 0 ? "restore" : "drain", amount: Math.abs(dot.base), intervalSeconds: dot.timeSeconds, durationSeconds: positiveDuration(component), ...componentSource(component), }, String(component.id)); } } const attack = component.attack; if (component.magicFunc === 0 && attack && (attack.damagePower !== 0 || attack.fixedValue !== 0)) { if (attack.type === 0) { const healing = target !== "hostile" && attack.damagePower >= 0 && attack.fixedValue >= 0; addIfMissing(rows, { kind: healing ? "heal" : "damage", school: schoolFor(component), power: Math.abs(attack.damagePower), powerPerSkillLevel: Math.abs(attack.damagePowerSkillLevelArg), fixedValue: Math.abs(attack.fixedValue), fixedType: attack.fixedType, fixedPerSkillLevel: Math.abs(attack.fixedDamageSkillLevelArg), calculationType: attack.calculationType ?? null, ...(healing && attack.calculationType === 8 ? { percentMaxHealth: Math.abs(attack.damagePower) / 100 } : {}), ...componentSource(component), }, String(component.id)); } else { const signedAmount = attack.fixedValue || attack.damagePower; addIfMissing(rows, { kind: "resource", resource: resourceForType(attack.type), operation: signedAmount > 0 ? "restore" : "drain", amount: Math.abs(signedAmount), intervalSeconds: null, ...componentSource(component), }, String(component.id)); } } for (const modifier of component.abilityModifiers ?? []) { if (modifier.type <= 0) continue; addIfMissing(rows, { kind: "aura", effect: MODIFIER_NAMES[modifier.type] ?? `source-modifier-${modifier.type}`, value: modifier.value, modifierType: modifier.type, durationSeconds: positiveDuration(component), ...componentSource(component), }, `${component.id}:${modifier.type}`); } if (hasFlag(component.specialActionFlags, SPECIAL_ACTION_ERASE_HATE_BIT)) { addIfMissing(rows, { kind: "custom", mechanicId: "threat-reset", summary: "Clears the target's hate according to the source SpecialAction flag.", unresolved: false, ...componentSource(component), }); } } function hasKind(rows: readonly RomAbilitySemantic[], kind: RomSemanticKind): boolean { return rows.some((row) => row.kind === kind); } function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySemantic[]): void { const text = cleanTooltip(ability.description); const lower = text.toLowerCase(); const target = ability.target ?? "self"; const durationSeconds = firstNumber(lower, /(?:for|lasts? for)\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0; if (/forces?.*turn towards you|most aggro|\btaunts?\b/.test(lower) && !hasKind(rows, "taunt")) { rows.push({ kind: "taunt", scope: /multiple targets|all targets|range of/.test(lower) ? "area" : "single", hateDelta: Math.max(0, ...(ability.source?.components ?? []).map((component) => finite(component.hateCost))), ...tooltipSource(), }); } if (/(?:removes?(?: all)?|cleanses?|dispels?)[^.]*?(?:curses?|poison|disease|harmful (?:magical )?effects?|cursed status)|\bremove curse\b|removes? target's cursed status/.test(lower) && !hasKind(rows, "dispel")) { rows.push({ kind: "dispel", polarity: "harmful", mode: "remove", ...(/all party members/.test(lower) ? { scope: "party" as const } : {}), ailment: /poison/.test(lower) ? "poison" : /disease/.test(lower) ? "disease" : /curse|cursed/.test(lower) ? "curse" : /magical|magic/.test(lower) ? "magic" : "all", ...tooltipSource(), }); } if (/removes? (?:all |a )?(?:beneficial|positive) effects?|steals? (?:a )?(?:beneficial|positive)/.test(lower) && !hasKind(rows, "dispel")) { rows.push({ kind: "dispel", polarity: "beneficial", mode: /steal/.test(lower) ? "steal" : "remove", ...tooltipSource() }); } for (const [pattern, effect] of [ [/\bstuns?\b/, "stun"], [/\bsilences?\b/, "silence"], [/\broots?\b|cannot move|immobili[sz]/, "root"], [/\bfears?\b/, "fear"], [/\bsleeps?\b|puts?.*to sleep/, "sleep"], [/\bdisarms?\b/, "disarm"], ] as const) { if (!pattern.test(lower)) continue; addIfMissing(rows, { kind: "control", effect, durationSeconds, ...tooltipSource() }); } if (/interrupts?.*(?:spell|cast)|interrupts? target/.test(lower) && !hasKind(rows, "interrupt")) { rows.push({ kind: "interrupt", lockoutSeconds: firstNumber(lower, /cooldown of\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0, ...tooltipSource(), }); } if (/absorbs?|protective shield|take \d+ (?:physical )?attacks? for you/.test(lower) && !hasKind(rows, "absorb")) { rows.push({ kind: "absorb", basis: /maximum hp|max hp/.test(lower) ? "max-health-percent" : /attacks?/.test(lower) ? "hits" : "points", school: /physical/.test(lower) ? "physical" : /magic/.test(lower) ? "magic" : "all", amount: 0, perSkillLevel: 0, durationSeconds, ...tooltipSource(), }); } if (/immune to all damage|immunity to all damage/.test(lower)) { addIfMissing(rows, { kind: "aura", effect: "damage-immunity", value: null, modifierType: null, durationSeconds, ...tooltipSource(), }); } const resistance = lower.match(/(?:increases?|increase).*?(earth|water|fire|wind|light|dark|all) resistance/); if (resistance) { addIfMissing(rows, { kind: "aura", effect: `${resistance[1]}-resistance`, value: null, modifierType: null, durationSeconds, ...tooltipSource(), }); } if (/\bresurrects?\b|\brevives?\b|returns? .* to life/.test(lower) && !hasKind(rows, "resurrection")) { rows.push({ kind: "resurrection", experiencePercent: 0, ...tooltipSource() }); } if (/\bsummons?\b|calls? forth/.test(lower) && !hasKind(rows, "summon")) { rows.push({ kind: "summon", creatureId: 0, summonType: -1, lifetimeSeconds: durationSeconds, ownerPowerRate: 0, ...tooltipSource() }); } if (/\bteleports?\b|\bcharges? (?:at|towards?)\b|\bpull(?:s|ed|ing)? .*?(?:towards?|close to)\b|moves? .* to/.test(lower) && !hasKind(rows, "movement")) { rows.push({ kind: "movement", effect: /pull/.test(lower) ? "pull" : /charge/.test(lower) ? "charge" : "teleport", distance: firstNumber(lower, /(?:distance|range) of\s+(\d+(?:\.\d+)?)/), ...tooltipSource(), }); } if (/resets?.*cooldown|cooldown.*reset/.test(lower) && !hasKind(rows, "cooldown-reset")) { rows.push({ kind: "cooldown-reset", mode: "reset", abilityName: null, seconds: null, ...tooltipSource() }); } else if (/reduces?.*cooldown/.test(lower) && !hasKind(rows, "cooldown-reset")) { rows.push({ kind: "cooldown-reset", mode: "reduce", abilityName: lower.match(/reduces? (.+?) cooldown/)?.[1]?.trim() ?? null, seconds: firstNumber(lower, /by\s+(\d+(?:\.\d+)?)\s+seconds?/), ...tooltipSource(), }); } const resourceMatch = lower.match(/(?:restores?|gains?|rewarding you with)\s+[^.]*?(mana|mp|rage|focus|energy|nature's power|soul points?)/); if (resourceMatch && !rows.some((row) => row.kind === "resource" && row.resource === resourceMatch[1])) { rows.push({ kind: "resource", resource: resourceMatch[1], operation: "restore", amount: firstNumber(resourceMatch[0], /(\d+(?:\.\d+)?)/) ?? 0, intervalSeconds: null, ...tooltipSource(), }); } const conversion = lower.match(/converts?\s+(\d+(?:\.\d+)?)%?\s*(mp|mana|rage|focus|energy)[^.]*?(?:into|to)\s+(\d+(?:\.\d+)?)\s*(?:points? of )?(mana|mp|rage|focus|energy|nature's power|psi points?)/); if (conversion) { addIfMissing(rows, { kind: "resource", resource: conversion[2], operation: "drain", amount: Number(conversion[1]), intervalSeconds: null, ...(conversion[0].includes("%") ? { percentage: true } : {}), ...tooltipSource(), }, "conversion-drain"); addIfMissing(rows, { kind: "resource", resource: conversion[4], operation: "restore", amount: Number(conversion[3]), intervalSeconds: null, ...tooltipSource(), }, "conversion-restore"); } const consumeYield = lower.match(/consumes?\s+(\d+(?:\.\d+)?)\s*(mana|mp|rage|focus|energy)[^.]*?yields?\s+(\d+(?:\.\d+)?)\s*(mana|mp|rage|focus|energy|nature's power|psi points?)/); if (consumeYield) { addIfMissing(rows, { kind: "resource", resource: consumeYield[2], operation: "drain", amount: Number(consumeYield[1]), intervalSeconds: null, ...tooltipSource() }, "yield-drain"); addIfMissing(rows, { kind: "resource", resource: consumeYield[4], operation: "restore", amount: Number(consumeYield[3]), intervalSeconds: null, ...tooltipSource() }, "yield-restore"); } const auraConversion = lower.match(/converts? (?:the )?target's \[?\d+\]? effect to (\d+(?:\.\d+)?) (mana|mp|rage|focus|energy|nature's power|psi points?)/); if (auraConversion) { addIfMissing(rows, { kind: "resource", resource: auraConversion[2], operation: "restore", amount: Number(auraConversion[1]), intervalSeconds: null, ...tooltipSource(), }, "aura-conversion"); } if (/\bbleed|damage over time|damage every|damage each (?:second|time)/.test(lower) && !hasKind(rows, "dot")) { rows.push({ kind: "dot", school: "tooltip-defined", resource: "health", intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0, durationSeconds, basePerTick: 0, perSkillLevel: 0, ...tooltipSource(), }); } if (/restores? .*hp.*(?:every|each) /.test(lower) && !hasKind(rows, "hot")) { rows.push({ kind: "hot", school: "healing", resource: "health", intervalSeconds: firstNumber(lower, /every\s+(\d+(?:\.\d+)?)\s+seconds?/) ?? 0, durationSeconds, basePerTick: 0, perSkillLevel: 0, ...tooltipSource(), }); } if (/(?:deals?|inflicts?|causes?)\b[^.]*\b(?:physical|magical|earth|water|fire|wind|light|dark)?\s*damage/.test(lower) && !hasKind(rows, "damage")) { rows.push({ kind: "damage", school: "tooltip-defined", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, calculationType: null, ...tooltipSource() }); } if (/(?:heals?|restores?|recovers?)\b[^.]*\b(?:hp|health)/.test(lower) && !hasKind(rows, "heal") && !hasKind(rows, "hot")) { const percentMaxHealth = firstNumber(lower, /(\d+(?:\.\d+)?)%[^.]*?(?:hp|health)/); rows.push({ kind: "heal", school: "healing", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, calculationType: null, ...(percentMaxHealth === null ? {} : { percentMaxHealth: percentMaxHealth / 100 }), ...tooltipSource(), }); } if (/protect.*from (?:getting )?poisoned|immune to poison/.test(lower)) { addIfMissing(rows, { kind: "aura", effect: "poison-immunity", value: null, modifierType: null, durationSeconds, ...tooltipSource() }); } if (/\brecall(?:s)? (?:your |the )?pet\b/.test(lower)) { addIfMissing(rows, { kind: "pet", action: "recall", ...tooltipSource() }); } if (/\bpet\b[^.]*\b(?:reborn|resurrected|revived)\b/.test(lower)) { addIfMissing(rows, { kind: "pet", action: "resurrect", ...tooltipSource() }); } if (/^inflicts?\s+(?:\[\d+\][,\s]*(?:and\s*)?)+\s+on\b/.test(lower)) { for (const id of lower.match(/\d{6}/g) ?? []) { addIfMissing(rows, { kind: "trigger-spell", spellId: Number(id), ...tooltipSource() }, id); } } if (ability.passive && /\b(?:increases?|reduces?|adds?|improves?)\b/.test(lower) && !hasKind(rows, "aura") && !hasKind(rows, "cooldown-reset")) { rows.push({ kind: "aura", effect: "passive-modifier", value: null, modifierType: null, durationSeconds: 0, ...tooltipSource() }); } if (/cannot be used|can only be used|requires?\b|from behind|while .* form|when .* active/.test(lower)) { addIfMissing(rows, { kind: "custom", mechanicId: "conditional-use", summary: text, unresolved: false, ...tooltipSource(), }); } if (/\b(?:transforms?|changes? into|converts?|redirects?|copies?|stores?|links?)\b/.test(lower)) { addIfMissing(rows, { kind: "custom", mechanicId: `specialized-${slug(ability.name)}`, summary: text, unresolved: false, ...tooltipSource(), }); } if (rows.length === 0) { const unavailable = !text || /source tooltip unavailable|runewaker skill \d+/.test(lower); rows.push({ kind: "custom", mechanicId: unavailable ? "unresolved-source-effect" : `specialized-${slug(ability.name)}`, summary: text || `Source semantics are unavailable for skill ${ability.sourceSkillId ?? "unknown"}.`, unresolved: unavailable, source: "fallback", sourceComponentIds: Object.freeze(ability.source?.componentIds ? [...ability.source.componentIds] : []), }); } } /** * Converts one generated RuneWaker skill or rank into faithful, engine-neutral * semantics. Unknown mechanics remain explicit custom records; no effect is * ever replaced with an unrelated mark, shield, or damage event. */ export function translateRomAbilitySemantics(ability: RomGeneratedAbilityLike): readonly RomAbilitySemantic[] { const rows: RomAbilitySemantic[] = []; const target = ability.target ?? "self"; for (const component of ability.source?.components ?? []) componentSemantics(component, target, rows); tooltipSemantics(ability, rows); return Object.freeze(rows.map((row) => Object.freeze(row))); } export function romSemanticCoverage(abilities: readonly RomGeneratedAbilityLike[]): RomSemanticCoverage { const translated = abilities.map(translateRomAbilitySemantics); return Object.freeze({ abilities: abilities.length, semantics: translated.reduce((sum, rows) => sum + rows.length, 0), customSemantics: translated.reduce((sum, rows) => sum + rows.filter((row) => row.kind === "custom").length, 0), unresolvedAbilities: translated.filter((rows) => rows.some((row) => row.kind === "custom" && row.unresolved)).length, }); }