v1.0.3.0: Alliance frames fixes, PvP support, shared hotbar persistence, config save on teleport
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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. Uses member EntityId matching to CrossRealm when GroupManager/CrossRealm use different orderings.</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)
|
||||
{
|
||||
// Get first member EntityId from our group (works for both GroupManager and CrossRealm data)
|
||||
uint matchEntityId = 0;
|
||||
if (_allianceMembers[allianceIndex].Count > 0)
|
||||
matchEntityId = _allianceMembers[allianceIndex][0].ObjectId;
|
||||
|
||||
// Find which CrossRealm group contains this member - they may use different ordering than GroupManager
|
||||
for (int crIdx = 0; crIdx < 3 && matchEntityId != 0; 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct lookup when CrossRealm index matches ours (e.g. both use same ordering)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// GetGroupIndex(displayLetter) returns internal index - find which letter maps to our index
|
||||
for (byte d = 0; d < 3; d++)
|
||||
{
|
||||
if (InfoProxyCrossRealm.GetGroupIndex(d) == allianceIndex)
|
||||
{
|
||||
letter = ((char)('A' + d)).ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback when no CrossRealm data (PvP, in-instance)
|
||||
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;
|
||||
|
||||
for (int allianceIdx = 0; allianceIdx < 3; allianceIdx++)
|
||||
{
|
||||
int count = 0;
|
||||
var list = new List<IPartyFramesMember>();
|
||||
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++;
|
||||
}
|
||||
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++)
|
||||
{
|
||||
var pm = 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);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user