311 lines
11 KiB
TypeScript
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;
|
|
}
|