diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Actualize.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Actualize.cs new file mode 100644 index 0000000000..bb227f30e2 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Actualize.cs @@ -0,0 +1,11 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class Actualize(BossModule module) : Components.RaidwideCast(module, AID.Actualize) +{ + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + base.OnEventCast(caster, spell); + if ((AID)spell.Action.ID == AID.Actualize) + Module.Arena.Bounds = T03Everkeep.NormalBounds; + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/BitterReaping.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/BitterReaping.cs new file mode 100644 index 0000000000..6e6828b108 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/BitterReaping.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class BitterReaping(BossModule module) : Components.SingleTargetCast(module, AID.BitterReapingAOE, "Tank busters"); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Burst.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Burst.cs new file mode 100644 index 0000000000..eff12d6ee8 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/Burst.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class Burst(BossModule module) : Components.StandardAOEs(module, AID.Burst, new AOEShapeCircle(8)); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/CalamitysEdge.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/CalamitysEdge.cs new file mode 100644 index 0000000000..bd9e8f5fff --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/CalamitysEdge.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class CalamitysEdge(BossModule module) : Components.RaidwideCast(module, AID.CalamitysEdge); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ChasmOfVollok.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ChasmOfVollok.cs new file mode 100644 index 0000000000..fa951b581f --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ChasmOfVollok.cs @@ -0,0 +1,57 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Two flavors of Chasm of Vollok exist: +// - Single-cast (Mode A): 37720 cells spawn inside the arena and ARE the damage; 37722 doesn't +// fire. We just render the 37720 cell with its 6.7s cast as the warning. +// - Telegraphed (Mode B): 37720 cells spawn on the OUTER ring (visual only, no damage), then +// 37722 fires 6s later on different cells inside the arena. The user only gets ~0.7s warning +// from 37722 alone, which the AI cannot react to in time. +// +// In Mode B the outer 37720 cell maps deterministically to its inner 37722 damage cell: shift +// ~21.21m (= 30/√2, three 5m cells along the rotated grid) toward the arena center along both +// world X and Z axes. We render the predicted damage cell with 6.7s warning so the AI has time +// to reposition. When 37722 actually fires, its rect also renders (final-moment reminder, +// redundant with the predicted one). +// +// Origin-at-back-corner shape (5f forward + 0 back): cast targets sit at the back corner of each +// 5×5 cell, not the center, so we extend the rect 5 forward from its origin. +class ChasmOfVollok(BossModule module) : Components.GenericAOEs(module) +{ + private readonly Dictionary _previewAoes = []; + private readonly Dictionary _damageAoes = []; + + private static readonly AOEShapeRect _shape = new(5f, 2.5f); + private const float ArenaRadius = 15f; // cells beyond this are outer-ring previews (Mode B) + private const float TranslateMagnitude = 21.21f; // 15√2; shift toward center per world axis + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => + _previewAoes.Values.Concat(_damageAoes.Values); + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.ChasmOfVollokPreview: + var pos = spell.LocXZ; + var dx = pos.X - Module.Center.X; + var dz = pos.Z - Module.Center.Z; + if (dx * dx + dz * dz > ArenaRadius * ArenaRadius) + pos = new WPos(pos.X - TranslateMagnitude * MathF.Sign(dx), + pos.Z - TranslateMagnitude * MathF.Sign(dz)); + _previewAoes[caster.InstanceID] = new AOEInstance(_shape, pos, spell.Rotation, Module.CastFinishAt(spell)); + break; + case AID.ChasmOfVollokAOE: + _damageAoes[caster.InstanceID] = new AOEInstance(_shape, spell.LocXZ, spell.Rotation, Module.CastFinishAt(spell)); + break; + } + } + + public override void OnCastFinished(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.ChasmOfVollokPreview: _previewAoes.Remove(caster.InstanceID); break; + case AID.ChasmOfVollokAOE: _damageAoes.Remove(caster.InstanceID); break; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DawnOfAnAge.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DawnOfAnAge.cs new file mode 100644 index 0000000000..2ee36187fb --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DawnOfAnAge.cs @@ -0,0 +1,50 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Dawn of an Age is the raidwide that also shrinks the arena: when the cast resolves the platform +// collapses to a 20x20 center square for the Chasm of Vollok + Forged Track sequence. Actualize +// restores the full 40x40 arena at the end of that sequence. +class DawnOfAnAge(BossModule module) : Components.RaidwideCast(module, AID.DawnOfAnAge) +{ + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + base.OnEventCast(caster, spell); + if ((AID)spell.Action.ID == AID.DawnOfAnAge) + Module.Arena.Bounds = T03Everkeep.SmallBounds; + } + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + base.AddAIHints(slot, actor, assignment, hints); + // While the cast is up, forbid everything outside the post-shrink diamond so ranged jobs + // pull in toward center before the platform collapses around them. + var center = Module.Center; + var bounds = T03Everkeep.SmallBounds; + foreach (var c in Casters) + hints.AddForbiddenZone(p => !bounds.Contains(p - center), Module.CastFinishAt(c.CastInfo)); + } + + // Cardinal extents of the rotated-45° diamonds: corners sit at half-extent × √2 from center. + private static readonly float OuterCardinal = 20f * MathF.Sqrt(2); + private static readonly float InnerCardinal = 10f * MathF.Sqrt(2); + + public override void DrawArenaBackground(int pcSlot, Actor pc) + { + if (Casters.Count == 0) + return; + // Paint the four diamond wings that will collapse — the donut between SmallBounds and + // NormalBounds, drawn as four quadrilaterals between adjacent diamond cardinals. + var c = Module.Center; + var nO = new WPos(c.X, c.Z - OuterCardinal); + var eO = new WPos(c.X + OuterCardinal, c.Z); + var sO = new WPos(c.X, c.Z + OuterCardinal); + var wO = new WPos(c.X - OuterCardinal, c.Z); + var nI = new WPos(c.X, c.Z - InnerCardinal); + var eI = new WPos(c.X + InnerCardinal, c.Z); + var sI = new WPos(c.X, c.Z + InnerCardinal); + var wI = new WPos(c.X - InnerCardinal, c.Z); + Arena.ZonePoly("doaa-ne", new[] { nO, eO, eI, nI }, ArenaColor.AOE); + Arena.ZonePoly("doaa-se", new[] { eO, sO, sI, eI }, ArenaColor.AOE); + Arena.ZonePoly("doaa-sw", new[] { sO, wO, wI, sI }, ArenaColor.AOE); + Arena.ZonePoly("doaa-nw", new[] { wO, nO, nI, wI }, ArenaColor.AOE); + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DoubleEdgedSwords.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DoubleEdgedSwords.cs new file mode 100644 index 0000000000..79fd514692 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DoubleEdgedSwords.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class DoubleEdgedSwords(BossModule module) : Components.StandardAOEs(module, AID.DoubleEdgedSwordsAOE, new AOEShapeRect(60, 60)); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DutysEdge.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DutysEdge.cs new file mode 100644 index 0000000000..4d45d34ce3 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/DutysEdge.cs @@ -0,0 +1,26 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Duty's Edge fires 4 line-stack hits (37750) after a 4.6s visual (37748), ~2.1s apart. The target +// icon (35567) is picked once per instance. Clear Source after the 4th hit so the rendering goes +// away, and reset NumCasts on each new target so the mechanic works for the second instance. +class DutysEdge(BossModule module) : Components.GenericWildCharge(module, 4, AID.DutysEdgeAOE, 100) +{ + private const int TotalHits = 4; + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.DutysEdgeTarget: + Source = caster; + NumCasts = 0; + foreach (var (i, p) in Raid.WithSlot(true)) + PlayerRoles[i] = p.InstanceID == spell.MainTargetID ? PlayerRole.Target : PlayerRole.Share; + break; + case AID.DutysEdgeAOE: + if (++NumCasts >= TotalHits) + Source = null; + break; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/FireIII.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/FireIII.cs new file mode 100644 index 0000000000..df81d12c16 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/FireIII.cs @@ -0,0 +1,15 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Spread on icon 376; 8 helpers instant-cast Fire III (37752) ~5s later. Forbidden radius is 7m +// (2m wider than the 5m hit) so the AI doesn't clip a neighbor while routing. +class FireIII(BossModule module) : Components.SpreadFromIcon(module, 376, AID.FireIII, 7f, 5.1f) +{ + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + base.AddAIHints(slot, actor, assignment, hints); + // Duty Support NPCs always fan to the perimeter, so running onto the boss leaves the player + // alone in the center — only their own Fire III circle hits, no overlap from a neighbor's. + if (Spreads.Any(s => s.Target == actor)) + hints.GoalZones.Add(hints.GoalSingleTarget(Module.PrimaryActor, 1f, 10f)); + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ForgedTrack.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ForgedTrack.cs new file mode 100644 index 0000000000..fe45ab7fc1 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/ForgedTrack.cs @@ -0,0 +1,55 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Forged Track: 4 intercardinal platforms spawn sword Fangs that telegraph narrow lane charges +// across the small arena. Each preview (37729, 11.6s, 0-hit) has an outer-platform caster; after +// the preview resolves, a separate inner Fang instant-casts 37730 for the actual damage along a +// parallel lane. +// +// The lane relationship between preview and damage: +// - NW-SE diagonal (rot +45° / -135°): damage lane coincides with preview lane (no offset). +// - NE-SW diagonal (rot -45° / +135°): damage lane is 5m perpendicular from preview lane. Offset +// direction is determined per-fang by which lane the outer fang stands on. +// +// The four valid NE-SW lanes within the arena lie at perpendicular offsets of -7.5, -2.5, +2.5, +// +7.5 from arena center (each 5m wide, the arena is 20m across the diamond). They form two +// interleaved pairs: outer fangs spawn on one pair, inner damage fangs on the other. So the +// offset sign alternates between adjacent lanes — `floor(perp / 5)` parity gives the right sign. +class ForgedTrack(BossModule module) : Components.GenericAOEs(module) +{ + private readonly Dictionary _aoes = []; + + public override IEnumerable ActiveAOEs(int slot, Actor actor) + { + // Preview entries linger past activation until the actual 37730 fires — auto-cull stale ones. + var cutoff = WorldState.CurrentTime.AddSeconds(-0.5); + foreach (var id in _aoes.Where(kv => kv.Value.Activation < cutoff).Select(kv => kv.Key).ToList()) + _aoes.Remove(id); + return _aoes.Values; + } + + private static readonly AOEShapeRect _shape = new(20f, 2.5f); + private const float ForwardOffset = 30f; + private const float PerpOffsetMagnitude = 5f; + // Activation = predicted damage moment so the rect stays forbidden through the preview→damage gap. + private const float DamageDelayAfterPreview = 1.4f; + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID != AID.ForgedTrackPreview) + return; + + var dir = spell.Rotation.ToDirection(); + // NE-SW diagonal: dir.X and dir.Z have opposite signs; NW-SE: same signs (no offset). + var perpOffset = default(WDir); + if (dir.X * dir.Z < 0) + { + var orthoL = dir.OrthoL(); + var perp = (caster.Position - Module.Center).Dot(orthoL); + var laneIndex = (int)MathF.Floor(perp / PerpOffsetMagnitude); + var perpSign = laneIndex % 2 == 0 ? 1f : -1f; + perpOffset = orthoL * (PerpOffsetMagnitude * perpSign); + } + var origin = caster.Position + dir * ForwardOffset + perpOffset; + _aoes[caster.InstanceID] = new AOEInstance(_shape, origin, spell.Rotation, Module.CastFinishAt(spell, DamageDelayAfterPreview)); + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfCircuit.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfCircuit.cs new file mode 100644 index 0000000000..87d02108d9 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfCircuit.cs @@ -0,0 +1,5 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class HalfCircuitRect(BossModule module) : Components.StandardAOEs(module, AID.HalfCircuitRect, new AOEShapeRect(60, 60)); +class HalfCircuitDonut(BossModule module) : Components.StandardAOEs(module, AID.HalfCircuitDonut, new AOEShapeDonut(10, 30)); +class HalfCircuitCircle(BossModule module) : Components.StandardAOEs(module, AID.HalfCircuitCircle, new AOEShapeCircle(10)); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfFull.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfFull.cs new file mode 100644 index 0000000000..5dce7ae883 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/HalfFull.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class HalfFull(BossModule module) : Components.StandardAOEs(module, AID.HalfFullAOE, new AOEShapeRect(60, 60)); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/PatricidalPique.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/PatricidalPique.cs new file mode 100644 index 0000000000..f22233eed2 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/PatricidalPique.cs @@ -0,0 +1,3 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class PatricidalPique(BossModule module) : Components.SingleTargetCast(module, AID.PatricidalPique); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SmitingCircuit.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SmitingCircuit.cs new file mode 100644 index 0000000000..d087105c13 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SmitingCircuit.cs @@ -0,0 +1,4 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class SmitingCircuitDonut(BossModule module) : Components.StandardAOEs(module, AID.SmitingCircuitDonutAOE, new AOEShapeDonut(10, 30)); +class SmitingCircuitCircle(BossModule module) : Components.StandardAOEs(module, AID.SmitingCircuitCircleAOE, new AOEShapeCircle(10)); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SoulOverflow.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SoulOverflow.cs new file mode 100644 index 0000000000..2e42585e8d --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/SoulOverflow.cs @@ -0,0 +1,4 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class SoulOverflow(BossModule module) : Components.RaidwideCast(module, AID.SoulOverflow); +class SoulOverflowEnrage(BossModule module) : Components.RaidwideCast(module, AID.SoulOverflowEnrage); diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03Everkeep.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03Everkeep.cs new file mode 100644 index 0000000000..0c2b13a462 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03Everkeep.cs @@ -0,0 +1,21 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +[ModuleInfo(BossModuleInfo.Maturity.Contributed, Contributors = "Gabriel Deleon", PrimaryActorOID = (uint)OID.Boss, GroupType = BossModuleInfo.GroupType.CFC, GroupID = 995, NameID = 12881)] +public class T03Everkeep(WorldState ws, Actor primary) : BossModule(ws, primary, new(100, 100), NormalBounds) +{ + public static readonly ArenaBoundsRect NormalBounds = new(20, 20, 45.Degrees()); + public static readonly ArenaBoundsRect SmallBounds = new(10, 10, 45.Degrees(), 20); + + public Actor? BossP1() => PrimaryActor; + + // The P2 boss actor is re-spawned mid-fight (the old instance is destroyed and a new one with + // the same OID is spawned ~0.4s later during the big arena transition), so we query dynamically + // and skip destroyed instances instead of caching the first one we see. + public Actor? BossP2() => Enemies(OID.BossP2).FirstOrDefault(a => !a.IsDestroyed); + + protected override void DrawEnemies(int pcSlot, Actor pc) + { + Arena.Actor(PrimaryActor, ArenaColor.Enemy); + Arena.Actor(BossP2(), ArenaColor.Enemy); + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepEnums.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepEnums.cs new file mode 100644 index 0000000000..c5855c6995 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepEnums.cs @@ -0,0 +1,86 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +public enum OID : uint +{ + Boss = 0x42A9, // R2.500, phase 1 (human form) + BossP2 = 0x42B4, // phase 2 (Vollok form) - spawns mid-fight + Helper = 0x233C, + ShadowOfTural = 0x43A8, // R0.500, initial add waves (phase 1) + Fang = 0x42AA, // spawn during fight + ShadowOfTuralSword = 0x42AC, // spawn during fight, later wave + ShadowOfTuralSpear = 0x42AD, // spawn during fight, later wave + // 0x42B0..0x42B3 observed in replay; purpose TBD (phase 2 adds or fang variants) + FangSmall = 0x42B6, // R1.000, spawn during fight, Phase 2 Fang that telegraphs Chasm of Vollok preview + HalfCircuitHelper = 0x42B9, // R10.050, spawn during fight, casts Smiting Circuit visuals (shared OID with Ex2) +} + +public enum AID : uint +{ + AutoAttack = 6497, // Boss->player, no cast, single-target + + // === Phase 1 === + SoulOverflow = 37707, // Boss->self, 4.7s cast, raidwide + SoulOverflowEnrage = 37744, // Boss->self, 6.7s cast, raidwide + inflicts bleed DoT (phase transition / enrage) + PatricidalPique = 37715, // Boss->MT, 4.7s cast, single-target tankbuster + CalamitysEdge = 37708, // Boss->self, 4.7s cast, raidwide + Burst = 37709, // ShadowOfTural->self, 7.7s cast, range 8 circle (8 adds spawn in a pattern, each casts) + + // Vorpal Trail: Fang adds charge across arena leaving a trail of circles + VorpalTrailVisual = 37710, // Boss->self, 3.4s cast, single-target visual (mechanic start) + VorpalTrailSprint = 37711, // Fang->self, 0.7s cast, sprint telegraph (fang rotates 90° CW after cast then sprints forward) + VorpalTrailAOE = 37712, // Helper->location, 2.0s cast, rect puddle at sprint waypoint + VorpalTrailInitial = 38183, // Fang->self, instant cast fired at initial sprint start; TargetPos = first waypoint endpoint + VorpalTrailTelegraph = 38184, // Helper->location, 4.0s cast, 0-hit path telegraph along outer perimeter + + // Double-edged Swords: two half-arena cleaves in sequence (forward-then-backward) + DoubleEdgedSwordsVisual = 37713, // Boss->self, 4.1s cast, single-target visual (mechanic start) + DoubleEdgedSwordsAOE = 37714, // Helper->self, 4.7s cast, range 60 width 120 rect (half-arena cleave; 2 casters with opposite rotations fire ~2.3s apart) + + // === Phase 2 (BossP2 / Zoraal Ja Vollok form, OID 0x42B4) === + DawnOfAnAge = 37716, // BossP2->self, 6.7s cast, raidwide + arena transition (distinct from Ex2 AID 37783) + Actualize = 37718, // BossP2->self, 4.7s cast, raidwide (distinct from Ex2 AID 37784 at 5.0s) + + // Chasm of Vollok chain: Vollok spawns Fangs -> preview telegraphs at fang positions -> Sync -> helper AOEs resolve at same positions + Vollok = 37719, // BossP2->self, 3.7s cast, visual (spawns FangSmall actors in a grid, no player damage) + ChasmOfVollokPreview = 37720, // FangSmall->self, 6.7s cast, 5x5 rect telegraph, no player damage + Sync = 37721, // BossP2->self, 4.7s cast, visual (activates AOEs), no player damage + ChasmOfVollokAOE = 37722, // Helper->self, 0.7s cast, range 5 width 5 rect (final damage; positions match preview in Normal - no mirroring) + + // Gateway / Blade Warp / Forged Track chain: boss creates portals -> places swords -> charges along them + Gateway = 37723, // BossP2->self, 3.7s cast, visual (spawns portals/gateways, no player damage) + BladeWarp = 37726, // BossP2->self, 3.7s cast, visual (places swords, no player damage) + ForgedTrackVisual = 37727, // BossP2->self, 3.7s cast, visual (no player damage) + ForgedTrackPreview = 37729, // Helper->self, 11.6s cast, outer-arena sword-path telegraph (0 hits in CST!) - TODO: component + ForgedTrackAOE = 37730, // Fang (OID 0x42AA)->self, instant cast, sword-charge damage along path - TODO: component paired with preview + + // Duty's Edge: target selection (35567) -> boss visual (37748) -> 4 repeated line-stack hits (37749 visual + 37750 damage) + DutysEdgeTarget = 35567, // Helper->player, instant, target marker (shared AID with Ex2) + DutysEdgeVisual = 37748, // BossP2->self, 4.6s cast, visual (mechanic start) + DutysEdgeRepeat = 37749, // BossP2->self, 2.1s cast, visual (fires 4x repeated with 37750) + DutysEdgeAOE = 37750, // Helper->self, instant, range 100 width 8 rect line-stack (distinct from Ex2 AID 38055) + + // Half Full: half-arena cleave (single variant observed in replay, west-facing) + HalfFullVisual = 37737, // BossP2->self, 5.7s cast, visual only (self-status, no player damage) + HalfFullAOE = 37738, // Helper->self, 6.0s cast, range 60 width 120 rect, actual cleave + + // Bitter Reaping: two simultaneous single-target tankbusters on MT + OT (distinct from Ex2 Bitter Whirlwind which is 3-hit tank-swap) + BitterReapingVisual = 37753, // BossP2->self, 4.1s cast, visual only + BitterReapingAOE = 37754, // Helper->player, 4.7s cast, single-target tankbuster (2 simultaneous casts targeting MT + Wuk/OT) + + // Fire III: spread — icon 376 appears on each party member, ~5s later 8 helpers instant-cast a per-target circle on each + FireIII = 37752, // Helper->player, instant cast, single-target spread circle (one per party member) + + // Half Circuit: 3 simultaneous AoEs — always a side-cleave rect + (circle OR donut) center shape + HalfCircuitVisualCircle = 37739, // BossP2->self, 6.7s cast, visual paired with circle variant (shared AID with Ex2) + HalfCircuitVisualDonut = 37740, // BossP2->self, 6.7s cast, visual paired with donut variant (shared AID with Ex2) + HalfCircuitRect = 37741, // Helper->self, 7.0s cast, range 60 width 120 rect (always fires, rotation varies) + HalfCircuitDonut = 37742, // Helper->self, 6.7s cast, range 10-30 donut (center safe) + HalfCircuitCircle = 37743, // Helper->self, 6.7s cast, range 10 circle (outer safe) + + // Smiting Circuit: precursor mechanic to Half Circuit (fires before it), donut OR circle center variant. Distinct from Ex2 where these are visuals. + SmitingCircuitVisual = 37731, // BossP2->self, 6.7s cast, visual only + SmitingCircuitHelperDonut = 37732, // HalfCircuitHelper->self, visual pairing for donut variant (shared AID with Ex2) + SmitingCircuitHelperCircle = 37733, // HalfCircuitHelper->self, visual pairing for circle variant (shared AID with Ex2) + SmitingCircuitDonutAOE = 37734, // Helper->self, 6.7s cast, range 10-30 donut (inside safe) + SmitingCircuitCircleAOE = 37735, // Helper->self, 6.7s cast, range 10 circle (outside safe) +} \ No newline at end of file diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepStates.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepStates.cs new file mode 100644 index 0000000000..b6e42b3f7c --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/T03EverkeepStates.cs @@ -0,0 +1,48 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +class T03EverkeepStates : StateMachineBuilder +{ + private readonly T03Everkeep _module; + + public T03EverkeepStates(T03Everkeep module) : base(module) + { + _module = module; + SimplePhase(0, Phase1, "P1") + .Raw.Update = () => Module.PrimaryActor.IsDeadOrDestroyed || (Module.PrimaryActor.CastInfo?.IsSpell(AID.SoulOverflowEnrage) ?? false); + // P2 ends only when a BossP2 has been defeated (HP = 0). Missing/destroyed BossP2 is not + // sufficient on its own — the actor is briefly destroyed and re-spawned when the arena + // shrinks at the big ENVC block, and we must not unload the module during that gap. + SimplePhase(1, Phase2, "Enrage") + .Raw.Update = () => Module.PrimaryActor.IsDeadOrDestroyed && (_module.BossP2()?.IsDead ?? false); + } + + private void Phase1(uint id) + { + SimpleState(id, 10000, "Enrage") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter(); + } + + private void Phase2(uint id) + { + SimpleState(id, 10000, "Enrage") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter(); + } +} diff --git a/BossMod/Modules/Dawntrail/Trial/T03Everkeep/VorpalTrail.cs b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/VorpalTrail.cs new file mode 100644 index 0000000000..8e70092f4c --- /dev/null +++ b/BossMod/Modules/Dawntrail/Trial/T03Everkeep/VorpalTrail.cs @@ -0,0 +1,134 @@ +namespace BossMod.Dawntrail.Trial.T03Everkeep; + +// Each 42AA Fang does a sequence of sprints (5 inward + 5 outward = 10 segments per fang); the +// rectangle AOE is the line segment each sprint covers, from the fang's current position to the +// sprint endpoint (carried in the cast's TargetPos / LocXZ). +// +// - Initial sprint (VorpalTrailInitial / 38183): instant cast fired when the sprint begins from +// the arena-edge spawn. Extend rect back by 3 so it reaches the diamond corner. +// - Subsequent sprints (VorpalTrailSprint / 37711): 0.7s cast fired while the fang is stationary +// at the current waypoint. Rect spans interior waypoints with no back extension. +// +// One rect per fang is tracked; a new sprint cast replaces the previous rect, and rects auto-expire +// shortly after the sprint would complete. +class VorpalTrail(BossModule module) : Components.GenericAOEs(module) +{ + private readonly Dictionary _fangAOE = []; + private readonly Dictionary _predictedNext = []; // sprint N+1, painted at sprint N's resolution to give the AI extra lead time + private readonly Dictionary _lastUnsafe = []; + private DateTime _mechanicActiveUntil; + private const float SettleSeconds = 4f; // require this much continuous safety before allowing casts + private const float NextSprintExpirationSec = 5f; // covers dash → arrival → next-cast window; the precise rect overwrites once sprint N+1's CST+ fires + private const float CenterAttractRadius = 0.1f; // hold-position tolerance for the center goal zone + private const float CenterAttractWeight = 10f; // strong attractor; rect forbids still override when a sprint crosses center + private const float AIRectHalfWidth = 3.5f; // AI-only padding beyond the 2.5f visible halfwidth so the AI commits to leaving instead of skimming the edge + + public override IEnumerable ActiveAOEs(int slot, Actor actor) + { + var now = WorldState.CurrentTime; + foreach (var id in _fangAOE.Where(kv => kv.Value.Activation < now).Select(kv => kv.Key).ToList()) + _fangAOE.Remove(id); + foreach (var id in _predictedNext.Where(kv => kv.Value.Activation < now).Select(kv => kv.Key).ToList()) + _predictedNext.Remove(id); + return _fangAOE.Values.Concat(_predictedNext.Values); + } + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + // Skipping base.AddAIHints here — we replace its rect-based forbidden zones with widened + // AI-only versions below so the visual stays at 2.5f while the pathfinder treats 3.5f as forbidden. + var now = WorldState.CurrentTime; + var mechanicActive = _fangAOE.Count > 0 || _mechanicActiveUntil > now; + if (!mechanicActive) + { + _lastUnsafe.Remove(actor.InstanceID); + return; + } + foreach (var aoe in ActiveAOEs(slot, actor)) + { + if (!aoe.Risky) + continue; + if (aoe.Shape is AOEShapeRect rect) + { + var paddedHalfWidth = MathF.Max(rect.HalfWidth, AIRectHalfWidth); + var widened = new AOEShapeRect(rect.LengthFront, paddedHalfWidth, rect.LengthBack, rect.DirectionOffset); + hints.AddForbiddenZone(widened, aoe.Origin, aoe.Rotation, aoe.Activation); + } + else + { + hints.AddForbiddenZone(aoe.Check, aoe.Activation); + } + } + // Inverted strategy: park the AI at arena center and let rect avoidance pull it off when a + // sprint actually crosses through. Most pinwheel rects miss exact center between converging + // dashes, so holding station beats dancing around the perimeter trying to read the next sprint. + hints.GoalZones.Add(hints.GoalSingleTarget(Module.Center, CenterAttractRadius, CenterAttractWeight)); + + var inDanger = _fangAOE.Values.Any(a => a.Check(actor.Position)) || _predictedNext.Values.Any(a => a.Check(actor.Position)); + if (inDanger || !_lastUnsafe.ContainsKey(actor.InstanceID)) + _lastUnsafe[actor.InstanceID] = now; + // Suppress casts (incl. instants) until the player has held a safe position long enough — + // the pinwheel cycles fast and a 1.5s GCD that locks movement is enough to clip the next sprint. + if ((now - _lastUnsafe[actor.InstanceID]).TotalSeconds < SettleSeconds) + hints.MaxCastTime = 0; + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (caster.OID != (uint)OID.Fang) + return; + var aid = (AID)spell.Action.ID; + if (aid == AID.VorpalTrailInitial) + { + SetRect(caster.InstanceID, caster.Position, spell.TargetXZ, 4f, 0f, 3f, WorldState.FutureTime(1.5f)); + return; + } + if (aid == AID.VorpalTrailSprint) + { + // Sprint N just resolved: fang dashes from caster.Position (A) to spell.TargetXZ (B). + // Pinwheel is deterministic — sprint N+1 rotates 90° CW with the same dash length. + // Pre-paint that rect now; sprint N+1's CST+ will overwrite the prediction with precise + // geometry once the fang stops at B and starts casting again. + var dirAB = spell.TargetXZ - caster.Position; + if (dirAB.LengthSq() < 0.01f) + return; + var b = spell.TargetXZ; + var c = b + dirAB.OrthoR(); // OrthoR = (-Z, X) = 90° CW; preserves length + SetRectInto(_predictedNext, caster.InstanceID, b, c, 2.5f, 2.5f, 3.0f, WorldState.FutureTime(NextSprintExpirationSec)); + } + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if (caster.OID != (uint)OID.Fang) + return; + if ((AID)spell.Action.ID != AID.VorpalTrailSprint) + return; + // Precise rect supersedes any prediction we put in _predictedNext for this fang. + _predictedNext.Remove(caster.InstanceID); + // Back extension reaches the 233C helper sitting 2 units behind the fang's sprint-start position. + SetRect(caster.InstanceID, caster.Position, spell.LocXZ, 2.5f, 2.5f, 3.0f, Module.CastFinishAt(spell, 1.0f)); + } + + private void SetRect(ulong fangId, WPos from, WPos to, float halfWidth, float frontExt, float backExt, DateTime expiration) + => SetRectInto(_fangAOE, fangId, from, to, halfWidth, frontExt, backExt, expiration); + + private void SetRectInto(Dictionary dict, ulong fangId, WPos from, WPos to, float halfWidth, float frontExt, float backExt, DateTime expiration) + { + var diff = to - from; + var length = diff.Length(); + if (length < 0.1f) + { + dict.Remove(fangId); + return; + } + var mid = from + diff * 0.5f; + var angle = Angle.FromDirection(diff); + dict[fangId] = new AOEInstance(new AOEShapeRect(length * 0.5f + frontExt, halfWidth, length * 0.5f + backExt), mid, angle, expiration); + // Bridge the ~0.7s gaps between sprints + a tail past the final sprint so cast suppression + // doesn't blink off mid-pinwheel just because the active rect just expired. + var until = expiration.AddSeconds(3); + if (until > _mechanicActiveUntil) + _mechanicActiveUntil = until; + } +}