From bc89951570420669452fbe8380cf5c5da1a75e1c Mon Sep 17 00:00:00 2001 From: Andrew Gilewsky Date: Fri, 10 Jan 2025 01:13:39 +0000 Subject: [PATCH] FRU LR AI WIP --- .../Dawntrail/Ultimate/FRU/FRUConfig.cs | 17 +- .../Dawntrail/Ultimate/FRU/FRUStates.cs | 17 +- .../Dawntrail/Ultimate/FRU/P2Banish.cs | 39 ++- .../Dawntrail/Ultimate/FRU/P2LightRampant.cs | 258 +++++++++++++----- 4 files changed, 252 insertions(+), 79 deletions(-) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs index 7fee41ace8..939db4a30e 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs @@ -64,12 +64,12 @@ public class FRUConfig() : ConfigNode() [PropertyDisplay("P3 Apocalypse: uptime swaps (only consider swaps within prio 1/2 and 3/4, assuming these are melee and ranged)")] public bool P3ApocalypseUptime; - [PropertyDisplay("P4 Darklit Dragonsong: assignments (lower prio stays more clockwise, lowest prio support takes N tower)", separator: true)] + [PropertyDisplay("P4 Darklit Dragonsong: assignments (lower prio stays more clockwise, lowest prio support takes N tower)")] [GroupDetails(["Support prio1", "Support prio2", "Support prio3", "Support prio4", "DD prio1", "DD prio2", "DD prio3", "DD prio4"])] [GroupPreset("Default (healer N)", [2, 3, 0, 1, 4, 5, 6, 7])] public GroupAssignmentUnique P4DarklitDragonsongAssignments = new() { Assignments = [2, 3, 0, 1, 4, 5, 6, 7] }; - [PropertyDisplay("P4 Crystallize Time: assignments for claws (lower prio goes west)")] + [PropertyDisplay("P4 Crystallize Time: assignments for claws (lower prio goes west)", separator: true)] [GroupDetails(["Prio 1", "Prio 2", "Prio 3", "Prio 4", "Prio 5", "Prio 6", "Prio 7", "Prio 8"])] [GroupPreset("Default HTMR", [3, 2, 1, 0, 4, 5, 6, 7])] public GroupAssignmentUnique P4CrystallizeTimeAssignments = new() { Assignments = [3, 2, 1, 0, 4, 5, 6, 7] }; @@ -111,4 +111,17 @@ public class FRUConfig() : ConfigNode() [GroupDetails(["Boss wall right", "Boss wall left", "Boss center", "Boss diagonal", "Mirror wall right", "Mirror wall left", "Mirror center", "Mirror diagonal"])] [GroupPreset("Default", [1, 0, 6, 7, 2, 3, 4, 5])] public GroupAssignmentUnique P2MirrorMirror2SpreadSpots = new() { Assignments = [1, 0, 6, 7, 2, 3, 4, 5] }; + + [PropertyDisplay("P2 Banish after Light Rampant: spread clock spots (supports should be near dd to resolve pairs)", tooltip: "Only used by AI")] + [GroupDetails(["N", "NE", "E", "SE", "S", "SW", "W", "NW"])] + [GroupPreset("Default", [0, 4, 6, 2, 5, 3, 7, 1])] + public GroupAssignmentUnique P2Banish2SpreadSpots = new() { Assignments = [0, 4, 6, 2, 5, 3, 7, 1] }; + + [PropertyDisplay("P2 Banish after Light Rampant: role that moves from their default spread spot to resolve pairs", tooltip: "Only used by AI")] + [PropertyCombo("DD", "Supports")] + public bool P2Banish2SupportsMoveToStack = true; + + [PropertyDisplay("P2 Banish after Light Rampant: direction to move to resolve pairs", tooltip: "Only used by AI")] + [PropertyCombo("CW", "CCW")] + public bool P2Banish2MoveCCWToStack = true; } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs index f9b99c19b7..e601177670 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs @@ -309,25 +309,34 @@ private void P2LightRampant(uint id, float delay) .ActivateOnEnter() .ActivateOnEnter() .ActivateOnEnter() + .ActivateOnEnter() .SetHint(StateMachine.StateHint.DowntimeStart); ComponentCondition(id + 0x20, 4.8f, comp => comp.NumCasts > 0, "Puddle bait"); ComponentCondition(id + 0x30, 3.3f, comp => comp.NumCasts > 0, "Towers") .ActivateOnEnter() + .DeactivateOnExit() .DeactivateOnExit(); - ComponentCondition(id + 0x40, 5.7f, comp => !comp.Active, "Stack") + ComponentCondition(id + 0x38, 3.2f, comp => comp.Casters.Count > 0) .ActivateOnEnter() .ActivateOnEnter() - .DeactivateOnExit() // last puddle is baited right before holy light burst casts start - .DeactivateOnExit(); + .ActivateOnEnter() + .DeactivateOnExit(); // last puddle is baited right before holy light burst casts start + ComponentCondition(id + 0x40, 2.5f, comp => !comp.Active, "Stack") + .DeactivateOnExit() + .DeactivateOnExit(); ComponentCondition(id + 0x50, 2.4f, comp => comp.NumCasts > 0, "Orbs 1") + .ActivateOnEnter() .ActivateOnEnter(); ComponentCondition(id + 0x60, 3, comp => comp.NumCasts > 3, "Orbs 2") + .DeactivateOnExit() .DeactivateOnExit() .DeactivateOnExit(); // tethers resolve right after first orbs - ActorCastStartMulti(id + 0x70, _module.BossP2, [AID.BanishStack, AID.BanishSpread], 1.7f, true); + ActorCastStartMulti(id + 0x70, _module.BossP2, [AID.BanishStack, AID.BanishSpread], 1.7f, true) + .ActivateOnEnter(); ComponentCondition(id + 0x71, 1.9f, comp => comp.NumCasts > 0, "Central tower") .ActivateOnEnter() + .DeactivateOnExit() .DeactivateOnExit() .DeactivateOnExit(); ActorCastEnd(id + 0x72, _module.BossP2, 3.1f, true); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs index 97a3597ba8..a60249fab5 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs @@ -76,9 +76,44 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme // this variant provides hints after rampant class P2Banish2(BossModule module) : P2Banish(module) { + private readonly FRUConfig _config = Service.Config.Get(); + private bool _allowHints; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { - // TODO - base.AddAIHints(slot, actor, assignment, hints); + if (!_allowHints) + return; // don't interfere with tower hints until it's done + var prepos = PrepositionLocation(assignment); + if (prepos != null) + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(prepos.Value, 1), DateTime.MaxValue); + else + base.AddAIHints(slot, actor, assignment, hints); + } + + private WPos? PrepositionLocation(PartyRolesConfig.Assignment assignment) + { + var clockspot = _config.P2Banish2SpreadSpots[assignment]; + if (clockspot < 0) + return null; // no assignment + + var assignedDirection = (180 - 45 * clockspot).Degrees(); + if (Stacks.Count > 0 && Stacks[0].Activation > WorldState.FutureTime(1)) + { + var isSupport = assignment is PartyRolesConfig.Assignment.MT or PartyRolesConfig.Assignment.OT or PartyRolesConfig.Assignment.H1 or PartyRolesConfig.Assignment.H2; + if (_config.P2Banish2SupportsMoveToStack == isSupport) + assignedDirection += (_config.P2Banish2MoveCCWToStack ? 45 : -45).Degrees(); + return Module.Center + 10 * assignedDirection.ToDirection(); + } + else if (Spreads.Count > 0 && Spreads[0].Activation > WorldState.FutureTime(1)) + { + return Module.Center + 13 * assignedDirection.ToDirection(); + } + return null; + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.BrightHunger) + _allowHints = true; } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs index fa22d4dbb9..479ccbb23e 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs @@ -30,64 +30,18 @@ public override void OnUntethered(Actor source, ActorTetherInfo tether) class P2LuminousHammer(BossModule module) : Components.BaitAwayIcon(module, new AOEShapeCircle(6), (uint)IconID.LuminousHammer, ActionID.MakeSpell(AID.LuminousHammer), 7.1f, true) { - private readonly int[] _baitsPerPlayer = new int[PartyState.MaxPartySize]; - private readonly WPos[] _prevBaitPos = new WPos[PartyState.MaxPartySize]; + public readonly int[] BaitsPerPlayer = new int[PartyState.MaxPartySize]; + public readonly WDir[] PrevBaitOffset = new WDir[PartyState.MaxPartySize]; - private const float FirstBaitOffset = 8; - - public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) - { - var bait = ActiveBaitsOn(actor).FirstOrDefault(); - if (bait.Target == null) - return; // no bait, don't care - - switch (_baitsPerPlayer[slot]) - { - case 0: - // position for first bait - var partner = ActiveBaitsNotOn(actor).FirstOrDefault().Target; - if (partner == null) - return; // we can't resolve the hint without knowing the partner - - // logic: - // - if actor and partner are north and south, stay on current side - // - if both are on the same side, the 'more clockwise' one (NE/SW) moves to the opposite side - // TODO: last rule is fuzzy in practice, see if we can adjust better - var north = actor.Position.Z < Module.Center.Z; - if (north == (partner.Position.Z < Module.Center.Z)) - { - // same side, see if we need to swap - var moreRight = actor.Position.X > partner.Position.X; - var moreCW = north == moreRight; - north ^= moreCW; - } - - var preposSpot = Module.Center + new WDir(0, north ? -FirstBaitOffset : FirstBaitOffset); - hints.AddForbiddenZone(ShapeDistance.InvertedCircle(preposSpot, 1), bait.Activation); - break; - case 1: - case 2: - // second/third bait - rotate 45 degrees CW - var secondSpot = Module.Center + (_prevBaitPos[slot] - Module.Center).Rotate(-45.Degrees()); - hints.AddForbiddenZone(ShapeDistance.InvertedCircle(secondSpot, 3)); - break; - case 3: - case 4: - // fourth/fifth bait - move towards N/S wall - var dest = Module.Center + new WDir(0, _prevBaitPos[slot].X > Module.Center.X ? +18 : -18); - var thirdSpot = _prevBaitPos[slot] + 6 * (dest - _prevBaitPos[slot]).Normalized(); - hints.AddForbiddenZone(ShapeDistance.InvertedCircle(thirdSpot, 3)); - break; - } - } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // there are dedicated components for hints public override void OnEventCast(Actor caster, ActorCastEvent spell) { if (spell.Action == WatchedAction && Raid.FindSlot(spell.MainTargetID) is var slot && slot >= 0) { ++NumCasts; - _prevBaitPos[slot] = Raid[slot]?.Position ?? default; - if (++_baitsPerPlayer[slot] == 5) + PrevBaitOffset[slot] = (Raid[slot]?.Position ?? Module.Center) - Module.Center; + if (++BaitsPerPlayer[slot] == 5) CurrentBaits.RemoveAll(b => b.Target == Raid[slot]); // last bait } } @@ -98,17 +52,7 @@ class P2BrightHunger1(BossModule module) : Components.GenericTowers(module, Acti private readonly FRUConfig _config = Service.Config.Get(); private BitMask _forbidden; - public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) - { - // if we have one tower assigned, stay inside it, somewhat closer to the edge - var assignedTowerIndex = Towers.FindIndex(t => !t.ForbiddenSoakers[slot]); - if (assignedTowerIndex >= 0 && Towers.FindIndex(assignedTowerIndex + 1, t => !t.ForbiddenSoakers[slot]) < 0) - { - ref var t = ref Towers.Ref(assignedTowerIndex); - hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center + (t.Position - Module.Center) * 1.125f, 2), t.Activation); // center is at R16, x1.125 == R18 - } - // else: we either have no towers assigned (== doing puddles), or have multiple assigned (== assignments failed), so do nothing - } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // there are dedicated components for hints public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) { @@ -164,18 +108,14 @@ private void RebuildTowers() } // note: we can start showing aoes ~3s earlier if we check spawns, but it's not really needed -class P2HolyLightBurst(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.HolyLightBurst), new AOEShapeCircle(11), 3); +class P2HolyLightBurst(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.HolyLightBurst), new AOEShapeCircle(11), 3) +{ + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // there are dedicated components for hints +} class P2PowerfulLight(BossModule module) : Components.UniformStackSpread(module, 5, 0, 4, 4) { - public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) - { - // TODO: there are several 'stages' here, which need different hints: - // 1. initially (before first holy light burst cast start / last puddle bait, which happen roughly at the same time), we want to stack strictly N/S (unless we are baiting puddles) - // 2. after that, we need to move CW closer to the holy light burst border; if we are stack target, we need to ensure we wait for puddle baiter, otherwise we can just follow stack target very closely - // 3. after this mechanic resolves, we need to dodge HLBs - base.AddAIHints(slot, actor, assignment, hints); - } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // there are dedicated components for hints public override void OnStatusGain(Actor actor, ActorStatus status) { @@ -194,6 +134,8 @@ class P2BrightHunger2(BossModule module) : Components.GenericTowers(module, Acti { private BitMask _forbidden; + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // there are dedicated components for hints + public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.Lightsteeped && status.Extra >= 3) @@ -219,3 +161,177 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) CurrentBaits.Add(new(caster, p, _shape, Module.CastFinishAt(spell, 0.9f))); } } + +// movement to soak towers and bait first 3 puddles (third puddle is baited right before towers resolve) +class P2LightRampantAITowers(BossModule module) : BossComponent(module) +{ + private readonly P2LuminousHammer? _puddles = module.FindComponent(); + private readonly P2BrightHunger1? _towers = module.FindComponent(); + + private const float BaitOffset = 8; + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_puddles == null || _towers == null) + return; + + var bait = _puddles.ActiveBaitsOn(actor).FirstOrDefault(); + if (bait.Target != null) + { + if (_puddles.BaitsPerPlayer[slot] == 0) + { + // position for first bait + var partner = _puddles.ActiveBaitsNotOn(actor).FirstOrDefault().Target; + if (partner == null) + return; // we can't resolve the hint without knowing the partner + + // logic: + // - if actor and partner are north and south, stay on current side + // - if both are on the same side, the 'more clockwise' one (NE/SW) moves to the opposite side + // TODO: last rule is fuzzy in practice, see if we can adjust better + var north = actor.Position.Z < Module.Center.Z; + if (north == (partner.Position.Z < Module.Center.Z)) + { + // same side, see if we need to swap + var moreRight = actor.Position.X > partner.Position.X; + var moreCW = north == moreRight; + north ^= moreCW; + } + + var preposSpot = Module.Center + new WDir(0, north ? -BaitOffset : BaitOffset); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(preposSpot, 1), bait.Activation); + } + else + { + // each next bait is just previous position rotated CW by 45 degrees + // note that this is only really relevant for second and third puddles - after that towers resolve and we use different component + //var nextSpot = Module.Center + BaitOffset * _puddles.PrevBaitOffset[slot].Normalized().Rotate(-45.Degrees()); + //hints.AddForbiddenZone(ShapeDistance.InvertedCircle(nextSpot, 3)); + var shape = ShapeDistance.DonutSector(Module.Center, BaitOffset - 1, BaitOffset + 2, Angle.FromDirection(_puddles.PrevBaitOffset[slot]) - 45.Degrees(), 30.Degrees()); + hints.AddForbiddenZone(p => -shape(p), DateTime.MaxValue); + } + } + else + { + // if we have one tower assigned, stay inside it, somewhat closer to the edge + var assignedTowerIndex = _towers.Towers.FindIndex(t => !t.ForbiddenSoakers[slot]); + if (assignedTowerIndex >= 0 && _towers.Towers.FindIndex(assignedTowerIndex + 1, t => !t.ForbiddenSoakers[slot]) < 0) + { + ref var t = ref _towers.Towers.Ref(assignedTowerIndex); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center + (t.Position - Module.Center) * 1.125f, 2), t.Activation); // center is at R16, x1.125 == R18 + } + // else: we either have no towers assigned (== doing puddles), or have multiple assigned (== assignments failed), so do nothing + } + } +} + +// movement to preposition for resolving stacks +class P2LightRampantAIStack(BossModule module) : BossComponent(module) +{ + private readonly P2LuminousHammer? _puddles = module.FindComponent(); + private readonly P2PowerfulLight? _stack = module.FindComponent(); + private readonly P2HolyLightBurst? _orbs = module.FindComponent(); + + public const float Radius = 18; + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_puddles == null || _stack == null || _orbs == null) + return; + + // initially we don't know whether first orbs will cover N/S, puddle baiter is still relatively far away and has two baits left + // when orbs start, baiter has just finished his puddles; he can still be relatively far away from N/S center, so we might need to wait for him + var northCamp = IsNorthCamp(actor); + var startingDir = (northCamp ? 180 : 0).Degrees(); + var startingPos = Module.Center + new WDir(0, northCamp ? -Radius : Radius); + if (_puddles.ActiveBaits.Any()) + { + // just move to starting position, until all puddles are resolved + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(startingPos, 1), DateTime.MaxValue); + return; + } + + var isStackTarget = _stack.IsStackTarget(actor); + var haveOrbs = _orbs.Casters.Count > 0; + var centerDangerous = haveOrbs && _orbs.ActiveCasters.Any(c => actor.Position.Z - Module.Center.Z is var off && (northCamp ? off < -15 : off > 15)); + var idealDestDir = startingDir - (centerDangerous ? 40 : 20).Degrees(); // alt: haveOrbs ? 20 : 30 (but i don't think it's how people really move...) + var idealPos = Module.Center + Radius * idealDestDir.ToDirection(); + + if (isStackTarget) + { + // as a stack target, our responsibility is to wait for everyone to stack up, then carefully move towards ideal dir + // note that we need to be careful to avoid oscillations + var toIdeal = idealPos - actor.Position; + foreach (var partner in Raid.WithoutSlot().Exclude(actor).Where(p => IsNorthCamp(p) == northCamp)) + { + var toPartner = partner.Position - actor.Position; + var distSq = toPartner.LengthSq(); + if (distSq > 9 && toIdeal.Dot(toPartner) < 0) + { + // partner is far enough away, and moving towards ideal pos will not bring us closer => just stay where we are + return; + } + } + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(idealPos, 1), DateTime.MaxValue); + } + else if (_stack.Stacks.FirstOrDefault(s => IsNorthCamp(s.Target) == northCamp).Target is var stackTarget && stackTarget != null) + { + // otherwise we just want to stay close to the stack target, slightly offset to the ideal position + var dirToIdeal = idealPos - stackTarget.Position; + var dest = dirToIdeal.LengthSq() <= 4 ? idealPos : stackTarget.Position + 2 * dirToIdeal.Normalized(); + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(dest, 1), DateTime.MaxValue); + } + } + + private bool IsNorthCamp(Actor actor) => (_puddles?.ActiveBaitsOn(actor).Any() ?? false) ? actor.Position.X < Module.Center.X : actor.Position.Z < Module.Center.Z; +} + +// movement to dodge orbs after resolving stack +class P2LightRampantAIOrbs(BossModule module) : BossComponent(module) +{ + private readonly P2HolyLightBurst? _orbs = module.FindComponent(); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_orbs == null || _orbs.Casters.Count == 0) + return; + + if (_orbs.NumCasts == 0) + { + // dodge first orbs, while staying near edge + hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, 16)); + } + else + { + // dodge second orbs, while trying to come closer to the center + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center, 6), DateTime.MaxValue); + } + + // actual orb aoes + foreach (var c in _orbs.ActiveCasters) + hints.AddForbiddenZone(_orbs.Shape.Distance(c.Position, default), Module.CastFinishAt(c.CastInfo, -1)); + } +} + +// movement to soak central tower (if needed) and preposition for banish +class P2LightRampantAIResolve(BossModule module) : BossComponent(module) +{ + private readonly FRUConfig _config = Service.Config.Get(); + private readonly P2BrightHunger2? _tower = module.FindComponent(); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + if (_tower == null || _tower.Towers.Count == 0) + return; // no towers + + ref var t = ref _tower.Towers.Ref(0); + hints.AddForbiddenZone(t.ForbiddenSoakers[slot] ? ShapeDistance.Circle(t.Position, t.Radius) : ShapeDistance.InvertedCircle(t.Position, t.Radius), t.Activation); + + var clockspot = _config.P2Banish2SpreadSpots[assignment]; + if (clockspot >= 0) + { + var assignedDirection = (180 - 45 * clockspot).Degrees(); + hints.AddForbiddenZone(ShapeDistance.InvertedCone(t.Position, 50, assignedDirection, 30.Degrees()), DateTime.MaxValue); + } + } +}