From f187ebe01f61368ef61dba3385bd635a48a98817 Mon Sep 17 00:00:00 2001 From: Andrew Gilewsky Date: Tue, 10 Dec 2024 21:32:56 +0000 Subject: [PATCH] FRU WIP - P2 fixes, P3 up to apoc --- BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs | 1 + .../Modules/Dawntrail/Ultimate/FRU/FRUAI.cs | 4 +- .../Dawntrail/Ultimate/FRU/FRUConfig.cs | 9 + .../Dawntrail/Ultimate/FRU/FRUEnums.cs | 32 ++- .../Dawntrail/Ultimate/FRU/FRUStates.cs | 72 ++++- .../Dawntrail/Ultimate/FRU/P2DiamondDust.cs | 2 +- .../Dawntrail/Ultimate/FRU/P2MirrorMirror.cs | 21 +- .../Dawntrail/Ultimate/FRU/P3Apocalypse.cs | 255 ++++++++++++++++++ .../Ultimate/FRU/P3UltimateRelativity.cs | 181 ++++++++++--- 9 files changed, 534 insertions(+), 43 deletions(-) create mode 100644 BossMod/Modules/Dawntrail/Ultimate/FRU/P3Apocalypse.cs diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs index 1695705d54..a2ffcb2ac7 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs @@ -5,6 +5,7 @@ class P2QuadrupleSlap(BossModule module) : Components.TankSwap(module, ActionID.MakeSpell(AID.QuadrupleSlapFirst), ActionID.MakeSpell(AID.QuadrupleSlapFirst), ActionID.MakeSpell(AID.QuadrupleSlapSecond), 4.1f, null, true); class P2CrystalOfLight(BossModule module) : Components.Adds(module, (uint)OID.CrystalOfLight); class P3Junction(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.Junction)); +class P3BlackHalo(BossModule module) : Components.CastSharedTankbuster(module, ActionID.MakeSpell(AID.BlackHalo), new AOEShapeCone(60, 45.Degrees())); // TODO: verify angle [ModuleInfo(BossModuleInfo.Maturity.WIP, GroupType = BossModuleInfo.GroupType.CFC, GroupID = 1006, NameID = 9707, PlanLevel = 100)] public class FRU(WorldState ws, Actor primary) : BossModule(ws, primary, new(100, 100), new ArenaBoundsCircle(20)) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUAI.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUAI.cs index 1b7648383c..3c449ff128 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUAI.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUAI.cs @@ -47,9 +47,9 @@ public override void Execute(StrategyValues strategy, Actor? primaryTarget, floa private WPos DragToCenterPosition(FRU module) { - if (module.PrimaryActor.Position.Z >= 100) + if (module.PrimaryActor.Position.Z >= module.Center.Z - 1) return module.Center - new WDir(0, 6); // boss is positioned, go to N clockspot - var dragSpot = module.Center + new WDir(0, 7.5f); // we need to stay approx here, it's fine to overshoot a little bit - then when boss teleports, it won't turn + var dragSpot = module.Center + new WDir(0, 7.75f); // we need to stay approx here, it's fine to overshoot a little bit - then when boss teleports, it won't turn var meleeSpot = ClosestInMelee(dragSpot, module.PrimaryActor); return UptimeDowntimePos(dragSpot, meleeSpot, 0, GCD); } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs index 064c6a2952..9ceba00781 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs @@ -42,4 +42,13 @@ public class FRUConfig() : ConfigNode() [GroupDetails(["1", "2", "3", "4"])] [GroupPreset("HTTH/RMMR", [1, 2, 0, 3, 1, 2, 0, 3])] public GroupAssignmentDDSupportPairs P3UltimateRelativityAssignment = GroupAssignmentDDSupportPairs.DefaultMeleeTogether(); + + [PropertyDisplay("P3 Apocalypse: assignments (G1 CCW from N, G2 CW from NE, in case of conflict 'lower' number flexes)")] + [GroupDetails(["G1 prio1", "G1 prio2", "G1 prio3", "G1 prio4", "G2 prio1", "G2 prio2", "G2 prio3", "G2 prio4"])] + [GroupPreset("TTHH/MMRR", [0, 1, 2, 3, 4, 5, 6, 7])] + [GroupPreset("TMRH/TMRH", [0, 4, 3, 7, 1, 5, 2, 6])] + public GroupAssignmentUnique P3ApocalypseAssignments = GroupAssignmentUnique.DefaultRoles(); + + [PropertyDisplay("P3 Apocalypse: uptime swaps (only consider swaps within prio 1/2 and 3/4, assuming these are melee and ranged)")] + public bool P3ApocalypseUptime; } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs index a608b61607..bff165f58f 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs @@ -25,6 +25,7 @@ public enum OID : uint BossP3 = 0x45A7, // R7.040, x0 (spawn during fight) DelightsHourglass = 0x45A8, // R1.000, x0 (spawn during fight) + ApocalypseLight = 0x1EB0FF, // R0.500, x0 (spawn during fight), EventObj type } public enum AID : uint @@ -103,7 +104,7 @@ public enum AID : uint DiamondDust = 40197, // BossP2->self, 5.0s cast, range 40 circle, raidwide AxeKick = 40202, // OraclesReflection->self, 6.0s cast, range 16 circle ScytheKick = 40203, // OraclesReflection/BossP2->self, 6.0s cast, range 4-20 donut - HouseOfLight = 40206, // Helper->self, no cast, range 60 ?-degree cone, baited on 4 closest + HouseOfLight = 40206, // Helper->self, no cast, range 60 30-degree cone, baited on 4 closest FrigidStone = 40199, // Helper->location, no cast, range 5 circle, baited on icons IcicleImpact = 40198, // Helper->location, 9.0s cast, range 10 circle, circles at cardinals/intercardinals FrigidNeedleCircle = 40200, // Helper->self, 5.0s cast, range 5 circle @@ -154,6 +155,7 @@ public enum AID : uint // P3 Junction = 40226, // Helper->self, no cast, range 40 circle, raidwide HellsJudgment = 40265, // BossP3->self, 4.0s cast, range 100 circle, maxhp-1 raidwide + AutoAttackP3 = 40264, // BossP3->player, no cast, single-target TeleportP3 = 40117, // BossP3->location, no cast, single-target UltimateRelativity = 40266, // BossP3->self, 10.0s cast, range 100 circle, raidwide + mechanic start @@ -166,6 +168,26 @@ public enum AID : uint UltimateRelativitySinboundMeltdownAOEFirst = 40235, // Helper->self, no cast, range 60 width 5 rect UltimateRelativitySinboundMeltdownAOERest = 40292, // Helper->self, no cast, range 50 width 5 rect UltimateRelativityDarkBlizzard = 40279, // Helper->player, no cast, range ?-12 donut + UltimateRelativityShadoweye = 40278, // Helper->self, no cast, gaze + DarkEruption = 40274, // Helper->player, no cast, range 6 circle + DarkWater = 40271, // Helper->players, no cast, range 6 circle, 4-man stack + ShellCrusher = 40286, // BossP3->self, 3.0s cast, single-target, visual (stack) + ShellCrusherAOE = 40287, // BossP3->players, no cast, range 6 circle stack + + ShockwavePulsar = 40282, // BossP3->self, 5.0s cast, range 40 circle, raidwide + BlackHalo = 40290, // BossP3->self/player, 5.0s cast, range 60 ?-degree cone, shared tankbuster + + SpellInWaitingRefrain = 40269, // BossP3->self, 2.0s cast, single-target, visual (next dark water is staggered) + ApocalypseDarkWater = 40270, // BossP3->self, 5.0s cast, single-target, visual (apply staggered stacks) + ApocalypseDarkWaterVisual = 40272, // Helper->player, no cast, single-target, visual (player stacks) + Apocalypse = 40296, // BossP3->self, 4.0s cast, single-target, visual (exploding lights) + ApocalypseAOE = 40297, // Helper->self, no cast, range 9 circle + SpiritTaker = 40288, // BossP3->self, 3.0s cast, single-target, visual (jump on random target) + SpiritTakerAOE = 40289, // BossP3->player, no cast, range 5 circle, jump on random target, knockback 40 on everyone else in aoe + ApocalypseDarkEruption = 40273, // BossP3->self, 4.0+1.0s cast, single-target, visual (spread) + DarkestDance = 40181, // BossP3->self, 5.0s cast, single-target, visual (baited tankbuster) + DarkestDanceBait = 40182, // BossP3->players, no cast, range 8 circle, baited tankbuster + DarkestDanceKnockback = 40183, // BossP3->self, no cast, range 40 circle, ??? } public enum SID : uint @@ -188,6 +210,10 @@ public enum SID : uint SpellInWaitingDarkBlizzard = 2462, // none->player, extra=0x0, donut SpellInWaitingReturn = 2464, // none->player, extra=0x0 DelightsHourglassRotation = 2970, // none->DelightsHourglass, extra=0x10D (ccw)/0x15C (cw) + Return = 2452, // none->player, extra=0x0 + Stun = 4163, // none->player, extra=0x0 + //SpellInWaitingRefrain = 4373, // BossP3->BossP3, extra=0x0 + //_Gen_ = 2458, // none->player, extra=0x0 } public enum IconID : uint @@ -196,6 +222,10 @@ public enum IconID : uint FrigidStone = 345, // player->self HallowedRay = 525, // BossP2->player LuminousHammer = 375, // player->self + BlackHalo = 259, // player->self + DelayedDarkWater = 62, // player->self + DarkWater = 184, // player->self + DarkEruption = 139, // player->self } public enum TetherID : uint diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs index 6adfd4261b..577b33a30a 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs @@ -46,6 +46,8 @@ private void Phase3(uint id) { P3JunctionHellsJudgment(id, 13.3f); P3UltimateRelativity(id + 0x10000, 4.3f); + P3BlackHalo(id + 0x20000, 3.2f); + P3Apocalypse(id + 0x30000, 7.2f); SimpleState(id + 0xFF0000, 100, "???"); } @@ -358,8 +360,74 @@ private void P3UltimateRelativity(uint id, float delay) ComponentCondition(id + 0x60, 4.9f, comp => comp.NumCasts >= 5, "Spread/stack 3") .ActivateOnEnter() .DeactivateOnExit(); - ComponentCondition(id + 0x70, 5.1f, comp => comp.NumCasts >= 6, "Lasers 3") + ComponentCondition(id + 0x70, 6.1f, comp => comp.NumCasts >= 6, "Lasers 3") .DeactivateOnExit(); - // TODO: resolve > stack > tankbuster + ComponentCondition(id + 0x80, 2.8f, comp => comp.NumReturnStuns > 0, "Return") + .ActivateOnEnter() // note: there are no hints for stack or eruption, as they should have been resolved earlier... + .SetHint(StateMachine.StateHint.DowntimeStart); + + ActorCastStart(id + 0x90, _module.BossP3, AID.ShellCrusher, 3.9f, true, "Relativity resolve") + .DeactivateOnExit() + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.DowntimeEnd); + ActorCastEnd(id + 0x91, _module.BossP3, 3, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x92, 0.4f, comp => comp.Stacks.Count == 0, "Stack") + .DeactivateOnExit(); + + ActorCast(id + 0x1000, _module.BossP3, AID.ShockwavePulsar, 3.5f, 5, true, "Raidwide") + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Raidwide); + } + + private void P3BlackHalo(uint id, float delay) + { + ActorCast(id, _module.BossP3, AID.BlackHalo, delay, 5, true) + .ActivateOnEnter(); + ComponentCondition(id + 2, 0.2f, comp => comp.NumCasts > 0, "Tankbuster") + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Tankbuster); + } + + private void P3Apocalypse(uint id, float delay) + { + ActorCast(id, _module.BossP3, AID.SpellInWaitingRefrain, delay, 2, true); + ActorCast(id + 0x10, _module.BossP3, AID.ApocalypseDarkWater, 3.2f, 5, true); + ComponentCondition(id + 0x12, 0.6f, comp => comp.NumStatuses >= 6) + .ActivateOnEnter() + .ActivateOnEnter() + .ExecOnExit(comp => comp.ShowOrder(1)); + ActorCast(id + 0x20, _module.BossP3, AID.Apocalypse, 2.6f, 4, true); + ActorCastStart(id + 0x30, _module.BossP3, AID.SpiritTaker, 2.2f, true); + ComponentCondition(id + 0x31, 1.3f, comp => comp.Stacks.Count == 0, "Stack 1"); + ActorCastEnd(id + 0x32, _module.BossP3, 1.7f, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x33, 0.3f, comp => comp.Spreads.Count == 0, "Jump") + .DeactivateOnExit(); + ActorCastStart(id + 0x40, _module.BossP3, AID.ApocalypseDarkEruption, 6.2f, true) + .ExecOnEnter(comp => comp.Show(8.5f)) + .ActivateOnEnter(); + ComponentCondition(id + 0x41, 2.4f, comp => comp.NumCasts > 0, "Apocalypse start"); + ActorCastEnd(id + 0x42, _module.BossP3, 1.6f, true); + ComponentCondition(id + 0x43, 0.4f, comp => comp.NumCasts > 4); + ComponentCondition(id + 0x44, 0.7f, comp => comp.NumFinishedSpreads > 0, "Spread") + .DeactivateOnExit() + .ExecOnExit(comp => comp.ShowOrder(2)); + ComponentCondition(id + 0x45, 1.3f, comp => comp.NumCasts > 10); + ActorCastStart(id + 0x50, _module.BossP3, AID.DarkestDance, 1.3f, true); + ComponentCondition(id + 0x51, 0.7f, comp => comp.NumCasts > 16); + ComponentCondition(id + 0x52, 2.0f, comp => comp.NumCasts > 22); + ComponentCondition(id + 0x53, 0.5f, comp => comp.Stacks.Count == 0, "Stack 2"); + ComponentCondition(id + 0x54, 1.5f, comp => comp.NumCasts > 22) + .ActivateOnEnter() + .DeactivateOnExit(); + ActorCastEnd(id + 0x55, _module.BossP3, 0.3f, true); + ComponentCondition(id + 0x56, 0.4f, comp => comp.NumCasts > 0, "Tankbuster") + .DeactivateOnExit() + .ExecOnExit(comp => comp.ShowOrder(3)) + .SetHint(StateMachine.StateHint.Tankbuster); + // +2.8s: kb + // +???: stack 3 + // then: shockwave pulsar raidwide > memory end enrage } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs index adbbc4b18e..6a2918841a 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs @@ -19,7 +19,7 @@ class P2DiamondDustHouseOfLight(BossModule module) : Components.GenericBaitAway( private Actor? _source; private DateTime _activation; - private static readonly AOEShapeCone _shape = new(60, 20.Degrees()); // TODO: verify angle + private static readonly AOEShapeCone _shape = new(60, 15.Degrees()); public override void Update() { diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs index 5eee631122..7ee5d68cf8 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs @@ -2,7 +2,7 @@ class P2MirrorMirrorReflectedScytheKickBlue(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.ReflectedScytheKickBlue)) { - private WPos _position; + private WDir _blueMirror; private AOEInstance? _aoe; private static readonly AOEShapeDonut _shape = new(4, 20); @@ -11,20 +11,27 @@ class P2MirrorMirrorReflectedScytheKickBlue(BossModule module) : Components.Gene public override void DrawArenaForeground(int pcSlot, Actor pc) { - if (_position != default) - Arena.Actor(_position, Angle.FromDirection(Module.Center - _position), ArenaColor.Object); + if (_blueMirror != default) + { + Arena.Actor(Module.Center + 20 * _blueMirror, Angle.FromDirection(-_blueMirror), ArenaColor.Object); + if (_aoe == null) + { + // draw hint for melees + Arena.AddCircle(Module.Center - 11 * _blueMirror, 1, ArenaColor.Safe); + } + } } public override void OnCastStarted(Actor caster, ActorCastInfo spell) { - if ((AID)spell.Action.ID == AID.ScytheKick && _position != default) - _aoe = new(_shape, _position, default, Module.CastFinishAt(spell)); + if ((AID)spell.Action.ID == AID.ScytheKick && _blueMirror != default) + _aoe = new(_shape, Module.Center + 20 * _blueMirror, default, Module.CastFinishAt(spell)); } public override void OnEventEnvControl(byte index, uint state) { if (index is >= 1 and <= 8 && state == 0x00020001) - _position = Module.Center + 20 * (225 - index * 45).Degrees().ToDirection(); + _blueMirror = (225 - index * 45).Degrees().ToDirection(); } } @@ -41,7 +48,7 @@ class P2MirrorMirrorHouseOfLight(BossModule module) : Components.GenericBaitAway private WPos _mirror; private readonly List<(Actor source, DateTime activation)> _sources = []; - private static readonly AOEShapeCone _shape = new(60, 20.Degrees()); // TODO: verify angle + private static readonly AOEShapeCone _shape = new(60, 15.Degrees()); public override void Update() { diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3Apocalypse.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3Apocalypse.cs new file mode 100644 index 0000000000..63948fe00d --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3Apocalypse.cs @@ -0,0 +1,255 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P3Apocalypse(BossModule module) : Components.GenericAOEs(module) +{ + private Angle? _starting; + private Angle _rotation; + private readonly List _aoes = []; + + private static readonly AOEShapeCircle _shape = new(9); + + public void Show(float delay) + { + void addAOE(WPos pos, DateTime activation) => _aoes.Add(new(_shape, pos, default, activation)); + void addPair(WDir offset, DateTime activation) + { + addAOE(Module.Center + offset, activation); + addAOE(Module.Center - offset, activation); + } + void addAt(int position, DateTime activation) + { + if (position >= 0 && _starting != null) + addPair(14 * (_starting.Value + _rotation * position).ToDirection(), activation); + else if (position == -1) + addPair(default, activation); + } + + var activation = WorldState.FutureTime(delay); + for (int i = -1; i < 6; ++i) + { + addAt(i + 1, activation); + addAt(i, activation); + addAt(i - 1, activation); + activation = activation.AddSeconds(2); + } + } + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => _aoes.Take(6); + + public override void OnActorCreated(Actor actor) + { + if ((OID)actor.OID == OID.ApocalypseLight) + { + if (actor.Position.AlmostEqual(Module.Center, 1)) + { + if (_starting == null) + _starting = actor.Rotation; + else if (!_starting.Value.AlmostEqual(actor.Rotation + 180.Degrees(), 0.1f)) + ReportError($"Inconsistent starting dir"); + } + else + { + var rot = 0.5f * (actor.Rotation - Angle.FromDirection(actor.Position - Module.Center)).Normalized(); + if (_rotation == default) + _rotation = rot; + else if (!_rotation.AlmostEqual(rot, 0.1f)) + ReportError($"Inconsistent rotation dir"); + } + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.ApocalypseAOE) + { + ++NumCasts; + var index = _aoes.FindIndex(aoe => aoe.Origin.AlmostEqual(caster.Position, 1)); + if (index >= 0) + _aoes.RemoveAt(index); + else + ReportError($"Failed to find aoe @ {caster.Position}"); + } + } +} + +class P3ApocalypseDarkWater(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4, includeDeadTargets: true) +{ + public struct State + { + public int Order; + public int AssignedGroup; + public DateTime Expiration; + } + + public int NumStatuses; + public readonly State[] States = new State[PartyState.MaxPartySize]; + private readonly FRUConfig _config = Service.Config.Get(); + private string _swaps = ""; + + // for uptime swaps, there are 6 possible swaps within each 'subgroup': no swaps, p1 with p1/p2, p2 with p1/p2 and both + private static readonly BitMask[] _uptimeSwaps = [default, BitMask.Build(0, 4), BitMask.Build(0, 5), BitMask.Build(1, 4), BitMask.Build(1, 5), BitMask.Build(0, 1, 4, 5)]; + + public void ShowOrder(int order) + { + for (int i = 0; i < States.Length; ++i) + if (States[i].Order == order && Raid[i] is var player && player != null) + AddStack(player, States[i].Expiration); + } + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + ref var state = ref States[slot]; + if (state.AssignedGroup > 0) + hints.Add($"Group: {state.AssignedGroup}", false); + if (state.Order > 0) + hints.Add($"Order: {state.Order}", false); + } + + public override void AddGlobalHints(GlobalHints hints) + { + if (_swaps.Length > 0) + hints.Add(_swaps); + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingDarkWater && Raid.FindSlot(actor.InstanceID) is var slot && slot >= 0) + { + States[slot].Expiration = status.ExpireAt; + States[slot].Order = (status.ExpireAt - WorldState.CurrentTime).TotalSeconds switch + { + < 15 => 1, + < 34 => 2, + _ => 3, + }; + if (++NumStatuses == 6) + InitAssignments(); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.DarkWater) + Stacks.Clear(); + } + + private void InitAssignments() + { + Span assignmentPerSlot = [-1, -1, -1, -1, -1, -1, -1, -1]; + Span slotPerAssignment = [-1, -1, -1, -1, -1, -1, -1, -1]; + foreach (var (slot, group) in _config.P3ApocalypseAssignments.Resolve(Raid)) + { + States[slot].AssignedGroup = group < 4 ? 1 : 2; + assignmentPerSlot[slot] = group; + slotPerAssignment[group] = slot; + } + + if (slotPerAssignment[0] < 0) + return; // no valid assignments + + var swap = _config.P3ApocalypseUptime ? FindUptimeSwap(slotPerAssignment) : FindStandardSwap(slotPerAssignment); + for (int role = 0; role < slotPerAssignment.Length; ++role) + { + if (swap[role]) + { + var slot = slotPerAssignment[role]; + ref var state = ref States[slot]; + state.AssignedGroup = 3 - state.AssignedGroup; + if (_swaps.Length > 0) + _swaps += ", "; + _swaps += Raid[slot]?.Name ?? ""; + } + } + _swaps = $"Swaps: {(_swaps.Length > 0 ? _swaps : "none")}"; + } + + private bool IsSwapValid(BitMask assignmentSwaps, ReadOnlySpan slotPerAssignment) + { + BitMask result = default; // bits 0-3 are set if order N is in G1, 4-7 for G2 + for (int role = 0; role < slotPerAssignment.Length; ++role) + { + ref var state = ref States[slotPerAssignment[role]]; + var isGroup2 = state.AssignedGroup == (assignmentSwaps[role] ? 1 : 2); + result.Set(state.Order + (isGroup2 ? 4 : 0)); + } + return result.Raw == 0xFF; + } + + private BitMask FindUptimeSwap(ReadOnlySpan slotPerAssignment) + { + // search for first valid swap, starting with swaps that don't touch higher prios + foreach (var highSwap in _uptimeSwaps) + { + foreach (var lowSwap in _uptimeSwaps) + { + var swap = lowSwap ^ new BitMask(highSwap.Raw << 2); + if (IsSwapValid(swap, slotPerAssignment)) + return swap; + } + } + ReportError("Failed to find uptime swap"); + return FindStandardSwap(slotPerAssignment); + } + + private BitMask FindStandardSwap(ReadOnlySpan slotPerAssignment) + { + BitMask swap = default; + Span assignmentPerOrder = [-1, -1, -1, -1]; + for (int role = 0; role < slotPerAssignment.Length; ++role) + { + var slot = slotPerAssignment[role]; + var order = States[slot].Order; + ref var partner = ref assignmentPerOrder[order]; + if (partner < 0) + partner = role; + else if ((role < 4) == (partner < 4)) + swap.Set(partner); + // else: partner is naturally in other group + } + return swap; + } +} + +class P3SpiritTaker(BossModule module) : Components.UniformStackSpread(module, 0, 5) +{ + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.SpiritTaker) + AddSpreads(Raid.WithoutSlot(true), Module.CastFinishAt(spell, 0.3f)); + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.SpiritTakerAOE) + Spreads.Clear(); + } +} + +class P3ApocalypseDarkEruption(BossModule module) : Components.SpreadFromIcon(module, (uint)IconID.DarkEruption, ActionID.MakeSpell(AID.DarkEruption), 6, 5.1f); + +class P3DarkestDanceBait(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.DarkestDanceBait), centerAtTarget: true) +{ + private Actor? _source; + private DateTime _activation; + + private static readonly AOEShapeCircle _shape = new(8); + + public override void Update() + { + CurrentBaits.Clear(); + if (_source != null && Raid.WithoutSlot().Farthest(_source.Position) is var target && target != null) + { + CurrentBaits.Add(new(_source, target, _shape, _activation)); + } + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.DarkestDance) + { + ForbiddenPlayers = Raid.WithSlot(true).WhereActor(p => p.Role != Role.Tank).Mask(); + _source = caster; + _activation = Module.CastFinishAt(spell, 0.4f); + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs index 7e5289f842..e2c97cbee9 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs @@ -10,14 +10,19 @@ public struct PlayerState public int LaserOrder; public bool HaveDark; public WDir AssignedDir; + public WPos ReturnPos; } public readonly PlayerState[] States = new PlayerState[PartyState.MaxPartySize]; + public readonly List<(Actor origin, Angle rotation, DateTime activation)> LaserRotations = []; + public int NumReturnStuns; private readonly FRUConfig _config = Service.Config.Get(); private WDir _relNorth; private int _numYellowTethers; private DateTime _nextProgress; + public Angle LaserRotationAt(WPos pos) => LaserRotations.FirstOrDefault(r => r.origin.Position.AlmostEqual(pos, 1)).rotation; + public override void AddHints(int slot, Actor actor, TextHints hints) { if (States[slot].RewindOrder == 0) @@ -31,8 +36,13 @@ public override void AddHints(int slot, Actor actor, TextHints hints) public override void DrawArenaForeground(int pcSlot, Actor pc) { var assignedDir = States[pcSlot].AssignedDir; - if (assignedDir != default) - Arena.AddCircle(Module.Center + RangeHint(States[pcSlot], pc.Class.IsSupport(), NumCasts) * assignedDir, 1, ArenaColor.Safe); + if (assignedDir != default && NumCasts < 6) + { + var safespot = Module.Center + RangeHint(States[pcSlot], pc.Class.IsSupport(), NumCasts) * assignedDir; + if (IsBaitingLaser(States[pcSlot], NumCasts) && LaserRotationAt(safespot) is var rot && rot != default) + safespot += 2 * (Angle.FromDirection(assignedDir) - 4.5f * rot).ToDirection(); + Arena.AddCircle(safespot, 1, ArenaColor.Safe); + } } public override void OnStatusGain(Actor actor, ActorStatus status) @@ -73,6 +83,36 @@ public override void OnStatusGain(Actor actor, ActorStatus status) if (slot >= 0) States[slot].RewindOrder = (status.ExpireAt - WorldState.CurrentTime).TotalSeconds < 20 ? 1 : 2; break; + case SID.DelightsHourglassRotation: + var rot = status.Extra switch + { + 0x10D => 15.Degrees(), + 0x15C => -15.Degrees(), + _ => default + }; + if (rot != default) + LaserRotations.Add((actor, rot, status.ExpireAt)); + else + ReportError($"Unexpected rotation status param: {status.Extra:X}"); + break; + case SID.Return: + slot = Raid.FindSlot(actor.InstanceID); + if (slot >= 0) + States[slot].ReturnPos = actor.Position; + break; + case SID.Stun: + ++NumReturnStuns; + break; + } + } + + public override void OnStatusLose(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.Stun: + --NumReturnStuns; + break; } } @@ -146,6 +186,14 @@ private void InitAssignments() _ => 180.Degrees() }; + private bool IsBaitingLaser(in PlayerState state, int order) => order switch + { + 1 => state.LaserOrder == 1, + 3 => state.LaserOrder == 2, + 5 => state.LaserOrder == 3, + _ => false + }; + private float RangeHint(in PlayerState state, bool isSupport, int order) => order switch { 0 => state.FireOrder == 1 ? 12 : 5, @@ -191,21 +239,21 @@ public override void OnStatusGain(Actor actor, ActorStatus status) class P3UltimateRelativitySinboundMeltdownBait(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.UltimateRelativitySinboundMeltdownAOEFirst)) { private readonly P3UltimateRelativity? _rel = module.FindComponent(); - private readonly List<(Actor origin, Angle rotation, DateTime activation)> _rotations = []; private static readonly AOEShapeRect _shape = new(60, 2.5f); - public Angle RotationAt(WPos pos) => _rotations.FirstOrDefault(r => r.origin.Position.AlmostEqual(pos, 1)).rotation; - public override void Update() { CurrentBaits.Clear(); - for (int i = NumCasts; i < _rotations.Count; ++i) + if (_rel != null) { - var closest = Raid.WithoutSlot().Closest(_rotations[i].origin.Position); - if (closest != null) + for (int i = NumCasts; i < _rel.LaserRotations.Count; ++i) { - CurrentBaits.Add(new(_rotations[i].origin, closest, _shape, _rotations[i].activation)); + var closest = Raid.WithoutSlot().Closest(_rel.LaserRotations[i].origin.Position); + if (closest != null) + { + CurrentBaits.Add(new(_rel.LaserRotations[i].origin, closest, _shape, _rel.LaserRotations[i].activation)); + } } } } @@ -213,6 +261,9 @@ public override void Update() // TODO: hints for proper baiting?.. public override void AddHints(int slot, Actor actor, TextHints hints) { + if (CurrentBaits.Count == 0) + return; + var shouldBait = (_rel?.States[slot].LaserOrder ?? 0) == CurrentOrder; var baitIndex = CurrentBaits.FindIndex(b => b.Target == actor); var isBaiting = baitIndex >= 0; @@ -231,33 +282,16 @@ public override void DrawArenaForeground(int pcSlot, Actor pc) foreach (ref var b in CurrentBaits.AsSpan()) { - if (b.Target == pc && b.Source.Position.AlmostEqual(AssignedHourglass(pcSlot), 1)) + if (b.Target == pc && b.Source.Position.AlmostEqual(AssignedHourglass(pcSlot), 1) && _rel != null) { // draw extra rotation hints for correctly baited hourglass - var rot = RotationAt(b.Source.Position); + var rot = _rel.LaserRotationAt(b.Source.Position); for (int i = 1; i < 10; ++i) _shape.Outline(Arena, b.Source.Position, b.Rotation + i * rot); } } } - public override void OnStatusGain(Actor actor, ActorStatus status) - { - if ((OID)actor.OID == OID.DelightsHourglass && (SID)status.ID == SID.DelightsHourglassRotation) - { - var rot = status.Extra switch - { - 0x10D => 15.Degrees(), - 0x15C => -15.Degrees(), - _ => default - }; - if (rot != default) - _rotations.Add((actor, rot, status.ExpireAt)); - else - ReportError($"Unexpected rotation status param: {status.Extra:X}"); - } - } - private int CurrentOrder => NumCasts switch { < 3 => 1, @@ -271,7 +305,7 @@ public override void OnStatusGain(Actor actor, ActorStatus status) class P3UltimateRelativitySinboundMeltdownAOE(BossModule module) : Components.GenericAOEs(module) { - private readonly P3UltimateRelativitySinboundMeltdownBait? _baits = module.FindComponent(); + private readonly P3UltimateRelativity? _rel = module.FindComponent(); private readonly List _aoes = []; private static readonly AOEShapeRect _shape = new(50, 2.5f); @@ -283,7 +317,7 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) switch ((AID)spell.Action.ID) { case AID.UltimateRelativitySinboundMeltdownAOEFirst: - var rot = _baits?.RotationAt(caster.Position) ?? default; + var rot = _rel?.LaserRotationAt(caster.Position) ?? default; for (int i = 1; i < 10; ++i) _aoes.Add(new(_shape, caster.Position, spell.Rotation + i * rot, WorldState.FutureTime(i + 1))); break; @@ -319,3 +353,90 @@ public override void OnStatusGain(Actor actor, ActorStatus status) } } } + +class P3UltimateRelativityShadoweye(BossModule module) : BossComponent(module) +{ + private readonly P3UltimateRelativity? _rel = module.FindComponent(); + private readonly List _eyes = []; + private DateTime _activation; + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + var pos = _rel?.States[slot].ReturnPos ?? actor.Position; + if (_eyes.Any(eye => eye != pos && HitByEye(pos, actor.Rotation, eye))) + hints.Add("Turn away from gaze!"); + } + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + var pos = _rel?.States[slot].ReturnPos ?? actor.Position; + foreach (var eye in _eyes) + if (eye != pos) + hints.ForbiddenDirections.Add((Angle.FromDirection(eye - pos), 45.Degrees(), _activation)); + } + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + var pos = _rel?.States[pcSlot].ReturnPos ?? pc.Position; + Arena.Actor(pos, pc.Rotation, ArenaColor.Object); + Arena.AddLine(pos, pc.Position, ArenaColor.Safe); + foreach (var eye in _eyes) + { + if (eye == pos) + continue; + + bool danger = HitByEye(pos, pc.Rotation, eye); + var eyeCenter = Arena.WorldPositionToScreenPosition(eye); + Components.GenericGaze.DrawEye(eyeCenter, danger); + + var (min, max) = (-45, 45); + Arena.PathArcTo(pos, 1, (pc.Rotation + min.Degrees()).Rad, (pc.Rotation + max.Degrees()).Rad); + Arena.PathStroke(false, ArenaColor.Enemy); + } + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.SpellInWaitingShadoweye: + var slot = Raid.FindSlot(actor.InstanceID); + if (slot >= 0 && _rel != null) + _eyes.Add(_rel.States[slot].ReturnPos); + break; + case SID.Return: + _activation = status.ExpireAt; + return; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.UltimateRelativityShadoweye) + _eyes.Clear(); + } + + private bool HitByEye(WPos pos, Angle rot, WPos eye) => rot.ToDirection().Dot((eye - pos).Normalized()) >= 0.707107f; // 45-degree +} + +class P3ShellCrusher(BossModule module) : Components.UniformStackSpread(module, 6, 0, 8, 8, includeDeadTargets: true) +{ + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.ShellCrusher) + { + // note: target is random?.. + var target = WorldState.Actors.Find(caster.TargetID); + if (target != null) + AddStack(target, Module.CastFinishAt(spell, 0.4f)); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.ShellCrusherAOE) + { + Stacks.Clear(); + } + } +}