Files
healer-man/src/game/aggro.ts
T
2026-08-14 15:56:39 -04:00

122 lines
3.5 KiB
TypeScript

export const PLAYER_AGGRO_ID = "__player__";
export type AggroActorId = string;
export type AggroRole = "tank" | "healer" | "damage";
export type ThreatTable = Readonly<Record<AggroActorId, number>>;
export interface ThreatSource {
readonly actorId: AggroActorId;
readonly role: AggroRole;
readonly abilityId?: string;
/** Final scalar applied after the role multiplier, typically from talents. */
readonly threatScale?: number;
}
export interface ForcedTarget {
readonly actorId: AggroActorId;
readonly endsAt: number;
}
export const TANK_DAMAGE_THREAT_MULTIPLIER = 5;
export const DAMAGE_THREAT_MULTIPLIER = 1;
export const HEALING_THREAT_MULTIPLIER = 0.25;
export const PACK_PULL_THREAT = 1;
function safeThreat(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function threatFromDamage(amount: number, role: AggroRole, threatScale = 1): number {
return safeThreat(amount) * (
role === "tank" ? TANK_DAMAGE_THREAT_MULTIPLIER : DAMAGE_THREAT_MULTIPLIER
) * safeThreat(threatScale);
}
export function threatFromHealing(amount: number, engagedMobCount: number): number {
const count = Math.max(0, Math.trunc(engagedMobCount));
return count > 0 ? safeThreat(amount) * HEALING_THREAT_MULTIPLIER / count : 0;
}
export function addActorThreat(
table: ThreatTable,
actorId: AggroActorId,
amount: number,
): ThreatTable {
const added = safeThreat(amount);
if (!actorId || added <= 0) return table;
return {
...table,
[actorId]: safeThreat(table[actorId] ?? 0) + added,
};
}
export function seedActorThreat(
table: ThreatTable,
actorId: AggroActorId,
amount = PACK_PULL_THREAT,
): ThreatTable {
if (!actorId || safeThreat(table[actorId] ?? 0) > 0) return table;
return addActorThreat(table, actorId, amount);
}
export function tauntThreat(
table: ThreatTable,
actorId: AggroActorId,
): ThreatTable {
const maximum = Object.values(table).reduce((highest, value) => (
Math.max(highest, safeThreat(value))
), 0);
return {
...table,
[actorId]: maximum + 1,
};
}
export interface AggroTargetResolution {
readonly targetActorId: AggroActorId | null;
readonly forcedTarget: ForcedTarget | null;
}
export function resolveAggroTarget(
table: ThreatTable,
eligibleActorIds: ReadonlySet<AggroActorId>,
currentTargetActorId: AggroActorId | null,
forcedTarget: ForcedTarget | null,
now: number,
): AggroTargetResolution {
const activeForced = forcedTarget
&& forcedTarget.endsAt > now
&& eligibleActorIds.has(forcedTarget.actorId)
? forcedTarget
: null;
if (activeForced) {
return { targetActorId: activeForced.actorId, forcedTarget: activeForced };
}
const ranked = Object.entries(table)
.filter(([actorId, threat]) => eligibleActorIds.has(actorId) && safeThreat(threat) > 0)
.sort(([leftId, leftThreat], [rightId, rightThreat]) => (
safeThreat(rightThreat) - safeThreat(leftThreat)
|| Number(rightId === currentTargetActorId) - Number(leftId === currentTargetActorId)
|| leftId.localeCompare(rightId)
));
return {
targetActorId: ranked[0]?.[0] ?? null,
forcedTarget: null,
};
}
export function pruneThreatTable(
table: ThreatTable,
eligibleActorIds: ReadonlySet<AggroActorId>,
): ThreatTable {
const entries = Object.entries(table).filter(([actorId, threat]) => (
eligibleActorIds.has(actorId) && safeThreat(threat) > 0
));
if (
entries.length === Object.keys(table).length
&& entries.every(([actorId, threat]) => table[actorId] === threat)
) return table;
return Object.fromEntries(entries);
}