import type { AbilityEffect } from "./abilityCatalog"; import type { ActiveAura, AuraDefinition } from "./combatAuras"; /** Current CoA Mage/Druid Green Guardian stack behavior (skill 494331). */ export const ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID = 494331; export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS = 20; export const ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS = 1_000; export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK = 1; export const ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG = "rom-mage-druid-green-guardian"; export const ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION = "While standing still, gain one Green Guardian stack each second, up to 20. Each stack grants 1% movement speed. Moving pauses stack generation. Current CoA also describes an MP restore when a stack is lost, but the loss trigger and percentage basis are unspecified, so that clause is not implemented."; export function romMageDruidGreenGuardianAuraDefinition(): AuraDefinition { return { id: "rom:mage-druid:green-guardian", name: "Green Guardian", description: ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION, disposition: "buff", durationMs: null, maxStacks: ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, stackBehavior: "replace", utilityTags: [ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG], modifiers: [{ kind: "stat", stat: "movement-speed", operation: "percent", value: ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK / 100, perStack: true, }], }; } export function romMageDruidGreenGuardianAuraEffect(): Extract { return { kind: "apply-aura", recipient: "caster", aura: romMageDruidGreenGuardianAuraDefinition(), sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID, }; } export function isRomMageDruidPair(row: { readonly classId?: string; readonly secondaryClassId?: string | null; }): boolean { return row.classId === "rom-mage" && row.secondaryClassId === "rom-druid"; } function boundedStacks(stacks: number): number { if (!Number.isFinite(stacks)) return 0; return Math.max(0, Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, Math.floor(stacks))); } /** Apply one source-described stack gain after an eligible second spent standing still. */ export function romMageDruidGreenGuardianStacksAfterStationarySecond(stacks: number): number { return Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, boundedStacks(stacks) + 1); } /** Percentage points of movement speed granted by the current stack count. */ export function romMageDruidGreenGuardianMovementSpeedPercent(stacks: number): number { return boundedStacks(stacks) * ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK; } export function romMageDruidGreenGuardianMovementSpeedMultiplier( auras: readonly ActiveAura[], targetId: string, now: number, ): number { const stacks = auras .filter((aura) => aura.targetId === targetId && aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) .reduce((total, aura) => total + boundedStacks(aura.stacks), 0); return 1 + romMageDruidGreenGuardianMovementSpeedPercent(stacks) / 100; } export interface RomMageDruidGreenGuardianStationaryProgress { readonly position: readonly [number, number, number]; readonly stationaryMs: number; } export interface RomMageDruidGreenGuardianStationaryAdvance { readonly progress: RomMageDruidGreenGuardianStationaryProgress; readonly stacksGained: number; } /** * Advance one simulator tick. Any observed position change resets progress; * this is the documented one-full-stationary-second approximation for CoA's * unspecific “brief” movement pause. It does not remove existing stacks. */ export function romMageDruidGreenGuardianAdvanceStationaryProgress( previous: RomMageDruidGreenGuardianStationaryProgress | undefined, position: readonly [number, number, number], deltaMs: number, ): RomMageDruidGreenGuardianStationaryAdvance { const safeDelta = Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0); if (!previous) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 }; const moved = (position[0] - previous.position[0]) ** 2 + (position[1] - previous.position[1]) ** 2 + (position[2] - previous.position[2]) ** 2 > 1e-8; if (moved) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 }; const total = Math.max(0, previous.stationaryMs) + safeDelta; return { progress: { position, stationaryMs: total % ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS }, stacksGained: Math.floor(total / ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS), }; }