forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -0,0 +1,369 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user