using System; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; using MareSynchronos.Services.Mediator; using MareSynchronos.MareConfiguration; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.WebAPI.AutoDetect; using Dalamud.Plugin.Services; using System.Numerics; using System.Linq; using System.Collections.Generic; using MareSynchronos.Utils; namespace MareSynchronos.Services.AutoDetect; public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber { private readonly ILogger _logger; private readonly MareMediator _mediator; private readonly MareConfigService _config; private readonly DiscoveryConfigProvider _configProvider; private readonly DalamudUtilService _dalamud; private readonly IObjectTable _objectTable; private readonly DiscoveryApiClient _api; private CancellationTokenSource? _loopCts; private string? _lastPublishedSignature; private bool _loggedLocalOnly; private int _lastLocalCount = -1; private int _lastMatchCount = -1; private bool _loggedConfigReady; private string? _lastSnapshotSig; private volatile bool _isConnected; private bool _notifiedDisabled; private bool _notifiedEnabled; private bool _disableSent; private bool _lastAutoDetectState; private DateTime _lastHeartbeat = DateTime.MinValue; private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(75); private readonly object _entriesLock = new(); private List _lastEntries = []; public NearbyDiscoveryService(ILogger logger, MareMediator mediator, MareConfigService config, DiscoveryConfigProvider configProvider, DalamudUtilService dalamudUtilService, IObjectTable objectTable, DiscoveryApiClient api) { _logger = logger; _mediator = mediator; _config = config; _configProvider = configProvider; _dalamud = dalamudUtilService; _objectTable = objectTable; _api = api; } public MareMediator Mediator => _mediator; public Task StartAsync(CancellationToken cancellationToken) { CancelAndDispose(ref _loopCts); _loopCts = new CancellationTokenSource(); _mediator.Subscribe(this, _ => { _isConnected = true; _configProvider.TryLoadFromStapled(); }); _mediator.Subscribe(this, _ => { _isConnected = false; _lastPublishedSignature = null; }); _mediator.Subscribe(this, OnAllowPairRequestsToggled); _ = Task.Run(() => Loop(_loopCts.Token)); _lastAutoDetectState = _config.Current.EnableAutoDetectDiscovery; return Task.CompletedTask; } private async void OnAllowPairRequestsToggled(AllowPairRequestsToggled msg) { try { if (!_config.Current.EnableAutoDetectDiscovery) return; // Force a publish now so the server immediately reflects the new allow/deny state _lastPublishedSignature = null; // ensure next loop won't skip await PublishSelfOnceAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "OnAllowPairRequestsToggled failed"); } } private async Task PublishSelfOnceAsync(CancellationToken ct) { try { if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected) return; if (!_configProvider.HasConfig || _configProvider.IsExpired()) { if (!_configProvider.TryLoadFromStapled()) { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } } var ep = _configProvider.PublishEndpoint; var saltBytes = _configProvider.Salt; if (string.IsNullOrEmpty(ep) || saltBytes is not { Length: > 0 }) return; var saltHex = Convert.ToHexString(saltBytes); string? displayName = null; ushort meWorld = 0; try { var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false); if (me != null) { displayName = me.Name.TextValue; if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc) meWorld = (ushort)mePc.HomeWorld.RowId; } } catch { } if (string.IsNullOrEmpty(displayName)) return; var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256(); var ok = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false); _logger.LogInformation("Nearby publish (manual/immediate): {result}", ok ? "success" : "failed"); if (ok) { _lastPublishedSignature = selfHash; _lastHeartbeat = DateTime.UtcNow; } } catch (Exception ex) { _logger.LogDebug(ex, "Immediate publish failed"); } } public Task StopAsync(CancellationToken cancellationToken) { _mediator.UnsubscribeAll(this); CancelAndDispose(ref _loopCts); return Task.CompletedTask; } public List SnapshotEntries() { lock (_entriesLock) { return _lastEntries.ToList(); } } private void UpdateSnapshot(List entries) { lock (_entriesLock) { _lastEntries = entries.ToList(); } } private static void CancelAndDispose(ref CancellationTokenSource? cts) { if (cts == null) return; try { cts.Cancel(); } catch (ObjectDisposedException) { } cts.Dispose(); cts = null; } private async Task Loop(CancellationToken ct) { _configProvider.TryLoadFromStapled(); while (!ct.IsCancellationRequested) { try { bool currentState = _config.Current.EnableAutoDetectDiscovery; if (currentState != _lastAutoDetectState) { _lastAutoDetectState = currentState; if (currentState) { // Force immediate publish on toggle ON try { // Ensure well-known is present if (!_configProvider.HasConfig || _configProvider.IsExpired()) { if (!_configProvider.TryLoadFromStapled()) { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } } var ep = _configProvider.PublishEndpoint; var saltBytes = _configProvider.Salt; if (!string.IsNullOrEmpty(ep) && saltBytes is { Length: > 0 }) { var saltHex = Convert.ToHexString(saltBytes); string? displayName = null; ushort meWorld = 0; try { var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false); if (me != null) { displayName = me.Name.TextValue; if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc) meWorld = (ushort)mePc.HomeWorld.RowId; } } catch { } if (!string.IsNullOrEmpty(displayName)) { var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256(); _lastPublishedSignature = null; // ensure future loop doesn't skip var okNow = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false); _logger.LogInformation("Nearby immediate publish on toggle ON: {result}", okNow ? "success" : "failed"); } } } catch (Exception ex) { _logger.LogDebug(ex, "Nearby immediate publish on toggle ON failed"); } if (!_notifiedEnabled) { _mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default)); _notifiedEnabled = true; _notifiedDisabled = false; _disableSent = false; } } else { var ep = _configProvider.PublishEndpoint; if (!string.IsNullOrEmpty(ep) && !_disableSent) { var disableUrl = ep.Replace("/publish", "/disable"); try { await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false); _disableSent = true; } catch { } } if (!_notifiedDisabled) { _mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default)); _notifiedDisabled = true; _notifiedEnabled = false; } } } if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected) { if (!_config.Current.EnableAutoDetectDiscovery && !string.IsNullOrEmpty(_configProvider.PublishEndpoint)) { var disableUrl = _configProvider.PublishEndpoint.Replace("/publish", "/disable"); try { if (!_disableSent) { await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false); _disableSent = true; } } catch { } if (!_notifiedDisabled) { _mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default)); _notifiedDisabled = true; _notifiedEnabled = false; } } await Task.Delay(1000, ct).ConfigureAwait(false); continue; } if (!_configProvider.HasConfig || _configProvider.IsExpired()) { if (!_configProvider.TryLoadFromStapled()) { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } } else if (!_loggedConfigReady && _configProvider.NearbyEnabled) { _loggedConfigReady = true; _logger.LogInformation("Nearby: well-known loaded and enabled; refresh={refresh}s, expires={exp}", _configProvider.RefreshSec, _configProvider.SaltExpiresAt); } var entries = await GetLocalNearbyAsync().ConfigureAwait(false); // Log when local count changes (including 0) to indicate activity if (entries.Count != _lastLocalCount) { _lastLocalCount = entries.Count; _logger.LogTrace("Nearby: {count} players detected locally", _lastLocalCount); } // Try query server if config and endpoints are present if (_configProvider.NearbyEnabled && !_configProvider.IsExpired() && _configProvider.Salt is { Length: > 0 }) { try { var saltHex = Convert.ToHexString(_configProvider.Salt!); // map hash->index for result matching Dictionary hashToIndex = new(StringComparer.Ordinal); List hashes = new(entries.Count); foreach (var (entry, idx) in entries.Select((e, i) => (e, i))) { var h = (saltHex + entry.Name + entry.WorldId.ToString()).GetHash256(); hashToIndex[h] = idx; hashes.Add(h); } try { var snapSig = string.Join(',', hashes.OrderBy(s => s, StringComparer.Ordinal)).GetHash256(); if (!string.Equals(snapSig, _lastSnapshotSig, StringComparison.Ordinal)) { _lastSnapshotSig = snapSig; var sample = entries.Take(5).Select(e => { var hh = (saltHex + e.Name + e.WorldId.ToString()).GetHash256(); var shortH = hh.Length > 8 ? hh[..8] : hh; return $"{e.Name}({e.WorldId})->{shortH}"; }); var saltShort = saltHex.Length > 8 ? saltHex[..8] : saltHex; _logger.LogTrace("Nearby snapshot: {count} entries; salt={saltShort}…; samples=[{samples}]", entries.Count, saltShort, string.Join(", ", sample)); } } catch { } if (!string.IsNullOrEmpty(_configProvider.PublishEndpoint)) { string? displayName = null; string? selfHash = null; try { var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false); if (me != null) { displayName = me.Name.TextValue; ushort meWorld = 0; if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc) meWorld = (ushort)mePc.HomeWorld.RowId; _logger.LogTrace("Nearby self ident: {name} ({world})", displayName, meWorld); selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256(); } } catch { /* ignore */ } if (!string.IsNullOrEmpty(selfHash)) { var sig = selfHash!; if (!string.Equals(sig, _lastPublishedSignature, StringComparison.Ordinal)) { _lastPublishedSignature = sig; var shortSelf = selfHash!.Length > 8 ? selfHash[..8] : selfHash; _logger.LogDebug("Nearby publish: self presence updated (hash={hash})", shortSelf); var ok = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false); _logger.LogInformation("Nearby publish result: {result}", ok ? "success" : "failed"); if (ok) _lastHeartbeat = DateTime.UtcNow; if (ok) { if (!_notifiedEnabled) { _mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default)); _notifiedEnabled = true; _notifiedDisabled = false; _disableSent = false; // allow future /disable when turning off again } } } else { // No changes; perform heartbeat publish if interval elapsed if (DateTime.UtcNow - _lastHeartbeat >= HeartbeatInterval) { var okHb = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false); _logger.LogDebug("Nearby heartbeat publish: {result}", okHb ? "success" : "failed"); if (okHb) _lastHeartbeat = DateTime.UtcNow; } else { _logger.LogDebug("Nearby publish skipped (no changes)"); } } } // else: no self character available; skip publish silently } // Query for matches if endpoint is available if (!string.IsNullOrEmpty(_configProvider.QueryEndpoint)) { // chunked queries int batch = Math.Max(1, _configProvider.MaxQueryBatch); List allMatches = new(); for (int i = 0; i < hashes.Count; i += batch) { var slice = hashes.Skip(i).Take(batch).ToArray(); var res = await _api.QueryAsync(_configProvider.QueryEndpoint!, slice, ct).ConfigureAwait(false); if (res != null && res.Count > 0) allMatches.AddRange(res); } if (allMatches.Count > 0) { foreach (var m in allMatches) { if (hashToIndex.TryGetValue(m.Hash, out var idx)) { var e = entries[idx]; entries[idx] = new NearbyEntry(e.Name, e.WorldId, e.Distance, true, m.Token, m.DisplayName, m.Uid); } } } if (allMatches.Count > 0) { _logger.LogInformation("Nearby: server returned {count} matches", allMatches.Count); } else { _logger.LogTrace("Nearby: server returned {count} matches", allMatches.Count); } // Log change in number of Umbra matches int matchCount = entries.Count(e => e.IsMatch); if (matchCount != _lastMatchCount) { _lastMatchCount = matchCount; if (matchCount > 0) { var matchSamples = entries.Where(e => e.IsMatch).Take(5) .Select(e => string.IsNullOrEmpty(e.DisplayName) ? e.Name : e.DisplayName!); _logger.LogInformation("Nearby: {count} Umbra users nearby [{samples}]", matchCount, string.Join(", ", matchSamples)); } else { _logger.LogTrace("Nearby: {count} Umbra users nearby", matchCount); } } } } catch (Exception ex) { _logger.LogDebug(ex, "Nearby query failed; falling back to local list"); if (ex.Message.Contains("DISCOVERY_SALT_EXPIRED", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Nearby: salt expired, refetching well-known"); try { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } catch { } } } } else { if (!_loggedLocalOnly) { _loggedLocalOnly = true; _logger.LogDebug("Nearby: well-known not available or disabled; running in local-only mode"); } } UpdateSnapshot(entries); _mediator.Publish(new DiscoveryListUpdated(entries)); var delayMs = Math.Max(1000, _configProvider.MinQueryIntervalMs); if (entries.Count == 0) delayMs = Math.Max(delayMs, 5000); await Task.Delay(delayMs, ct).ConfigureAwait(false); } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogDebug(ex, "NearbyDiscoveryService loop error"); await Task.Delay(2000, ct).ConfigureAwait(false); } } } private async Task> GetLocalNearbyAsync() { var list = new List(); try { var local = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false); var localPos = local?.Position ?? Vector3.Zero; int maxDist = Math.Clamp(_config.Current.AutoDetectMaxDistanceMeters, 5, 100); int limit = Math.Min(200, _objectTable.Length); for (int i = 0; i < limit; i++) { var obj = await _dalamud.RunOnFrameworkThread(() => _objectTable[i]).ConfigureAwait(false); if (obj == null || obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue; if (local != null && obj.Address == local.Address) continue; float dist = local == null ? float.NaN : Vector3.Distance(localPos, obj.Position); if (!float.IsNaN(dist) && dist > maxDist) continue; string name = obj.Name.TextValue; ushort worldId = 0; if (obj is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter pc) worldId = (ushort)pc.HomeWorld.RowId; list.Add(new NearbyEntry(name, worldId, dist, false, null, null, null)); } } catch { // ignore } return list; } }