using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; namespace LightlessSync.Services; public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable { private readonly ILogger _logger; private readonly IObjectTable _objectTable; private readonly IFramework _framework; private readonly BroadcastService _broadcastService; private readonly NameplateHandler _nameplateHandler; private readonly ConcurrentDictionary _broadcastCache = new(); private readonly Queue _lookupQueue = new(); private readonly HashSet _lookupQueuedCids = new(); private readonly HashSet _syncshellCids = new(); private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1); private readonly CancellationTokenSource _cleanupCts = new(); private Task? _cleanupTask; private readonly int _checkEveryFrames = 20; private int _frameCounter = 0; private int _lookupsThisFrame = 0; private const int MaxLookupsPerFrame = 30; private const int MaxQueueSize = 100; private volatile bool _batchRunning = false; public IReadOnlyDictionary BroadcastCache => _broadcastCache; public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); public BroadcastScannerService(ILogger logger, IClientState clientState, IObjectTable objectTable, IFramework framework, BroadcastService broadcastService, LightlessMediator mediator, NameplateHandler nameplateHandler, DalamudUtilService dalamudUtil, LightlessConfigService configService) : base(logger, mediator) { _logger = logger; _objectTable = objectTable; _broadcastService = broadcastService; _nameplateHandler = nameplateHandler; _logger = logger; _framework = framework; _framework.Update += OnFrameworkUpdate; Mediator.Subscribe(this, OnBroadcastStatusChanged); _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop); _nameplateHandler.Init(); } private void OnFrameworkUpdate(IFramework framework) => Update(); public void Update() { _frameCounter++; _lookupsThisFrame = 0; if (!_broadcastService.IsBroadcasting) return; var now = DateTime.UtcNow; foreach (var obj in _objectTable) { if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero) continue; var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address); var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now; if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize) _lookupQueue.Enqueue(cid); } if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0) { var cidsToLookup = new List(); while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame) { var cid = _lookupQueue.Dequeue(); _lookupQueuedCids.Remove(cid); cidsToLookup.Add(cid); _lookupsThisFrame++; } if (cidsToLookup.Count > 0 && !_batchRunning) { _batchRunning = true; _ = BatchUpdateBroadcastCacheAsync(cidsToLookup).ContinueWith(_ => _batchRunning = false); } } } private async Task BatchUpdateBroadcastCacheAsync(List cids) { var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false); var now = DateTime.UtcNow; foreach (var (cid, info) in results) { if (string.IsNullOrWhiteSpace(cid) || info == null) continue; var ttl = info.IsBroadcasting && info.TTL.HasValue ? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks)) : RetryDelay; var expiry = now + ttl; _broadcastCache.AddOrUpdate(cid, new BroadcastEntry(info.IsBroadcasting, expiry, info.GID), (_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID)); } var activeCids = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now) .Select(e => e.Key) .ToList(); _nameplateHandler.UpdateBroadcastingCids(activeCids); UpdateSyncshellBroadcasts(); } private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { if (!msg.Enabled) { _broadcastCache.Clear(); _lookupQueue.Clear(); _lookupQueuedCids.Clear(); _syncshellCids.Clear(); _nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty()); } } private void UpdateSyncshellBroadcasts() { var now = DateTime.UtcNow; var newSet = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Select(e => e.Key) .ToHashSet(); if (!_syncshellCids.SetEquals(newSet)) { _syncshellCids.Clear(); foreach (var cid in newSet) _syncshellCids.Add(cid); Mediator.Publish(new SyncshellBroadcastsUpdatedMessage()); } } public List GetActiveSyncshellBroadcasts() { var now = DateTime.UtcNow; return _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Select(e => new BroadcastStatusInfoDto { HashedCID = e.Key, IsBroadcasting = true, TTL = e.Value.ExpiryTime - now, GID = e.Value.GID }) .ToList(); } 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) _broadcastCache.TryRemove(cid, out _); } } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, "Broadcast cleanup loop crashed"); } UpdateSyncshellBroadcasts(); } public int CountActiveBroadcasts(string? excludeHashedCid = null) { var now = DateTime.UtcNow; var comparer = StringComparer.Ordinal; return _broadcastCache.Count(entry => entry.Value.IsBroadcasting && entry.Value.ExpiryTime > now && (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid))); } public List> GetActiveBroadcasts(string? excludeHashedCid = null) { var now = DateTime.UtcNow; var comparer = StringComparer.Ordinal; return [.. _broadcastCache.Where(entry => entry.Value.IsBroadcasting && entry.Value.ExpiryTime > now && (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))]; } protected override void Dispose(bool disposing) { base.Dispose(disposing); _framework.Update -= OnFrameworkUpdate; _cleanupCts.Cancel(); _cleanupTask?.Wait(100); _nameplateHandler.Uninit(); } }