Files
healer-man/src/game/romAbilitySemantics.ts
T
2026-09-30 09:57:13 -04:00

883 lines
32 KiB
TypeScript

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 classId?: string;
readonly secondaryClassId?: string;
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<Record<number, string>> = 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<RomSemanticBase, "source" | "sourceComponentIds"> {
return { source: "component", sourceComponentIds: Object.freeze([component.id]) };
}
function tooltipSource(): Pick<RomSemanticBase, "source" | "sourceComponentIds"> {
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") {
const seedBranches = new Set([503815, 620458, 503814, 620457]);
const previousComponent = row.sourceComponentIds[0];
const nextComponent = semantic.sourceComponentIds[0];
if (row.effect === semantic.effect && previousComponent !== nextComponent
&& seedBranches.has(previousComponent) && seedBranches.has(nextComponent)) return false;
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,
});
}