Files
healer-man/src/game/healingAbilityRules.ts
T
2026-09-28 22:46:45 -04:00

63 lines
3.3 KiB
TypeScript

import type { AbilityDefinition, AbilityHealingRecipients } from "./abilityCatalog";
import type { CombatActorState } from "./combatActors";
import type { TimedCombatEffect } from "./combatStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { targetingRulesForAbility } from "./abilityRules";
type Position = readonly [number, number, number];
/** Healing riders retain their own recipients even when the cast targets an enemy. */
export function healingRecipientIds(
effect: AbilityHealingRecipients,
sourceId: string,
targetId: string | null,
actors: Readonly<Record<string, CombatActorState>>,
positionFor: (id: string) => Position | null,
fallback: readonly string[],
hasLineOfSight: (from: Position, to: Position) => boolean,
seed = 0,
): readonly string[] {
if (effect.scope !== "party") return effect.recipient === "caster" ? [sourceId] : fallback;
const center = positionFor(effect.center === "ability-target" && targetId ? targetId : sourceId);
if (!center) return [];
const candidates = Object.values(actors)
.filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0)
.filter(actor => !effect.excludeCaster || actor.id !== sourceId)
.filter(actor => {
const position = positionFor(actor.id);
return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2
&& hasLineOfSight(center, position);
})
.sort((a, b) => effect.selection === "lowest-max-health"
? a.maxHealth - b.maxHealth || a.id.localeCompare(b.id)
: a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
if (effect.selection === "random" && candidates.length > 0) {
let hash = 2166136261;
for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619);
const offset = Math.floor((hash >>> 0) / 0x1_0000_0000 * candidates.length);
return [...candidates.slice(offset), ...candidates.slice(0, offset)].slice(0, effect.maximumTargets ?? 1).map(actor => actor.id);
}
return candidates.slice(0, effect.maximumTargets ?? Infinity).map(actor => actor.id);
}
export function healingAbilityCanAidOthers(ability: AbilityDefinition): boolean {
return ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
&& "scope" in effect && effect.scope === "party")
|| (ability.target !== "self" && ability.target !== "hostile");
}
/** Let an existing HoT finish rather than resetting its first tick on every GCD. */
export function healingAbilityHasPendingHot(
ability: AbilityDefinition, sourceId: string, targetId: string, effects: readonly TimedCombatEffect[],
): boolean {
return ability.effects.some(effect => effect.kind === "hot") && effects.some(effect => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id && effect.sourceActorId === sourceId
&& (effect.targetId ?? PLAYER_AGGRO_ID) === targetId && effect.remainingTicks > 1);
}
export function healingAbilityIsArea(ability: AbilityDefinition): boolean {
const rules = targetingRulesForAbility(ability);
return rules.shape !== "unit" || (rules.maximumTargets ?? 1) > 1 || (rules.radius ?? ability.radius ?? 0) > 0
|| ability.effects.some(effect => "scope" in effect && effect.scope === "party");
}