using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; namespace LightlessSync.Services.LightFinder; public class LightFinderScannerService : DisposableMediatorSubscriberBase { private readonly ILogger _logger; private readonly ActorObjectService _actorTracker; private readonly IFramework _framework; private readonly LightFinderService _broadcastService; private readonly LightFinderPlateHandler _lightFinderPlateHandler; private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); private readonly Queue _lookupQueue = new(); private readonly HashSet _lookupQueuedCids = []; private readonly HashSet _syncshellCids = []; private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); private readonly CancellationTokenSource _cleanupCts = new(); private readonly Task? _cleanupTask; private readonly int _checkEveryFrames = 20; private int _frameCounter = 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 LightFinderScannerService(ILogger logger, IFramework framework, LightFinderService broadcastService, LightlessMediator mediator, LightFinderPlateHandler lightFinderPlateHandler, ActorObjectService actorTracker) : base(logger, mediator) { _logger = logger; _actorTracker = actorTracker; _broadcastService = broadcastService; _lightFinderPlateHandler = lightFinderPlateHandler; _logger = logger; _framework = framework; _framework.Update += OnFrameworkUpdate; Mediator.Subscribe(this, OnBroadcastStatusChanged); _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token); _actorTracker = actorTracker; } private void OnFrameworkUpdate(IFramework framework) => Update(); public void Update() { _frameCounter++; var lookupsThisFrame = 0; if (!_broadcastService.IsBroadcasting) return; var now = DateTime.UtcNow; foreach (var address in _actorTracker.PlayerAddresses) { if (address == nint.Zero) continue; var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(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(); _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); UpdateSyncshellBroadcasts(); } private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { if (!msg.Enabled) { _broadcastCache.Clear(); _lookupQueue.Clear(); _lookupQueuedCids.Clear(); _syncshellCids.Clear(); _lightFinderPlateHandler.UpdateBroadcastingCids([]); } } 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(StringComparer.Ordinal); 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 })]; } private async Task ExpiredBroadcastCleanupLoop() { var token = _cleanupCts.Token; try { while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false); var now = DateTime.UtcNow; foreach (var (cid, entry) in _broadcastCache.ToArray()) { if (entry.ExpiryTime <= now) _broadcastCache.TryRemove(cid, out _); } } } catch (OperationCanceledException) { // No action needed when cancelled } 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; if (_cleanupTask != null) { _cleanupTask?.Wait(100, _cleanupCts.Token); } _cleanupCts.Cancel(); _cleanupCts.Dispose(); _cleanupTask?.Wait(100); _cleanupCts.Dispose(); } }