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 LightFinderNativePlateHandler _lightFinderNativePlateHandler; private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); private readonly Queue _lookupQueue = new(); private readonly HashSet _lookupQueuedCids = []; private readonly HashSet _syncshellCids = []; private volatile bool _pendingLocalBroadcast; private TimeSpan? _pendingLocalTtl; 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, LightFinderNativePlateHandler lightFinderNativePlateHandler, ActorObjectService actorTracker) : base(logger, mediator) { _logger = logger; _actorTracker = actorTracker; _broadcastService = broadcastService; _lightFinderPlateHandler = lightFinderPlateHandler; _lightFinderNativePlateHandler = lightFinderNativePlateHandler; _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; TryPrimeLocalBroadcastCache(); 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); _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); UpdateSyncshellBroadcasts(); } private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { if (!msg.Enabled) { _broadcastCache.Clear(); _lookupQueue.Clear(); _lookupQueuedCids.Clear(); _syncshellCids.Clear(); _pendingLocalBroadcast = false; _pendingLocalTtl = null; _lightFinderPlateHandler.UpdateBroadcastingCids([]); _lightFinderNativePlateHandler.UpdateBroadcastingCids([]); return; } _pendingLocalBroadcast = true; _pendingLocalTtl = msg.Ttl; TryPrimeLocalBroadcastCache(); } private void TryPrimeLocalBroadcastCache() { if (!_pendingLocalBroadcast) return; if (!TryGetLocalHashedCid(out var localCid)) return; var ttl = _pendingLocalTtl ?? _maxAllowedTtl; var expiry = DateTime.UtcNow + ttl; _broadcastCache.AddOrUpdate(localCid, new BroadcastEntry(true, expiry, null), (_, old) => new BroadcastEntry(true, expiry, old.GID)); _pendingLocalBroadcast = false; _pendingLocalTtl = null; var now = DateTime.UtcNow; var activeCids = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now) .Select(e => e.Key) .ToList(); _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); } private void UpdateSyncshellBroadcasts() { var now = DateTime.UtcNow; var nearbyCids = GetNearbyHashedCids(out _); var newSet = nearbyCids.Count == 0 ? new HashSet(StringComparer.Ordinal) : _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Where(e => nearbyCids.Contains(e.Key)) .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(bool excludeLocal = false) { var now = DateTime.UtcNow; var nearbyCids = GetNearbyHashedCids(out var localCid); if (nearbyCids.Count == 0) return []; return [.. _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Where(e => nearbyCids.Contains(e.Key)) .Where(e => !excludeLocal || !string.Equals(e.Key, localCid, StringComparison.Ordinal)) .Select(e => new BroadcastStatusInfoDto { HashedCID = e.Key, IsBroadcasting = true, TTL = e.Value.ExpiryTime - now, GID = e.Value.GID })]; } public bool TryGetLocalHashedCid(out string hashedCid) { hashedCid = string.Empty; var descriptors = _actorTracker.PlayerDescriptors; if (descriptors.Count == 0) return false; foreach (var descriptor in descriptors) { if (!descriptor.IsLocalPlayer || string.IsNullOrWhiteSpace(descriptor.HashedContentId)) continue; hashedCid = descriptor.HashedContentId; return true; } return false; } private HashSet GetNearbyHashedCids(out string? localCid) { localCid = null; var descriptors = _actorTracker.PlayerDescriptors; if (descriptors.Count == 0) return new HashSet(StringComparer.Ordinal); var set = new HashSet(StringComparer.Ordinal); foreach (var descriptor in descriptors) { if (string.IsNullOrWhiteSpace(descriptor.HashedContentId)) continue; if (descriptor.IsLocalPlayer) localCid = descriptor.HashedContentId; set.Add(descriptor.HashedContentId); } return set; } 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(); } }