3b5ddbe2f9
- Alliance Frames: Populate other alliances (A/B) via GetAllianceMemberByIndex flat indices (0-7, 8-15) instead of GetAllianceMemberByGroupAndIndex which returns empty in-instance; keep own party from GetPartyMemberByIndex. - Alliance Frames: Visibility: do not apply HideInDuty to Alliance Frames so they show in alliance raids when visibility rules are enabled. - Hotbars: Crafting action tooltips: fallback Action sheet lookup with +100000 offset when hotbar stores CraftAction row ID. Co-authored-by: Cursor <cursoragent@cursor.com>
507 lines
23 KiB
C#
507 lines
23 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// Provides member lists for Alliance A, B, and C when in 24-player raid content.
|
|
/// Only populated when CrossRealm has 3 groups (alliance raid).
|
|
/// </summary>
|
|
public unsafe class AllianceManager : IDisposable
|
|
{
|
|
public static AllianceManager Instance { get; private set; } = null!;
|
|
|
|
private readonly List<IPartyFramesMember>[] _allianceMembers = new List<IPartyFramesMember>[3];
|
|
private int[] _lastMemberCounts = new int[3];
|
|
private bool _wasInAllianceRaid = false;
|
|
|
|
private AllianceManager()
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
_allianceMembers[i] = new List<IPartyFramesMember>();
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
Instance = new AllianceManager();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
_allianceMembers[i].Clear();
|
|
Instance = null!;
|
|
}
|
|
|
|
/// <summary>Internal alliance index (0, 1, or 2) containing the local player, or -1 if not in alliance raid / unknown.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Slot 0 = first other alliance, slot 1 = second. Excludes the player's alliance.</summary>
|
|
public IReadOnlyList<IPartyFramesMember> GetMembersForOtherAlliance(int slotIndex)
|
|
{
|
|
if (slotIndex < 0 || slotIndex > 1) return Array.Empty<IPartyFramesMember>();
|
|
if (!TryGetAllianceIndexForSlot(slotIndex, out int allianceIdx)) return Array.Empty<IPartyFramesMember>();
|
|
return _allianceMembers[allianceIdx].AsReadOnly();
|
|
}
|
|
|
|
/// <summary>Gets the alliance index (0=A, 1=B, 2=C) for the given slot. Returns false when not in raid.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>True when in 24-player content with 3 alliances (PvE) or PvP Frontlines/Rival Wings.</summary>
|
|
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;
|
|
|
|
// GroupManager layout: _allianceMembers flat array (indices 0-19) has groups 0 and 1.
|
|
// Group 2 (our party) is stored in _partyMembers. GetAllianceMemberByGroupAndIndex(0/1, slot)
|
|
// returns empty in-instance for some game versions; use GetAllianceMemberByIndex with flat
|
|
// indices: group 0 = indices 0-7, group 1 = indices 8-15.
|
|
for (int allianceIdx = 0; allianceIdx < 3; allianceIdx++)
|
|
{
|
|
int count = 0;
|
|
var list = new List<IPartyFramesMember>();
|
|
|
|
if (allianceIdx == 2)
|
|
{
|
|
// Our party: _partyMembers via GetPartyMemberByIndex
|
|
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++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Other alliances: _allianceMembers flat indices. Group 0 = 0-7, group 1 = 8-15.
|
|
int baseIdx = allianceIdx * 8;
|
|
for (int slot = 0; slot < 8; slot++)
|
|
{
|
|
var pm = mainGroup.GetAllianceMemberByIndex(baseIdx + 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;
|
|
if (allianceIdx == 2)
|
|
pm = mainGroup.GetPartyMemberByIndex(slot);
|
|
else
|
|
pm = mainGroup.GetAllianceMemberByIndex(allianceIdx * 8 + 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);
|
|
}
|
|
|
|
/// <summary>Dumps alliance detection and letter-mapping debug info to the log. Run /hsui debug alliance while in an alliance raid.</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|