diff --git a/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_1238_4567_KT_Strat.cs b/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_1238_4567_KT_Strat.cs index 0ec983d8..0db31631 100644 --- a/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_1238_4567_KT_Strat.cs +++ b/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_1238_4567_KT_Strat.cs @@ -27,7 +27,7 @@ internal class P2_Missing_1238_4567_KT_Strat : SplatoonScript { #region Metadata - public override Metadata? Metadata => new(6, "mirage"); + public override Metadata? Metadata => new(7, "mirage"); public override HashSet? ValidTerritories => [TerritoryDmad]; #endregion @@ -193,6 +193,21 @@ private static readonly (string Label, Vector3 Position)[] TowerSlots = private InterludeNavPhase _interludeNavPhase; private bool _interludeBossCastSeen; + // Priority index cache (rebuilt only when the priority list order changes). + private Dictionary? _priorityIndexCache; + + // Ordered party cache (rebuilt only when party membership or priority order changes). + private List? _orderedPartyCache; + private bool _orderedPartyCacheValid; + private int _orderedPartyMembershipHash; + private int _orderedPartyPriorityHash; + + // Live role result cache (recomputed only when role inputs change). + private bool _liveRoleResultValid; + private int _liveRoleInputHash; + private int _liveRolePatternIdCache; + private string? _liveRoleLabelCache; + #endregion #region Types @@ -479,6 +494,8 @@ public override void OnMapEffect(uint position, ushort data1, ushort data2) public override void OnSettingsDraw() { + _priorityIndexCache = null; + if(ImGui.BeginTabBar("##P212384567KTSettings")) { if(ImGui.BeginTabItem("Main###tabMain")) @@ -1373,6 +1390,7 @@ private void ResetState() _step4AutoMarkDueAt = 0; _interludeNavPhase = InterludeNavPhase.None; _interludeBossCastSeen = false; + InvalidateRoleCaches(); if(Controller.TryGetElementByName(ElActiveTower0, out var tower0)) tower0.Enabled = false; @@ -1384,6 +1402,20 @@ private void ResetState() DisableMyRoleMarker(); } + // Clear priority, ordered party, and live role caches. + private void InvalidateRoleCaches() + { + _priorityIndexCache = null; + _orderedPartyCache = null; + _orderedPartyCacheValid = false; + _orderedPartyMembershipHash = 0; + _orderedPartyPriorityHash = 0; + _liveRoleResultValid = false; + _liveRoleInputHash = 0; + _liveRolePatternIdCache = -1; + _liveRoleLabelCache = null; + } + private void DisableAllMarkers() { if(Controller.TryGetElementByName(ElActiveTower0, out var tower0)) @@ -1742,37 +1774,77 @@ private void DrawPartyDebuffStatus() ImGui.TextUnformatted($"Party debuffs: waiting ({assigned}/{PartyPlayerCount})"); } + // Party members ordered by priority list (cached until membership/priority changes). private List GetOrderedPartyPlayers() { C.EnsureDefaults(); var priority = C.PriorityData.GetPlayers(_ => true); if(priority == null || priority.Count != PartyPlayerCount) + { + _orderedPartyCacheValid = false; return []; + } + + var priorityHash = 17; + foreach(var player in priority) + priorityHash = priorityHash * 31 + StringComparer.Ordinal.GetHashCode(player.Name); + if(priorityHash != _orderedPartyPriorityHash) + _priorityIndexCache = null; + + var members = Controller.GetPartyMembers().ToList(); + var membershipHash = ComputeMembershipHash(members); + + if(_orderedPartyCacheValid && _orderedPartyCache != null + && membershipHash == _orderedPartyMembershipHash && priorityHash == _orderedPartyPriorityHash) + return _orderedPartyCache; var names = priority.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); - var members = Controller.GetPartyMembers() - .Where(p => names.Contains(p.Name.ToString())) - .ToList(); - if(members.Count != PartyPlayerCount) + var filtered = members.Where(p => names.Contains(p.Name.ToString())).ToList(); + if(filtered.Count != PartyPlayerCount) + { + _orderedPartyCacheValid = false; return []; + } - return OrderByPriority(members).ToList(); + _orderedPartyCache = OrderByPriority(filtered).ToList(); + _orderedPartyMembershipHash = membershipHash; + _orderedPartyPriorityHash = priorityHash; + _orderedPartyCacheValid = true; + return _orderedPartyCache; } + // Order-independent hash of party member identities for cache invalidation. + private static int ComputeMembershipHash(List members) + { + var hash = members.Count; + foreach(var member in members) + hash ^= (int)member.EntityId; + return hash; + } + + // Priority list index for a player from the cached index map. private int GetPriorityIndex(IPlayerCharacter player) + => GetPriorityIndexMap().TryGetValue(player.Name.ToString(), out var index) ? index : int.MaxValue; + + // Cached name->priority index map, built once until priority order changes. + private Dictionary GetPriorityIndexMap() { - var priorityList = C.PriorityData.GetPlayers(_ => true); - if(priorityList == null) - return int.MaxValue; + if(_priorityIndexCache != null) + return _priorityIndexCache; - var name = player.Name.ToString(); - for(var i = 0; i < priorityList.Count; i++) + var map = new Dictionary(StringComparer.Ordinal); + var priorityList = C.PriorityData.GetPlayers(_ => true); + if(priorityList != null) { - if(priorityList[i].Name == name) - return i; + for(var i = 0; i < priorityList.Count; i++) + { + if(!map.ContainsKey(priorityList[i].Name)) + map[priorityList[i].Name] = i; + } } - return int.MaxValue; + _priorityIndexCache = map; + return map; } private IEnumerable OrderInfosByPriority(IEnumerable infos) @@ -2496,6 +2568,52 @@ private bool TryUpdateLiveRoles(out int patternId, out string roleLabel) _initialGroupResolved = true; } + // Marker-based role resolution reads live in-game head markers, so its result is never cached. + var useMarkerResolve = (C.SpreadUseMarker && C.SpreadMarkerType != MarkerResolveKind.None) + || (C.ConeUseMarker && C.ConeMarkerType != MarkerResolveKind.None); + var inputHash = ComputeLiveRoleInputHash(); + if(!useMarkerResolve && _liveRoleResultValid && inputHash == _liveRoleInputHash) + { + patternId = _liveRolePatternIdCache; + roleLabel = _liveRoleLabelCache ?? ""; + return _liveRoleLabelCache != null; + } + + var resolved = ResolveLiveRolesUncached(out patternId, out roleLabel); + _liveRoleInputHash = inputHash; + _liveRoleResultValid = true; + _liveRolePatternIdCache = resolved ? patternId : -1; + _liveRoleLabelCache = resolved ? roleLabel : null; + return resolved; + } + + // Compute a hash of every input that affects live role assignment. + private int ComputeLiveRoleInputHash() + { + var hash = 17; + hash = hash * 31 + _step; + hash = hash * 31 + (_initialGroupResolved ? 1 : 0); + foreach(var info in _infos) + { + hash = hash * 31 + (int)info.Player.EntityId; + hash = hash * 31 + (int)info.Half; + hash = hash * 31 + (int)info.Debuff; + } + + hash = hash * 31 + C.Step1PairMode; + hash = hash * 31 + (C.SpreadUseMarker ? 1 : 0); + hash = hash * 31 + (C.ConeUseMarker ? 1 : 0); + hash = hash * 31 + (int)C.SpreadMarkerType; + hash = hash * 31 + (int)C.ConeMarkerType; + return hash; + } + + // Resolve live role labels and apply step/pattern rules without caching. + private bool ResolveLiveRolesUncached(out int patternId, out string roleLabel) + { + patternId = -1; + roleLabel = ""; + if(!TryDetectPartyPattern(out patternId)) return false; diff --git a/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_KT_Alt.cs b/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_KT_Alt.cs index 1e972104..0af05bee 100644 --- a/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_KT_Alt.cs +++ b/SplatoonScripts/Duties/Dawntrail/Dancing Mad/P2_Forsaken_Pattern/P2_Missing_KT_Alt.cs @@ -28,7 +28,7 @@ internal class P2_Misisng_KT_Alt : SplatoonScript { #region Metadata - public override Metadata Metadata { get; } = new(5, "mirage"); + public override Metadata Metadata { get; } = new(7, "mirage"); public override HashSet? ValidTerritories => [TerritoryDmad]; #endregion @@ -103,7 +103,13 @@ internal class P2_Misisng_KT_Alt : SplatoonScript private static readonly Vector3 ArenaCenter = new(100f, 0f, 100f); private static readonly Vector3 TrueNorth = new(100f, 0f, 120f); - private static readonly (int A, int B)[] Step1PartyPairIndices = [(0, 2), (1, 3), (4, 6), (5, 7)]; + private static readonly (int A, int B)[] Step1PartyPairIndicesYarn = [(0, 2), (1, 3), (4, 6), (5, 7)]; + private static readonly (int A, int B)[] Step1PartyPairIndicesReen = [(0, 1), (2, 3), (4, 5), (6, 7)]; + private static readonly string[] GroupResolveModeLabels = + [ + "Yarn ([0,2] / [1,3] / [4,6] / [5,7])", + "Reen ([0,1] / [2,3] / [4,5] / [6,7])", + ]; private static readonly HashSet FirstHalfTowerSteps = [1, 2, 3, 8]; private static readonly HashSet SecondHalfTowerSteps = [4, 5, 6, 7]; @@ -189,6 +195,7 @@ private sealed class Config : IEzConfig public PatternRuleSettings[][] PatternRules = CreateDefaultPatternRules(); public int DebugPatternPreview = DebugPatternPreviewNone; public int DebugPatternPreviewRole = DebugPatternPreviewRoleNone; + public int GroupResolveMode = (int)GroupResolveModeKind.Yarn; public int Step1RoleMode = (int)Step1AssignmentMode.ChangeWithPair; public int TowerPairSwapSideMode = (int)TowerPairSwapSide.BackSide; public MarkerResolveKind SpreadMarkerType = MarkerResolveKind.None; @@ -217,6 +224,7 @@ public void EnsureDefaults() if(DebugPatternPreview >= PatternCount) DebugPatternPreview = DebugPatternPreviewNone; DebugPatternPreviewRole = ClampPatternPreviewRole(DebugPatternPreview, DebugPatternPreviewRole); + GroupResolveMode = ClampGroupResolveMode(GroupResolveMode); Step1RoleMode = ClampStep1RoleMode(Step1RoleMode); TowerPairSwapSideMode = ClampTowerPairSwapSide(TowerPairSwapSideMode); SpreadMarkerType = ClampMarkerResolveKind(SpreadMarkerType); @@ -238,6 +246,7 @@ public void ResetToDefaults() PatternRules = CreateDefaultPatternRules(); DebugPatternPreview = DebugPatternPreviewNone; DebugPatternPreviewRole = DebugPatternPreviewRoleNone; + GroupResolveMode = (int)GroupResolveModeKind.Yarn; Step1RoleMode = (int)Step1AssignmentMode.ChangeWithPair; TowerPairSwapSideMode = (int)TowerPairSwapSide.BackSide; SpreadMarkerType = MarkerResolveKind.None; @@ -313,6 +322,20 @@ private static PriorityData CreateDefaultPriorityData() private long _step4DebuffReminderDueAt; private bool _wave8ShowDualTether; + // Priority index cache (rebuilt only when the priority list order changes). + private Dictionary? _priorityIndexCache; + + // Ordered party cache (rebuilt only when party membership or priority order changes). + private List? _orderedPartyCache; + private bool _orderedPartyCacheValid; + private int _orderedPartyMembershipHash; + private int _orderedPartyPriorityHash; + + // Live role result cache (recomputed only when role inputs change). + private bool _liveRoleResultValid; + private int _liveRoleInputHash; + private string? _liveRoleLabelCache; + #endregion #region Private Class @@ -355,12 +378,26 @@ private enum InterludeNavPhase FutureOpposite, } + private enum GroupResolveModeKind + { + Yarn, + Reen, + } + private enum Step1AssignmentMode { ChangeWithPair, ChangeStackOnly, } + private enum AdjacentPairKind + { + StackAndSpread, + StackAndCone, + DoubleSpread, + DoubleCone, + } + private enum TowerPairSwapSide { BackSide, @@ -501,6 +538,8 @@ public override void OnMapEffect(uint position, ushort data1, ushort data2) public override void OnSettingsDraw() { + _priorityIndexCache = null; + if(ImGui.BeginTabBar("##P2MissingKTAltSettings")) { if(ImGui.BeginTabItem("Main###tabMain")) @@ -523,16 +562,46 @@ public override void OnSettingsDraw() #region Private Method - // Assign Step1 roles using configured stack-pair assignment mode. + // Assign Step1 roles; Reen uses FirstHalf debuff rules, Yarn uses configured pair modes. private void AssignStep1Roles() { C.EnsureDefaults(); + if(IsReenGroupResolveMode()) + { + AssignStep1RolesReen(); + return; + } + if((Step1AssignmentMode)C.Step1RoleMode == Step1AssignmentMode.ChangeStackOnly) AssignStep1RolesChangeStackOnly(); else AssignStep1RolesChangeWithPair(); } + // Assign Step1 FirstHalf roles for Reen by debuff across the four First players. + private void AssignStep1RolesReen() + { + var firstPlayers = OrderInfosByPriority(_infos.Where(i => i.Half == MechanicHalf.First)).ToList(); + if(firstPlayers.Count != 4) + return; + + var stackPlayers = OrderInfosByPriority(firstPlayers.Where(i => i.Debuff == DebuffKind.Stack)).ToList(); + if(stackPlayers.Count > 0) + stackPlayers[0].RoleLabel = Role211LeftStack; + if(stackPlayers.Count > 1) + stackPlayers[1].RoleLabel = Role211RightStack; + + foreach(var info in firstPlayers) + { + if(info.Debuff == DebuffKind.Cone) + info.RoleLabel = Role211Cone; + else if(info.Debuff == DebuffKind.Spread) + info.RoleLabel = Role211Spread; + } + + AssignStep1SecondGroupRoles(); + } + // Assign Step1 roles per stack pair (stack + cone/spread partner). private void AssignStep1RolesChangeWithPair() { @@ -728,7 +797,7 @@ private static bool TryClassifyStep1Pair(PlayerInfo playerA, PlayerInfo playerB, && playerB.Debuff is DebuffKind.Spread or DebuffKind.Cone; } - // Resolve initial First/Second group from alternate priority pairs. + // Resolve initial First/Second group from configured pair mode. private bool TryResolveInitialGroup() { if(!TryEnsureInfos()) @@ -736,6 +805,15 @@ private bool TryResolveInitialGroup() UpdateDebuffs(); + if(IsReenGroupResolveMode()) + return TryResolveInitialGroupReen(); + + return TryResolveInitialGroupYarn(); + } + + // Yarn: stack pairs -> FirstHalf, non-stack pairs -> SecondHalf (whole pair). + private bool TryResolveInitialGroupYarn() + { var stackPairCount = 0; var nonStackPlayerCount = 0; @@ -767,6 +845,110 @@ private bool TryResolveInitialGroup() return _infos.All(i => i.Half != MechanicHalf.None); } + // Reen: per-player half within each adjacent priority pair. + private bool TryResolveInitialGroupReen() + { + foreach(var info in _infos) + info.Half = MechanicHalf.None; + + for(var pairIndex = 0; pairIndex < Step1PartyPairCount; pairIndex++) + { + if(!TryGetStep1Pair(pairIndex, out var playerA, out var playerB)) + return false; + + if(!TryClassifyAdjacentPair(playerA, playerB, out var stackPlayer, out var partnerPlayer, out var pairKind)) + return false; + + if(pairKind is AdjacentPairKind.StackAndSpread or AdjacentPairKind.StackAndCone) + { + stackPlayer.Half = MechanicHalf.First; + partnerPlayer.Half = MechanicHalf.Second; + continue; + } + + if(pairKind is AdjacentPairKind.DoubleSpread or AdjacentPairKind.DoubleCone) + { + var ordered = OrderInfosByPriority([playerA, playerB]).ToList(); + ordered[0].Half = MechanicHalf.First; + ordered[1].Half = MechanicHalf.Second; + continue; + } + + return false; + } + + if(_infos.Count(i => i.Half == MechanicHalf.First) != PartyPlayerCount / 2) + return false; + if(_infos.Count(i => i.Half == MechanicHalf.Second) != PartyPlayerCount / 2) + return false; + + return _infos.All(i => i.Half != MechanicHalf.None); + } + + // Classify adjacent pair debuff composition for Reen group resolve. + private static bool TryClassifyAdjacentPair(PlayerInfo playerA, PlayerInfo playerB, out PlayerInfo stackPlayer, + out PlayerInfo partnerPlayer, out AdjacentPairKind pairKind) + { + stackPlayer = null!; + partnerPlayer = null!; + pairKind = default; + + CountPairDebuffKinds(playerA, playerB, out var stack, out var cone, out var spread); + + if(stack == 1 && spread == 1 && cone == 0) + { + if(!TryGetStackPartnerPlayers(playerA, playerB, out stackPlayer, out partnerPlayer)) + return false; + pairKind = AdjacentPairKind.StackAndSpread; + return true; + } + + if(stack == 1 && spread == 0 && cone == 1) + { + if(!TryGetStackPartnerPlayers(playerA, playerB, out stackPlayer, out partnerPlayer)) + return false; + pairKind = AdjacentPairKind.StackAndCone; + return true; + } + + if(stack == 0 && spread == 2 && cone == 0) + { + pairKind = AdjacentPairKind.DoubleSpread; + return true; + } + + if(stack == 0 && spread == 0 && cone == 2) + { + pairKind = AdjacentPairKind.DoubleCone; + return true; + } + + return false; + } + + // Return stack and partner players from a stack pair. + private static bool TryGetStackPartnerPlayers(PlayerInfo playerA, PlayerInfo playerB, out PlayerInfo stackPlayer, + out PlayerInfo partnerPlayer) + { + if(playerA.Debuff == DebuffKind.Stack) + { + stackPlayer = playerA; + partnerPlayer = playerB; + return true; + } + + if(playerB.Debuff == DebuffKind.Stack) + { + stackPlayer = playerB; + partnerPlayer = playerA; + return true; + } + + stackPlayer = null!; + partnerPlayer = null!; + return false; + } + // Return two players for a step1 pair by priority-list indices. private bool TryGetStep1Pair(int pairIndex, out PlayerInfo playerA, out PlayerInfo playerB) { @@ -775,12 +957,20 @@ private bool TryGetStep1Pair(int pairIndex, out PlayerInfo playerA, out PlayerIn if(pairIndex < 0 || pairIndex >= Step1PartyPairCount || _infos.Count != PartyPlayerCount) return false; - var (indexA, indexB) = Step1PartyPairIndices[pairIndex]; + var (indexA, indexB) = GetStep1PartyPairIndices()[pairIndex]; playerA = _infos[indexA]; playerB = _infos[indexB]; return true; } + // Return step1 pair indices for the active group resolve mode. + private (int A, int B)[] GetStep1PartyPairIndices() + => IsReenGroupResolveMode() ? Step1PartyPairIndicesReen : Step1PartyPairIndicesYarn; + + // Return whether Reen adjacent group resolve is selected. + private bool IsReenGroupResolveMode() + => C.GroupResolveMode == (int)GroupResolveModeKind.Reen; + // Detect pattern id from active tower group debuff counts. private bool TryDetectPartyPattern(out int patternId) { @@ -829,6 +1019,51 @@ private bool TryUpdateLiveRoles(out string roleLabel) _initialGroupResolved = true; } + // Step8 marker resolve reads live in-game head markers, so its result is never cached. + var useMarkerResolve = _step == ActiveStepMax && C.Wave8ResolveByMarkerEnabled; + var inputHash = ComputeLiveRoleInputHash(); + if(!useMarkerResolve && _liveRoleResultValid && inputHash == _liveRoleInputHash) + { + roleLabel = _liveRoleLabelCache ?? ""; + return _liveRoleLabelCache != null; + } + + var resolved = ResolveLiveRolesUncached(out roleLabel); + _liveRoleInputHash = inputHash; + _liveRoleResultValid = true; + _liveRoleLabelCache = resolved ? roleLabel : null; + return resolved; + } + + // Compute a hash of every input that affects live role assignment. + private int ComputeLiveRoleInputHash() + { + var hash = 17; + hash = hash * 31 + _step; + hash = hash * 31 + (_initialGroupResolved ? 1 : 0); + foreach(var info in _infos) + { + hash = hash * 31 + (int)info.Player.EntityId; + hash = hash * 31 + (int)info.Half; + hash = hash * 31 + (int)info.Debuff; + } + + hash = hash * 31 + C.Step1RoleMode; + hash = hash * 31 + C.GroupResolveMode; + hash = hash * 31 + C.TowerPairSwapSideMode; + hash = hash * 31 + (C.Wave8ResolveByMarkerEnabled ? 1 : 0); + hash = hash * 31 + (int)C.Wave8LeftSpreadMarker; + hash = hash * 31 + (int)C.Wave8RightSpreadMarker; + hash = hash * 31 + (int)C.Wave8LeftConeMarker; + hash = hash * 31 + (int)C.Wave8RightConeMarker; + return hash; + } + + // Resolve live role labels and apply step/pattern rules without caching. + private bool ResolveLiveRolesUncached(out string roleLabel) + { + roleLabel = ""; + if(!TryDetectPartyPattern(out var patternId)) return false; @@ -1269,22 +1504,52 @@ private static DebuffKind GetDebuffKind(IPlayerCharacter player) return DebuffKind.None; } - // Return party members ordered by configured priority list. + // Return party members ordered by configured priority list (cached until membership/priority changes). private List GetOrderedPartyPlayers() { C.EnsureDefaults(); var priority = C.PriorityData.GetPlayers(_ => true); if(priority == null || priority.Count != PartyPlayerCount) + { + _orderedPartyCacheValid = false; return []; + } + + var priorityHash = 17; + foreach(var player in priority) + priorityHash = priorityHash * 31 + StringComparer.Ordinal.GetHashCode(player.Name); + if(priorityHash != _orderedPartyPriorityHash) + _priorityIndexCache = null; + + var members = Controller.GetPartyMembers().ToList(); + var membershipHash = ComputeMembershipHash(members); + + if(_orderedPartyCacheValid && _orderedPartyCache != null + && membershipHash == _orderedPartyMembershipHash && priorityHash == _orderedPartyPriorityHash) + return _orderedPartyCache; var names = priority.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); - var members = Controller.GetPartyMembers() - .Where(p => names.Contains(p.Name.ToString())) - .ToList(); - if(members.Count != PartyPlayerCount) + var filtered = members.Where(p => names.Contains(p.Name.ToString())).ToList(); + if(filtered.Count != PartyPlayerCount) + { + _orderedPartyCacheValid = false; return []; + } - return OrderByPriority(members).ToList(); + _orderedPartyCache = OrderByPriority(filtered).ToList(); + _orderedPartyMembershipHash = membershipHash; + _orderedPartyPriorityHash = priorityHash; + _orderedPartyCacheValid = true; + return _orderedPartyCache; + } + + // Order-independent hash of party member identities for cache invalidation. + private static int ComputeMembershipHash(List members) + { + var hash = members.Count; + foreach(var member in members) + hash ^= (int)member.EntityId; + return hash; } // Sort players by priority index then entity id. @@ -1295,21 +1560,29 @@ private IEnumerable OrderByPriority(IEnumerable OrderInfosByPriority(IEnumerable infos) => infos.OrderBy(i => GetPriorityIndex(i.Player)).ThenBy(i => i.Player.EntityId); - // Return priority list index for a player. + // Return priority list index for a player from the cached index map. private int GetPriorityIndex(IPlayerCharacter player) + => GetPriorityIndexMap().TryGetValue(player.Name.ToString(), out var index) ? index : int.MaxValue; + + // Return cached name->priority index map, built once until priority order changes. + private Dictionary GetPriorityIndexMap() { - var priorityList = C.PriorityData.GetPlayers(_ => true); - if(priorityList == null) - return int.MaxValue; + if(_priorityIndexCache != null) + return _priorityIndexCache; - var name = player.Name.ToString(); - for(var i = 0; i < priorityList.Count; i++) + var map = new Dictionary(StringComparer.Ordinal); + var priorityList = C.PriorityData.GetPlayers(_ => true); + if(priorityList != null) { - if(priorityList[i].Name == name) - return i; + for(var i = 0; i < priorityList.Count; i++) + { + if(!map.ContainsKey(priorityList[i].Name)) + map[priorityList[i].Name] = i; + } } - return int.MaxValue; + _priorityIndexCache = map; + return map; } // Return priority rank within a subset of player infos. @@ -2054,6 +2327,20 @@ private void ResetState() _step4DebuffReminderSkipLogged = false; _step4DebuffReminderDueAt = 0; _wave8ShowDualTether = false; + InvalidateRoleCaches(); + } + + // Clear priority, ordered party, and live role caches. + private void InvalidateRoleCaches() + { + _priorityIndexCache = null; + _orderedPartyCache = null; + _orderedPartyCacheValid = false; + _orderedPartyMembershipHash = 0; + _orderedPartyPriorityHash = 0; + _liveRoleResultValid = false; + _liveRoleInputHash = 0; + _liveRoleLabelCache = null; } // Draw debug tab with live player info and pattern preview controls. @@ -2061,6 +2348,8 @@ private void DrawDebugTab() { DrawDebugStepSection(); ImGui.Spacing(); + DrawDebugStep1PairsSection(); + ImGui.Spacing(); DrawDebugInfosSection(); ImGui.Spacing(); ImGui.TextUnformatted("Preview"); @@ -2094,6 +2383,115 @@ private void DrawDebugStepSection() DrawDebugWave8MarkerSection(); } + // Draw step1 pair assignments for the active group resolve mode. + private void DrawDebugStep1PairsSection() + { + C.EnsureDefaults(); + ImGui.TextUnformatted("Step1 pairs"); + ImGui.Separator(); + ImGui.TextUnformatted($"Group resolve mode: {GroupResolveModeLabels[C.GroupResolveMode]}"); + + if(IsReenGroupResolveMode()) + DrawDebugStep1PairsReenSection(); + else + DrawDebugStep1PairsYarnSection(); + } + + // Draw Yarn pair -> half group assignments. + private void DrawDebugStep1PairsYarnSection() + { + for(var pairIndex = 0; pairIndex < Step1PartyPairCount; pairIndex++) + { + if(!TryGetStep1Pair(pairIndex, out var playerA, out var playerB)) + { + ImGui.TextUnformatted($" P{pairIndex}: (pair unavailable)"); + continue; + } + + var (indexA, indexB) = Step1PartyPairIndicesYarn[pairIndex]; + if(!TryClassifyStep1Pair(playerA, playerB, out _, out _, out var isStackPair)) + { + ImGui.TextUnformatted($" P{pairIndex} [{indexA},{indexB}]: invalid debuffs"); + continue; + } + + var half = isStackPair ? MechanicHalf.First : MechanicHalf.Second; + DrawStep1PairAssignmentLine(pairIndex, indexA, indexB, playerA, playerB, half, half); + } + } + + // Draw Reen pair -> per-player half assignments. + private void DrawDebugStep1PairsReenSection() + { + for(var pairIndex = 0; pairIndex < Step1PartyPairCount; pairIndex++) + { + if(!TryGetStep1Pair(pairIndex, out var playerA, out var playerB)) + { + ImGui.TextUnformatted($" P{pairIndex}: (pair unavailable)"); + continue; + } + + var (indexA, indexB) = Step1PartyPairIndicesReen[pairIndex]; + if(!TryGetAdjacentPairHalves(playerA, playerB, out var halfA, out var halfB)) + { + ImGui.TextUnformatted($" P{pairIndex} [{indexA},{indexB}]: invalid debuffs"); + continue; + } + + DrawStep1PairAssignmentLine(pairIndex, indexA, indexB, playerA, playerB, halfA, halfB); + } + } + + // Resolve per-player halves for one Reen adjacent pair. + private bool TryGetAdjacentPairHalves(PlayerInfo playerA, PlayerInfo playerB, out MechanicHalf halfA, + out MechanicHalf halfB) + { + halfA = MechanicHalf.None; + halfB = MechanicHalf.None; + + if(!TryClassifyAdjacentPair(playerA, playerB, out var stackPlayer, out var partnerPlayer, out var pairKind)) + return false; + + if(pairKind is AdjacentPairKind.StackAndSpread or AdjacentPairKind.StackAndCone) + { + MapPairHalves(playerA, playerB, stackPlayer, partnerPlayer, out halfA, out halfB); + return true; + } + + if(pairKind is AdjacentPairKind.DoubleSpread or AdjacentPairKind.DoubleCone) + { + var ordered = OrderInfosByPriority([playerA, playerB]).ToList(); + MapPairHalves(playerA, playerB, ordered[0], ordered[1], out halfA, out halfB); + return true; + } + + return false; + } + + // Map each pair member to First/Second half from resolved first/second players. + private static void MapPairHalves(PlayerInfo playerA, PlayerInfo playerB, PlayerInfo firstHalfPlayer, + PlayerInfo secondHalfPlayer, out MechanicHalf halfA, out MechanicHalf halfB) + { + halfA = playerA.Player.EntityId == firstHalfPlayer.Player.EntityId + ? MechanicHalf.First + : MechanicHalf.Second; + halfB = playerB.Player.EntityId == firstHalfPlayer.Player.EntityId + ? MechanicHalf.First + : MechanicHalf.Second; + } + + // Draw one step1 pair assignment line for debug. + private static void DrawStep1PairAssignmentLine(int pairIndex, int indexA, int indexB, PlayerInfo playerA, + PlayerInfo playerB, MechanicHalf halfA, MechanicHalf halfB) + { + ImGui.TextUnformatted( + $" P{pairIndex} [{indexA},{indexB}]: {FormatStep1PairPlayerAssignment(playerA, halfA)} / {FormatStep1PairPlayerAssignment(playerB, halfB)}"); + } + + // Format one player debuff and assigned half for debug. + private static string FormatStep1PairPlayerAssignment(PlayerInfo info, MechanicHalf half) + => $"{FormatDebuffKindDebug(info.Debuff)}->{FormatHalf(half)}"; + // Draw Step4 FirstGroup debuff reminder debug status. private void DrawDebugStep4DebuffReminderSection() { @@ -2274,6 +2672,14 @@ private static int ClampStep1RoleMode(int mode) _ => (int)Step1AssignmentMode.ChangeWithPair, }; + // Clamp group resolve mode to valid enum range. + private static int ClampGroupResolveMode(int mode) + => mode switch + { + (int)GroupResolveModeKind.Reen => (int)GroupResolveModeKind.Reen, + _ => (int)GroupResolveModeKind.Yarn, + }; + // Clamp tower pair swap side to valid enum range. private static int ClampTowerPairSwapSide(int mode) => mode switch @@ -2341,7 +2747,7 @@ private void DrawMainSettings() C.EnsureDefaults(); DrawSettingsSectionHeader("General"); - ImGui.TextUnformatted("Starting Pairs: [H1,T1], [H2,T2], [M1,R1], [M2,R2]."); + DrawGroupResolveModeSettings(); ImGui.TextUnformatted("Steps 1,2,3,8 = FirstGroup tower. / Steps 4,5,6,7 = SecondGroup tower."); DrawSettingsSectionHeader("First Stack Swap Rule"); @@ -2400,10 +2806,37 @@ private static float ClampBaitAllThingsEndingRange(float range) return MathF.Round(clamped / BaitAllThingsEndingRangeStep) * BaitAllThingsEndingRangeStep; } - // Draw Step1 role assignment mode toggles. + // Draw group resolve mode combo and pair rule summary. + private void DrawGroupResolveModeSettings() + { + C.EnsureDefaults(); + var mode = C.GroupResolveMode; + if(ImGui.Combo("Group resolve mode", ref mode, GroupResolveModeLabels, GroupResolveModeLabels.Length)) + C.GroupResolveMode = ClampGroupResolveMode(mode); + + if(IsReenGroupResolveMode()) + { + ImGui.TextUnformatted("Stack->FirstHalf / partner->SecondHalf; Spread x2 or Cone x2 split by priority."); + ImGui.TextUnformatted("Pair: [H1, H2], [T1, T2], [M1, M2], [R1, R2] (default priority)."); + } + else + { + ImGui.TextUnformatted("Stack pairs->FirstHalf; non-stack pairs->SecondHalf."); + ImGui.TextUnformatted("Pair: [H1, T1], [H2, T2], [M1, R1], [M2, R2] (default priority)."); + } + } + + // Draw Step1 role assignment mode toggles (Yarn only; Reen uses fixed FirstHalf rules). private void DrawStep1RoleModeSettings() { C.EnsureDefaults(); + if(IsReenGroupResolveMode()) + { + ImGui.TextUnformatted( + "Reen Step1: Stack by priority -> Left/RightStack; Cone -> Cone; Spread -> Spread."); + return; + } + var mode = C.Step1RoleMode; if(ImGui.RadioButton("Pair Debuff (Cone => Left, Spread => Right)###step1WithPair", mode == (int)Step1AssignmentMode.ChangeWithPair))