diff --git a/BossMod/Components/StackSpread.cs b/BossMod/Components/StackSpread.cs index 9e20e983dd..61c611c7f3 100644 --- a/BossMod/Components/StackSpread.cs +++ b/BossMod/Components/StackSpread.cs @@ -39,8 +39,8 @@ public record struct Spread( public IEnumerable ActiveStacks => IncludeDeadTargets ? Stacks : Stacks.Where(s => !s.Target.IsDead); public IEnumerable ActiveSpreads => IncludeDeadTargets ? Spreads : Spreads.Where(s => !s.Target.IsDead); - public bool IsStackTarget(Actor actor) => Stacks.Any(s => s.Target == actor); - public bool IsSpreadTarget(Actor actor) => Spreads.Any(s => s.Target == actor); + public bool IsStackTarget(Actor? actor) => Stacks.Any(s => s.Target == actor); + public bool IsSpreadTarget(Actor? actor) => Spreads.Any(s => s.Target == actor); public override void AddHints(int slot, Actor actor, TextHints hints) { diff --git a/BossMod/Components/Towers.cs b/BossMod/Components/Towers.cs index 9108626055..710ebf026c 100644 --- a/BossMod/Components/Towers.cs +++ b/BossMod/Components/Towers.cs @@ -63,8 +63,9 @@ public override void AddAIHints(int slot, Actor actor, PartyRolesConfig.Assignme var forbidden = new List>(); if (!Towers.Any(x => x.ForbiddenSoakers[slot])) { - foreach (var t in Towers.Where(x => !x.IsInside(actor) && x.InsufficientAmountInside(Module) && x.NumInside(Module) > 0)) - forbiddenInverted.Add(ShapeDistance.InvertedCircle(t.Position, t.Radius)); + if (Raid.WithoutSlot(true).Count() <= 8) // don't do this in unorganized content where people do whatever + foreach (var t in Towers.Where(x => !x.IsInside(actor) && x.InsufficientAmountInside(Module) && x.NumInside(Module) > 0)) + forbiddenInverted.Add(ShapeDistance.InvertedCircle(t.Position, t.Radius)); var inTower = Towers.Any(x => x.IsInside(actor) && x.CorrectAmountInside(Module)); var missingSoakers = !inTower && Towers.Any(x => x.InsufficientAmountInside(Module)); if (forbiddenInverted.Count == 0) diff --git a/BossMod/Config/GroupAssignment.cs b/BossMod/Config/GroupAssignment.cs index 23ede8744d..2165fc54ad 100644 --- a/BossMod/Config/GroupAssignment.cs +++ b/BossMod/Config/GroupAssignment.cs @@ -136,6 +136,20 @@ public static GroupAssignmentUnique Default() return r; } + public static GroupAssignmentUnique DefaultRoles() + { + GroupAssignmentUnique r = new(); + r[PartyRolesConfig.Assignment.MT] = 0; + r[PartyRolesConfig.Assignment.OT] = 1; + r[PartyRolesConfig.Assignment.H1] = 2; + r[PartyRolesConfig.Assignment.H2] = 3; + r[PartyRolesConfig.Assignment.M1] = 4; + r[PartyRolesConfig.Assignment.M2] = 5; + r[PartyRolesConfig.Assignment.R1] = 6; + r[PartyRolesConfig.Assignment.R2] = 7; + return r; + } + public override bool Validate() { BitMask mask = new(); diff --git a/BossMod/Data/WorldState.cs b/BossMod/Data/WorldState.cs index e081bfe6e3..0f12a2e772 100644 --- a/BossMod/Data/WorldState.cs +++ b/BossMod/Data/WorldState.cs @@ -110,9 +110,7 @@ public sealed record class OpRSVData(string Key, string Value) : Operation { protected override void Exec(WorldState ws) { - // TODO: reconsider... - //lock (Service.LuminaRSVLock) - // Service.LuminaGameData?.Excel.RsvProvider.Add(Key, Value); + Service.LuminaRSV[Key] = System.Text.Encoding.UTF8.GetBytes(Value); // TODO: reconsider... ws.RSVEntries[Key] = Value; ws.RSVDataReceived.Fire(this); } diff --git a/BossMod/Framework/Service.cs b/BossMod/Framework/Service.cs index 55efb12696..1d1ae7ec7a 100644 --- a/BossMod/Framework/Service.cs +++ b/BossMod/Framework/Service.cs @@ -4,6 +4,7 @@ using Dalamud.IoC; using Dalamud.Plugin; using Dalamud.Plugin.Services; +using System.Collections.Concurrent; namespace BossMod; @@ -34,10 +35,10 @@ public sealed class Service public static Action? LogHandler; public static void Log(string msg) => LogHandler?.Invoke(msg); - public static object LuminaRSVLock = new(); // TODO: replace with System.Threading.Lock public static Lumina.GameData? LuminaGameData; public static Lumina.Excel.ExcelSheet? LuminaSheet() where T : struct, Lumina.Excel.IExcelRow => LuminaGameData?.GetExcelSheet(Lumina.Data.Language.English); public static T? LuminaRow(uint row) where T : struct, Lumina.Excel.IExcelRow => LuminaSheet()?.GetRowOrDefault(row); + public static ConcurrentDictionary LuminaRSV = []; // TODO: reconsider public static WindowSystem? WindowSystem; #pragma warning restore CA2211 diff --git a/BossMod/Modules/Dawntrail/Alliance/A14ShadowLord/CthonicFury.cs b/BossMod/Modules/Dawntrail/Alliance/A14ShadowLord/CthonicFury.cs index e1bfa298e3..55779a1ab7 100644 --- a/BossMod/Modules/Dawntrail/Alliance/A14ShadowLord/CthonicFury.cs +++ b/BossMod/Modules/Dawntrail/Alliance/A14ShadowLord/CthonicFury.cs @@ -65,7 +65,7 @@ public override void OnCastFinished(Actor caster, ActorCastInfo spell) } } -class EchoesOfAgony(BossModule module) : Components.StackWithIcon(module, (uint)IconID.EchoesOfAgony, ActionID.MakeSpell(AID.EchoesOfAgonyAOE), 5, 9.2f, 8) +class EchoesOfAgony(BossModule module) : Components.StackWithIcon(module, (uint)IconID.EchoesOfAgony, ActionID.MakeSpell(AID.EchoesOfAgonyAOE), 5, 9.2f, 8, PartyState.MaxAllianceSize) { public override void OnCastStarted(Actor caster, ActorCastInfo spell) { diff --git a/BossMod/Modules/Dawntrail/TreasureHunt/CenoteJaJaGural/GoldenMolter.cs b/BossMod/Modules/Dawntrail/TreasureHunt/CenoteJaJaGural/GoldenMolter.cs index e92fc45bf6..81f4614299 100644 --- a/BossMod/Modules/Dawntrail/TreasureHunt/CenoteJaJaGural/GoldenMolter.cs +++ b/BossMod/Modules/Dawntrail/TreasureHunt/CenoteJaJaGural/GoldenMolter.cs @@ -184,7 +184,7 @@ protected override void CalculateModuleAIHints(int slot, Actor actor, PartyRoles OID.TuraliOnion => 5, OID.TuraliEggplant => 4, OID.TuraliGarlic => 3, - OID.TuraliTomato => 2, + OID.TuraliTomato or OID.AlpacaOfFortune => 2, OID.TuligoraQueen or OID.UolonOfFortune => 1, _ => 0 }; diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs new file mode 100644 index 0000000000..7a00885328 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRU.cs @@ -0,0 +1,32 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P1BrightfireSmall(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.BrightfireSmall), new AOEShapeCircle(5)); +class P1BrightfireLarge(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.BrightfireLarge), new AOEShapeCircle(10)); +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); + +[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)) +{ + private Actor? _bossP2; + private Actor? _iceVeil; + + public Actor? BossP1() => PrimaryActor; + public Actor? BossP2() => _bossP2; + public Actor? IceVeil() => _iceVeil; + + protected override void UpdateModule() + { + // TODO: this is an ugly hack, think how multi-actor fights can be implemented without it... + // the problem is that on wipe, any actor can be deleted and recreated in the same frame + _bossP2 ??= StateMachine.ActivePhaseIndex == 1 ? Enemies(OID.BossP2).FirstOrDefault() : null; + _iceVeil ??= StateMachine.ActivePhaseIndex == 1 ? Enemies(OID.IceVeil).FirstOrDefault() : null; + } + + protected override void DrawEnemies(int pcSlot, Actor pc) + { + Arena.Actor(PrimaryActor); + Arena.Actor(_bossP2); + Arena.Actor(_iceVeil); + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs new file mode 100644 index 0000000000..7f96e30551 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUConfig.cs @@ -0,0 +1,35 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +[ConfigDisplay(Order = 0x200, Parent = typeof(DawntrailConfig))] +public class FRUConfig() : ConfigNode() +{ + // TODO: fixed tethers option + [PropertyDisplay("P1 Bound of Faith (light party tethers): group assignments & flex priority (lower number flexes)")] + [GroupDetails(["N prio 1", "N prio 2", "N prio 3", "N prio 4", "S prio 1", "S prio 2", "S prio 3", "S prio 4"])] + [GroupPreset("Supports N, DD S", [0, 1, 2, 3, 4, 5, 6, 7])] + public GroupAssignmentUnique P1BoundOfFaithAssignment = GroupAssignmentUnique.DefaultRoles(); + + [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])] + public GroupAssignmentUnique P1FallOfFaithAssignment = GroupAssignmentUnique.DefaultRoles(); + + [PropertyDisplay("P1 Fall of Faith (cone tethers): odd groups go W (rather than N)")] + public bool P1FallOfFaithEW = false; + + [PropertyDisplay("P1 Explosions: tower fill priority (lower number goes north)")] + [GroupDetails(["MT (ignore)", "OT (ignore)", "Fixed N", "Fixed Center", "Fixed S", "Flex 1", "Flex 2", "Flex 3"])] + [GroupPreset("H1-R2-H2 fixed, M1-M2-R1 flex", [0, 1, 2, 4, 5, 6, 7, 3])] + public GroupAssignmentUnique P1ExplosionsAssignment = GroupAssignmentUnique.DefaultRoles(); + + [PropertyDisplay("P2 Diamond Dust: cardinal assignments")] + [GroupDetails(["Support N", "Support E", "Support S", "Support W", "DD N", "DD E", "DD S", "DD W"])] + public GroupAssignmentUnique P2DiamondDustCardinals = GroupAssignmentUnique.DefaultRoles(); + + [PropertyDisplay("P2 Diamond Dust: supports go to CCW intercardinal")] + public bool P2DiamondDustSupportsCCW; + + [PropertyDisplay("P2 Diamond Dust: DD go to CCW intercardinal")] + public bool P2DiamondDustDDCCW; +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs new file mode 100644 index 0000000000..bd011df6ed --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUEnums.cs @@ -0,0 +1,182 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +public enum OID : uint +{ + Boss = 0x459B, // R5.004, x1 + Helper = 0x233C, // R0.500, x24, Helper type + FatebreakersImage = 0x459C, // R5.004, x15 + FatebreakersImageHelper = 0x45B0, // R1.800, x8 + HaloOfFlame = 0x459D, // R1.000, x0 (spawn during fight) + HaloOfLevin = 0x459E, // R1.000, x0 (spawn during fight) + + BossP2 = 0x459F, // R6.125, x0 (spawn during fight) + OraclesReflection = 0x45A0, // R6.125, x0 (spawn during fight) + FrozenMirror = 0x45A1, // R1.000, x0 (spawn during fight) + HolyLight = 0x45A2, // R2.000, x0 (spawn during fight) (light rampant orb) + SinboundHolyVoidzone = 0x1EBC4F, // R0.500, x0 (spawn during fight), EventObj type + + CrystalOfLight = 0x45A3, // R1.500, x0 (spawn during fight) + CrystalOfDarkness = 0x45A4, // R1.500, x0 (spawn during fight) + IceVeil = 0x45A5, // R5.000, x0 (spawn during fight) + Gaia = 0x45A6, // R1.000, x0 (spawn during fight) + //_Gen_OracleOfDarkness = 0x45A7, // R7.040, x0 (spawn during fight) + //_Gen_CrystalOfLight = 0x464F, // R1.000, x0 (spawn during fight) + HiemalRayVoidzone = 0x1EA1CB, // R0.500, x0 (spawn during fight), EventObj type +} + +public enum AID : uint +{ + // P1 + AutoAttackP1 = 40116, // Boss->player, no cast, single-target + TeleportP1 = 40173, // Boss->location, no cast, ??? + + PowderMarkTrail = 40168, // Boss->player, 5.0s cast, single-target + BurnMark = 40169, // Helper->self, no cast, range 10 circle, spread on tankbuster target and closest player + BurnishedGlory = 40170, // Boss->self, 5.0s cast, range 40 circle, raidwide with bleed + + CyclonicBreakBossStack = 40144, // Boss->self, 6.5s cast, single-target, visual (proteans + pairs) + CyclonicBreakBossSpread = 40148, // Boss->self, 6.5s cast, single-target, visual (proteans + spread) + CyclonicBreakImageStack = 40329, // FatebreakersImage->self, 7.0s cast, single-target, visual (proteans + pairs) + CyclonicBreakImageSpread = 40330, // FatebreakersImage->self, 7.0s cast, single-target, visual (proteans + spread) + CyclonicBreakAOEFirst = 40145, // Helper->self, no cast, range 60 ?-degree cone + CyclonicBreakAOERest = 40146, // Helper->self, no cast, range 50 ?-degree cone + CyclonicBreakSinsmoke = 40147, // Helper->players, no cast, range 6 circle, 2-man stack + CyclonicBreakSinsmite = 40149, // Helper->players, no cast, range 6 circle spread + + UtopianSkyStack = 40154, // Boss->self, 4.0s cast, single-target, visual (3 lines + stack) + UtopianSkySpread = 40155, // Boss->self, 4.0s cast, single-target, visual (3 lines + spread) + BlastingZoneAOE = 40157, // FatebreakersImage->self, no cast, range 50 width 16 rect + BlastingZone = 40158, // FatebreakersImageHelper->self, 10.0s cast, single-target, visual (line aoe) + SinboundFire = 40159, // Helper->players, no cast, range 6 circle 4-man stack + SinboundThunder = 40160, // Helper->players, no cast, range 5 circle spread + + TurnOfHeavensFire = 40150, // FatebreakersImage->self, 7.0s cast, single-target, visual (large fire) + TurnOfHeavensLightning = 40151, // FatebreakersImage->self, 7.0s cast, single-target, visual (large lightning) + TurnOfHeavensBurntStrikeFire = 40161, // FatebreakersImage->self, 8.0s cast, range 80 width 10 rect, followed by knockback + TurnOfHeavensBlastburn = 40162, // Helper->self, 10.0s cast, range 80 width 50 rect, knockback 15 to the side + TurnOfHeavensBurntStrikeLightning = 40163, // FatebreakersImage->self, 8.0s cast, range 80 width 10 rect, followed by wide aoe + TurnOfHeavensBurnout = 40164, // Helper->self, 9.7s cast, range 80 width 20 rect + BrightfireSmall = 40152, // HaloOfFlame/HaloOfLevin->self, 8.0s cast, range 5 circle + BrightfireLarge = 40153, // HaloOfFlame/HaloOfLevin->self, 8.0s cast, range 10 circle + BoundOfFaith = 40165, // FatebreakersImage->self, 10.0s cast, single-target, visual (tethers for stacks) + FloatingFetters = 40171, // FatebreakersImage/Boss->player, no cast, single-target, apply floating on stack targets + TurnOfHeavensSolemnCharge = 40166, // FatebreakersImage->player, no cast, single-target, visual (stack) + BoundOfFaithSinsmoke = 40167, // Helper->players, no cast, range 6 circle, 4-man stack + FatedBurnMark = 40331, // Helper->location, no cast, range 100 circle, raidwide if tether target dies + + FallOfFaithFire = 40137, // Boss/FatebreakersImage->self, 9.0s cast, single-target, visual (fire tether -> shared cone) + FallOfFaithLightning = 40140, // Boss/FatebreakersImage->self, 9.0s cast, single-target, visual (lightning tether -> proteans) + FallOfFaithApply = 40172, // Helper->player, no cast, single-target, ??? (attract to current position, right before applying fetters) + FallOfFaithSolemnChargeFire = 40138, // FatebreakersImage/Boss->player, no cast, single-target, visual (tether resolve) + FallOfFaithSolemnChargeLightning = 40141, // Boss/FatebreakersImage->player, no cast, single-target, visual (tether resolve) + FallOfFaithSinsmite = 40142, // Helper->player, no cast, single-target, primary target hit + FallOfFaithBowShock = 40143, // Helper->self, no cast, range 60 120-degree cone, protean + FallOfFaithSinblaze = 40156, // Helper->self, no cast, range 60 90-degree cone, 4-man stack + + ExplosionBurntStrikeFire = 40129, // Boss->self, 6.5s cast, range 80 width 10 rect, followed by knockback + ExplosionBlastburn = 40130, // Helper->self, 8.5s cast, range 80 width 50 rect, knockback 15 to the side + ExplosionBurntStrikeLightning = 40133, // Boss->self, 6.5s cast, range 80 width 10 rect, followed by wide aoe + ExplosionBurnout = 40134, // Helper->self, 8.2s cast, range 80 width 20 rect + Explosion11 = 40135, // Helper->self, 10.5s cast, range 4 circle, 1-man tower + Explosion12 = 40131, // Helper->self, 10.5s cast, range 4 circle, 1-man tower + Explosion21 = 40125, // Helper->self, 10.5s cast, range 4 circle, 2-man tower + Explosion22 = 40122, // Helper->self, 10.5s cast, range 4 circle, 2-man tower + Explosion31 = 40126, // Helper->self, 10.5s cast, range 4 circle, 3-man tower + Explosion32 = 40123, // Helper->self, 10.5s cast, range 4 circle, 3-man tower + Explosion41 = 40124, // Helper->self, 10.5s cast, range 4 circle, 4-man tower + Explosion42 = 40127, // Helper->self, 10.5s cast, range 4 circle, 4-man tower + UnmitigatedExplosion1 = 40132, // Helper->self, no cast, range 40 circle, raidwide if not enough soakers + UnmitigatedExplosion2 = 40136, // Helper->self, no cast, range 40 circle, raidwide if not enough soakers (what's the difference?) + + EnrageP1 = 40128, // Boss->self, 10.0s cast, range 40 circle, enrage + + // P2 + AutoAttackP2 = 40176, // BossP2->player, no cast, single-target + TeleportP2 = 40175, // BossP2/OraclesReflection->location, no cast, single-target + QuadrupleSlapFirst = 40191, // BossP2->player, 5.0s cast, single-target, tankbuster with vuln + QuadrupleSlapSecond = 40192, // BossP2->player, 2.5s cast, single-target, tankbuster second hit + + MirrorImage = 40180, // BossP2->self, 3.0s cast, single-target, visual (show clones) + 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 + 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 + FrigidNeedleCross = 40201, // Helper->self, 5.0s cast, range 40 width 5 cross + HeavenlyStrike = 40207, // BossP2->self, no cast, range 40 circle, knockback 12 + SinboundHoly = 40208, // OraclesReflection->self, 5.0s cast, single-target, visual (multi-hit light party stacks) + SinboundHolyAOE = 40209, // Helper->location, no cast, range 6 circle, 4-man stack on healers + ShiningArmor = 40185, // Helper->self, no cast, range 40 circle, gaze (stun + damage down) + FrostArmor = 40184, // Helper->self, no cast, single-target, visual (thin ice) + TwinStillnessFirst = 40193, // OraclesReflection->self, 3.5s cast, range 30 270-degree cone (front) + TwinStillnessSecond = 40196, // OraclesReflection->self, no cast, range 40 90-degree cone (back) + TwinSilenceFirst = 40194, // OraclesReflection->self, 3.5s cast, range 40 90-degree cone (back) + TwinSilenceSecond = 40195, // OraclesReflection->self, no cast, range 30 270-degree cone (front) + + HallowedRay = 40210, // BossP2->self, 5.0s cast, single-target, visual (line stack) + HallowedRayAOE = 40211, // BossP2->self, no cast, range 65 width 6 rect, line stack + + MirrorMirror = 40179, // BossP2->self, 3.0s cast, single-target, visual (mechanic start) + ReflectedScytheKickBlue = 40204, // FrozenMirror->self, no cast, range 4-20 donut + ReflectedScytheKickRed = 40205, // FrozenMirror->self, 10.0s cast, range 4-20 donut + BanishStack = 40220, // BossP2->self, 5.0s cast, single-target, visual (pairs) + BanishStackAOE = 40222, // Helper->location, no cast, range 5 circle + BanishSpread = 40221, // BossP2->self, 5.0s cast, single-target, visual (spread) + BanishSpreadAOE = 40223, // Helper->location, no cast, range 5 circle + + LightRampant = 40212, // BossP2->self, 5.0s cast, range 40 circle, raidwide + mechanic start + LuminousHammer = 40218, // Helper->player, no cast, range 6 circle, baited puddle + BrightHunger = 40213, // Helper->location, no cast, range 4 circle, tower + //_Weaponskill_RefulgentFate = 40215, // Helper->location, no cast, range 40 circle + //_Weaponskill_InescapableIllumination = 40214, // Helper->location, no cast, range 40 circle + HolyLightBurst = 40219, // HolyLight->self, 5.0s cast, range 11 circle + PowerfulLight = 40217, // Helper->players, no cast, range 5 circle stack + HouseOfLightBoss = 40189, // BossP2->self, 5.0s cast, single-target, visual (proteans) + HouseOfLightBossAOE = 40188, // Helper->self, no cast, range 60 ?-degree cone + //_Weaponskill_Lightsteep = 40216, // Helper->players, no cast, range 40 circle + + AbsoluteZero = 40224, // BossP2->self, 10.0s cast, single-target, visual (raidwide + intermission start) + AbsoluteZeroAOE = 40333, // Helper->self, no cast, range 100 circle, raidwide + SwellingFrost = 40225, // Helper->self, no cast, range 40 circle, knockback 10 + freeze + EndlessIceAge = 40259, // IceVeil->self, 40.0s cast, range 100 circle, enrage + SinboundBlizzard = 40258, // CrystalOfDarkness->self, 3.3s cast, single-target, visual (baited cone) + SinboundBlizzardAOE = 40262, // Helper->self, 3.3s cast, range 50 ?-degree cone + HiemalStorm = 40255, // CrystalOfLight->self, no cast, single-target, visual (baited puddle) + HiemalStormAOE = 40256, // Helper->location, 3.3s cast, range 7 circle + HiemalRay = 40257, // Helper->player, no cast, range 4 circle +} + +public enum SID : uint +{ + PowderMarkTrail = 4166, // Boss->player, extra=0x0 + Concealed = 1621, // none->FatebreakersImage, extra=0x1 + Prey = 1051, // none->player, extra=0x0 + FatedBurnMark = 4165, // none->player, extra=0x0 + FloatingFetters = 2304, // FatebreakersImage/Boss->player, extra=0xC8 + MarkOfMortality = 4372, // Helper->player, extra=0x1 + ChainsOfEverlastingLight = 4157, // none->player, extra=0x0, light rampant first tether + CurseOfEverlastingLight = 4158, // none->player, extra=0x0, light rampant second tether + WeightOfLight = 4159, // none->player, extra=0x0, light rampant stack + Lightsteeped = 2257, // Helper/HolyLight->player, extra=0x1/0x2/0x3/0x4/0x5 +} + +public enum IconID : uint +{ + PowderMarkTrail = 218, // player->self + FrigidStone = 345, // player->self + HallowedRay = 525, // BossP2->player + LuminousHammer = 375, // player->self +} + +public enum TetherID : uint +{ + Fire = 249, // Boss/FatebreakersImage->player + Lightning = 287, // Boss/FatebreakersImage->player + LightRampantChains = 110, // player->player + LightRampantCurse = 111, // player->player + IntermissionGaia = 112, // Gaia->IceVeil + IntermissionCrystal = 8, // CrystalOfLight/CrystalOfDarkness->IceVeil + HiemalRay = 84, // CrystalOfLight->player +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs new file mode 100644 index 0000000000..f3d468cd88 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/FRUStates.cs @@ -0,0 +1,316 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class FRUStates : StateMachineBuilder +{ + private readonly FRU _module; + + public FRUStates(FRU module) : base(module) + { + _module = module; + SimplePhase(0, Phase1, "P1: Fatebreaker") + .Raw.Update = () => Module.PrimaryActor.IsDeadOrDestroyed; + SimplePhase(1, Phase2, "P2: Usurper of Frost") + .SetHint(StateMachine.PhaseHint.StartWithDowntime) + .Raw.Update = () => !Module.PrimaryActor.IsDead || (_module.BossP2()?.IsDeadOrDestroyed ?? false); + } + + private void Phase1(uint id) + { + P1CyclonicBreakPowderMarkTrail(id, 7.2f); + P1UtopianSky(id + 0x10000, 6.7f); + P1CyclonicBreakImage(id + 0x20000, 6.3f); + P1TurnOfTheHeavensBoundOfFaith(id + 0x30000, 2.2f); + P1BurnishedGlory(id + 0x40000, 1.1f); + P1FallOfFaith(id + 0x50000, 6.6f); + P1BurnishedGlory(id + 0x60000, 2.7f); + P1PowderMarkTrailExplosions(id + 0x70000, 3.5f); + ActorCast(id + 0x80000, _module.BossP1, AID.EnrageP1, 4.6f, 10, true, "Enrage"); + } + + private void Phase2(uint id) + { + ActorTargetable(id, _module.BossP2, true, 4.4f, "Boss appears") + .SetHint(StateMachine.StateHint.DowntimeEnd); + P2QuadrupleSlap(id + 0x10000, 6.1f); + P2DiamondDust(id + 0x20000, 6.5f); + P2HallowedRay(id + 0x30000, 2.1f); + P2MirrorMirror(id + 0x40000, 6.1f); + P2LightRampant(id + 0x50000, 4.4f); + P2AbsoluteZero(id + 0x60000, 8.4f); + + SimpleState(id + 0xFF0000, 100, "???"); + } + + private void P1CyclonicBreakPowderMarkTrail(uint id, float delay) + { + ActorCastMulti(id, _module.BossP1, [AID.CyclonicBreakBossStack, AID.CyclonicBreakBossSpread], delay, 6.5f, true) + .ActivateOnEnter() + .ActivateOnEnter(); + ComponentCondition(id + 0x10, 0.6f, comp => comp.NumCasts > 0, "Protean 1") + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 0x11, 2.1f, comp => comp.NumCasts > 0, "Protean 2 + Spread/Stack") // both happen at the same time + .DeactivateOnExit(); + ComponentCondition(id + 0x12, 2.1f, comp => comp.NumCasts > 1, "Protean 3"); + + ActorCastStart(id + 0x100, _module.BossP1, AID.PowderMarkTrail, 0.8f, true); + ComponentCondition(id + 0x101, 1.3f, comp => comp.NumCasts > 2, "Protean 4") + .DeactivateOnExit(); + ActorCastEnd(id + 0x102, _module.BossP1, 3.7f, true, "Tankbuster") + .SetHint(StateMachine.StateHint.Tankbuster); + } + + private void P1UtopianSky(uint id, float delay) + { + ActorCastMulti(id, _module.BossP1, [AID.UtopianSkyStack, AID.UtopianSkySpread], delay, 4, true, "Boss disappears") + .ActivateOnEnter() + .SetHint(StateMachine.StateHint.DowntimeStart); + ComponentCondition(id + 0x10, 5.2f, comp => comp.AOEs.Count > 0) + .ActivateOnEnter() + .ActivateOnEnter(); + ComponentCondition(id + 0x11, 0.2f, comp => comp.NumCasts > 0, "Tankbusters") + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Tankbuster); + ComponentCondition(id + 0x20, 8.9f, comp => comp.NumCasts > 0, "Lines") + .ExecOnEnter(comp => comp.Show(Module.WorldState.FutureTime(9.7f))) + .DeactivateOnExit(); + ComponentCondition(id + 0x21, 0.8f, comp => !comp.Active, "Spread/stack") + .DeactivateOnExit(); + } + + private void P1CyclonicBreakImage(uint id, float delay) + { + ComponentCondition(id, delay, comp => comp.NumCasts > 0, "Protean 1") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 1, 2.1f, comp => comp.NumCasts > 0, "Protean 2 + Spread/Stack") // both happen at the same time + .DeactivateOnExit(); + ComponentCondition(id + 2, 2.1f, comp => comp.NumCasts > 1, "Protean 3"); + ComponentCondition(id + 3, 2.1f, comp => comp.NumCasts > 2, "Protean 4") + .DeactivateOnExit(); + } + + private void P1TurnOfTheHeavensBoundOfFaith(uint id, float delay) + { + ComponentCondition(id, delay, comp => comp.NumCasts > 0, "Line 1") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 0x10, 1.7f, comp => comp.NumCasts > 0, "Line 2") + .DeactivateOnExit(); + ComponentCondition(id + 0x11, 0.4f, comp => comp.Casters.Count > 0) + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter(); + ComponentCondition(id + 0x12, 3.9f, comp => comp.NumCasts > 0, "Line 3") + .DeactivateOnExit(); + ComponentCondition(id + 0x13, 2, comp => comp.NumCasts > 0, "Knockback") + .DeactivateOnExit(); + ComponentCondition(id + 0x20, 2.1f, comp => comp.NumCasts > 0, "Circles") + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x30, 4, comp => !comp.Active, "Stacks") // note: won't happen if both targets die, but that's a wipe anyway + .DeactivateOnExit(); + ActorTargetable(id + 0x100, _module.BossP1, true, 1.4f, "Boss reappears") + .SetHint(StateMachine.StateHint.DowntimeEnd); + } + + private void P1BurnishedGlory(uint id, float delay) + { + ActorCast(id, _module.BossP1, AID.BurnishedGlory, delay, 5, true, "Raidwide") + .SetHint(StateMachine.StateHint.Raidwide); + } + + private void P1FallOfFaith(uint id, float delay) + { + ActorCastMulti(id, _module.BossP1, [AID.FallOfFaithFire, AID.FallOfFaithLightning], delay, 9, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x10, 4.1f, comp => comp.NumCasts >= 1, "Baits 1"); + ComponentCondition(id + 0x20, 3.1f, comp => comp.NumCasts >= 2, "Baits 2"); + ComponentCondition(id + 0x30, 2.5f, comp => comp.NumCasts >= 3, "Baits 3"); + ComponentCondition(id + 0x40, 2.5f, comp => comp.NumCasts >= 4, "Baits 4") + .DeactivateOnExit(); + } + + private void P1PowderMarkTrailExplosions(uint id, float delay) + { + ActorCast(id, _module.BossP1, AID.PowderMarkTrail, delay, 5, true, "Tankbuster") + .SetHint(StateMachine.StateHint.Tankbuster); + ComponentCondition(id + 0x100, 5.1f, comp => comp.Towers.Count > 0) + .ActivateOnEnter(); + Condition(id + 0x101, 6.5f, () => Module.FindComponent()?.NumCasts > 0 || Module.FindComponent()?.NumCasts > 0, "Narrow line") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit() + .DeactivateOnExit(); + Condition(id + 0x102, 2, () => Module.FindComponent()?.NumCasts > 0 || Module.FindComponent()?.NumCasts > 0, "Line/Knockback", checkDelay: 2) // note: kb and wide line have slightly different cast time... + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x103, 2, comp => comp.NumCasts > 0, "Towers") + .DeactivateOnExit(); + ComponentCondition(id + 0x104, 0.5f, comp => comp.NumCasts > 0, "Tankbusters") + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Tankbuster); + } + + private void P2QuadrupleSlap(uint id, float delay) + { + ActorCast(id, _module.BossP2, AID.QuadrupleSlapFirst, delay, 5, true, "Tankbuster 1") + .ActivateOnEnter() + .SetHint(StateMachine.StateHint.Tankbuster); + ActorCast(id + 0x10, _module.BossP2, AID.QuadrupleSlapSecond, 1.7f, 2.5f, true, "Tankbuster 2") + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Tankbuster); + } + + private void P2DiamondDust(uint id, float delay) + { + ActorCast(id, _module.BossP2, AID.MirrorImage, delay, 3, true); + ActorCast(id + 0x10, _module.BossP2, AID.DiamondDust, 2.1f, 5, true, "Raidwide") + .SetHint(StateMachine.StateHint.Raidwide); + ActorTargetable(id + 0x20, _module.BossP2, false, 3.1f, "Boss disappears") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .SetHint(StateMachine.StateHint.DowntimeStart); + Condition(id + 0x30, 5.7f, () => Module.FindComponent()?.NumCasts > 0 || Module.FindComponent()?.NumCasts > 0, "In/out") + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x31, 0.8f, comp => comp.NumCasts > 0, "Proteans") + .DeactivateOnExit(); + ComponentCondition(id + 0x32, 1.6f, comp => comp.NumCasts > 0, "Ice baits") + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x33, 0.4f, comp => comp.NumCasts > 0, "Ice circle 1"); + ComponentCondition(id + 0x40, 3.9f, comp => comp.NumCasts > 0, "Knockback") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() // show the cone caster early, to simplify finding movement direction... + .DeactivateOnExit(); + ComponentCondition(id + 0x50, 2.8f, comp => comp.NumCasts > 0, "Stars") + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x60, 1.3f, comp => comp.NumCasts > 0); + ComponentCondition(id + 0x70, 4.7f, comp => comp.NumCasts >= 4) + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit() // last icicle explodes together with first stack + .DeactivateOnExit(); + ComponentCondition(id + 0x80, 3.7f, comp => comp.NumCasts > 0, "Gaze") + .DeactivateOnExit(); + ComponentCondition(id + 0x90, 3.0f, comp => comp.AOEs.Count > 0); + ComponentCondition(id + 0x91, 3.5f, comp => comp.NumCasts > 0, "Front/back"); + ComponentCondition(id + 0x92, 2.1f, comp => comp.NumCasts > 1, "Back/front") + .DeactivateOnExit() + .DeactivateOnExit(); + ActorTargetable(id + 0xA0, _module.BossP2, true, 3.6f, "Boss reappears") + .SetHint(StateMachine.StateHint.DowntimeEnd); + } + + private void P2HallowedRay(uint id, float delay) + { + ActorCastStart(id, _module.BossP2, AID.HallowedRay, delay, true) + .ActivateOnEnter(); + ActorCastEnd(id + 1, _module.BossP2, 5, true); + ComponentCondition(id + 2, 0.6f, comp => comp.NumCasts > 0, "Line stack") + .DeactivateOnExit(); + } + + private void P2MirrorMirror(uint id, float delay) + { + ActorCast(id, _module.BossP2, AID.MirrorMirror, delay, 3, true) + .ActivateOnEnter() + .ActivateOnEnter(); + ActorCast(id + 0x10, _module.BossP2, AID.ScytheKick, 8.2f, 6, true) + .ActivateOnEnter() + .DeactivateOnExit() + .DeactivateOnExit(); + ComponentCondition(id + 0x12, 0.7f, comp => comp.NumCasts > 0, "Mirror 1"); + ComponentCondition(id + 0x20, 9.3f, comp => comp.NumCasts > 0) + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 0x21, 0.6f, comp => comp.NumCasts > 1, "Mirror 2") + .DeactivateOnExit(); + + ActorCastMulti(id + 0x100, _module.BossP2, [AID.BanishStack, AID.BanishSpread], 0.5f, 5, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x102, 0.1f, comp => !comp.Active, "Spread/Stack") + .DeactivateOnExit(); + } + + private void P2LightRampant(uint id, float delay) + { + ActorCast(id, _module.BossP2, AID.LightRampant, delay, 5, true, "Raidwide (light rampant)") + .SetHint(StateMachine.StateHint.Raidwide); + ActorTargetable(id + 0x10, _module.BossP2, false, 3.1f, "Boss disappears") + .ActivateOnEnter() + .ActivateOnEnter() + .SetHint(StateMachine.StateHint.DowntimeStart); + ComponentCondition(id + 0x20, 4.8f, comp => comp.NumCasts > 0, "Puddle bait"); + ComponentCondition(id + 0x30, 3.3f, comp => comp.NumCasts > 0, "Towers") + .ActivateOnEnter() + .DeactivateOnExit(); + ComponentCondition(id + 0x40, 5.7f, comp => !comp.Active, "Stack") + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit() // last puddle is baited right before holy light burst casts start + .DeactivateOnExit(); + ComponentCondition(id + 0x50, 2.4f, comp => comp.NumCasts > 0, "Orbs 1") // tethers resolve somewhere here?.. + .ActivateOnEnter(); + ComponentCondition(id + 0x60, 3, comp => comp.NumCasts > 3, "Orbs 2") + .DeactivateOnExit(); + + ActorCastStartMulti(id + 0x70, _module.BossP2, [AID.BanishStack, AID.BanishSpread], 1.7f, true); + ComponentCondition(id + 0x71, 1.9f, comp => comp.NumCasts > 0, "Central tower") + .ActivateOnEnter() + .DeactivateOnExit() + .DeactivateOnExit(); + ActorCastEnd(id + 0x72, _module.BossP2, 3.1f, true); + ComponentCondition(id + 0x73, 0.1f, comp => !comp.Active, "Spread/Stack") + .DeactivateOnExit(); + ActorTargetable(id + 0x80, _module.BossP2, true, 3, "Boss reappears") + .SetHint(StateMachine.StateHint.DowntimeEnd); + + ActorCast(id + 0x1000, _module.BossP2, AID.HouseOfLightBoss, 0.1f, 5, true) + .ActivateOnEnter(); + ComponentCondition(id + 0x1002, 0.9f, comp => comp.NumCasts > 0, "Proteans") + .DeactivateOnExit(); + } + + private void P2AbsoluteZero(uint id, float delay) + { + ActorCast(id, _module.BossP2, AID.AbsoluteZero, delay, 10, true, "Intermission start") + .SetHint(StateMachine.StateHint.DowntimeStart); + ComponentCondition(id + 2, 0.9f, comp => comp.NumCasts > 0, "Raidwide") + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit() + .SetHint(StateMachine.StateHint.Raidwide); + ComponentCondition(id + 3, 2.3f, comp => comp.NumCasts > 0, "Knockback") + .DeactivateOnExit(); + + ComponentCondition(id + 0x1000, 18.9f, comp => comp.ActiveActors.Any(), "Crystals appear") + .ActivateOnEnter() + .SetHint(StateMachine.StateHint.DowntimeEnd); + ActorCast(id + 0x1010, _module.IceVeil, AID.EndlessIceAge, 4.7f, 40, true, "Enrage") + .ActivateOnEnter() + .ActivateOnEnter() + .ActivateOnEnter() + .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit() + .DeactivateOnExit(); + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BoundOfFaith.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BoundOfFaith.cs new file mode 100644 index 0000000000..3256b2988c --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BoundOfFaith.cs @@ -0,0 +1,88 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +// TODO: fixed tethers strat variant (tether target with clone on safe side goes S, other goes N, if any group has 5 players prio1 adjusts) +class P1BoundOfFaith(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4) +{ + private readonly FRUConfig _config = Service.Config.Get(); + private readonly int[] _assignedGroups = new int[PartyState.MaxPartySize]; + private OID _safeHalo; + private int _safeSide; // -1 if X<0, +1 if X>0 + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + base.DrawArenaForeground(pcSlot, pc); + if (_assignedGroups[pcSlot] != 0 && _safeSide != 0) + { + var safeDir = _safeSide * (90 - _assignedGroups[pcSlot] * 22.5f).Degrees(); + Arena.AddCircle(Module.Center + 19 * safeDir.ToDirection(), 1, Colors.Safe); + } + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + _safeHalo = (AID)spell.Action.ID switch + { + AID.TurnOfHeavensFire => OID.HaloOfLevin, + AID.TurnOfHeavensLightning => OID.HaloOfFlame, + _ => _safeHalo + }; + } + + public override void OnTethered(Actor source, ActorTetherInfo tether) + { + if (tether.ID == (uint)TetherID.Fire && WorldState.Actors.Find(tether.Target) is var target && target != null) + { + AddStack(target, WorldState.FutureTime(10.6f)); + if (Stacks.Count == 2) + InitAssignments(); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.BoundOfFaithSinsmoke) + { + Stacks.Clear(); + } + } + + private void InitAssignments() + { + if (_safeHalo != default) + { + WDir averageOffset = default; + foreach (var aoe in Module.Enemies(_safeHalo)) + averageOffset += aoe.Position - Module.Center; + _safeSide = averageOffset.X > 0 ? 1 : -1; + } + + // initial assignments + Span tetherSlots = [-1, -1]; + Span prio = [0, 0, 0, 0, 0, 0, 0, 0]; + foreach (var (slot, group) in _config.P1BoundOfFaithAssignment.Resolve(Raid)) + { + _assignedGroups[slot] = group < 4 ? -1 : 1; + prio[slot] = group & 3; + if (IsStackTarget(Raid[slot])) + tetherSlots[tetherSlots[0] < 0 ? 0 : 1] = slot; + } + + // swaps + if (tetherSlots[0] >= 0 && _assignedGroups[tetherSlots[0]] == _assignedGroups[tetherSlots[1]]) + { + // flex tether with lower prio + var tetherFlexSlot = prio[tetherSlots[0]] < prio[tetherSlots[1]] ? tetherSlots[0] : tetherSlots[1]; + _assignedGroups[tetherFlexSlot] = -_assignedGroups[tetherFlexSlot]; + + // now the group where we've moved flex slot has 5 people, find untethered with lowest prio + for (var normalFlexSlot = 0; normalFlexSlot < PartyState.MaxPartySize; ++normalFlexSlot) + { + if (normalFlexSlot != tetherFlexSlot && prio[normalFlexSlot] == 0 && _assignedGroups[normalFlexSlot] == _assignedGroups[tetherFlexSlot]) + { + _assignedGroups[normalFlexSlot] = -_assignedGroups[normalFlexSlot]; + break; + } + } + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BurntStrike.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BurntStrike.cs new file mode 100644 index 0000000000..845b51d1ef --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1BurntStrike.cs @@ -0,0 +1,40 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P1TurnOfHeavensBurntStrikeFire(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.TurnOfHeavensBurntStrikeFire), new AOEShapeRect(40, 5, 40)); +class P1TurnOfHeavensBurntStrikeLightning(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.TurnOfHeavensBurntStrikeLightning), new AOEShapeRect(40, 5, 40)); +class P1TurnOfHeavensBurnout(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.TurnOfHeavensBurnout), new AOEShapeRect(40, 10, 40)); +class P1ExplosionBurntStrikeFire(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ExplosionBurntStrikeFire), new AOEShapeRect(40, 5, 40)); +class P1ExplosionBurntStrikeLightning(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ExplosionBurntStrikeLightning), new AOEShapeRect(40, 5, 40)); +class P1ExplosionBurnout(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ExplosionBurnout), new AOEShapeRect(40, 10, 40)); + +class P1Blastburn(BossModule module) : Components.Knockback(module, default, true) +{ + private Actor? _caster; + + public override IEnumerable Sources(int slot, Actor actor) + { + if (_caster != null) + { + var dir = _caster.CastInfo?.Rotation ?? _caster.Rotation; + var kind = dir.ToDirection().OrthoL().Dot(actor.Position - _caster.Position) > 0 ? Kind.DirLeft : Kind.DirRight; + yield return new(_caster.Position, 15, Module.CastFinishAt(_caster.CastInfo), null, dir, kind); + } + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID is AID.TurnOfHeavensBlastburn or AID.ExplosionBlastburn) + { + _caster = caster; + } + } + + public override void OnCastFinished(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID is AID.TurnOfHeavensBlastburn or AID.ExplosionBlastburn) + { + _caster = null; + ++NumCasts; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs new file mode 100644 index 0000000000..25335e10dd --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1CyclonicBreak.cs @@ -0,0 +1,66 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P1CyclonicBreakSpreadStack(BossModule module) : Components.UniformStackSpread(module, 6, 6, 2, 2, true) +{ + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.CyclonicBreakBossStack: + case AID.CyclonicBreakImageStack: + // TODO: this can target either supports or dd + AddStacks(Module.Raid.WithoutSlot(true).Where(p => p.Class.IsSupport()), Module.CastFinishAt(spell, 2.7f)); + break; + case AID.CyclonicBreakBossSpread: + case AID.CyclonicBreakImageSpread: + AddSpreads(Module.Raid.WithoutSlot(true), Module.CastFinishAt(spell, 2.7f)); + break; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.CyclonicBreakSinsmoke: + Stacks.Clear(); + break; + case AID.CyclonicBreakSinsmite: + Spreads.Clear(); + break; + } + } +} + +class P1CyclonicBreakProtean(BossModule module) : Components.BaitAwayEveryone(module, module.PrimaryActor, new AOEShapeCone(60, 11.25f.Degrees()), ActionID.MakeSpell(AID.CyclonicBreakAOEFirst)); // TODO: verify angle + +class P1CyclonicBreakCone(BossModule module) : Components.GenericAOEs(module) +{ + private readonly List _aoes = []; + private DateTime _currentBundle; + + private static readonly AOEShapeCone _shape = new(60, 11.25f.Degrees()); // TODO: verify angle + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => _aoes; + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.CyclonicBreakAOEFirst: + _aoes.Add(new(_shape, caster.Position, spell.Rotation, WorldState.FutureTime(2))); + break; + case AID.CyclonicBreakAOERest: + if (WorldState.CurrentTime > _currentBundle) + { + _currentBundle = WorldState.CurrentTime.AddSeconds(1); + ++NumCasts; + foreach (ref var aoe in _aoes.AsSpan()) + aoe.Rotation -= 22.5f.Degrees(); + } + if (!_aoes.Any(aoe => aoe.Rotation.AlmostEqual(spell.Rotation - 22.5f.Degrees(), 0.1f))) + ReportError($"Failed to find protean @ {spell.Rotation}"); + break; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs new file mode 100644 index 0000000000..ca10a97e5f --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1Explosion.cs @@ -0,0 +1,59 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +// TODO: non-fixed conga? +class P1Explosion(BossModule module) : Components.GenericTowers(module) +{ + private readonly FRUConfig _config = Service.Config.Get(); + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + var numSoakers = (AID)spell.Action.ID switch + { + AID.Explosion11 or AID.Explosion12 => 1, + AID.Explosion21 or AID.Explosion22 => 2, + AID.Explosion31 or AID.Explosion32 => 3, + AID.Explosion41 or AID.Explosion42 => 4, + _ => 0 + }; + if (numSoakers != 0) + { + Towers.Add(new(caster.Position, 4, numSoakers, numSoakers, default, Module.CastFinishAt(spell))); + if (Towers.Count == 3) + InitAssignments(); + } + } + + public override void OnCastFinished(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID is AID.Explosion11 or AID.Explosion12 or AID.Explosion21 or AID.Explosion22 or AID.Explosion31 or AID.Explosion32 or AID.Explosion41 or AID.Explosion42) + { + ++NumCasts; + Towers.RemoveAll(t => t.Position.AlmostEqual(caster.Position, 1)); + } + } + + private void InitAssignments() + { + Towers.SortBy(t => t.Position.Z); + if (Towers.Count != 3 || Towers.Sum(t => t.MinSoakers) != 6) + { + ReportError($"Unexpected tower state"); + return; + } + + Span slotByGroup = [-1, -1, -1, -1, -1, -1, -1, -1]; + foreach (var (slot, group) in _config.P1ExplosionsAssignment.Resolve(Raid)) + slotByGroup[group] = slot; + if (slotByGroup.Contains(-1)) + return; + var nextFlex = 5; + for (int i = 0; i < 3; ++i) + { + 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++]); + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1FallOfFaith.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1FallOfFaith.cs new file mode 100644 index 0000000000..8c73cf9383 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1FallOfFaith.cs @@ -0,0 +1,172 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +// TODO: more positioning options?.. +class P1FallOfFaith(BossModule module) : Components.CastCounter(module, default) +{ + private struct PlayerState + { + public int TetherOrder; // 0 if no tether, otherwise 1-4 + public bool? OddGroup; + public WPos Spot1; + public WPos Spot2; + } + + private readonly FRUConfig _config = Service.Config.Get(); + private readonly PlayerState[] _states = new PlayerState[PartyState.MaxPartySize]; + private readonly List _tetherTargets = []; + private readonly List _currentBaiters = []; + private BitMask _fireTethers; // bit i is set if i'th tether is fire + + private static readonly AOEShapeCone _shapeFire = new(60, 45.Degrees()); + private static readonly AOEShapeCone _shapeLightning = new(60, 60.Degrees()); + + public override void Update() + { + _currentBaiters.Clear(); + if (_tetherTargets.Count == 4 && NumCasts < _tetherTargets.Count) + { + var nextSource = _tetherTargets[NumCasts]; + _currentBaiters.AddRange(Raid.WithoutSlot().Exclude(nextSource).SortedByRange(nextSource.Position).Take(_fireTethers[NumCasts] ? 1 : 3)); + } + } + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + ref var state = ref _states[slot]; + if (state.TetherOrder != 0) + hints.Add($"Order: {state.TetherOrder}", false); + else if (state.OddGroup != null) + hints.Add($"Help group {(state.OddGroup.Value ? 1 : 2)}", false); + + if (ActiveBaits(slot, actor, true).Any(bait => bait.shape.Check(actor.Position, bait.origin, bait.dir))) + hints.Add("GTFO from baited aoe!"); + // TODO: hint if actor is baiter while it's not his turn? + // TODO: hint if actor is clipping others? + } + + public override void AddGlobalHints(GlobalHints hints) + { + if (_tetherTargets.Count > NumCasts) + hints.Add(string.Join(" -> ", Enumerable.Range(NumCasts, _tetherTargets.Count - NumCasts).Select(i => _fireTethers[i] ? "Fire" : "Lightning"))); + } + + public override PlayerPriority CalcPriority(int pcSlot, Actor pc, int playerSlot, Actor player, ref uint customColor) => PlayerPriority.Normal; + + public override void DrawArenaBackground(int pcSlot, Actor pc) + { + foreach (var bait in ActiveBaits(pcSlot, pc, true)) + bait.shape.Draw(Arena, bait.origin, bait.dir, Colors.AOE); + } + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + foreach (var bait in ActiveBaits(pcSlot, pc, false)) + bait.shape.Outline(Arena, bait.origin, bait.dir, _fireTethers[NumCasts] ? Colors.Safe : Colors.Danger); + + ref var state = ref _states[pcSlot]; + var firstBait = state.OddGroup == true ? 0 : 1; + var safespot = NumCasts <= firstBait ? state.Spot1 : NumCasts <= firstBait + 2 ? state.Spot2 : default; + if (safespot != default) + Arena.AddCircle(safespot, 1, Colors.Safe); + } + + public override void OnTethered(Actor source, ActorTetherInfo tether) + { + if ((TetherID)tether.ID is TetherID.Fire or TetherID.Lightning) + { + _fireTethers[_tetherTargets.Count] = tether.ID == (uint)TetherID.Fire; + + var target = WorldState.Actors.Find(tether.Target); + if (target != null) + _tetherTargets.Add(target); + + var slot = Raid.FindSlot(tether.Target); + if (slot >= 0) + { + var order = _states[slot].TetherOrder = _tetherTargets.Count; + var odd = (order & 1) != 0; + var firstBait = order <= 2; + _states[slot].OddGroup = odd; + _states[slot].Spot1 = TetherSpot(odd, !firstBait); + _states[slot].Spot2 = TetherSpot(odd, firstBait); + } + + if (_tetherTargets.Count == 4) + InitAssignments(); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID is AID.FallOfFaithSinsmite or AID.FallOfFaithSinblaze) + { + ++NumCasts; + } + } + + private void InitAssignments() + { + List<(int slot, int prio)> conga = []; + foreach (var (slot, group) in _config.P1FallOfFaithAssignment.Resolve(Raid)) + if (_states[slot].TetherOrder == 0) + conga.Add((slot, group)); + if (conga.Count != 4) + return; // no assignments + + conga.SortBy(c => c.prio); + InitNormalSpots(conga[0].slot, true, true); + InitNormalSpots(conga[1].slot, true, false); + InitNormalSpots(conga[2].slot, false, false); + InitNormalSpots(conga[3].slot, false, true); + } + + private WPos TetherSpot(bool odd, bool far) + { + var dir = _config.P1FallOfFaithEW ? 90.Degrees() : 0.Degrees(); + if (odd) + dir -= 180.Degrees(); + return Module.Center + (far ? 7 : 4) * dir.ToDirection(); + } + + private WPos ProteanSpot(bool odd, bool close) + { + var baiter = TetherSpot(odd, false); + var offset = close ? 3 : -3; + WDir dir = _config.P1FallOfFaithEW ? new(0, -1) : new(1, 0); + return baiter + offset * dir; + } + + private WPos NormalSpot(bool odd, bool close, int order) => _fireTethers[order] ? TetherSpot(odd, true) : ProteanSpot(odd, close); + + private void InitNormalSpots(int slot, bool odd, bool close) + { + ref var state = ref _states[slot]; + state.OddGroup = odd; + state.Spot1 = NormalSpot(odd, close, odd ? 0 : 1); + state.Spot2 = NormalSpot(odd, close, odd ? 2 : 3); + } + + private bool ShouldBeBaiting(int slot) + { + var nextBaitsOdd = (NumCasts & 1) == 0; + ref var state = ref _states[slot]; + return state.OddGroup == null || state.OddGroup == nextBaitsOdd; // if there are no assignments, we don't actually know whether player should be baiting... + } + + private IEnumerable<(AOEShapeCone shape, WPos origin, Angle dir)> ActiveBaits(int slot, Actor actor, bool wantDangerous) + { + if (_tetherTargets.Count < 4 || NumCasts >= _tetherTargets.Count) + yield break; + + var isFire = _fireTethers[NumCasts]; + var shouldBait = ShouldBeBaiting(slot); + var source = _tetherTargets[NumCasts]; + foreach (var target in _currentBaiters) + { + // if we shouldn't be baiting - all baits are dangerous, otherwise only other proteans are dangerous + var dangerous = !shouldBait || !isFire && target != actor; + if (dangerous == wantDangerous) + yield return (isFire ? _shapeFire : _shapeLightning, source.Position, Angle.FromDirection(target.Position - source.Position)); + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs new file mode 100644 index 0000000000..bb7c4f33a4 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1PowderMarkTrail.cs @@ -0,0 +1,50 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P1PowderMarkTrail(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.BurnMark), centerAtTarget: true) +{ + private Actor? _target; + private Actor? _closest; + private DateTime _activation; + + private static readonly AOEShapeCircle _shape = new(10); + + public override void Update() + { + CurrentBaits.Clear(); + _closest = _target != null ? Raid.WithoutSlot().Exclude(_target).Closest(_target.Position) : null; + if (_target != null) + CurrentBaits.Add(new(Module.PrimaryActor, _target, _shape, _activation)); + if (_closest != null) + CurrentBaits.Add(new(Module.PrimaryActor, _closest, _shape, _activation)); + } + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + base.AddHints(slot, actor, hints); + if (_closest != null && _closest.Role != Role.Tank) + { + if (actor == _closest) + hints.Add("GTFO from tank!"); + else if (actor == _target || actor.Role == Role.Tank) + hints.Add("Get closer to co-tank!"); + } + } + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.PowderMarkTrail) + { + _target = actor; + _activation = status.ExpireAt; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (spell.Action == WatchedAction) + { + ++NumCasts; + _target = null; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P1UtopianSky.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1UtopianSky.cs new file mode 100644 index 0000000000..601b14af10 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P1UtopianSky.cs @@ -0,0 +1,77 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P1UtopianSkyBlastingZone(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.BlastingZoneAOE)) +{ + public readonly List AOEs = []; + + private static readonly AOEShapeRect _shape = new(50, 8); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => AOEs; + + public override void OnActorModelStateChange(Actor actor, byte modelState, byte animState1, byte animState2) + { + if ((OID)actor.OID == OID.FatebreakersImage && modelState == 4) + { + AOEs.Add(new(_shape, actor.Position, actor.Rotation, WorldState.FutureTime(9.1f))); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (spell.Action == WatchedAction) + { + ++NumCasts; + AOEs.Clear(); + } + } +} + +class P1UtopianSkySpreadStack(BossModule module) : Components.UniformStackSpread(module, 6, 5, 4, 4, true) +{ + public enum Mechanic { None, Spread, Stack } + + private Mechanic _curMechanic; + + public void Show(DateTime activation) + { + switch (_curMechanic) + { + case Mechanic.Stack: + // TODO: this can target either tanks or healers + AddStacks(Module.Raid.WithoutSlot(true).Where(p => p.Role == Role.Healer), activation); + break; + case Mechanic.Spread: + AddSpreads(Module.Raid.WithoutSlot(true), activation); + break; + } + } + + public override void AddGlobalHints(GlobalHints hints) + { + if (_curMechanic != Mechanic.None) + hints.Add($"Next: {_curMechanic}"); + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + _curMechanic = (AID)spell.Action.ID switch + { + AID.UtopianSkyStack => Mechanic.Stack, + AID.UtopianSkySpread => Mechanic.Spread, + _ => _curMechanic + }; + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.SinboundFire: + Stacks.Clear(); + break; + case AID.SinboundThunder: + Spreads.Clear(); + break; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs new file mode 100644 index 0000000000..7e3790a245 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2AbsoluteZero.cs @@ -0,0 +1,17 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2AbsoluteZero(BossModule module) : Components.CastCounter(module, ActionID.MakeSpell(AID.AbsoluteZeroAOE)); + +class P2SwellingFrost(BossModule module) : Components.Knockback(module, ActionID.MakeSpell(AID.SwellingFrost)) // TODO: verify whether it ignores KB +{ + private readonly DateTime _activation = module.WorldState.FutureTime(3.2f); + + public override IEnumerable Sources(int slot, Actor actor) + { + yield return new(Module.Center, 10, _activation); + } +} + +class P2SinboundBlizzard(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.SinboundBlizzardAOE), new AOEShapeCone(50, 10.Degrees())); +class P2HiemalStorm(BossModule module) : Components.LocationTargetedAOEs(module, ActionID.MakeSpell(AID.HiemalStormAOE), 7); +class P2HiemalRay(BossModule module) : Components.PersistentVoidzoneAtCastTarget(module, 4, ActionID.MakeSpell(AID.HiemalRay), module => module.Enemies(OID.HiemalRayVoidzone).Where(z => z.EventState != 7), 0.7f); diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs new file mode 100644 index 0000000000..3a4cd8411e --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2Banish.cs @@ -0,0 +1,31 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2Banish(BossModule module) : Components.UniformStackSpread(module, 5, 5, 2, 2, true) +{ + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.BanishStack: + // TODO: this can target either supports or dd + AddStacks(Module.Raid.WithoutSlot(true).Where(p => p.Class.IsSupport()), Module.CastFinishAt(spell, 0.1f)); + break; + case AID.BanishSpread: + AddSpreads(Module.Raid.WithoutSlot(true), Module.CastFinishAt(spell, 0.1f)); + break; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + switch ((AID)spell.Action.ID) + { + case AID.BanishStackAOE: + Stacks.Clear(); + break; + case AID.BanishSpreadAOE: + Spreads.Clear(); + break; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs new file mode 100644 index 0000000000..5c8e1352a5 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2DiamondDust.cs @@ -0,0 +1,225 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2AxeKick(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.AxeKick), new AOEShapeCircle(16)); +class P2ScytheKick(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ScytheKick), new AOEShapeDonut(4, 20)); +class P2IcicleImpact(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.IcicleImpact), new AOEShapeCircle(10)); +class P2FrigidNeedleCircle(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.FrigidNeedleCircle), new AOEShapeCircle(5)); +class P2FrigidNeedleCross(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.FrigidNeedleCross), new AOEShapeCross(40, 2.5f)); + +class P2FrigidStone : Components.BaitAwayIcon +{ + public P2FrigidStone(BossModule module) : base(module, new AOEShapeCircle(5), (uint)IconID.FrigidStone, ActionID.MakeSpell(AID.FrigidStone), 8.1f, true) + { + IgnoreOtherBaits = true; + } +} + +class P2DiamondDustHouseOfLight(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.HouseOfLight)) +{ + private Actor? _source; + private DateTime _activation; + + private static readonly AOEShapeCone _shape = new(60, 20.Degrees()); // TODO: verify angle + + public override void Update() + { + CurrentBaits.Clear(); + if (_source != null && ForbiddenPlayers.Any()) + foreach (var p in Raid.WithoutSlot().SortedByRange(_source.Position).Take(4)) + CurrentBaits.Add(new(_source, p, _shape, _activation)); + } + + public override void AddHints(int slot, Actor actor, TextHints hints) + { + if (ForbiddenPlayers[slot]) + { + if (ActiveBaitsOn(actor).Any()) + hints.Add("Stay farther away!"); + } + else + { + if (ActiveBaitsOn(actor).Any(b => PlayersClippedBy(b).Any())) + hints.Add("Bait cone away from raid!"); + } + + if (ActiveBaitsNotOn(actor).Any(b => IsClippedBy(actor, b))) + hints.Add("GTFO from baited cone!"); + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID is AID.AxeKick or AID.ScytheKick) + { + _source = caster; + _activation = Module.CastFinishAt(spell, 0.8f); + } + } + + public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) + { + if (iconID == (uint)IconID.FrigidStone) + ForbiddenPlayers.Set(Raid.FindSlot(actor.InstanceID)); + } +} + +class P2DiamondDustSafespots(BossModule module) : BossComponent(module) +{ + private readonly FRUConfig _config = Service.Config.Get(); + private bool? _out; + private bool? _supportsBaitCones; + private bool? _conesAtCardinals; + private readonly WPos[] _safespots = new WPos[PartyState.MaxPartySize]; + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + if (_safespots[pcSlot] != default) + Arena.AddCircle(_safespots[pcSlot], 1, Colors.Safe); + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.IcicleImpact: + _conesAtCardinals ??= IsCardinal(caster.Position - Module.Center); + InitIfReady(); + break; + case AID.AxeKick: + _out = true; + InitIfReady(); + break; + case AID.ScytheKick: + _out = false; + InitIfReady(); + break; + } + } + + public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) + { + if (iconID == (uint)IconID.FrigidStone) + { + _supportsBaitCones ??= actor.Class.IsDD(); + InitIfReady(); + } + } + + private void InitIfReady() + { + if (_out == null || _supportsBaitCones == null || _conesAtCardinals == null) + return; + var supportsAtCardinals = _supportsBaitCones == _conesAtCardinals; + var offsetTH = supportsAtCardinals ? 0.Degrees() : _config.P2DiamondDustSupportsCCW ? 45.Degrees() : -45.Degrees(); + var offsetDD = !supportsAtCardinals ? 0.Degrees() : _config.P2DiamondDustDDCCW ? 45.Degrees() : -45.Degrees(); + foreach (var (slot, group) in _config.P2DiamondDustCardinals.Resolve(Raid)) + { + var support = group < 4; + var baitCone = _supportsBaitCones == support; + var dir = 180.Degrees() - (group & 3) * 90.Degrees(); + dir += support ? offsetTH : offsetDD; + var radius = (_out.Value ? 16 : 0) + (baitCone ? 1 : 3); + _safespots[slot] = Module.Center + radius * dir.ToDirection(); + } + } + + private bool IsCardinal(WDir off) => Math.Abs(off.X) < 1 || Math.Abs(off.Z) < 1; +} + +class P2HeavenlyStrike(BossModule module) : Components.Knockback(module, ActionID.MakeSpell(AID.HeavenlyStrike)) +{ + private readonly DateTime _activation = module.WorldState.FutureTime(3.9f); + + public override IEnumerable Sources(int slot, Actor actor) + { + yield return new(Module.Center, 12, _activation); + } +} + +class P2SinboundHoly(BossModule module) : Components.UniformStackSpread(module, 6, 0, 4, 4) +{ + public int NumCasts; + private DateTime _nextExplosion; + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.SinboundHoly) + { + AddStacks(Raid.WithoutSlot().Where(p => p.Role == Role.Healer), Module.CastFinishAt(spell, 0.9f)); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.SinboundHolyAOE && WorldState.CurrentTime > _nextExplosion) + { + ++NumCasts; + _nextExplosion = WorldState.FutureTime(0.5f); + } + } +} + +class P2SinboundHolyVoidzone(BossModule module) : Components.PersistentVoidzone(module, 6, m => m.Enemies(OID.SinboundHolyVoidzone).Where(z => z.EventState != 7)); + +class P2ShiningArmor(BossModule module) : Components.GenericGaze(module, ActionID.MakeSpell(AID.ShiningArmor)) +{ + private Actor? _source; + private DateTime _activation; + + public override IEnumerable ActiveEyes(int slot, Actor actor) + { + if (_source != null) + yield return new(_source.Position, _activation); + } + + public override void OnActorPlayActionTimelineEvent(Actor actor, ushort id) + { + if ((OID)actor.OID == OID.BossP2 && id == 0x1E43) + { + _source = actor; + _activation = WorldState.FutureTime(7.2f); + } + } +} + +class P2TwinStillnessSilence(BossModule module) : Components.GenericAOEs(module) +{ + private readonly Actor? _source = module.Enemies(OID.OraclesReflection).FirstOrDefault(); + public readonly List AOEs = []; + + private readonly AOEShapeCone _shapeFront = new(30, 135.Degrees()); + private readonly AOEShapeCone _shapeBack = new(30, 45.Degrees()); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => AOEs.Take(1); + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + Arena.Actor(_source, Colors.Object, true); + if (AOEs.Count > 0) + Arena.AddCircle(pc.Position, 32, Colors.Safe); + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + var (shape1, off1, shape2, off2) = (AID)spell.Action.ID switch + { + AID.TwinStillnessFirst => (_shapeFront, 0.Degrees(), _shapeBack, 180.Degrees()), + AID.TwinSilenceFirst => (_shapeBack, 0.Degrees(), _shapeFront, 180.Degrees()), + _ => (null, default, null, default) + }; + if (shape1 != null && shape2 != null) + { + AOEs.Add(new(shape1, caster.Position, spell.Rotation + off1, Module.CastFinishAt(spell))); + AOEs.Add(new(shape2, caster.Position, spell.Rotation + off2, Module.CastFinishAt(spell, 2.1f))); + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID is AID.TwinStillnessFirst or AID.TwinStillnessSecond or AID.TwinSilenceFirst or AID.TwinSilenceSecond) + { + ++NumCasts; + if (AOEs.Count > 0) + AOEs.RemoveAt(0); + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2HallowedRay.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2HallowedRay.cs new file mode 100644 index 0000000000..2266e6bb74 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2HallowedRay.cs @@ -0,0 +1,15 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2HallowedRay(BossModule module) : Components.GenericWildCharge(module, 3, ActionID.MakeSpell(AID.HallowedRayAOE), 65) +{ + public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) + { + if (iconID == (uint)IconID.HallowedRay) + { + Source = actor; + Activation = WorldState.FutureTime(5.7f); + foreach (var (i, p) in Raid.WithSlot(true)) + PlayerRoles[i] = p.InstanceID == targetID ? PlayerRole.Target : PlayerRole.Share; + } + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs new file mode 100644 index 0000000000..5b93e6536c --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2LightRampant.cs @@ -0,0 +1,81 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2LuminousHammer(BossModule module) : Components.BaitAwayIcon(module, new AOEShapeCircle(6), (uint)IconID.LuminousHammer, ActionID.MakeSpell(AID.LuminousHammer), 7.1f, true) +{ + private readonly int[] _baitsPerPlayer = new int[PartyState.MaxPartySize]; + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (spell.Action == WatchedAction) + { + ++NumCasts; + var slot = Raid.FindSlot(spell.MainTargetID); + if (slot >= 0 && ++_baitsPerPlayer[slot] >= 5) + CurrentBaits.RemoveAll(b => b.Target == Raid[slot]); + } + } +} + +// TODO: tower assignments (based on angle sorting?) +class P2BrightHunger1(BossModule module) : Components.GenericTowers(module, ActionID.MakeSpell(AID.BrightHunger)) +{ + public override void OnEventIcon(Actor actor, uint iconID, ulong targetID) + { + if (iconID == (uint)IconID.LuminousHammer) + { + if (Towers.Count == 0) + for (int i = 0; i < 6; ++i) + Towers.Add(new(Module.Center + 16 * (i * 60.Degrees()).ToDirection(), 4, 1, 1, default, WorldState.FutureTime(10.3f))); + foreach (ref var t in Towers.AsSpan()) + t.ForbiddenSoakers.Set(Raid.FindSlot(actor.InstanceID)); + } + } +} + +// TODO: we can start showing aoes ~3s earlier if we check spawns +class P2HolyLightBurst(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.HolyLightBurst), new AOEShapeCircle(11), 3); + +class P2PowerfulLight(BossModule module) : Components.UniformStackSpread(module, 5, 0, 4, 4) +{ + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.WeightOfLight) + AddStack(actor, status.ExpireAt); + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if ((AID)spell.Action.ID == AID.PowerfulLight) + Stacks.Clear(); + } +} + +class P2BrightHunger2(BossModule module) : Components.GenericTowers(module, ActionID.MakeSpell(AID.BrightHunger)) +{ + private BitMask _forbidden; + + public override void OnStatusGain(Actor actor, ActorStatus status) + { + if ((SID)status.ID == SID.Lightsteeped && status.Extra >= 3) + _forbidden.Set(Raid.FindSlot(actor.InstanceID)); + } + + // TODO: better criteria for activating a tower... + public override void OnCastFinished(Actor caster, ActorCastInfo spell) + { + if (Towers.Count == 0 && (AID)spell.Action.ID == AID.HolyLightBurst) + Towers.Add(new(Module.Center, 4, 1, 8, _forbidden, WorldState.FutureTime(6.5f))); + } +} + +class P2HouseOfLightBoss(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.HouseOfLightBossAOE), false) +{ + private static readonly AOEShapeCone _shape = new(60, 30.Degrees()); // TODO: verify angle + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + if ((AID)spell.Action.ID == AID.HouseOfLightBoss) + foreach (var p in Raid.WithoutSlot(true)) + CurrentBaits.Add(new(caster, p, _shape, Module.CastFinishAt(spell, 0.9f))); + } +} diff --git a/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs new file mode 100644 index 0000000000..3ef92ac3c6 --- /dev/null +++ b/BossMod/Modules/Dawntrail/Ultimate/FRU/P2MirrorMirror.cs @@ -0,0 +1,89 @@ +namespace BossMod.Dawntrail.Ultimate.FRU; + +class P2MirrorMirrorReflectedScytheKickBlue(BossModule module) : Components.GenericAOEs(module, ActionID.MakeSpell(AID.ReflectedScytheKickBlue)) +{ + private WPos _position; + private AOEInstance? _aoe; + + private static readonly AOEShapeDonut _shape = new(4, 20); + + public override IEnumerable ActiveAOEs(int slot, Actor actor) => Utils.ZeroOrOne(_aoe); + + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + if (_position != default) + Arena.Actor(_position, Angle.FromDirection(Arena.Center - _position), Colors.Object); + } + + 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)); + } + + public override void OnEventEnvControl(byte index, uint state) + { + if (index is >= 1 and <= 8 && state == 0x00020001) + _position = Arena.Center + 20 * (225 - index * 45).Degrees().ToDirection(); + } +} + +class P2MirrorMirrorReflectedScytheKickRed(BossModule module) : Components.SelfTargetedAOEs(module, ActionID.MakeSpell(AID.ReflectedScytheKickRed), new AOEShapeDonut(4, 20)) +{ + public override void DrawArenaForeground(int pcSlot, Actor pc) + { + Arena.Actors(Casters, Colors.Object, true); + } +} + +class P2MirrorMirrorHouseOfLight(BossModule module) : Components.GenericBaitAway(module, ActionID.MakeSpell(AID.HouseOfLight)) +{ + private WPos _mirror; + private readonly List<(Actor source, DateTime activation)> _sources = []; + + private static readonly AOEShapeCone _shape = new(60, 20.Degrees()); // TODO: verify angle + + public override void Update() + { + CurrentBaits.Clear(); + foreach (var s in _sources.Take(2)) + foreach (var p in Raid.WithoutSlot().SortedByRange(s.source.Position).Take(4)) + CurrentBaits.Add(new(s.source, p, _shape, s.activation)); + } + + public override void OnEventEnvControl(byte index, uint state) + { + if (index is >= 1 and <= 8 && state == 0x00020001) + { + _mirror = Arena.Center + 20 * (225 - index * 45).Degrees().ToDirection(); + } + } + + public override void OnCastStarted(Actor caster, ActorCastInfo spell) + { + switch ((AID)spell.Action.ID) + { + case AID.ScytheKick: + var activation = Module.CastFinishAt(spell, 0.7f); + _sources.Add((caster, activation)); + var mirror = Module.Enemies(OID.FrozenMirror).Closest(_mirror); + if (mirror != null) + _sources.Add((mirror, activation)); + break; + case AID.ReflectedScytheKickRed: + _sources.Add((caster, Module.CastFinishAt(spell, 0.6f))); + break; + } + } + + public override void OnEventCast(Actor caster, ActorCastEvent spell) + { + if (spell.Action == WatchedAction) + { + var deadline = WorldState.FutureTime(2); + var firstInBunch = _sources.RemoveAll(s => s.activation < deadline) > 0; + if (firstInBunch) + ++NumCasts; + } + } +} diff --git a/BossMod/Replay/Analysis/StateTransitionTimings.cs b/BossMod/Replay/Analysis/StateTransitionTimings.cs index a87e176df5..acef642754 100644 --- a/BossMod/Replay/Analysis/StateTransitionTimings.cs +++ b/BossMod/Replay/Analysis/StateTransitionTimings.cs @@ -34,6 +34,7 @@ class StateMetrics(string name, double expectedTime) private readonly List<(Replay, Replay.Encounter, Replay.EncounterError)> _errors = []; private readonly List<(Replay, Replay.Encounter)> _encounters = []; private float _lastSecondsToIgnore; + private bool _showTransitionsToEnd; private object? _selected; public StateTransitionTimings(List replays, uint oid) @@ -85,6 +86,7 @@ public StateTransitionTimings(List replays, uint oid) public void Draw(UITree tree) { + ImGui.Checkbox("Show transitions to end", ref _showTransitionsToEnd); foreach (var n in tree.Node("Encounters", _encounters.Count == 0)) { tree.LeafNodes(_encounters, e => $"{e.Item1.Path} @ {e.Item2.Time.Start:O} ({e.Item2.Time.Duration:f3}s)"); @@ -112,7 +114,8 @@ UITree.NodeProperties map(KeyValuePair kv) //bool warn = from.ExpectedTime < Math.Round(m.MinTime, 1) || from.ExpectedTime > Math.Round(m.MaxTime, 1); return new($"{name}: {value}###{name}", kv.Value.Instances.Count == 0, color); } - foreach (var (toID, m) in tree.Nodes(from.Transitions, map, kv => TransitionContextMenu(from, kv.Key, kv.Value, tree, ref actions), select: kv => _selected = kv.Value)) + var transitions = _showTransitionsToEnd ? from.Transitions : from.Transitions.Where(kv => kv.Key != uint.MaxValue); + foreach (var (toID, m) in tree.Nodes(transitions, map, kv => TransitionContextMenu(from, kv.Key, kv.Value, tree, ref actions), select: kv => _selected = kv.Value)) { foreach (var inst in m.Instances) { diff --git a/TODO b/TODO index 109384baab..bc4044dfaf 100644 --- a/TODO +++ b/TODO @@ -6,6 +6,8 @@ immediate plans - on ex1 the cleave is still telegraphed a bit too wide - for p2 thordan cleavebuster the telegraph on the minimap is narrower than the actual hitbox - alt style for player indicator on arena +- melee range issues on uneven ground +- ishape general: - horizontal timeline / cooldown planner diff --git a/UIDev/UITest.cs b/UIDev/UITest.cs index e38c314636..c2bacf308d 100644 --- a/UIDev/UITest.cs +++ b/UIDev/UITest.cs @@ -36,7 +36,8 @@ public static void Main(string[] args) Service.LogHandler = msg => Debug.WriteLine(msg); Service.LuminaGameData = new(FindGameDataPath()); - Service.LuminaGameData.Options.PanicOnSheetChecksumMismatch = false; // TODO: remove - temporary workaround until lumina is updated + //Service.LuminaGameData.Options.PanicOnSheetChecksumMismatch = false; // TODO: remove - temporary workaround until lumina is updated + Service.LuminaGameData.Options.RsvResolver = Service.LuminaRSV.TryGetValue; Service.WindowSystem = new("uitest"); //Service.Device = (SharpDX.Direct3D11.Device?)scene.Renderer.GetType().GetField("_device", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(scene.Renderer);