diff --git a/BossMod/Autorotation/PlanExecution.cs b/BossMod/Autorotation/PlanExecution.cs index c18da86c2..7a9694fad 100644 --- a/BossMod/Autorotation/PlanExecution.cs +++ b/BossMod/Autorotation/PlanExecution.cs @@ -164,6 +164,10 @@ private List BuildEntries(List entries) var windowStart = s.EnterTime + Math.Min(s.Duration, entry.TimeSinceActivation); res.Add(new(windowStart, windowStart + entry.WindowLength, s.BranchID, s.NumBranches, entry.Value)); } + else + { + Service.Log($"Failed to find state {entry.StateID:X} for plan {Plan?.Guid}"); + } } } return res; diff --git a/BossMod/Config/GroupAssignment.cs b/BossMod/Config/GroupAssignment.cs index eab014d3f..bc69a5b55 100644 --- a/BossMod/Config/GroupAssignment.cs +++ b/BossMod/Config/GroupAssignment.cs @@ -22,7 +22,7 @@ public class GroupAssignment public int this[PartyRolesConfig.Assignment r] { - get => Assignments[(int)r]; + get => (int)r is var index && index >= 0 && index < Assignments.Length ? Assignments[index] : -1; set => Assignments[(int)r] = value; } diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Break.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Break.cs index ed046b4ec..75c7dc1f3 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Break.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Break.cs @@ -4,7 +4,7 @@ class Break(BossModule module) : Components.GenericGaze(module) { public readonly List Eyes = []; - public override IEnumerable ActiveEyes(int slot, Actor actor) => Eyes;//_casters.Where(c => c.CastInfo?.TargetID != actor.InstanceID).Select(c => new Eye(EyePosition(c), Module.CastFinishAt(c.CastInfo), Range: range)); + public override IEnumerable ActiveEyes(int slot, Actor actor) => Eyes; public override void OnCastStarted(Actor caster, ActorCastInfo spell) { diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarkness.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarkness.cs index 89b282cce..511b8792c 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarkness.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarkness.cs @@ -10,10 +10,9 @@ class Atomos(BossModule module) : Components.Adds(module, (uint)OID.Atomos); class LoomingChaos(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.LoomingChaosAOE)); // TODO: tankswap hints component for phase1 -// TODO: phase 2 squares, break timer, teleport zones -// TODO: particle concentration towers -// TODO: evil seed -[ModuleInfo(BossModuleInfo.Maturity.WIP, GroupType = BossModuleInfo.GroupType.CFC, GroupID = 1010, NameID = 13624)] +// TODO: phase 2 teleport zones? +// TODO: grim embrace / curse of darkness prevent turning +[ModuleInfo(BossModuleInfo.Maturity.Verified, GroupType = BossModuleInfo.GroupType.CFC, GroupID = 1010, NameID = 13624)] public class Ch01CloudOfDarkness(WorldState ws, Actor primary) : BossModule(ws, primary, DefaultCenter, InitialBounds) { public static readonly WPos DefaultCenter = new(100, 100); diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessEnums.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessEnums.cs index 4a57119ea..e7def4781 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessEnums.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessEnums.cs @@ -10,12 +10,10 @@ public enum OID : uint StygianTendrils = 0x4622, // R1.200, x0 (spawn during fight), evil seed CloudletOfDarkness = 0x4623, // R3.000, x0 (spawn during fight), criss-cross source BallOfNaught = 0x4624, // R1.500, x1, (en)death sphere - //_Gen_DreadGale = 0x4625, // R1.200, x1 + //DreadGale = 0x4625, // R1.200, x1, ??? SinisterEye = 0x4626, // R2.800, x2, break gaze source AtomosSpawnPoint = 0x1EBD7B, // R0.500, x0 (spawn during fight), EventObj type EvilSeed = 0x1E9B3B, // R0.500, x0 (spawn during fight), EventObj type - //_Gen_Actor1e8536 = 0x1E8536, // R2.000, x1, EventObj type - //_Gen_Exit = 0x1E850B, // R0.500, x1, EventObj type } public enum AID : uint @@ -140,28 +138,27 @@ public enum AID : uint public enum SID : uint { //_Gen_ArcaneDesign = 4180, // Boss->Boss, extra=0x0 + //_Gen_VeilOfDarkness = 4179, // Boss->Boss, extra=0x0 //_Gen_LightningResistanceDown = 4386, // Helper/Boss->player, extra=0x1/0x2/0x3/0x4/0x5/0x6/0x7/0x8/0x9/0xA/0xB/0xC/0xD/0xE/0xF/0x10 DeadlyEmbrace = 4181, // none->player, extra=0x0 AbyssalEdge = 4182, // Boss->Boss, extra=0x0 (endeath/enaero stored) - //_Gen_VeilOfDarkness = 4179, // Boss->Boss, extra=0x0 - //_Gen_CloyingCondensation = 2532, // none->player, extra=0x0 + //_Gen_CloyingCondensation = 2532, // none->player, extra=0x0, prevent jumps? //_Gen_ = 4388, // none->StygianShadow, extra=0x1052 //_Gen_ = 4387, // none->Boss, extra=0x1051 InnerDarkness = 4177, // none->player, extra=0x0, on main platform OuterDarkness = 4178, // none->player, extra=0x0, on side platform //_Gen_Rehabilitation = 4191, // none->Boss, extra=0x1/0x4/0x3/0x2 //_Gen_LifeDrain = 1377, // none->player, extra=0x0 - //_Gen_CraftersGrace = 45, // player->player, extra=0x50 CurseOfDarkness = 2387, // none->player, extra=0x0 //_Gen_StabWound = 3061, // none->player, extra=0x0 //_Gen_StabWound = 3062, // none->player, extra=0x0 //_Gen_ThornyVine = 445, // none->player, extra=0x0 //_Gen_ForwardWithThee = 2240, // none->player, extra=0x33F - //_Gen_Stun = 149, // none->player, extra=0x0 //_Gen_BackWithThee = 2241, // none->player, extra=0x340 //_Gen_LeftWithThee = 2242, // none->player, extra=0x341 - //_Gen_Stun = 2656, // none->player, extra=0x0 //_Gen_RightWithThee = 2243, // none->player, extra=0x342 + //_Gen_Stun = 149, // none->player, extra=0x0 + //_Gen_Stun = 2656, // none->player, extra=0x0 } public enum IconID : uint diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessStates.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessStates.cs index 0797a8333..c688c9c97 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessStates.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/Ch01CloudOfDarknessStates.cs @@ -72,19 +72,18 @@ private void Subphase1Variant2End(uint id, float delay) private void Subphase2(uint id, float delay) { DelugeOfDarkness2(id, delay); - DarkDominion(id + 0x10000, 9.3f); - ThirdArtOfDarknessParticleConcentration(id + 0x20000, 4); + DarkDominion(id + 0x10000, 9.3f); // note: 1s after cast ends, outer ring becomes dangerous + ThirdArtOfDarknessParticleConcentration(id + 0x20000, 4); // note: 3s after towers resolve, outer ring becomes normal GhastlyGloom(id + 0x30000, 12.3f); CurseOfDarkness(id + 0x40000, 8.3f); - EvilSeed(id + 0x50000, 9.9f); - ChaosCondensedDiffusiveForceParticleBeam(id + 0x60000, 8.1f); + EvilSeedChaosCondensedDiffusiveForceParticleBeam(id + 0x50000, 9.9f); ActivePivotParticleBeam(id + 0x70000, 4.4f); LoomingChaos(id + 0x80000, 6.2f); CurseOfDarkness(id + 0x100000, 11.9f); ParticleConcentrationPhaser(id + 0x110000, 4.2f); - DarkDominion(id + 0x120000, 1); - FeintParticleBeamThirdActOfDarkness(id + 0x130000, 3.1f); + DarkDominion(id + 0x120000, 1); // note: 1s after cast ends, outer ring becomes dangerous + FeintParticleBeamThirdActOfDarkness(id + 0x130000, 3.1f); // note: 2.5s after act of darkness resolves, outer ring becomes normal GhastlyGloom(id + 0x140000, 11.4f); PhaserChaosCondensedDiffusiveForceParticleBeam(id + 0x150000, 3.4f); FloodOfDarknessAdds(id + 0x160000, 3); @@ -223,6 +222,8 @@ private void DelugeOfDarkness2(uint id, float delay) ComponentCondition(id + 0x10, 4.2f, comp => comp.ActiveActors.Any(), "Platform adds") .ActivateOnEnter() .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() .ActivateOnEnter(); // overlaps with multiple mechanics } @@ -246,7 +247,8 @@ private void ThirdArtOfDarknessParticleConcentration(uint id, float delay) .DeactivateOnExit() .DeactivateOnExit() .DeactivateOnExit(); - ComponentCondition(id + 0x50, 3.6f, comp => comp.NumCasts > 0, "Towers") + ComponentCondition(id + 0x50, 3.6f, comp => comp.Towers.Count == 0, "Towers") + .ExecOnEnter(comp => comp.ShowOuterTowers()) .DeactivateOnExit(); } @@ -276,29 +278,29 @@ private State FloodOfDarknessAdds(uint id, float delay) .DeactivateOnExit(); } - // TODO: this one needs a lot of thought - private void EvilSeed(uint id, float delay) + private void EvilSeedChaosCondensedDiffusiveForceParticleBeam(uint id, float delay) { ComponentCondition(id, delay, comp => comp.Baiters.Any()) .ActivateOnEnter(); ComponentCondition(id + 0x10, 8.1f, comp => comp.Casters.Count > 0, "Seed plant") .ActivateOnEnter() + .ActivateOnEnter() .DeactivateOnExit(); + GhastlyGloom(id + 0x1000, 2.8f) .DeactivateOnExit(); + ComponentCondition(id + 0x2000, 14, comp => comp.Targets.Any()) .ActivateOnEnter(); - ComponentCondition(id + 0x2010, 3, comp => comp.HaveTethers); - FloodOfDarknessAdds(id + 0x2020, 2.2f) - .DeactivateOnExit(); - } + ComponentCondition(id + 0x2010, 3, comp => comp.TethersAssigned, "Tethers"); + FloodOfDarknessAdds(id + 0x2020, 2.2f); - private void ChaosCondensedDiffusiveForceParticleBeam(uint id, float delay) - { - CastMulti(id, [AID.ChaosCondensedParticleBeam, AID.DiffusiveForceParticleBeam], delay, 8) + CastMulti(id + 0x3000, [AID.ChaosCondensedParticleBeam, AID.DiffusiveForceParticleBeam], 8.1f, 8) .ActivateOnEnter() - .ActivateOnEnter(); - Condition(id + 0x10, 0.7f, () => Module.FindComponent()?.NumCasts > 0 || Module.FindComponent()?.Spreads.Count == 0, "Spread/line stacks") + .ActivateOnEnter() + .DeactivateOnExit() + .DeactivateOnExit(); + Condition(id + 0x3010, 0.7f, () => Module.FindComponent()?.NumCasts > 0 || Module.FindComponent()?.Spreads.Count == 0, "Spread/line stacks") .DeactivateOnExit() .DeactivateOnExit(); // TODO: show second wave ... } @@ -341,7 +343,8 @@ private void ParticleConcentrationPhaser(uint id, float delay) .ActivateOnEnter(); // TODO: towers appear 1s after cast end ComponentCondition(id + 0x11, 1.5f, comp => comp.NumCasts >= 6, "Adds sides/front") .DeactivateOnExit(); - ComponentCondition(id + 0x20, 6.6f, comp => comp.NumCasts > 0, "Towers") + ComponentCondition(id + 0x20, 6.6f, comp => comp.Towers.Count == 0, "Towers") + .ExecOnEnter(comp => comp.ShowOuterTowers()) .DeactivateOnExit(); } @@ -386,6 +389,8 @@ private void FloodOfDarkness2(uint id, float delay) CastStart(id, AID.FloodOfDarkness2, delay, "Adds disappear") .DeactivateOnExit() .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit() .DeactivateOnExit(); CastEnd(id + 1, 7, "Raidwide + arena transition") .OnExit(() => Module.Arena.Bounds = Ch01CloudOfDarkness.InitialBounds) diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DelugeOfDarkness.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DelugeOfDarkness.cs index c0e0dfe56..f3735d934 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DelugeOfDarkness.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DelugeOfDarkness.cs @@ -1,5 +1,10 @@ namespace BossMod.Dawntrail.Chaotic.Ch01CloudOfDarkness; +// envcontrols: +// 00 = main bounds telegraph +// - 00200010 - phase 1 +// - 00020001 - phase 2 +// - 00040004 - remove telegraph (note that actual bounds are controlled by something else!) class DelugeOfDarkness1(BossModule module) : Components.GenericAOEs(module) { private AOEInstance? _aoe; @@ -15,30 +20,6 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) } } -// envcontrols: -// 00 = main bounds telegraph -// - 00200010 - phase 1 -// - 00020001 - phase 2 -// - 00040004 - remove telegraph (note that actual bounds are controlled by something else!) -// 02 = outer ring -// - 00020001 - become dangerous -// - 00080004 - restore to normal -// 03-1E = mid squares -// - 08000001 - init -// - 00200010 - become occupied -// - 02000001 - become free -// - 00800040 - player is standing for too long, will break soon -// - 00080004 - break -// - 00020001 - repair -// - arrangement: -// 04 0B -// 03 05 06 07 0E 0D 0C 0A -// 08 0F -// 09 10 -// 17 1E -// 16 1D -// 11 13 14 15 1C 1B 1A 18 -// 12 19 class DelugeOfDarkness2(BossModule module) : Components.GenericAOEs(module) { private AOEInstance? _aoe; @@ -53,3 +34,117 @@ public override void OnCastStarted(Actor caster, ActorCastInfo spell) _aoe = new(_shape, Module.Center, default, Module.CastFinishAt(spell)); } } + +class Phase2OuterRing(BossModule module) : Components.GenericAOEs(module) +{ + public bool Dangerous; + private AOEInstance? _aoe; + + private static readonly AOEShapeDonut _shape = new(34, 40); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => Utils.ZeroOrOne(_aoe); + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.DarkDominion) + _aoe = new(_shape, Module.Center, default, Module.CastFinishAt(spell, 1.1f)); + } + + public override void OnEventEnvControl(byte index, uint state) + { + if (index != 2) + return; + switch (state) + { + case 0x00020001: + Dangerous = true; + break; + case 0x00080004: + Dangerous = false; + _aoe = null; + break; + default: + ReportError($"Unexpected envcontrol {state:X8}"); + break; + } + } +} + +class Phase2InnerCells(BossModule module) : BossComponent(module) +{ + private readonly DateTime[] _breakTime = new DateTime[28]; + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + var cell = CellIndex(actor.Position - Module.Center) - 3; + var breakTime = cell >= 0 && cell < _breakTime.Length ? _breakTime[cell] : default; + if (breakTime != default) + { + var remaining = Math.Max(0, (breakTime - WorldState.CurrentTime).TotalSeconds); + hints.Add($"Cell breaks in {remaining:f1}s", remaining < 10); + } + } + + public override void OnEventEnvControl(byte index, uint state) + { + // 03-1E = mid squares + // - 08000001 - init + // - 00200010 - become occupied + // - 02000001 - become free + // - 00800040 - player is standing for too long (38s), will break soon (in 6s) + // - 00080004 - break + // - 00020001 - repair + // - arrangement: + // 04 0B + // 03 05 06 07 0E 0D 0C 0A + // 08 0F + // 09 10 + // 17 1E + // 16 1D + // 11 13 14 15 1C 1B 1A 18 + // 12 19 + if (index is < 3 or > 30) + return; + _breakTime[index - 3] = state switch + { + 0x00200010 => WorldState.FutureTime(44), + 0x00800040 => WorldState.FutureTime(6), + _ => default, + }; + } + + private int CoordinateToCell(float c) => (int)Math.Floor(c / 6); + private int CellIndex(WDir offset) => CellIndex(CoordinateToCell(offset.X), CoordinateToCell(offset.Z)); + private int CellIndex(int x, int y) => (x, y) switch + { + (-4, -3) => 3, + (-3, -4) => 4, + (-3, -3) => 5, + (-2, -3) => 6, + (-1, -3) => 7, + (-3, -2) => 8, + (-3, -1) => 9, + (+3, -3) => 10, + (+2, -4) => 11, + (+2, -3) => 12, + (+1, -3) => 13, + (+0, -3) => 14, + (+2, -2) => 15, + (+2, -1) => 16, + (-4, +2) => 17, + (-3, +3) => 18, + (-3, +2) => 19, + (-2, +2) => 20, + (-1, +2) => 21, + (-3, +1) => 22, + (-3, +0) => 23, + (+3, +2) => 24, + (+2, +3) => 25, + (+2, +2) => 26, + (+1, +2) => 27, + (+0, +2) => 28, + (+2, +1) => 29, + (+2, +0) => 30, + _ => -1 + }; +} diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DiffusiveForceParticleBeam.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DiffusiveForceParticleBeam.cs index 9e92c4d42..5c5166af9 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DiffusiveForceParticleBeam.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/DiffusiveForceParticleBeam.cs @@ -1,13 +1,16 @@ namespace BossMod.Dawntrail.Chaotic.Ch01CloudOfDarkness; -// TODO: who gets radius 7 and who gets radius 5? -// TODO: show for second wave too... +// note: it seems that normally first wave (radius 7) hits people inside, and second wave (radius 5) hits people outside +// however, if some of the players in the mid are dead, some players on the outside will be hit by first wave (up to a total of 12 hits) +// if there are <= 12 players alive, everyone will be hit by the first wave, and the second wave will never happen +// so for safety we just show larger radius around everyone +// TODO: show second wave for players not hit by first wave class DiffusiveForceParticleBeam(BossModule module) : Components.UniformStackSpread(module, 0, 7) { public override void OnCastStarted(Actor caster, ActorCastInfo spell) { if ((AID)spell.Action.ID == AID.DiffusiveForceParticleBeam) - AddSpreads(Raid.WithoutSlot(), Module.CastFinishAt(spell, 0.7f)); + AddSpreads(Raid.WithoutSlot(true), Module.CastFinishAt(spell, 0.7f)); } public override void OnEventCast(Actor caster, ActorCastEvent spell) diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/EvilSeed.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/EvilSeed.cs index a5e0d983f..28ab8b2bd 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/EvilSeed.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/EvilSeed.cs @@ -19,21 +19,15 @@ public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) class EvilSeedAOE(BossModule module) : Components.LocationTargetedAOEs(module, ActionID.MakeSpell(AID.EvilSeedAOE), 5); -// todo: should be chains... -class ThornyVine(BossModule module) : BossComponent(module) +class EvilSeedVoidzone(BossModule module) : Components.PersistentVoidzone(module, 5, module => module.Enemies(OID.EvilSeed).Where(z => z.EventState != 7)); + +class ThornyVine(BossModule module) : Components.Chains(module, (uint)TetherID.ThornyVine, default, 25) { public BitMask Targets; - public bool HaveTethers; public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) { if (iconID == (uint)IconID.ThornyVineBait) Targets.Set(Raid.FindSlot(actor.InstanceID)); } - - public override void OnTethered(Actor source, ActorTetherInfo tether) - { - if (tether.ID == (uint)TetherID.ThornyVine) - HaveTethers = true; - } } diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ParticleConcentration.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ParticleConcentration.cs index 37fc1876a..5b7848001 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ParticleConcentration.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ParticleConcentration.cs @@ -1,4 +1,6 @@ -namespace BossMod.Dawntrail.Chaotic.Ch01CloudOfDarkness; +using System.Collections.Specialized; + +namespace BossMod.Dawntrail.Chaotic.Ch01CloudOfDarkness; // envcontrols: // 1F-2E = 1-man towers @@ -44,4 +46,107 @@ // 47-56 = 1-man tower falling orb // 57-66 = 2-man tower falling orb // 67-6E = 3-man tower falling orb -class ParticleConcentration(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.ParticleBeam1)) { } +class ParticleConcentration(BossModule module) : Components.GenericTowers(module) +{ + private BitMask _innerPlayers; + private BitMask _outerPlayers; + private readonly List _outerTowers = []; // note: initially we don't show outer towers, as players resolve different mechanics first + + public void ShowOuterTowers() + { + var activation = Towers.Count > 0 ? Towers[0].Activation : default; + Towers.AddRange(_outerTowers.Select(p => new Tower(p, 3, 3, 3, _innerPlayers, activation))); + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.InnerDarkness: + _innerPlayers.Set(Raid.FindSlot(actor.InstanceID)); + break; + case SID.OuterDarkness: + _outerPlayers.Set(Raid.FindSlot(actor.InstanceID)); + break; + } + } + + public override void OnStatusLose(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.InnerDarkness: + _innerPlayers.Clear(Raid.FindSlot(actor.InstanceID)); + break; + case SID.OuterDarkness: + _outerPlayers.Clear(Raid.FindSlot(actor.InstanceID)); + break; + } + } + + public override void OnEventEnvControl(byte index, uint state) + { + if (state != 0x00020001) // appear + return; + + var (offset, count) = index switch + { + 0x1F => (new(-9, -15), 1), + 0x20 => (new(+9, -15), 1), + 0x21 => (new(-21, -15), 1), + 0x22 => (new(+21, -15), 1), + 0x23 => (new(-15, -9), 1), + 0x24 => (new(+15, -9), 1), + 0x25 => (new(-15, -21), 1), + 0x26 => (new(+15, -21), 1), + 0x27 => (new(-9, +15), 1), + 0x28 => (new(+9, +15), 1), + 0x29 => (new(-21, +15), 1), + 0x2A => (new(+21, +15), 1), + 0x2B => (new(-15, +9), 1), + 0x2C => (new(+15, +9), 1), + 0x2D => (new(-15, +21), 1), + 0x2E => (new(+15, +21), 1), + 0x2F => (new(-12, -15), 2), + 0x30 => (new(+12, -15), 2), + 0x31 => (new(-18, -15), 2), + 0x32 => (new(+18, -15), 2), + 0x33 => (new(-15, -12), 2), + 0x34 => (new(+15, -12), 2), + 0x35 => (new(-15, -18), 2), + 0x36 => (new(+15, -18), 2), + 0x37 => (new(-12, +15), 2), + 0x38 => (new(+12, +15), 2), + 0x39 => (new(-18, +15), 2), + 0x3A => (new(+18, +15), 2), + 0x3B => (new(-15, +12), 2), + 0x3C => (new(+15, +12), 2), + 0x3D => (new(-15, +18), 2), + 0x3E => (new(+15, +18), 2), + 0x3F => (new(-26.5f, -4.5f), 3), + 0x40 => (new(-22, 0), 3), + 0x41 => (new(-26.5f, +4.5f), 3), + 0x42 => (new(-31, 0), 3), + 0x43 => (new(+26.5f, -4.5f), 3), + 0x44 => (new(+22, 0), 3), + 0x45 => (new(+26.5f, +4.5f), 3), + 0x46 => (new(+31, 0), 3), + _ => (default(WDir), 0) + }; + + if (count == 3) + _outerTowers.Add(Module.Center + offset); + else if (count > 0) + Towers.Add(new(Module.Center + offset, 3, count, count, _outerPlayers, WorldState.FutureTime(10.1f))); + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID is AID.ParticleBeam1 or AID.ParticleBeam2 or AID.ParticleBeam3) + { + ++NumCasts; + if (Towers.RemoveAll(t => t.Position.AlmostEqual(caster.Position, 1)) != 1) + ReportError($"Unexpected tower position @ {caster.Position}"); + } + } +} diff --git a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ThirdArtOfDarkness.cs b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ThirdArtOfDarkness.cs index a1049e5f5..e58095e50 100644 --- a/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ThirdArtOfDarkness.cs +++ b/BossMod/Modules/Dawntrail/Chaotic/Ch01CloudOfDarkness/ThirdArtOfDarkness.cs @@ -5,6 +5,7 @@ class ThirdArtOfDarknessCleave(BossModule module) : Components.GenericAOEs(modul public enum Mechanic { None, Left, Right, Stack, Spread } public readonly Dictionary> Mechanics = []; + public BitMask PlatformPlayers; private static readonly AOEShapeCone _shape = new(15, 90.Degrees()); @@ -25,12 +26,28 @@ public override IEnumerable ActiveAOEs(int slot, Actor actor) public override void AddHints(int slot, Actor actor, TextHints hints) { - var (a, m) = Mechanics.FirstOrDefault(kv => kv.Key.InstanceID == actor.TargetID); - if (a != null && m.Count > 0) - hints.Add($"Order: {string.Join(" > ", m.Select(m => m.mechanic))}", false); + if (PlatformPlayers[slot]) + { + var playerSide = actor.Position.X - Module.Center.X; + var (a, m) = Mechanics.FirstOrDefault(kv => (kv.Key.Position.X - Module.Center.X) * playerSide > 0); + if (a != null && m.Count > 0) + hints.Add($"Order: {string.Join(" > ", m.Select(m => m.mechanic))}", false); + } base.AddHints(slot, actor, hints); } + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.OuterDarkness) + PlatformPlayers.Set(Raid.FindSlot(actor.InstanceID)); + } + + public override void OnStatusLose(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.OuterDarkness) + PlatformPlayers.Clear(Raid.FindSlot(actor.InstanceID)); + } + public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) { if ((OID)actor.OID == OID.StygianShadow) @@ -100,7 +117,7 @@ public override void Update() foreach (var (a, m) in _main.Mechanics) if (m.Count > 0 && m[0].mechanic == ThirdArtOfDarknessCleave.Mechanic.Stack) foreach (var p in Raid.WithoutSlot().SortedByRange(a.Position).Take(3)) - AddStack(p, m[0].activation); + AddStack(p, m[0].activation, ~_main.PlatformPlayers); base.Update(); } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs index 82c98a5bd..836c1645f 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs @@ -4,7 +4,6 @@ class P2QuadrupleSlap(BossModule module) : Components.TankSwap(module, ActionID. 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 -class P4EdgeOfOblivion(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.EdgeOfOblivion)); class P4HallowedWingsL(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.HallowedWingsL), new AOEShapeRect(80, 20, 0, 90.Degrees())); class P4HallowedWingsR(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.HallowedWingsR), new AOEShapeRect(80, 20, 0, -90.Degrees())); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs index 4cbfcee49..40e6be78f 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs @@ -10,7 +10,7 @@ public class FRUConfig() : ConfigNode() [GroupPreset("G1 N, G2 S, TMRH", [0, 4, 3, 7, 1, 5, 2, 6])] public GroupAssignmentUnique P1BoundOfFaithAssignment = GroupAssignmentUnique.DefaultRoles(); - [PropertyDisplay("P1 Fall of Faith (cone tethers) : conga priority (two people without tethers with lower priorities join odd group)")] + [PropertyDisplay("P1 Fall of Faith (cone tethers): conga priority (two people without tethers with lower priorities join odd group)")] [GroupDetails(["1", "2", "3", "4", "5", "6", "7", "8"])] [GroupPreset("TTHHMMRR", [0, 1, 2, 3, 4, 5, 6, 7])] [GroupPreset("RHMTTMHR", [3, 4, 1, 6, 2, 5, 0, 7])] @@ -24,6 +24,12 @@ public class FRUConfig() : ConfigNode() [GroupPreset("H1-R2-H2 fixed, M1-M2-R1 flex", [0, 1, 2, 4, 5, 6, 7, 3])] public GroupAssignmentUnique P1ExplosionsAssignment = new() { Assignments = [0, 1, 2, 4, 5, 6, 7, 3] }; + [PropertyDisplay("P1 Explosions: flex roles only fill 3/4 if their natural tower is 1 (instead of doing conga)")] + public bool P1ExplosionsPriorityFill; + + [PropertyDisplay("P1 Explosions: have tanks stack on tankbuster (survivable with saves, simplifies uptime)")] + public bool P1ExplosionsTankbusterCheese; + [PropertyDisplay("P2 Diamond Dust: cardinal assignments")] [GroupDetails(["Support N", "Support E", "Support S", "Support W", "DD N", "DD E", "DD S", "DD W"])] [GroupPreset("Default", [0, 2, 3, 1, 7, 6, 4, 5])] @@ -63,6 +69,11 @@ public class FRUConfig() : ConfigNode() [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)")] + [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] }; + // ai-only settings [PropertyDisplay("P1 Cyclonic Break (proteans): bait clock spots (supports should be near dd to resolve pairs)", tooltip: "Only used by AI")] [GroupDetails(["N", "NE", "E", "SE", "S", "SW", "W", "NW"])] diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs index c2a466f76..a8817cf68 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs @@ -30,10 +30,12 @@ public enum OID : uint UsurperOfFrostP4 = 0x45A9, // R6.125, x0 (spawn during fight) GreatWyrm = 0x45AA, // R3.500, x0 (spawn during fight), Part type OracleOfDarknessP4 = 0x45AB, // R7.040, x0 (spawn during fight) + DrachenWanderer = 0x45AC, // R1.000-2.000, x0 (spawn during fight), crystallize time dragon head SorrowsHourglass = 0x45AD, // R1.000, x0 (spawn during fight) FragmentOfFate = 0x45B1, // R3.500, x0 (spawn during fight) VisionOfRyne = 0x45B4, // R0.750, x0 (spawn during fight) VisionOfGaia = 0x45B5, // R1.500, x0 (spawn during fight) + DragonPuddle = 0x1EBD41, // R0.500, x0 (spawn during fight), EventObj type, puddle appears when head is touched } public enum AID : uint @@ -206,7 +208,7 @@ public enum AID : uint DrachenArmor = 40186, // Helper->self, no cast, single-target, visual (wings appear) AutoAttackP4Wyrm = 40178, // GreatWyrm->player, no cast, single-target AutoAttackP4Usurper = 40177, // UsurperOfFrostP4->player, no cast, single-target - EdgeOfOblivion = 40174, // FragmentOfFate->self, 5.0s cast, range 100 circle, raidwide + EdgeOfOblivion = 40174, // FragmentOfFate->self, 5.0s cast, range 100 circle, minor raidwide AkhRhai = 40237, // Helper->location, 2.5s cast, range 4 circle, visual (puddle) AkhRhaiAOE = 40238, // Helper->location, no cast, range 4 circle, repeated puddle x10 @@ -229,6 +231,17 @@ public enum AID : uint CrystallizeTimeUsurper = 40240, // UsurperOfFrostP4->self, 10.0s cast, single-target, visual CrystallizeTimeOracle = 40298, // OracleOfDarknessP4->self, 10.0s cast, range 100 circle, raidwide + CrystallizeTimeMaelstrom = 40299, // SorrowsHourglass->self, 1.5s cast, range 12 circle, hourglass aoe + CrystallizeTimeDarkAero = 40280, // Helper->players, no cast, range 15 circle, knockback 30 + TidalLight = 40251, // UsurperOfFrostP4->self, 3.0s cast, single-target, visual (exalines) + TidalLightAOEFirst = 40252, // Helper->self, 3.0s cast, range 10 width 40 rect + TidalLightAOERest = 40253, // Helper->self, 2.0s cast, range 10 width 40 rect + Quietus = 40281, // Helper->self, no cast, range 50 circle, raidwide + LongingOfTheLost = 40241, // Helper->location, no cast, range 12 circle, aoe when head is touched + DrachenWandererDisappear = 40244, // DrachenWanderer->self, no cast, single-target, visual (disappear) + JoylessDragonsong = 40242, // Helper->self, no cast, range 40 circle, wipe if ??? + CrystallizeTimeHallowedWings = 40229, // UsurperOfFrostP4->self, 4.7+1.3s cast, single-target, visual (??? knockbacks?) + //_Weaponskill_HallowedWings = 40332, // UsurperOfFrostP4->self, 0.5s cast, range 40 width 50 rect } public enum SID : uint diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs index 8e02df855..ea9334362 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs @@ -52,7 +52,7 @@ private void Phase34(uint id) P4AkhRhai(id + 0x100000, 5.5f); P4DarklitDragonsong(id + 0x110000, 1.9f); - P4AkhMornMornAfah(id + 0x120000, 2.4f); + P4AkhMornMornAfah(id + 0x120000, 5.8f); P4CrystallizeTime(id + 0x130000, 4.6f); SimpleState(id + 0xFF0000, 100, "???"); @@ -196,6 +196,7 @@ private void P1PowderMarkTrailExplosions(uint id, float delay) .DeactivateOnExit(); ComponentCondition(id + 0x103, 2, comp => comp.NumCasts > 0, "Towers") .ActivateOnEnter() + .ExecOnEnter(comp => comp.AllowTankStacking = Service.Config.Get().P1ExplosionsTankbusterCheese) .DeactivateOnExit(); ComponentCondition(id + 0x104, 0.5f, comp => comp.NumCasts > 0, "Tankbusters") .DeactivateOnExit() @@ -487,16 +488,14 @@ private void P3Enrage(uint id, float delay) private void P4AkhRhai(uint id, float delay) { ActorTargetable(id, _module.BossP4Usurper, true, delay, "Usurper appears") + .ActivateOnEnter() + .DeactivateOnExit() .SetHint(StateMachine.StateHint.DowntimeEnd); ActorCast(id + 0x10, _module.BossP4Usurper, AID.Materialization, 5.1f, 3, true); ComponentCondition(id + 0x20, 11.2f, comp => comp.AOEs.Count > 0, "Puddle baits") .ActivateOnEnter(); ComponentCondition(id + 0x30, 2.6f, comp => comp.NumCasts > 0); - ComponentCondition(id + 0x40, 2.4f, comp => comp.NumCasts > 0, "Raidwide") - .ActivateOnEnter() - .DeactivateOnExit() - .SetHint(StateMachine.StateHint.Raidwide); - ActorTargetable(id + 0x50, _module.BossP4Oracle, true, 1.2f, "Oracle appears"); + ActorTargetable(id + 0x50, _module.BossP4Oracle, true, 3.6f, "Oracle appears"); ComponentCondition(id + 0x60, 1.6f, comp => comp.NumCasts >= 10 * comp.AOEs.Count, "Puddle resolve") .ActivateOnEnter() .DeactivateOnExit(); @@ -508,6 +507,7 @@ private void P4DarklitDragonsong(uint id, float delay) .ActivateOnEnter() .ActivateOnEnter() .ActivateOnEnter() + .ActivateOnEnter() .SetHint(StateMachine.StateHint.Raidwide); ActorCast(id + 0x10, _module.BossP4Usurper, AID.PathOfLight, 3.2f, 8); ActorCastStart(id + 0x20, _module.BossP4Oracle, AID.SpiritTaker, 0.1f, true, "Towers") // towers resolve right as cast starts @@ -522,7 +522,7 @@ private void P4DarklitDragonsong(uint id, float delay) ActorCastStart(id + 0x25, _module.BossP4Oracle, AID.SomberDance, 2.8f) .ActivateOnEnter() .ActivateOnEnter() - .ActivateOnEnter(); + .ExecOnEnter(comp => comp.ResolveImminent = true); ComponentCondition(id + 0x26, 1.7f, comp => comp.Stacks.Count == 0, "Stacks") .DeactivateOnExit(); ActorCastEnd(id + 0x27, _module.BossP4Usurper, 0.2f, false, "Side cleave") @@ -536,11 +536,6 @@ private void P4DarklitDragonsong(uint id, float delay) ComponentCondition(id + 0x2A, 3.2f, comp => comp.NumCasts > 1, "Tankbuster 2") .DeactivateOnExit() .SetHint(StateMachine.StateHint.Tankbuster); - - ComponentCondition(id + 0x30, 3.4f, comp => comp.NumCasts > 0, "Raidwide") - .ActivateOnEnter() - .DeactivateOnExit() - .SetHint(StateMachine.StateHint.Raidwide); } private void P4AkhMornMornAfah(uint id, float delay) @@ -564,10 +559,52 @@ private void P4CrystallizeTime(uint id, float delay) ActorCast(id, _module.BossP4Oracle, AID.CrystallizeTimeOracle, delay, 10, true, "Raidwide (crystallize)") .SetHint(StateMachine.StateHint.Raidwide); ActorTargetable(id + 0x10, _module.BossP4Usurper, false, 3.1f, "Usurper disappears") - .ActivateOnEnter(); + .ActivateOnEnter() + .ActivateOnEnter(); ActorTargetable(id + 0x11, _module.BossP4Oracle, false, 1.1f, "Oracle disappears") .SetHint(StateMachine.StateHint.DowntimeStart); - ActorCast(id + 0x20, _module.BossP4Oracle, AID.UltimateRelativitySpeed, 0.1f, 5.5f, true); - // TODO: ... + ActorCast(id + 0x20, _module.BossP4Oracle, AID.UltimateRelativitySpeed, 0.1f, 5.5f, true) + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter(); + ComponentCondition(id + 0x30, 2.2f, comp => comp.NumCasts > 0, "Hourglass 1"); + ComponentCondition(id + 0x31, 1.0f, comp => comp.Stacks.Count == 0) + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 0x32, 2.0f, comp => comp.NumCasts > 0, "Knockbacks") // aero + eruption + blizzard donuts resolve at the same time + .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x33, 2.5f, comp => comp.NumCasts > 2, "Hourglass 2") + .ActivateOnEnter(); + ComponentCondition(id + 0x34, 0.5f, comp => comp.Stacks.Count == 0) + .DeactivateOnExit(); + ActorCast(id + 0x40, _module.BossP4Usurper, AID.TidalLight, 0.5f, 3, true, "Exaline EW start") + .ActivateOnEnter(); + ComponentCondition(id + 0x50, 1.1f, comp => comp.NumCasts > 4, "Hourglass 3") + .DeactivateOnExit() + .DeactivateOnExit() + .ExecOnExit(comp => comp.ShowPuddles = true); + ActorCast(id + 0x60, _module.BossP4Usurper, AID.TidalLight, 2.3f, 3, true, "Exaline NS start") + .ActivateOnEnter(); + ComponentCondition(id + 0x70, 4.1f, comp => comp.NumCasts > 0) + .ActivateOnEnter() + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Raidwide); + ComponentCondition(id + 0x80, 1.9f, comp => comp.Done, "Rewind") + .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit(); + ActorCastStart(id + 0x90, _module.BossP4Oracle, AID.SpiritTaker, 0.4f); + ActorCastStart(id + 0x91, _module.BossP4Usurper, AID.CrystallizeTimeHallowedWings, 2.2f) + .ActivateOnEnter(); + ActorCastEnd(id + 0x92, _module.BossP4Oracle, 0.8f); + ComponentCondition(id + 0x93, 0.3f, comp => comp.Spreads.Count == 0, "Jump") + .DeactivateOnExit(); + ActorCastEnd(id + 0x94, _module.BossP4Usurper, 3.6f); + // TODO: knockbacks resolve, downtime end } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs index a4ae563e6..18756a523 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs @@ -3,6 +3,17 @@ class P1CyclonicBreakSpreadStack(BossModule module) : Components.UniformStackSpread(module, 6, 6, 2, 2, true) { public DateTime Activation = DateTime.MaxValue; + private bool _fullHints; // we only need to actually stack/spread after first protean bait + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + if (_fullHints) + base.AddHints(slot, actor, hints); + else if (Stacks.Count > 0) + hints.Add("Prepare to stack", false); + else if (Spreads.Count > 0) + hints.Add("Prepare to spread", false); + } public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) { } // handled by dedicated component @@ -34,6 +45,9 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) case AID.CyclonicBreakSinsmite: Spreads.Clear(); break; + case AID.CyclonicBreakAOEFirst: + _fullHints = true; + break; } } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs index 47417762b..e14e742f9 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs @@ -21,13 +21,16 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme if (role < 2) { - // tanks: stay opposite towers on N/S side + // tanks: stay opposite towers on N/S side (unless cheesing tankbusters) // tweak for WAR: if PR is up, assume player will want to maintain full uptime on wide line by using it right before resolve - we want to stay far to increase travel time var horizOffset = _isWideLine && !_lineDone && actor.Class == Class.WAR && actor.FindStatus(WAR.SID.PrimalRend) != null ? 17 : 0; hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center - horizOffset * TowerDir, -TowerDir), Activation); - var vertDir = new WDir(0, role == 0 ? -1 : +1); - hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center + 5 * vertDir, vertDir), Activation); + if (!_config.P1ExplosionsTankbusterCheese) + { + var vertDir = new WDir(0, role == 0 ? -1 : +1); + hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center + 5 * vertDir, vertDir), Activation); + } } else { @@ -120,8 +123,25 @@ private void AddTower(WPos pos, int numSoakers, DateTime activation) ref var tower = ref Towers.Ref(i); tower.ForbiddenSoakers.Raw = 0xFF; tower.ForbiddenSoakers.Clear(slotByGroup[i + 2]); // fixed assignment - for (int j = 1; j < tower.MinSoakers; ++j) - tower.ForbiddenSoakers.Clear(slotByGroup[nextFlex++]); + if (tower.MinSoakers == 1) + continue; // this tower doesn't need anyone else + + if (_config.P1ExplosionsPriorityFill) + { + // priority fill strategy - grab assigned flex soaker + tower.ForbiddenSoakers.Clear(slotByGroup[i + 5]); + // if the tower requires >2 soakers, also assign each flex soaker that has natural 1-man tower (this works, because only patterns are 2-2-2, 1-2-3 and 1-1-4) + if (tower.MinSoakers > 2) + for (int j = 0; j < 3; ++j) + if (Towers[j].MinSoakers == 1) + tower.ForbiddenSoakers.Clear(slotByGroup[j + 5]); + } + else + { + // conga fill strategy - grab next N flex soakers in priority order + for (int j = 1; j < tower.MinSoakers; ++j) + tower.ForbiddenSoakers.Clear(slotByGroup[nextFlex++]); + } } } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs index 78a93da77..b7d2350cc 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs @@ -2,6 +2,7 @@ class P1PowderMarkTrail(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.BurnMark), centerAtTarget: true) { + public bool AllowTankStacking; private Actor? _target; private Actor? _closest; private DateTime _activation; @@ -21,13 +22,19 @@ public override void Update() public override void AddHints(int slot, Actor actor, TextHints hints) { - base.AddHints(slot, actor, hints); - if (_closest != null && _closest.Role != Role.Tank) + if (_target == null || _closest == null) + return; // no baits active + + if (actor.Role == Role.Tank) { - if (actor == _closest) - hints.Add("GTFO from tank!"); - else if (actor == _target || actor.Role == Role.Tank) + if (actor != _closest && actor != _target) hints.Add("Get closer to co-tank!"); + else if (Raid.WithoutSlot().InRadiusExcluding(actor, _shape.Radius).Any(p => !AllowTankStacking || p.Role != Role.Tank)) + hints.Add("Bait away from raid!"); + } + else if (actor == _closest || actor.Position.InCircle(_target.Position, _shape.Radius) || actor.Position.InCircle(_closest.Position, _shape.Radius)) + { + hints.Add("GTFO from tanks!"); } } @@ -42,7 +49,8 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme if (isTank && otherTank) { // tanks should stay near but not too near other tank - hints.AddForbiddenZone(_shape.Distance(p.Position, default), _activation); + if (!AllowTankStacking) + hints.AddForbiddenZone(_shape.Distance(p.Position, default), _activation); hints.AddForbiddenZone(ShapeDistance.InvertedCircle(p.Position, _avoidBaitDistance), _activation); } else if (isTank != otherTank) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs index f25b59ae1..bb897cfb7 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs @@ -250,6 +250,7 @@ class P2SinboundHoly(BossModule module) : Components.UniformStackSpread(module, public int NumCasts; private DateTime _nextExplosion; private readonly WDir _destinationDir = CalculateDestination(module); + private readonly WPos[] _initialSpots = new WPos[PartyState.MaxPartySize]; private static WDir CalculateDestination(BossModule module) { @@ -276,11 +277,29 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme if (master != null && ((master.Position - actor.Position).LengthSq() > 100 || (master.Position - Module.Center).LengthSq() < 196)) master = null; // our closest healer is too far away or too close to center, something is wrong (maybe kb didn't finish yet, or healer fucked up) + // determine movement speed and direction + // baseline is towards safety (opposite boss), or CW (arbitrary) if there's no obvious safe direction + // however, if we're non-healer, it is overridden by healer's decision (we can slide over later) + var moveQuickly = _destinationDir == default; + var preferredDir = !moveQuickly ? _destinationDir : (actor.Position - Module.Center).Normalized().OrthoR(); + moveQuickly &= NumCasts > 0; // don't start moving while waiting for first cast + if (master != null) { + var masterSlot = Raid.FindSlot(master.InstanceID); + if (masterSlot >= 0 && NumCasts > 0) + { + var masterMovement = preferredDir.Dot(master.Position - _initialSpots[masterSlot]); + if (masterMovement < -2) + preferredDir = -preferredDir; // swap movement direction to follow healer + } + + moveQuickly &= (actor.Position - master.Position).LengthSq() < 25; // don't move too quickly if healer can't catch up + // non-healers should just stack with whatever closest healer is - var moveDir = master.LastFrameMovement.Normalized(); - var capsule = ShapeDistance.Capsule(master.Position + 2 * moveDir, moveDir, 4, 2); + // before first cast, ignore master's movements + var moveDir = NumCasts > 0 ? master.LastFrameMovement.Normalized() : default; + var capsule = ShapeDistance.Capsule(master.Position + 2 * moveDir, moveDir, 4, 1); hints.AddForbiddenZone(p => -capsule(p), DateTime.MaxValue); } @@ -292,9 +311,7 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme hints.AddForbiddenZone(ShapeDistance.Circle(Module.Center, 16), hintTime); // prefer moving towards safety (CW is arbitrary) - var moveQuickly = _destinationDir == default; - var preferredDir = !moveQuickly ? _destinationDir : (actor.Position - Module.Center).Normalized().OrthoR(); - var planeOffset = moveQuickly && master == null && NumCasts > 0 ? 2 : -2; // if we're moving quickly, mark our current spot as forbidden (don't bother if we have master or waiting for first cast, though) + var planeOffset = moveQuickly ? 2 : -2; // if we're moving quickly, mark our current spot as forbidden hints.AddForbiddenZone(ShapeDistance.HalfPlane(Module.Center + planeOffset * preferredDir, preferredDir), hintTime); } @@ -310,6 +327,10 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) { if ((AID)spell.Action.ID == AID.SinboundHolyAOE && WorldState.CurrentTime > _nextExplosion) { + if (NumCasts == 0) + foreach (var (i, p) in Raid.WithSlot()) + _initialSpots[i] = p.Position; + ++NumCasts; _nextExplosion = WorldState.FutureTime(0.5f); } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs index 7ee5d68cf..87da66847 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs @@ -9,6 +9,18 @@ class P2MirrorMirrorReflectedScytheKickBlue(BossModule module) : Components.Gene public override IEnumerable ActiveAOEs(int slot, Actor actor) => Utils.ZeroOrOne(_aoe); + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + base.AddAIHints(slot, actor, assignment, hints); + if (_aoe == null && Module.Enemies(OID.BossP2).FirstOrDefault() is var boss && boss != null && boss.TargetID == actor.InstanceID) + { + // main tank should drag the boss away + // note: before mirror appears, we want to stay near center (to minimize movement no matter where mirror appears), so this works fine if blue mirror is zero + // TODO: verify distance calculation - we want boss to be at least 4m away from center + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(Module.Center - 16 * _blueMirror, 1), DateTime.MaxValue); + } + } + public override void DrawArenaForeground(int pcSlot, Actor pc) { if (_blueMirror != default) @@ -58,6 +70,12 @@ public override void Update() CurrentBaits.Add(new(s.source, p, _shape, s.activation)); } + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + // TODO: preassigned spots + //base.AddAIHints(slot, actor, assignment, hints); + } + public override void OnEventEnvControl(byte index, uint state) { if (index is >= 1 and <= 8 && state == 0x00020001) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs index e2c97cbee..60f223e5e 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P3UltimateRelativity.cs @@ -1,6 +1,5 @@ namespace BossMod.Dawntrail.Ultimate.FRU; -// TODO: hints etc... class P3UltimateRelativity(BossModule module) : Components.CastCounter(module, default) { public struct PlayerState @@ -8,7 +7,7 @@ public struct PlayerState public int FireOrder; public int RewindOrder; public int LaserOrder; - public bool HaveDark; + public bool HaveDarkEruption; // all dark eruptions have rewind order 1 public WDir AssignedDir; public WPos ReturnPos; } @@ -21,6 +20,14 @@ public struct PlayerState private int _numYellowTethers; private DateTime _nextProgress; + public const float RangeHintOut = 12; // explosion radius is 8 + public const float RangeHintStack = 1; + public const float RangeHintLaser = 9.5f; // hourglass location + public const float RangeHintDarkEruption = 9; // radius is 6, especially for fire-order 2 has to be < 9.5, otherwise will be clipped by own laser + public const float RangeHintDarkWater = 1; + public const float RangeHintEye = 2; + public const float RangeHintChill = -1; // simplifies looking outside and hitting boss + 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) @@ -38,6 +45,8 @@ public override void DrawArenaForeground(int pcSlot, Actor pc) var assignedDir = States[pcSlot].AssignedDir; if (assignedDir != default && NumCasts < 6) { + Arena.AddLine(Module.Center, Module.Center + Module.Bounds.Radius * assignedDir, ArenaColor.Safe); + 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(); @@ -76,7 +85,7 @@ public override void OnStatusGain(Actor actor, ActorStatus status) case SID.SpellInWaitingDarkEruption: slot = Raid.FindSlot(actor.InstanceID); if (slot >= 0) - States[slot].HaveDark = true; + States[slot].HaveDarkEruption = true; break; case SID.SpellInWaitingReturn: slot = Raid.FindSlot(actor.InstanceID); @@ -196,24 +205,24 @@ private void InitAssignments() private float RangeHint(in PlayerState state, bool isSupport, int order) => order switch { - 0 => state.FireOrder == 1 ? 12 : 5, - 1 => state.LaserOrder == 1 || state.HaveDark ? 9.5f : 1, - 2 => state.FireOrder == 2 ? 12 : 1, - 3 => state.LaserOrder == 2 ? 9.5f : 2, - 4 => state.FireOrder == 3 ? 12 : 1, - 5 => state.LaserOrder == 3 ? 9.5f : 5, - _ => 9.5f + 0 => state.FireOrder == 1 ? RangeHintOut : RangeHintStack, + 1 => state.LaserOrder == 1 ? RangeHintLaser : state.HaveDarkEruption ? RangeHintDarkEruption : RangeHintDarkWater, + 2 => state.FireOrder == 2 ? RangeHintOut : RangeHintStack, + 3 => state.LaserOrder == 2 ? RangeHintLaser : state.RewindOrder == 2 ? RangeHintEye : RangeHintChill, + 4 => state.FireOrder == 3 ? RangeHintOut : RangeHintStack, + 5 => state.LaserOrder == 3 ? RangeHintLaser : RangeHintChill, + _ => RangeHintChill }; // TODO: rethink this... private string Hint(in PlayerState state, bool isSupport, int order) => order switch { 0 => state.FireOrder == 1 ? "Out" : "Stack", // 10s - 1 => state.LaserOrder == 1 ? "Laser" : state.HaveDark ? "Hourglass" : "Mid", // 15s + 1 => state.LaserOrder == 1 ? "Laser" : state.HaveDarkEruption ? "Hourglass" : "Mid", // 15s - at this point everyone either baits laser or does rewind (eruption or water) 2 => state.FireOrder == 2 ? "Out" : "Stack", // 20s - 3 => state.LaserOrder == 2 ? "Laser" : state.HaveDark ? "Hourglass" : "Mid", // 25s + 3 => state.LaserOrder == 2 ? "Laser" : state.RewindOrder == 2 ? "Mid" : "Chill", // 25s - at this point people bait lasers, rewind eyes or chill 4 => state.FireOrder == 3 ? "Out" : "Stack", // 30s - 5 => state.LaserOrder == 3 ? "Laser" : "Mid", // 35s + 5 => state.LaserOrder == 3 ? "Laser" : "Chill", // 35s _ => "Look out" }; } @@ -333,22 +342,18 @@ public override void OnEventCast(Actor caster, ActorCastEvent spell) class P3UltimateRelativityDarkBlizzard(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.UltimateRelativityDarkBlizzard)) { - private Actor? _source; + private readonly List _sources = []; private DateTime _activation; private static readonly AOEShapeDonut _shape = new(2, 12); // TODO: verify inner radius - public override IEnumerable ActiveAOEs(int slot, Actor actor) - { - if (_source != null) - yield return new(_shape, _source.Position, default, _activation); - } + public override IEnumerable ActiveAOEs(int slot, Actor actor) => _sources.Select(s => new AOEInstance(_shape, s.Position, default, _activation)); public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.SpellInWaitingDarkBlizzard) { - _source = actor; + _sources.Add(actor); _activation = status.ExpireAt; } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs index 74c525329..702be5885 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4CrystallizeTime.cs @@ -1,6 +1,437 @@ namespace BossMod.Dawntrail.Ultimate.FRU; +// TODO: hints and stuff... class P4CrystallizeTime(BossModule module) : BossComponent(module) { + public enum Mechanic { None, FangEruption, FangWater, FangDarkness, FangBlizzard, ClawAir, ClawBlizzard } + public readonly Mechanic[] PlayerMechanics = new Mechanic[PartyState.MaxPartySize]; + public readonly int[] ClawSides = new int[PartyState.MaxPartySize]; // 0 if not assigned (bad config or no claw), +/-1 otherwise for E/W + public WDir NorthSlowHourglass; + public BitMask Cleansed; + private int _numClaws; + + public Actor? FindPlayerByAssignment(Mechanic mechanic, int side) + { + for (int i = 0; i < PlayerMechanics.Length; ++i) + if (PlayerMechanics[i] == mechanic && ClawSides[i] == side) + return Raid[i]; + return null; + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + switch ((SID)status.ID) + { + case SID.SpellInWaitingDarkEruption: + AssignMechanic(actor, Mechanic.FangEruption); // always paired with fang + break; + case SID.SpellInWaitingDarkWater: + AssignMechanic(actor, Mechanic.FangWater); // always paired with fang + break; + case SID.SpellInWaitingUnholyDarkness: + AssignMechanic(actor, Mechanic.FangDarkness); // always paired with fang + break; + case SID.SpellInWaitingDarkBlizzard: + AssignMechanic(actor, Mechanic.FangBlizzard, higherPrio: Mechanic.ClawBlizzard); // paired with either, we'll reassign to claw when reacting to claw buff + break; + case SID.SpellInWaitingDarkAero: + AssignMechanic(actor, Mechanic.ClawAir); // always paired with claw + break; + case SID.Wyrmfang: + break; // don't react + case SID.Wyrmclaw: + var duration = (status.ExpireAt - WorldState.CurrentTime).TotalSeconds; // 40s for aero, 17s for claw + if (duration > 25) + AssignMechanic(actor, Mechanic.ClawAir); + else + AssignMechanic(actor, Mechanic.ClawBlizzard, Mechanic.FangBlizzard); + if (++_numClaws == 4) + AssignClawSides(); + break; + } + } + + public override void OnStatusLose(Actor actor, ActorStatus status) + { + if ((SID)status.ID is SID.Wyrmclaw or SID.Wyrmfang) + Cleansed.Set(Raid.FindSlot(actor.InstanceID)); + } + + public override void OnTethered(Actor source, ActorTetherInfo tether) + { + if (tether.ID == (uint)TetherID.UltimateRelativitySlow && source.Position.Z < Module.Center.Z) + NorthSlowHourglass = source.Position - Module.Center; + } + + private void AssignMechanic(Actor player, Mechanic mechanic, Mechanic lowerPrio = Mechanic.None, Mechanic higherPrio = Mechanic.None) + { + var slot = Raid.FindSlot(player.InstanceID); + if (slot < 0) + return; + ref var mech = ref PlayerMechanics[slot]; + if (mech == Mechanic.None || mech == lowerPrio) + mech = mechanic; + else if (mech != higherPrio && mech != mechanic) + ReportError($"Trying to assing {mechanic} to {player} who already has {mech}"); + } + + private void AssignClawSides() + { + void assign(int slot, int prio, ref (int slot, int prio) prev) + { + if (prev.prio < 0) + { + prev = (slot, prio); + } + else + { + var prevWest = prev.prio < prio; + ClawSides[prev.slot] = prevWest ? -1 : 1; + ClawSides[slot] = prevWest ? 1 : -1; + } + } + Span<(int slot, int prio)> prios = [(-1, -1), (-1, -1), (-1, -1), (-1, -1), (-1, -1), (-1, -1), (-1, -1)]; + foreach (var (slot, group) in Service.Config.Get().P4CrystallizeTimeAssignments.Resolve(Raid)) + assign(slot, group, ref prios[(int)PlayerMechanics[slot]]); + } +} + +class P4CrystallizeTimeDragonHead(BossModule module) : BossComponent(module) +{ + public bool ShowPuddles; + private readonly P4CrystallizeTime? _ct = module.FindComponent(); + private readonly List<(Actor head, int side)> _heads = []; + private readonly List<(Actor puddle, P4CrystallizeTime.Mechanic soaker)> _puddles = []; + + public Actor? FindHead(int side) => _heads.FirstOrDefault(v => v.side == side).head; + public static int NumHeadHits(Actor? head) => head == null ? 2 : head.HitboxRadius < 2 ? 1 : 0; + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + foreach (var h in _heads) + { + Arena.Actor(h.head, ArenaColor.Object, true); + var interceptor = _ct?.FindPlayerByAssignment(NumHeadHits(h.head) > 0 ? P4CrystallizeTime.Mechanic.ClawAir : P4CrystallizeTime.Mechanic.ClawBlizzard, h.side); + if (interceptor != null) + Arena.AddCircle(interceptor.Position, 12, ArenaColor.Danger); + + } + + if (ShowPuddles && _ct != null && !_ct.Cleansed[pcSlot]) + { + var pcAssignment = _ct.PlayerMechanics[pcSlot]; + foreach (var p in _puddles) + if (p.puddle.EventState != 7) + Arena.AddCircle(p.puddle.Position, 1, p.soaker == pcAssignment ? ArenaColor.Safe : ArenaColor.Danger); + } + } + + public override void OnActorCreated(Actor actor) + { + switch ((OID)actor.OID) + { + case OID.DrachenWanderer: + _heads.Add((actor, actor.Position.X > Module.Center.X ? 1 : -1)); + break; + case OID.DragonPuddle: + // TODO: this is very arbitrary + var mechanic = actor.Position.X < Module.Center.X + ? AssignPuddle(P4CrystallizeTime.Mechanic.FangEruption, P4CrystallizeTime.Mechanic.FangBlizzard) + : AssignPuddle(P4CrystallizeTime.Mechanic.FangDarkness, P4CrystallizeTime.Mechanic.FangWater); + _puddles.Add((actor, mechanic)); + break; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.DrachenWandererDisappear) + _heads.RemoveAll(h => h.head == caster); + } + + private P4CrystallizeTime.Mechanic AssignPuddle(P4CrystallizeTime.Mechanic first, P4CrystallizeTime.Mechanic second) => _puddles.Any(p => p.soaker == first) ? second : first; +} + +class P4CrystallizeTimeMaelstrom(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.CrystallizeTimeMaelstrom)) +{ + private readonly List _aoes = []; + + private static readonly AOEShapeCircle _shape = new(12); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => _aoes.Take(2); + + // assuming that this component is activated when speed cast starts - all hourglasses should be already created, and tethers should have appeared few frames ago + public override void OnActorCreated(Actor actor) + { + if ((OID)actor.OID == OID.SorrowsHourglass) + _aoes.Add(new(_shape, actor.Position, actor.Rotation, WorldState.FutureTime(13.2f))); + } + + public override void OnTethered(Actor source, ActorTetherInfo tether) + { + var delay = (TetherID)tether.ID switch + { + TetherID.UltimateRelativitySlow => 18.3f, + TetherID.UltimateRelativityQuicken => 7.7f, + _ => 0 + }; + if (delay != 0) + { + var index = _aoes.FindIndex(aoe => aoe.Origin.AlmostEqual(source.Position, 1)); + if (index >= 0) + { + _aoes.Ref(index).Activation = WorldState.FutureTime(delay); + _aoes.SortBy(aoe => aoe.Activation); + } + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (spell.Action == WatchedAction) + { + ++NumCasts; + _aoes.RemoveAll(aoe => aoe.Origin.AlmostEqual(caster.Position, 1)); + } + } +} + +class P4CrystallizeTimeDarkWater(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4) +{ + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingDarkWater) + { + BitMask forbidden = default; + if (Module.FindComponent() is var ct && ct != null) + { + for (int i = 0; i < ct.PlayerMechanics.Length; ++i) + { + // should not be shared by eruption and all claws except air on slow side + forbidden[i] = ct.PlayerMechanics[i] switch + { + P4CrystallizeTime.Mechanic.FangEruption => true, + P4CrystallizeTime.Mechanic.ClawBlizzard => true, + P4CrystallizeTime.Mechanic.ClawAir => ct.ClawSides[i] * ct.NorthSlowHourglass.X > 0, + _ => false + }; + } + } + AddStack(actor, status.ExpireAt, forbidden); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.DarkWater) + Stacks.Clear(); + } +} + +class P4CrystallizeTimeDarkEruption(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.DarkEruption)) +{ + private static readonly AOEShapeCircle _shape = new(6); + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingDarkEruption) + { + CurrentBaits.Add(new(actor, actor, _shape, status.ExpireAt)); + } + } +} + +class P4CrystallizeTimeDarkAero(BossModule module) : Components.Knockback(module, ActionID.MakeSpell(AID.CrystallizeTimeDarkAero)) // TODO: not sure whether it actually ignores immunes, if so need to warn about immunity +{ + private readonly List _sources = []; + private DateTime _activation; + + private static readonly AOEShapeCircle _shape = new(15); + + public override IEnumerable Sources(int slot, Actor actor) => _sources.Exclude(actor).Select(s => new Source(s.Position, 30, _activation, _shape)); + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingDarkAero) + { + _sources.Add(actor); + _activation = status.ExpireAt; + } + } +} + +class P4CrystallizeTimeUnholyDarkness(BossModule module) : Components.UniformStackSpread(module, 6, 0, 5, 5) +{ + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.SpellInWaitingUnholyDarkness) + { + BitMask forbidden = default; + if (Module.FindComponent() is var ct && ct != null) + { + for (int i = 0; i < ct.PlayerMechanics.Length; ++i) + { + // should not be shared by all claws except blizzard on slow side + forbidden[i] = ct.PlayerMechanics[i] switch + { + P4CrystallizeTime.Mechanic.ClawBlizzard => ct.ClawSides[i] * ct.NorthSlowHourglass.X < 0, + P4CrystallizeTime.Mechanic.ClawAir => true, + _ => false + }; + } + } + AddStack(actor, status.ExpireAt, forbidden); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.UltimateRelativityUnholyDarkness) + Stacks.Clear(); + } +} + +class P4CrystallizeTimeTidalLight : Components.Exaflare +{ + public WDir StartingOffset; + + public P4CrystallizeTimeTidalLight(BossModule module) : base(module, new AOEShapeRect(10, 20)) + { + ImminentColor = ArenaColor.AOE; + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID is AID.TidalLightAOEFirst) + { + Lines.Add(new() { Next = caster.Position, Advance = 10 * spell.Rotation.ToDirection(), Rotation = spell.Rotation, NextExplosion = Module.CastFinishAt(spell), TimeToMove = 2.1f, ExplosionsLeft = 4, MaxShownExplosions = 1 }); + StartingOffset += caster.Position - Module.Center; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID is AID.TidalLightAOEFirst or AID.TidalLightAOERest) + { + ++NumCasts; + int index = Lines.FindIndex(item => item.Next.AlmostEqual(caster.Position, 1)); + if (index == -1) + { + ReportError($"Failed to find entry for {caster.InstanceID:X}"); + return; + } + + AdvanceLine(Lines[index], caster.Position); + if (Lines[index].ExplosionsLeft == 0) + Lines.RemoveAt(index); + } + } +} + +class P4CrystallizeTimeQuietus(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.Quietus)); + +class P4CrystallizeTimeHints(BossModule module) : BossComponent(module) +{ + private readonly P4CrystallizeTime? _ct = module.FindComponent(); + private readonly P4CrystallizeTimeDragonHead? _heads = module.FindComponent(); + private readonly P4CrystallizeTimeMaelstrom? _hourglass = module.FindComponent(); + private bool KnockbacksDone; + private bool DarknessDone; + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + var safeOffset = SafeOffset(pcSlot); + if (safeOffset != default) + Arena.AddCircle(Module.Center + safeOffset, 1, ArenaColor.Safe); + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.CrystallizeTimeDarkAero: + KnockbacksDone = true; + break; + case AID.UltimateRelativityUnholyDarkness: + DarknessDone = true; + break; + } + } + + private WDir SafeOffset(int slot) + { + if (_ct == null || _heads == null || _hourglass == null || _ct.NorthSlowHourglass.X == 0) + return default; + var clawSide = _ct.ClawSides[slot]; + return _ct.PlayerMechanics[slot] switch + { + P4CrystallizeTime.Mechanic.ClawAir => clawSide != 0 ? SafeOffsetClawAir(clawSide, _hourglass.NumCasts, _ct.NorthSlowHourglass.X) : default, + P4CrystallizeTime.Mechanic.ClawBlizzard => clawSide != 0 ? SafeOffsetClawBlizzard(clawSide, _hourglass.NumCasts, _ct.NorthSlowHourglass.X) : default, + P4CrystallizeTime.Mechanic.FangEruption => SafeOffsetFangEruption(_ct.NorthSlowHourglass.X), + _ => SafeOffsetFangOther(_hourglass.NumCasts, _ct.NorthSlowHourglass.X) + }; + } + + private WDir SafeOffsetClawAir(int side, int numHourglassesDone, float northSlowX) + { + if (numHourglassesDone < 2) + return 19 * (side * 40).Degrees().ToDirection(); // dodge first hourglass by the south side + if (!KnockbacksDone) + return 19 * (side * 30).Degrees().ToDirection(); // preposition to knock party across + if (numHourglassesDone < 4) + return 19 * (side * 20).Degrees().ToDirection(); // dodge second hourglass; note that player on the slow side can't really boop head earlier anyway + // by now, blizzards have booped their heads, so now it's our turn + var head = _heads?.FindHead(side); + if (head != null) + return head.Position - Module.Center; + // head is done, so dodge between last two hourglasses + return 6 * (northSlowX > 0 ? 30 : -30).Degrees().ToDirection(); + } + + private WDir SafeOffsetClawBlizzard(int side, int numHourglassesDone, float northSlowX) + { + var head = _heads?.FindHead(side); + if (head != null && P4CrystallizeTimeDragonHead.NumHeadHits(head) == 0) + return (head.Position - Module.Center).Length() * (side * 90).Degrees().ToDirection(); // intercept first head at E/W cardinal + var shareDarknessStack = side * northSlowX > 0; + if (shareDarknessStack) + return SafeOffsetFangEruption(northSlowX); // go stack with eruption after intercepting head + // dodge hourglasses + return numHourglassesDone < 4 ? 19 * (side * 80).Degrees().ToDirection() : SafeOffsetFinalNonAir(northSlowX); + } + + private WDir SafeOffsetFangEruption(float northSlowX) => !DarknessDone ? SafeOffsetDarknessStack(northSlowX) : SafeOffsetFinalNonAir(northSlowX); + + private WDir SafeOffsetFangOther(int numHourglassesDone, float northSlowX) + { + if (numHourglassesDone < 2) + return 19 * (northSlowX > 0 ? -40 : 40).Degrees().ToDirection(); // dodge first hourglass by the south side + if (!KnockbacksDone) + return 17 * (northSlowX > 0 ? -30 : 30).Degrees().ToDirection(); // preposition to knockback across arena + // from now on move together with eruption + return SafeOffsetFangEruption(northSlowX); + } + + private WDir SafeOffsetDarknessStack(float northSlowX) => 19 * (northSlowX > 0 ? 140 : -140).Degrees().ToDirection(); + private WDir SafeOffsetFinalNonAir(float northSlowX) => 6 * (northSlowX > 0 ? -150 : 150).Degrees().ToDirection(); +} + +class P4CrystallizeTimeRewind(BossModule module) : BossComponent(module) +{ + public bool Done; + private readonly P4CrystallizeTime? _ct = module.FindComponent(); + private readonly P4CrystallizeTimeTidalLight? _exalines = module.FindComponent(); + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + if (_ct != null && _exalines != null && _ct.Cleansed[pcSlot]) + Arena.AddCircle(Module.Center + 0.5f * _exalines.StartingOffset, 1, ArenaColor.Safe); // TODO: better hints... + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.Return) + Done = true; + } } diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs index 9114fb9f4..5c891f131 100644 --- a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4DarklitDragonsong.cs @@ -177,8 +177,15 @@ public override void OnTethered(Actor source, ActorTetherInfo tether) class P4DarklitDragonsongDarkWater(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, includeDeadTargets: true) { + public bool ResolveImminent; private readonly P4DarklitDragonsong? _assignments = module.FindComponent(); + public override void AddHints(int slot, Actor actor, TextHints hints) + { + if (ResolveImminent) + base.AddHints(slot, actor, hints); + } + public override void OnStatusGain(Actor actor, ActorStatus status) { if ((SID)status.ID == SID.SpellInWaitingDarkWater) diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P4Preposition.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4Preposition.cs new file mode 100644 index 000000000..4f0af9f13 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P4Preposition.cs @@ -0,0 +1,19 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +// boss can spawn either N or S from center +class P4Preposition(BossModule module) : BossComponent(module) +{ + private readonly IReadOnlyList _boss = module.Enemies(OID.UsurperOfFrostP4); + + public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignment assignment, AIHints hints) + { + foreach (var b in _boss) + hints.AddForbiddenZone(ShapeDistance.InvertedCircle(b.Position, 8), DateTime.MaxValue); + } + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + foreach (var b in _boss) + Arena.AddCircle(b.Position, 1, ArenaColor.Safe); + } +} diff --git a/BossMod/Replay/Analysis/TetherInfo.cs b/BossMod/Replay/Analysis/TetherInfo.cs index 44859e906..27b68f2a7 100644 --- a/BossMod/Replay/Analysis/TetherInfo.cs +++ b/BossMod/Replay/Analysis/TetherInfo.cs @@ -6,10 +6,54 @@ namespace BossMod.ReplayAnalysis; class TetherInfo : CommonEnumInfo { + public readonly record struct Instance(Replay Replay, Replay.Encounter Enc, Replay.Tether Tether) + { + public string TimestampString() => $"{Replay.Path} @ {Enc.Time.Start:O}+{(Tether.Time.Start - Enc.Time.Start).TotalSeconds:f4}"; + + public override string ToString() => $"{TimestampString()}: {ReplayUtils.ParticipantPosRotString(Tether.Source, Tether.Time.Start)} -> {ReplayUtils.ParticipantPosRotString(Tether.Target, Tether.Time.Start)}, active for {Tether.Time}s"; + } + + class BreakAnalysis + { + private readonly UIPlot _plot = new(); + private readonly List<(Instance inst, Vector2 startEnd)> _points = []; + + public BreakAnalysis(List infos) + { + _plot.DataMin = new(float.MaxValue, float.MaxValue); + _plot.DataMax = new(float.MinValue, float.MinValue); + _plot.TickAdvance = new(5, 5); + foreach (var inst in infos) + { + var s = (inst.Tether.Target.PosRotAt(inst.Tether.Time.Start).XZ() - inst.Tether.Source.PosRotAt(inst.Tether.Time.Start).XZ()).Length(); + var e = (inst.Tether.Target.PosRotAt(inst.Tether.Time.End).XZ() - inst.Tether.Source.PosRotAt(inst.Tether.Time.End).XZ()).Length(); + _plot.DataMin.X = Math.Min(_plot.DataMin.X, s); + _plot.DataMin.Y = Math.Min(_plot.DataMin.Y, e); + _plot.DataMax.X = Math.Max(_plot.DataMax.X, s); + _plot.DataMax.Y = Math.Max(_plot.DataMax.Y, e); + _points.Add((inst, new(s, e))); + } + _plot.DataMin.X -= 1; + _plot.DataMin.Y -= 1; + _plot.DataMax.X += 1; + _plot.DataMax.Y += 1; + } + + public void Draw() + { + _plot.Begin(); + foreach (var i in _points) + _plot.Point(i.startEnd, 0xff808080, i.inst.TimestampString); + _plot.End(); + } + } + private class TetherData { - public HashSet SourceOIDs = []; - public HashSet TargetOIDs = []; + public readonly List Instances = []; + public readonly HashSet SourceOIDs = []; + public readonly HashSet TargetOIDs = []; + public BreakAnalysis? BreakAnalysis; } private readonly Type? _tidType; @@ -27,6 +71,7 @@ public TetherInfo(List replays, uint oid) foreach (var tether in replay.EncounterTethers(enc)) { var data = _data.GetOrAdd(tether.ID); + data.Instances.Add(new(replay, enc, tether)); data.SourceOIDs.Add(tether.Source.Type != ActorType.DutySupport ? tether.Source.OID : 0); data.TargetOIDs.Add(tether.Target.Type != ActorType.DutySupport ? tether.Target.OID : 0); } @@ -46,6 +91,15 @@ UITree.NodeProperties map(KeyValuePair kv) tree.LeafNode($"Source IDs: {OIDListString(data.SourceOIDs)}"); tree.LeafNode($"Target IDs: {OIDListString(data.TargetOIDs)}"); tree.LeafNode($"VFX: {Service.LuminaRow(tid)?.File}"); + foreach (var n in tree.Node("Instances", data.Instances.Count == 0)) + { + tree.LeafNodes(data.Instances, inst => inst.ToString()); + } + foreach (var an in tree.Node("Break distance analysis")) + { + data.BreakAnalysis ??= new(data.Instances); + data.BreakAnalysis.Draw(); + } } } diff --git a/BossMod/Replay/Visualization/EventList.cs b/BossMod/Replay/Visualization/EventList.cs index f3ad2df43..f176cb839 100644 --- a/BossMod/Replay/Visualization/EventList.cs +++ b/BossMod/Replay/Visualization/EventList.cs @@ -125,7 +125,14 @@ private void DrawContents(Replay.Encounter? filter, BossModuleRegistry.Info? mod foreach (var n in _tree.Node("EnvControls", !envControls.Any())) { - _tree.LeafNodes(envControls, ec => $"{tp(ec.Timestamp)}: {ec.Index:X2} = {ec.State:X8}"); + foreach (var n2 in _tree.Node("All")) + { + _tree.LeafNodes(envControls, ec => $"{tp(ec.Timestamp)}: {ec.Index:X2} = {ec.State:X8}"); + } + foreach (var index in _tree.Nodes(new SortedSet(envControls.Select(ec => ec.Index)), index => new($"Index {index:X2}"))) + { + _tree.LeafNodes(envControls.Where(ec => ec.Index == index), ec => $"{tp(ec.Timestamp)}: {ec.Index:X2} = {ec.State:X8}"); + } } }