Files
LightlessClient/LightlessSync/Services/LightFinder/LightFinderScannerService.cs
cake 8e08da7471 Chat changes for 2.0.3 (#134)
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #134
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2026-01-05 19:58:10 +00:00

383 lines
12 KiB
C#

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<LightFinderScannerService> _logger;
private readonly ActorObjectService _actorTracker;
private readonly IFramework _framework;
private readonly LightFinderService _broadcastService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
private readonly Queue<string> _lookupQueue = new();
private readonly HashSet<string> _lookupQueuedCids = [];
private readonly HashSet<string> _syncshellCids = [];
private volatile bool _pendingLocalBroadcast;
private TimeSpan? _pendingLocalTtl;
private string? _pendingLocalGid;
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;
private volatile bool _disposed = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
public LightFinderScannerService(ILogger<LightFinderScannerService> 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<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token);
_actorTracker = actorTracker;
}
private void OnFrameworkUpdate(IFramework framework) => Update();
public void Update()
{
if (_disposed)
return;
_frameCounter++;
var lookupsThisFrame = 0;
if (!_broadcastService.IsBroadcasting)
return;
TryPrimeLocalBroadcastCache();
var now = DateTime.UtcNow;
foreach (var descriptor in _actorTracker.PlayerDescriptors)
{
if (string.IsNullOrEmpty(descriptor.HashedContentId))
continue;
var cid = descriptor.HashedContentId;
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<string>();
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<string> cids)
{
if (_disposed)
return;
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
if (_disposed)
return;
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));
}
if (_disposed)
return;
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 (_disposed)
return;
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;
_pendingLocalGid = msg.Gid;
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, _pendingLocalGid),
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
_pendingLocalBroadcast = false;
_pendingLocalTtl = null;
_pendingLocalGid = 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);
UpdateSyncshellBroadcasts();
}
private void UpdateSyncshellBroadcasts()
{
if (_disposed)
return;
var now = DateTime.UtcNow;
var nearbyCids = GetNearbyHashedCids(out _);
var newSet = nearbyCids.Count == 0
? new HashSet<string>(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<BroadcastStatusInfoDto> 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<string> GetNearbyHashedCids(out string? localCid)
{
localCid = null;
var descriptors = _actorTracker.PlayerDescriptors;
if (descriptors.Count == 0)
return new HashSet<string>(StringComparer.Ordinal);
var set = new HashSet<string>(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<KeyValuePair<string, BroadcastEntry>> 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)
{
_disposed = true;
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
try
{
_cleanupCts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed, can be ignored :)
}
try
{
_cleanupTask?.Wait(100);
}
catch (Exception)
{
// Task may have already completed or been cancelled?
}
try
{
_cleanupCts.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
}