Files
healer-man/src/game/coaGuardianFormationRuntime.ts
T
2026-10-06 22:25:34 -04:00

311 lines
11 KiB
TypeScript

import type { ClassId } from "../app/characterCatalog";
import type { AbilityDefinition, AbilityEffect, MobStatusKind } from "./abilityCatalog";
import {
type ActiveAura,
type AuraDefinition,
type AuraModifier,
} from "./combatAuras";
export const COA_GUARDIAN_TOWER_FORMATION_SPELL_ID = 800317;
export const COA_GUARDIAN_LINE_FORMATION_SPELL_ID = 803130;
export const COA_GUARDIAN_FOOTMANS_CALLING_TALENT_ENTRY_ID = 4019;
export const COA_GUARDIAN_TOWER_FORMATION_AURA_ID = "coa:800317:effect:0:aura:87";
export const COA_GUARDIAN_LINE_FORMATION_AURA_ID = "coa:803130:effect:0:aura:232";
export const COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP = "coa-guardian-formation";
export const COA_GUARDIAN_FORMATION_DURATION_MS = null;
export const COA_GUARDIAN_FOOTMANS_CALLING_DAMAGE_REDUCTION = 0.1;
export const COA_GUARDIAN_LINE_ROOT_DURATION_REDUCTION = 0.25;
export const COA_GUARDIAN_LINE_MOVEMENT_SPEED_MULTIPLIER = 1.15;
export const COA_GUARDIAN_TOWER_SHIELD_ARMOR_BONUS = 0.5;
const FOOTMANS_CALLING_RANK_KEY = `coa-entry-${COA_GUARDIAN_FOOTMANS_CALLING_TALENT_ENTRY_ID}`;
export interface CoaGuardianFormationActor {
readonly id: string;
readonly classId: ClassId;
readonly talentRanks: Readonly<Record<string, number | undefined>>;
/** Omitted health is treated as alive for call sites that only hold a combat snapshot. */
readonly health?: number;
}
export type CoaGuardianFormation = "tower" | "line";
const formationAuraId = (formation: CoaGuardianFormation): string => (
formation === "tower"
? COA_GUARDIAN_TOWER_FORMATION_AURA_ID
: COA_GUARDIAN_LINE_FORMATION_AURA_ID
);
const towerModifiers: readonly AuraModifier[] = [
{
kind: "stat",
stat: "block",
operation: "flat",
value: 10,
perStack: false,
},
{
kind: "damage",
direction: "dealt",
operation: "percent",
value: -0.1,
},
];
const lineModifiers: readonly AuraModifier[] = [
{
kind: "stat",
stat: "movement-speed",
operation: "percent",
value: 0.15,
},
];
/** Source-specific Tower effects; shield armor is projected from equipped gear at runtime. */
export const COA_GUARDIAN_TOWER_FORMATION_AURA: AuraDefinition = {
id: COA_GUARDIAN_TOWER_FORMATION_AURA_ID,
name: "Tower Formation",
description: "Increases block chance and shield armor contribution, but reduces damage dealt.",
disposition: "buff",
durationMs: COA_GUARDIAN_FORMATION_DURATION_MS,
stackBehavior: "replace",
uniqueBySource: true,
exclusiveGroup: COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP,
modifiers: towerModifiers,
};
/** Source-specific Line effects; miscValue 7 on Aura 232 narrows this to root controls. */
export const COA_GUARDIAN_LINE_FORMATION_AURA: AuraDefinition = {
id: COA_GUARDIAN_LINE_FORMATION_AURA_ID,
name: "Line Formation",
description: "Reduces root duration and increases movement speed.",
disposition: "buff",
durationMs: COA_GUARDIAN_FORMATION_DURATION_MS,
stackBehavior: "replace",
uniqueBySource: true,
exclusiveGroup: COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP,
modifiers: lineModifiers,
};
function actorCanUseFormation(actor: CoaGuardianFormationActor): boolean {
return actor.classId === "guardian"
&& (actor.health === undefined || (Number.isFinite(actor.health) && actor.health > 0));
}
function isActiveOwnedFormationAura(
aura: ActiveAura,
actor: CoaGuardianFormationActor,
formation: CoaGuardianFormation,
now: number,
): boolean {
return actorCanUseFormation(actor)
&& aura.definition.id === formationAuraId(formation)
&& aura.sourceId === actor.id
&& aura.targetId === actor.id
&& (aura.expiresAt === null || aura.expiresAt > now);
}
/** True only for the actor's own live, self-targeted Guardian formation marker. */
export function hasCoaGuardianFormationAura(
auras: readonly ActiveAura[],
actor: CoaGuardianFormationActor,
formation: CoaGuardianFormation,
now: number,
): boolean {
return auras.some((aura) => isActiveOwnedFormationAura(aura, actor, formation, now));
}
export function hasCoaGuardianFootmansCalling(actor: CoaGuardianFormationActor): boolean {
return actor.classId === "guardian"
&& Math.max(0, Math.trunc(actor.talentRanks[FOOTMANS_CALLING_RANK_KEY] ?? 0)) > 0;
}
function isProjectedFootmansCallingModifier(modifier: AuraModifier): boolean {
return modifier.kind === "damage"
&& modifier.direction === "taken"
&& modifier.operation === "percent"
&& modifier.value === -COA_GUARDIAN_FOOTMANS_CALLING_DAMAGE_REDUCTION
&& modifier.school === undefined
&& modifier.attackKind === undefined
&& modifier.perStack === false;
}
function isProjectedTowerShieldArmorModifier(modifier: AuraModifier): boolean {
return modifier.kind === "stat"
&& modifier.stat === "armor"
&& modifier.operation === "flat"
&& modifier.perStack === false;
}
/**
* Return shield armor contributed by Tower Formation. The input is the equipped
* shield's already-resolved armor value, not total armor or resistance.
*/
export function getCoaGuardianTowerShieldArmorBonus(
actor: CoaGuardianFormationActor,
auras: readonly ActiveAura[],
now: number,
shieldArmor: number,
): number {
if (!hasCoaGuardianFormationAura(auras, actor, "tower", now) || !Number.isFinite(shieldArmor)) return 0;
return Math.max(0, shieldArmor) * COA_GUARDIAN_TOWER_SHIELD_ARMOR_BONUS;
}
/**
* Project source-owned Guardian formation behavior into an aura view. Footman's
* Calling is conditional on the actor's own live form and talent; Tower's shield
* armor bonus uses only the equipped shield's resolved armor contribution.
*/
export function projectCoaGuardianFormationAuras(
auras: readonly ActiveAura[],
actor: CoaGuardianFormationActor,
now: number,
shieldArmor = 0,
): readonly ActiveAura[] {
if (!actorCanUseFormation(actor)) return auras;
const hasFootmansCalling = hasCoaGuardianFootmansCalling(actor);
const towerShieldArmorBonus = getCoaGuardianTowerShieldArmorBonus(actor, auras, now, shieldArmor);
let changed = false;
const projected = auras.map((aura) => {
const formation = aura.definition.id === COA_GUARDIAN_TOWER_FORMATION_AURA_ID
? "tower"
: aura.definition.id === COA_GUARDIAN_LINE_FORMATION_AURA_ID ? "line" : null;
if (!formation || !isActiveOwnedFormationAura(aura, actor, formation, now)) return aura;
const currentModifiers = aura.definition.modifiers ?? [];
const baseModifiers = currentModifiers.filter((modifier) => (
!isProjectedFootmansCallingModifier(modifier)
&& !(formation === "tower" && isProjectedTowerShieldArmorModifier(modifier))
));
const extraModifiers: AuraModifier[] = [];
if (hasFootmansCalling) {
extraModifiers.push({
kind: "damage",
direction: "taken",
operation: "percent",
value: -COA_GUARDIAN_FOOTMANS_CALLING_DAMAGE_REDUCTION,
perStack: false,
});
}
if (formation === "tower" && towerShieldArmorBonus > 0) {
extraModifiers.push({
kind: "stat",
stat: "armor",
operation: "flat",
value: towerShieldArmorBonus,
perStack: false,
});
}
if (extraModifiers.length === 0 && baseModifiers.length === currentModifiers.length) return aura;
changed = true;
return {
...aura,
definition: {
...aura.definition,
modifiers: [...baseModifiers, ...extraModifiers],
},
};
});
return changed ? projected : auras;
}
/** Aura 232 carries miscValue 7 (Root in the project's source mechanic mapping). */
export function resolveCoaGuardianControlDurationMs(
durationMs: number,
actor: CoaGuardianFormationActor,
auras: readonly ActiveAura[],
mechanic: MobStatusKind,
now: number,
): number {
if (mechanic !== "root"
|| !Number.isFinite(durationMs)
|| durationMs <= 0
|| !hasCoaGuardianFormationAura(auras, actor, "line", now)) return durationMs;
return Math.max(0, Math.round(durationMs * (1 - COA_GUARDIAN_LINE_ROOT_DURATION_REDUCTION)));
}
/** Movement multiplier for PlayerRig/party paths that do not read aura stat modifiers. */
export function coaGuardianFormationMovementSpeedMultiplier(
auras: readonly ActiveAura[],
actor: CoaGuardianFormationActor,
now: number,
): number {
return hasCoaGuardianFormationAura(auras, actor, "line", now)
? COA_GUARDIAN_LINE_MOVEMENT_SPEED_MULTIPLIER
: 1;
}
function formationAuraEffect(
aura: AuraDefinition,
sourceAuraType: number,
miscValue: number,
): AbilityEffect {
return {
kind: "apply-aura",
aura,
recipient: "caster",
sourceEffectType: 6,
sourceAuraType,
miscValue,
triggerSpellId: 0,
};
}
/** Replace misconverted generic source effects with each formation's runtime-owned marker. */
export function coaGuardianFormationAbilityForCast(ability: AbilityDefinition): AbilityDefinition {
if (ability.classId !== "guardian") return ability;
if (ability.dbcSpellId === COA_GUARDIAN_TOWER_FORMATION_SPELL_ID) {
return {
...ability,
target: "self",
description: "Increases block chance by 10% and the armor contribution of your shield by 50%, but reduces your damage dealt by 10%. Replaces your current formation.",
effects: [formationAuraEffect(COA_GUARDIAN_TOWER_FORMATION_AURA, 87, 0)],
};
}
if (ability.dbcSpellId === COA_GUARDIAN_LINE_FORMATION_SPELL_ID) {
return {
...ability,
target: "self",
description: "Reduces root duration by 25% and increases movement speed by 15%. Replaces your current formation.",
effects: [formationAuraEffect(COA_GUARDIAN_LINE_FORMATION_AURA, 232, 7)],
};
}
return ability;
}
export interface CoaGuardianCompanionFormationOptions {
readonly actor: CoaGuardianFormationActor;
/** The Vanguard tank role is authored to prefer Tower; other Guardian roles prefer Line. */
readonly role: "vanguard-tank" | "other";
readonly towerAvailable: boolean;
readonly lineAvailable: boolean;
readonly auras: readonly ActiveAura[];
readonly now: number;
}
/** Choose a preferred form only when no own active form is already present. */
export function coaGuardianCompanionFormationSpellId(
options: CoaGuardianCompanionFormationOptions,
): number | null {
const { actor, auras, now } = options;
if (!actorCanUseFormation(actor)
|| hasCoaGuardianFormationAura(auras, actor, "tower", now)
|| hasCoaGuardianFormationAura(auras, actor, "line", now)) return null;
const candidates = options.role === "vanguard-tank"
? [
[options.towerAvailable, COA_GUARDIAN_TOWER_FORMATION_SPELL_ID],
[options.lineAvailable, COA_GUARDIAN_LINE_FORMATION_SPELL_ID],
] as const
: [
[options.lineAvailable, COA_GUARDIAN_LINE_FORMATION_SPELL_ID],
[options.towerAvailable, COA_GUARDIAN_TOWER_FORMATION_SPELL_ID],
] as const;
return candidates.find(([available]) => available)?.[1] ?? null;
}