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>; /** 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; }