883 lines
32 KiB
TypeScript
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,
|
|
});
|
|
}
|