using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.Gui.NamePlate; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; namespace LightlessSync.Services; public class NameplateService : DisposableMediatorSubscriberBase { private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly IClientState _clientState; private readonly INamePlateGui _namePlateGui; private readonly PairManager _pairManager; private readonly BroadcastService _broadcastService; private readonly DalamudUtilService _dalamudUtil; private readonly NameplateHandler _nameplatehandler; private readonly IFramework _framework; private readonly IGameGui _gameGui; private readonly ConcurrentDictionary _broadcastCache = new(); private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(5); private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1); private readonly Queue _lookupQueue = new(); private readonly HashSet _lookupQueuedCids = new(); private readonly HashSet _syncshellCids = new(); private readonly CancellationTokenSource _cleanupCts = new(); private Task? _cleanupTask; private const int MaxLookupsPerFrame = 15; private const int MaxQueueSize = 100; private int _lookupsThisFrame = 0; private int _frameCounter = 0; public IReadOnlyDictionary BroadcastCache => _broadcastCache; public readonly struct BroadcastEntry { public readonly bool IsBroadcasting; public readonly DateTime ExpiryTime; public readonly bool PrefixApplied; public readonly string? GID; public BroadcastEntry(bool isBroadcasting, DateTime expiryTime, bool prefixApplied, string? gid = null) { IsBroadcasting = isBroadcasting; ExpiryTime = expiryTime; PrefixApplied = prefixApplied; GID = gid; } } public NameplateService(ILogger logger, LightlessConfigService configService, INamePlateGui namePlateGui, IClientState clientState, PairManager pairManager, BroadcastService broadcastService, LightlessMediator lightlessMediator, DalamudUtilService dalamudUtil, NameplateHandler nameplatehandler, IGameGui gameGui) : base(logger, lightlessMediator) { _logger = logger; _configService = configService; _namePlateGui = namePlateGui; _clientState = clientState; _pairManager = pairManager; _broadcastService = broadcastService; _dalamudUtil = dalamudUtil; _nameplatehandler = nameplatehandler; _gameGui = gameGui; _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; _namePlateGui.RequestRedraw(); Mediator.Subscribe(this, (_) => _namePlateGui.RequestRedraw()); Mediator.Subscribe(this, OnBroadcastStatusChanged); _nameplatehandler.Init(); _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop); } private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList handlers) { _frameCounter++; _lookupsThisFrame = 0; if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) return; var visibleUsersIds = _pairManager.GetOnlineUserPairs() .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId) .ToHashSet(); var now = DateTime.UtcNow; var colors = _configService.Current.NameplateColors; foreach (var handler in handlers) { var playerCharacter = handler.PlayerCharacter; if (playerCharacter == null) continue; var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(playerCharacter.Address); var hasEntry = _broadcastCache.TryGetValue(cid, out var entry); var isEntryStale = !hasEntry || entry.ExpiryTime <= now; var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend); bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty); bool friendColorAllowed = (_configService.Current.overrideFriendColor && isFriend); if (visibleUsersIds.Contains(handler.GameObjectId) && !( (isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed) )) { _logger.LogInformation("added nameplate color to {Name}", playerCharacter.Name.TextValue); handler.NameParts.TextWrap = CreateTextWrap(colors); } if (!_broadcastService.IsBroadcasting) continue; if (isEntryStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize) { _lookupQueue.Enqueue(cid); } } if (_broadcastService.IsBroadcasting && _frameCounter % 2 == 0) { var cidsToLookup = new List(); while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame) { var nextCid = _lookupQueue.Dequeue(); _lookupQueuedCids.Remove(nextCid); cidsToLookup.Add(nextCid); _lookupsThisFrame++; } if (cidsToLookup.Count > 0) _ = BatchUpdateBroadcastCacheAsync(cidsToLookup); } } private async Task BatchUpdateBroadcastCacheAsync(List cidList) { var results = await _broadcastService.AreUsersBroadcastingAsync(cidList).ConfigureAwait(false); var now = DateTime.UtcNow; foreach (var (cid, info) in results) { if (string.IsNullOrWhiteSpace(cid) || info == null) { _logger.LogWarning("Skipping broadcast entry: cid={Cid}, info=null or empty", cid); continue; } bool isBroadcasting = info.IsBroadcasting; TimeSpan effectiveTtl = isBroadcasting && info.TTL.HasValue ? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks)) : RetryDelay; var expiryTime = now + effectiveTtl; _broadcastCache.AddOrUpdate(cid, new BroadcastEntry(isBroadcasting, expiryTime, false, info.GID), (_, old) => new BroadcastEntry(isBroadcasting, expiryTime, old.PrefixApplied, info.GID)); } var activeCids = _broadcastCache .Where(kvp => kvp.Value.IsBroadcasting) .Select(kvp => kvp.Key) .ToList(); _nameplatehandler.UpdateBroadcastingCids(activeCids); _namePlateGui.RequestRedraw(); UpdateSyncshellBroadcasts(); } private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { if (!msg.Enabled) { _logger.LogInformation("Broadcast disabled, clearing prefix cache and queue"); _broadcastCache.Clear(); _lookupQueue.Clear(); _lookupQueuedCids.Clear(); _syncshellCids.Clear(); _nameplatehandler.UpdateBroadcastingCids(Enumerable.Empty()); _namePlateGui.RequestRedraw(); } } public List GetActiveSyncshellBroadcasts() { var now = DateTime.UtcNow; return _broadcastCache .Where(kvp => kvp.Value.IsBroadcasting && kvp.Value.ExpiryTime > now && !string.IsNullOrEmpty(kvp.Value.GID)) .Select(kvp => new BroadcastStatusInfoDto { HashedCID = kvp.Key, IsBroadcasting = true, TTL = kvp.Value.ExpiryTime - now, GID = kvp.Value.GID }) .ToList(); } private void UpdateSyncshellBroadcasts() { var now = DateTime.UtcNow; var newSet = _broadcastCache .Where(kvp => kvp.Value.IsBroadcasting && kvp.Value.ExpiryTime > now && !string.IsNullOrEmpty(kvp.Value.GID)) .Select(kvp => kvp.Key) .ToHashSet(); if (!_syncshellCids.SetEquals(newSet)) { _syncshellCids.Clear(); foreach (var cid in newSet) _syncshellCids.Add(cid); _logger.LogInformation("Syncshell broadcast entries changed, sending update lol"); Mediator.Publish(new SyncshellBroadcastsUpdatedMessage()); } } public bool IsBroadcastingKnown(string cidHash, out bool isBroadcasting) { if (_broadcastCache.TryGetValue(cidHash, out var entry)) { isBroadcasting = entry.IsBroadcasting; return true; } isBroadcasting = false; return false; } private async Task ExpiredBroadcastCleanupLoop() { var token = _cleanupCts.Token; try { while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(10), token); var now = DateTime.UtcNow; foreach (var (cid, entry) in _broadcastCache.ToArray()) { if (entry.ExpiryTime <= now) { if (_broadcastCache.TryRemove(cid, out _)) { _logger.LogInformation("Removed expired broadcast entry: {Cid}", cid); } } } } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, "Error in ExpiredBroadcastCleanupLoop"); } UpdateSyncshellBroadcasts(); } public void RequestRedraw() { _namePlateGui.RequestRedraw(); } private static (SeString, SeString) CreateTextWrap(DtrEntry.Colors color) { var left = new Lumina.Text.SeStringBuilder(); var right = new Lumina.Text.SeStringBuilder(); left.PushColorRgba(color.Foreground); right.PopColor(); left.PushEdgeColorRgba(color.Glow); right.PopEdgeColor(); return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString()); } protected override void Dispose(bool disposing) { base.Dispose(disposing); _cleanupCts.Cancel(); _cleanupTask?.Wait(100); _namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate; _namePlateGui.RequestRedraw(); _nameplatehandler.Uninit(); } }