370 lines
13 KiB
TypeScript
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);
|
|
}
|