using Dalamud.Game.ClientState.Objects.Types; using HSUI.Helpers; using FFXIVClientStructs.FFXIV.Client.Game.Group; using FFXIVClientStructs.FFXIV.Client.UI.Info; using System; using System.Collections.Generic; using System.Linq; namespace HSUI.Interface.Party { public delegate void AllianceMembersChangedEventHandler(AllianceManager sender); /// /// Provides member lists for Alliance A, B, and C when in 24-player raid content. /// Only populated when CrossRealm has 3 groups (alliance raid). /// public unsafe class AllianceManager : IDisposable { public static AllianceManager Instance { get; private set; } = null!; private readonly List[] _allianceMembers = new List[3]; private int[] _lastMemberCounts = new int[3]; private bool _wasInAllianceRaid = false; private AllianceManager() { for (int i = 0; i < 3; i++) _allianceMembers[i] = new List(); } public static void Initialize() { Instance = new AllianceManager(); } public void Dispose() { for (int i = 0; i < 3; i++) _allianceMembers[i].Clear(); Instance = null!; } /// Internal alliance index (0, 1, or 2) containing the local player, or -1 if not in alliance raid / unknown. public int PlayerAllianceIndex { get { var player = Plugin.ObjectTable?.LocalPlayer; if (player == null) return -1; uint playerEntityId = player.EntityId; ulong playerObjectId = (ulong)player.GameObjectId; // Primary: Check our populated member lists first - most reliable (GroupManager/CrossRealm ordering may differ) for (int i = 0; i < 3; i++) { foreach (var m in _allianceMembers[i]) { if (m.ObjectId == playerEntityId || (ulong)m.ObjectId == playerObjectId) return i; if (m.Character != null && m.Character.Address == player.Address) return i; } } // Fallback: InfoProxyCrossRealm.LocalPlayerGroupIndex (when in duty finder / cross-world before instance) var info = InfoProxyCrossRealm.Instance(); if (info != null && info->IsInAllianceRaid && info->GroupCount >= 3) { byte localIdx = info->LocalPlayerGroupIndex; if (localIdx < 3) return localIdx; } // Fallback: CrossRealm iteration if (info != null && info->IsInAllianceRaid && info->GroupCount >= 3) { for (int i = 0; i < 3; i++) { var group = info->CrossRealmGroups[i]; for (int j = 0; j < group.GroupMemberCount; j++) { var member = group.GroupMembers[j]; if (member.EntityId == playerEntityId) return i; } } } // Fallback 3: Match any of our party members - we're in the same alliance as them var partyList = Plugin.PartyList; if (partyList != null) { for (int i = 0; i < 3; i++) { foreach (var m in _allianceMembers[i]) { foreach (var pm in partyList) { if (pm != null && pm.EntityId == m.ObjectId) { return i; } } } } } // Fallback: GroupManager - player is in MainGroup._partyMembers (their 8-man). // Group layout: 0,1 = other alliances from _allianceMembers; 2 = main party from _partyMembers. var gm = GroupManager.Instance(); if (gm != null && gm->MainGroup.IsAlliance) { ref var mainGroup = ref gm->MainGroup; if (mainGroup.GetPartyMemberByEntityId(playerEntityId) != null) { return 2; // Player in main party = group 2 (our alliance) } } return -1; } } /// Slot 0 = first other alliance, slot 1 = second. Excludes the player's alliance. public IReadOnlyList GetMembersForOtherAlliance(int slotIndex) { if (slotIndex < 0 || slotIndex > 1) return Array.Empty(); if (!TryGetAllianceIndexForSlot(slotIndex, out int allianceIdx)) return Array.Empty(); return _allianceMembers[allianceIdx].AsReadOnly(); } /// Gets the alliance index (0=A, 1=B, 2=C) for the given slot. Returns false when not in raid. public bool TryGetAllianceIndexForSlot(int slotIndex, out int allianceIndex) { allianceIndex = -1; if (slotIndex < 0 || slotIndex > 1) return false; int playerIdx = PlayerAllianceIndex; int idx = 0; for (int i = 0; i < 3; i++) { if (i == playerIdx) continue; if (idx == slotIndex) { allianceIndex = i; return true; } idx++; } return false; } /// Gets the alliance letter (A/B/C) for an internal group index. GroupManager ordering can differ from game display; use LocalPlayerGroupIndex to map when CrossRealm member data is unavailable. public bool TryGetAllianceLetter(int allianceIndex, out string letter) { letter = ""; if (allianceIndex < 0 || allianceIndex > 2) return false; var info = InfoProxyCrossRealm.Instance(); if (info != null && info->GroupCount >= 3) { // Check if CrossRealm has member data - when empty, we're in-instance using GroupManager (indices differ from InfoProxy) bool crossRealmHasData = false; for (int i = 0; i < 3; i++) { if (info->CrossRealmGroups[i].GroupMemberCount > 0) { crossRealmHasData = true; break; } } if (crossRealmHasData) { // CrossRealm populated: use EntityId lookup or direct lookup uint matchEntityId = 0; if (_allianceMembers[allianceIndex].Count > 0) matchEntityId = _allianceMembers[allianceIndex][0].ObjectId; if (matchEntityId != 0) { for (int crIdx = 0; crIdx < 3; crIdx++) { var crGroup = info->CrossRealmGroups[crIdx]; for (int j = 0; j < crGroup.GroupMemberCount; j++) { if (crGroup.GroupMembers[j].EntityId == matchEntityId) { byte displayIdx = crGroup.GroupMembers[j].GroupIndex; if (displayIdx < 3) { letter = ((char)('A' + displayIdx)).ToString(); return true; } } } } } var group = info->CrossRealmGroups[allianceIndex]; if (group.GroupMemberCount > 0) { byte displayIdx = group.GroupMembers[0].GroupIndex; if (displayIdx < 3) { letter = ((char)('A' + displayIdx)).ToString(); return true; } } for (byte d = 0; d < 3; d++) { if (InfoProxyCrossRealm.GetGroupIndex(d) == allianceIndex) { letter = ((char)('A' + d)).ToString(); return true; } } } // CrossRealm empty (in-instance with GroupManager): use direct GM-index mapping. // GroupManager alliance index 0=A, 1=B, 2=C in the game's party list display order. letter = ((char)('A' + allianceIndex)).ToString(); return true; } // Fallback when no CrossRealm/InfoProxy data (PvP edge cases) letter = ((char)('A' + allianceIndex)).ToString(); return true; } /// True when in 24-player content with 3 alliances (PvE) or PvP Frontlines/Rival Wings. public bool IsInAllianceRaid { get { var gm = GroupManager.Instance(); if (gm != null) { // GroupManager: IsAlliance = 3x8 (alliance raids, Frontlines), IsSmallGroupAlliance = 6x4 (Rival Wings) if (gm->MainGroup.IsAlliance || gm->MainGroup.IsSmallGroupAlliance) return true; } var info = InfoProxyCrossRealm.Instance(); if (info == null) return false; if (info->IsInAllianceRaid) return true; return info->IsCrossRealm && info->GroupCount >= 3; } } public event AllianceMembersChangedEventHandler? MembersChangedEvent; public void Update() { var gm = GroupManager.Instance(); // Use GroupManager for PvE alliance raids and PvP (Frontlines, Rival Wings) - CrossRealm is not populated in PvP bool useGroupManager = gm != null && (gm->MainGroup.IsAlliance || gm->MainGroup.IsSmallGroupAlliance); if (useGroupManager) { UpdateFromGroupManager(gm); return; } var info = InfoProxyCrossRealm.Instance(); if (info == null || !info->IsInAllianceRaid || info->GroupCount < 3) { if (_wasInAllianceRaid) { _wasInAllianceRaid = false; for (int i = 0; i < 3; i++) _allianceMembers[i].Clear(); MembersChangedEvent?.Invoke(this); } return; } _wasInAllianceRaid = true; UpdateFromCrossRealm(info); } private void UpdateFromGroupManager(GroupManager* gm) { _wasInAllianceRaid = true; bool anyChanged = false; ref var mainGroup = ref gm->MainGroup; for (int allianceIdx = 0; allianceIdx < 3; allianceIdx++) { int count = 0; var list = new List(); for (int slot = 0; slot < 8; slot++) { var pm = mainGroup.GetAllianceMemberByGroupAndIndex(allianceIdx, slot); if (pm == null || pm->EntityId == 0) continue; var pfMember = new PartyFramesMember( pm->EntityId, count, count, EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, false ); pfMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, pm->ClassJob); list.Add(pfMember); count++; } // GroupManager stores our party in _partyMembers, not GetAllianceMemberByGroupAndIndex. // When group 2 is empty, it's our alliance—populate from GetPartyMemberByIndex. if (count == 0 && allianceIdx == 2) { for (int slot = 0; slot < 8; slot++) { var pm = mainGroup.GetPartyMemberByIndex(slot); if (pm == null || pm->EntityId == 0) continue; var pfMember = new PartyFramesMember( pm->EntityId, count, count, EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, false ); pfMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, pm->ClassJob); list.Add(pfMember); count++; } } if (count != _lastMemberCounts[allianceIdx]) { _allianceMembers[allianceIdx].Clear(); _allianceMembers[allianceIdx].AddRange(list); _lastMemberCounts[allianceIdx] = count; anyChanged = true; } else { int ourIdx = 0; for (int slot = 0; slot < 8 && ourIdx < _allianceMembers[allianceIdx].Count; slot++) { PartyMember* pm = allianceIdx == 2 ? mainGroup.GetPartyMemberByIndex(slot) : mainGroup.GetAllianceMemberByGroupAndIndex(allianceIdx, slot); if (pm == null || pm->EntityId == 0) continue; if (_allianceMembers[allianceIdx][ourIdx] is PartyFramesMember pfMember) pfMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, pm->ClassJob); ourIdx++; } } } if (anyChanged) MembersChangedEvent?.Invoke(this); } /// Dumps alliance detection and letter-mapping debug info to the log. Run /hsui debug alliance while in an alliance raid. public static void DumpAllianceDebugToLog() { var inst = Instance; if (inst == null) { Plugin.Logger.Information("[HSUI Alliance DBG] AllianceManager not initialized."); return; } var player = Plugin.ObjectTable?.LocalPlayer; uint playerEntityId = player?.EntityId ?? 0; string playerName = player?.Name.ToString() ?? "(null)"; Plugin.Logger.Information("=== HSUI Alliance Debug ==="); Plugin.Logger.Information($"[Alliance] IsInAllianceRaid={inst.IsInAllianceRaid}"); Plugin.Logger.Information($"[Alliance] Player: EntityId={playerEntityId} Name={playerName}"); var gm = GroupManager.Instance(); bool useGM = gm != null && (gm->MainGroup.IsAlliance || gm->MainGroup.IsSmallGroupAlliance); Plugin.Logger.Information($"[Alliance] Data source: GroupManager={useGM} (IsAlliance={gm != null && gm->MainGroup.IsAlliance}, IsSmallGroupAlliance={gm != null && gm->MainGroup.IsSmallGroupAlliance})"); var info = InfoProxyCrossRealm.Instance(); if (info != null) { Plugin.Logger.Information($"[Alliance] InfoProxyCrossRealm: GroupCount={info->GroupCount} IsInAllianceRaid={info->IsInAllianceRaid} LocalPlayerGroupIndex={info->LocalPlayerGroupIndex}"); if (info->GroupCount >= 3) { for (byte d = 0; d < 3; d++) Plugin.Logger.Information($"[Alliance] GetGroupIndex({d}) => internal index {InfoProxyCrossRealm.GetGroupIndex(d)}"); } } else Plugin.Logger.Information("[Alliance] InfoProxyCrossRealm: null"); int playerIdx = inst.PlayerAllianceIndex; Plugin.Logger.Information($"[Alliance] PlayerAllianceIndex={playerIdx} (internal index we treat as 'our' alliance, will exclude from display)"); for (int i = 0; i < 3; i++) { int count = inst._allianceMembers[i].Count; string firstEntityId = count > 0 ? inst._allianceMembers[i][0].ObjectId.ToString() : "none"; string firstName = count > 0 ? inst._allianceMembers[i][0].Name : ""; bool hasPlayer = playerEntityId != 0 && inst._allianceMembers[i].Any(m => m.ObjectId == playerEntityId); string letter = inst.TryGetAllianceLetter(i, out string l) ? l : "?"; Plugin.Logger.Information($"[Alliance] _allianceMembers[{i}]: count={count} firstEntityId={firstEntityId} firstName={firstName} hasPlayer={hasPlayer} resolvedLetter={letter}"); } if (info != null && info->GroupCount >= 3) { for (int crIdx = 0; crIdx < 3; crIdx++) { var grp = info->CrossRealmGroups[crIdx]; int gc = grp.GroupMemberCount; string crFirstEntity = gc > 0 ? grp.GroupMembers[0].EntityId.ToString() : "none"; string crFirstName = gc > 0 ? grp.GroupMembers[0].NameString : ""; byte crGroupIndex = gc > 0 ? grp.GroupMembers[0].GroupIndex : (byte)255; char crLetter = crGroupIndex < 3 ? (char)('A' + crGroupIndex) : '?'; Plugin.Logger.Information($"[Alliance] CrossRealmGroups[{crIdx}]: count={gc} firstEntityId={crFirstEntity} firstName={crFirstName} GroupIndex={crGroupIndex} => letter '{crLetter}'"); } } for (int slot = 0; slot <= 1; slot++) { if (inst.TryGetAllianceIndexForSlot(slot, out int aidx) && inst.TryGetAllianceLetter(aidx, out string let)) Plugin.Logger.Information($"[Alliance] Display slot {slot}: allianceIndex={aidx} letter={let}"); else Plugin.Logger.Information($"[Alliance] Display slot {slot}: no data"); } // Letter-resolution path debug: which code path is used and why bool crossRealmHasData = false; if (info != null && info->GroupCount >= 3) { for (int i = 0; i < 3; i++) { if (info->CrossRealmGroups[i].GroupMemberCount > 0) { crossRealmHasData = true; break; } } } Plugin.Logger.Information($"[Alliance] crossRealmHasData={crossRealmHasData} → {(crossRealmHasData ? "EntityId/GetGroupIndex path" : "LocalPlayerGroupIndex path")}"); int dbgPlayerIdx = inst.PlayerAllianceIndex; byte dbgLocalDisplayIdx = info != null ? info->LocalPlayerGroupIndex : (byte)255; Plugin.Logger.Information($"[Alliance] LocalPlayerGroupIndex mapping: playerIdx={dbgPlayerIdx} localDisplayIdx={dbgLocalDisplayIdx} (0=A,1=B,2=C for our alliance)"); Plugin.Logger.Information($"[Alliance] Formula: displayIndex = (gmIndex - playerIdx + localDisplayIdx + 3) % 3"); for (int i = 0; i < 3; i++) { int displayIndex = (i - dbgPlayerIdx + dbgLocalDisplayIdx + 3) % 3; char computedLetter = displayIndex >= 0 && displayIndex < 3 ? (char)('A' + displayIndex) : '?'; string ours = i == dbgPlayerIdx ? " (OURS)" : ""; Plugin.Logger.Information($"[Alliance] GM[{i}] → displayIndex=({i}-{dbgPlayerIdx}+{dbgLocalDisplayIdx}+3)%3={displayIndex} → letter '{computedLetter}'{ours}"); } Plugin.Logger.Information("=== End Alliance Debug ==="); } private void UpdateFromCrossRealm(InfoProxyCrossRealm* info) { bool anyChanged = false; for (int allianceIdx = 0; allianceIdx < 3; allianceIdx++) { var group = info->CrossRealmGroups[allianceIdx]; int count = group.GroupMemberCount; if (count != _lastMemberCounts[allianceIdx]) { _allianceMembers[allianceIdx].Clear(); _lastMemberCounts[allianceIdx] = count; for (int i = 0; i < count; i++) { CrossRealmMember member = group.GroupMembers[i]; var partyMember = new PartyFramesMember( member, i, i, PartyMemberStatus.None, ReadyCheckStatus.None, member.IsPartyLeader, false ); _allianceMembers[allianceIdx].Add(partyMember); } anyChanged = true; } else { for (int i = 0; i < _allianceMembers[allianceIdx].Count; i++) { if (_allianceMembers[allianceIdx][i] is PartyFramesMember pfMember) { pfMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, pfMember.IsPartyLeader, pfMember.JobId); } } } } if (anyChanged) MembersChangedEvent?.Invoke(this); } } }