63 lines
3.3 KiB
TypeScript
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");
|
|
}
|