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( 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> = 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); }