Files
healer-man/src/game/romFiveRotation.ts
T
2026-10-04 13:13:35 -04:00

370 lines
13 KiB
TypeScript

import type { ClassId, RomClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import type { RotationContext, RotationProfile } from "./combatRotation";
import type { PartyRole } from "./partyRoles";
/** An exact RoM five build. These profiles are clean-room heuristics, not imported add-on presets. */
export interface NativeRotationProfile extends RotationProfile {
readonly secondaryClassId: RomClassId;
readonly role: PartyRole;
}
/** Reapply a maintained effect only in this short window before it expires. */
export const ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS = 1_500;
const nativeProfile = (
classId: RomClassId,
secondaryClassId: RomClassId,
role: PartyRole,
abilityPriority: readonly string[],
): NativeRotationProfile => {
const id = `rom-five:${classId}+${secondaryClassId}:${role}`;
return Object.freeze({
id,
specializationId: id,
classId,
secondaryClassId,
role,
abilityPriority: Object.freeze([...abilityPriority]),
});
};
/**
* Exact primary/secondary/role priorities for the native RoM five. Names are
* drawn from the project's active RoM character catalog; normal learned,
* cooldown, resource, target, weapon, and canUse gates still apply.
*/
export const NATIVE_ROTATION_PROFILES: readonly NativeRotationProfile[] = Object.freeze([
nativeProfile("rom-knight", "rom-scout", "tank", [
"Arrow of Vengeance",
"Whirlwind Shield",
"Holy Seal",
"Punishment",
"Holy Strike",
"Heavenly Arrow",
"Truth Shield Bash",
"Shock",
"Sacred Protection",
"Shield of Valor",
"Disregard Danger",
"Resolution",
]),
nativeProfile("rom-priest", "rom-knight", "healer", [
"Urgent Heal",
"Group Heal",
"Wave Armor",
"Heal",
"Regenerate",
"Cleanse",
"Resurrection",
"Divine Incarnation",
"Calm Heart",
"Rising Tide",
]),
nativeProfile("rom-rogue", "rom-warrior", "damage", [
"Assassins Rage",
"Death's Touch",
"Poisonous Infection",
"Decay",
"Wound Attack",
"Low Blow",
"Blind Spot",
"Poison Spray",
"Shadowstab",
"Combo Throw",
]),
nativeProfile("rom-scout", "rom-rogue", "damage", [
"Vampire Arrows",
"Piercing Arrow",
"Weak Spot",
"Deadly Poison Bite",
"Combo Shot",
"Reflected Shot",
"Snipe",
"Wind Arrows",
"Shot",
"Neck Strike",
"Sapping Arrow",
"Blinding Powder",
]),
nativeProfile("rom-mage", "rom-warlock", "damage", [
"Elemental Catalysis",
"Weakening Weave Curse",
"Soul Pain",
"Elemental Weakness",
"Fire Lightning Burst",
"Flame",
"Fireball",
"Lightning",
"Electric Bolt",
"Thunderstorm",
"Meteor Shower",
]),
]);
const PROFILE_BY_TUPLE = new Map<string, NativeRotationProfile>(
NATIVE_ROTATION_PROFILES.map((profile) => [tupleKey(profile.classId, profile.secondaryClassId, profile.role), profile]),
);
function tupleKey(classId: ClassId, secondaryClassId: ClassId, role: PartyRole): string {
return `${classId}|${secondaryClassId}|${role}`;
}
function isRomClassId(classId: ClassId): classId is RomClassId {
return classId.startsWith("rom-");
}
/**
* Return a priority profile only for an exact native five tuple. Pre-secondary
* levels intentionally keep the shared semantic fallback; they do not borrow
* a profile from the primary class alone.
*/
export function nativeRotationProfileFor(context: RotationContext): NativeRotationProfile | null {
if (context.statRuleset !== "rom" || context.level < 10 || !isRomClassId(context.classId)) return null;
const secondaryClassId = context.secondaryClassId;
if (!secondaryClassId || !isRomClassId(secondaryClassId)) return null;
const profile = PROFILE_BY_TUPLE.get(tupleKey(context.classId, secondaryClassId, context.role));
if (!profile) return null;
// Preserve every learned damaging action in the exact build as a lower
// priority fallback, so a newly learned/current-catalog skill is not starved
// merely because this deliberately small policy has not ranked it by name.
const preferred = new Set(profile.abilityPriority);
const fallbackDamage = [...(context.knownDamagingAbilityNames ?? [])]
.filter((name) => !preferred.has(name))
.sort((left, right) => left.localeCompare(right));
return fallbackDamage.length === 0
? profile
: Object.freeze({
...profile,
abilityPriority: Object.freeze([...profile.abilityPriority, ...fallbackDamage]),
});
}
const PERIODIC_ACTION_NAMES = new Set([
"Vampire Arrows",
"Piercing Arrow",
"Deadly Poison Bite",
"Death's Touch",
"Poisonous Infection",
"Poison Spray",
"Decay",
"Soul Pain",
"Weakening Weave Curse",
"Elemental Weakness",
]);
const PREPARATORY_DEBUFF_NAMES = new Set([
"Weak Spot",
"Elemental Weakness",
]);
const MIN_HOSTILE_TARGETS: Readonly<Record<string, number>> = Object.freeze({
"Whirlwind Shield": 2,
"Heavenly Arrow": 2,
"Poison Spray": 2,
"Reflected Shot": 2,
"Thunderstorm": 2,
"Meteor Shower": 2,
});
function healthFraction(health: number, maximum: number): number {
return maximum > 0 ? Math.max(0, Math.min(1, health / maximum)) : 1;
}
function selfHealthFraction(context: RotationContext): number {
return healthFraction(context.health, context.maxHealth);
}
function injuredFriendlyTargets(context: RotationContext) {
return (context.friendlyTargets ?? [])
.filter((target) => !target.fallen && target.health > 0 && target.maxHealth > 0 && target.health < target.maxHealth)
.sort((left, right) => healthFraction(left.health, left.maxHealth) - healthFraction(right.health, right.maxHealth)
|| left.id.localeCompare(right.id));
}
function selectedFriendlyTargetId(context: RotationContext): string | null {
return injuredFriendlyTargets(context)[0]?.id ?? null;
}
function isDurableHostile(context: RotationContext): boolean {
const target = context.hostileTarget;
return Boolean(target && (
target.boss
|| (target.healthMultiplier ?? 1) > 1
|| target.maxHealth >= context.maxHealth * 5
));
}
function hasUsefulSetupWindow(context: RotationContext): boolean {
const target = context.hostileTarget;
return Boolean(target
&& healthFraction(target.health, target.maxHealth) > 0.15
&& (isDurableHostile(context) || target.maxHealth >= context.maxHealth * 2));
}
function maintainedForTarget(
abilityId: string,
targetId: string,
context: RotationContext,
): boolean {
const actorId = context.selfId;
if (!actorId) return false;
if (context.maintainedPeriodicEffects !== undefined) return context.maintainedPeriodicEffects.some((effect) => (
effect.abilityId === abilityId
&& effect.sourceActorId === actorId
&& effect.targetId === targetId
&& effect.remainingMs > ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS
));
// Older callers provide a target-scoped active ability set without duration.
// Treat it conservatively as active until it falls out of that set.
return context.maintainedAbilityIds?.has(abilityId) ?? false;
}
function periodicEffectIsMaintained(ability: AbilityDefinition, context: RotationContext): boolean {
const targetId = context.hostileTarget?.id;
if (!targetId) return false;
const isPeriodic = PERIODIC_ACTION_NAMES.has(ability.name)
|| ability.effects.some((effect) => effect.kind === "dot");
if (!isPeriodic) return false;
return maintainedForTarget(ability.id, targetId, context);
}
function isAreaDamage(ability: AbilityDefinition): boolean {
const hasDamageSemantics = ability.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "dot"
|| effect.kind === "execute"
|| effect.kind === "finisher-damage"
));
const targeting = ability.targeting;
return hasDamageSemantics && (
aiHintsForAbility(ability).categories.includes("area-damage")
|| Boolean(targeting && (
targeting.shape !== "unit"
|| (targeting.maximumTargets ?? 1) > 1
|| (targeting.radius ?? 0) > 0
))
|| (ability.radius ?? 0) > 0
);
}
function periodicSetupHasEnoughUptime(ability: AbilityDefinition, context: RotationContext): boolean {
if (ability.target !== "hostile" || !context.hostileTarget) return true;
// Longer setups are for bosses/elites or targets with enough remaining life
// to tick. Skip them on a near-dead or very short-lived ordinary target.
return hasUsefulSetupWindow(context);
}
function selectedFriendlyPeriodicEffectIsMaintained(
ability: AbilityDefinition,
context: RotationContext,
): boolean {
const targetId = selectedFriendlyTargetId(context);
return targetId ? maintainedForTarget(ability.id, targetId, context) : false;
}
function hasPrerequisiteEffect(context: RotationContext, abilityId: string, targetId: string): boolean {
return maintainedForTarget(abilityId, targetId, context);
}
function isEmergencyHealingContext(context: RotationContext): boolean {
const lowest = injuredFriendlyTargets(context)[0];
return Boolean((lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.45)
|| context.incomingDefensiveMechanic
|| context.incomingMeleeDanger);
}
function allowNativeAction(ability: AbilityDefinition, context: RotationContext): boolean {
const hostileCount = Math.max(1, Math.trunc(context.hostileCount ?? 1));
const minimumHostiles = Math.max(MIN_HOSTILE_TARGETS[ability.name] ?? 1, isAreaDamage(ability) ? 2 : 1);
if (hostileCount < minimumHostiles) return false;
if (periodicEffectIsMaintained(ability, context)) return false;
if ((PERIODIC_ACTION_NAMES.has(ability.name) || ability.effects.some((effect) => effect.kind === "dot"))
&& !periodicSetupHasEnoughUptime(ability, context)) return false;
if (PREPARATORY_DEBUFF_NAMES.has(ability.name) && !hasUsefulSetupWindow(context)) return false;
switch (ability.name) {
case "Arrow of Vengeance":
return context.role === "tank" && context.targetIsLoose === true;
case "Truth Shield Bash":
return context.role === "tank"
&& (Boolean(context.incomingDefensiveMechanic) || context.incomingMeleeDanger === true || selfHealthFraction(context) <= 0.4);
case "Shock":
case "Holy Chains":
case "Neck Strike":
case "Sapping Arrow":
case "Blinding Powder":
return context.requiredControl === true || Boolean(context.hostileTarget?.activeCast?.interruptible);
case "Sacred Protection":
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| context.hasDangerousThreat === true
|| selfHealthFraction(context) <= 0.35;
case "Shield of Valor":
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| selfHealthFraction(context) <= 0.4;
case "Disregard Danger":
return context.needsEscape === true || context.requiredControl === true;
case "Resolution":
return selfHealthFraction(context) <= 0.6;
case "Heavenly Arrow":
return !context.moving && !context.incomingMeleeDanger;
case "Urgent Heal":
return isEmergencyHealingContext(context);
case "Group Heal":
return injuredFriendlyTargets(context).length >= 2;
case "Wave Armor": {
const lowest = injuredFriendlyTargets(context)[0];
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| Boolean(lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.55);
}
case "Regenerate":
return injuredFriendlyTargets(context).length > 0
&& !selectedFriendlyPeriodicEffectIsMaintained(ability, context);
case "Sacrificial Cure": {
// The checked local catalog still contains the historical HP-cost row.
// Never spend health on that stale form; the current CoA version is MP-cost.
const staleHealthCost = (ability.costs?.length ? ability.costs : [ability.cost])
.some((cost) => cost.resource === "health" && cost.amount > 20);
if (staleHealthCost) return false;
const lowest = injuredFriendlyTargets(context)[0];
return Boolean((lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.3)
|| context.incomingDefensiveMechanic);
}
case "Calm Heart":
return context.requiredControl === true;
case "Divine Incarnation":
return injuredFriendlyTargets(context).length >= 2 || isEmergencyHealingContext(context);
case "Assassins Rage":
return hasUsefulSetupWindow(context);
case "Deadly Poison Bite":
// CoA requires Vampire Arrows' current target effect before this follow-up.
return Boolean(context.hostileTarget
&& hasPrerequisiteEffect(context, "rom-scout-general-2", context.hostileTarget.id));
case "Elemental Catalysis":
case "Fire Lightning Burst":
return hasUsefulSetupWindow(context);
case "Soul Stepping":
return context.needsEscape === true;
default:
return true;
}
}
/**
* Native-only semantic guards. A null exact profile means the caller should
* keep its existing generic fallback and should not apply a guessed pair rule.
*/
export function nativeRotationAbilityAllowed(
ability: AbilityDefinition,
context: RotationContext,
): boolean {
if (!nativeRotationProfileFor(context)) return true;
return allowNativeAction(ability, context);
}