using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace AnsonBot.Core; public class TrackerOptions { public int PollSeconds { get; set; } = 60; /// /// Consecutive polls a player must be absent before their session is /// closed. Guards against a single dropped/partial ShowPlayers response /// registering as everyone leaving and rejoining. /// public int MissedPollsBeforeLeave { get; set; } = 2; } public record PlayerEvent(string PlayerId, string Name, TimeSpan Duration); public class PlayerTrackerService : BackgroundService { private readonly IRconService _rcon; private readonly IDbContextFactory _dbFactory; private readonly TrackerOptions _options; private readonly ILogger _logger; // PlayerId -> consecutive polls missing. private readonly Dictionary _missing = new(); public event Func? PlayerJoined; public event Func? PlayerLeft; public PlayerTrackerService( IRconService rcon, IDbContextFactory dbFactory, IOptions options, ILogger logger) { _rcon = rcon; _dbFactory = dbFactory; _options = options.Value; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken ct) { await RecoverOrphanedSessionsAsync(ct); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollSeconds)); do { try { await PollAsync(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { // Deliberately swallow: a failed poll (server restarting, tunnel // blipped) must not kill the loop or close everyone's session. _logger.LogWarning(ex, "Poll failed; leaving sessions untouched."); } } while (await timer.WaitForNextTickAsync(ct)); } /// /// If the bot died without closing sessions, settle them at the last time /// we actually saw the player rather than leaving them open forever. /// private async Task RecoverOrphanedSessionsAsync(CancellationToken ct) { await using var db = await _dbFactory.CreateDbContextAsync(ct); await db.Database.MigrateAsync(ct); var open = await db.PlaySessions .Where(s => s.EndedUtc == null) .ToListAsync(ct); if (open.Count == 0) return; foreach (var s in open) s.EndedUtc = s.LastSeenUtc; await db.SaveChangesAsync(ct); _logger.LogInformation("Closed {Count} orphaned session(s) from a previous run.", open.Count); } private async Task PollAsync(CancellationToken ct) { var raw = await _rcon.SendAsync("ShowPlayers", ct); var online = ShowPlayersParser.Parse(raw); var now = DateTime.UtcNow; await using var db = await _dbFactory.CreateDbContextAsync(ct); var openSessions = await db.PlaySessions .Where(s => s.EndedUtc == null) .ToListAsync(ct); var openById = openSessions.ToDictionary(s => s.PlayerId); var onlineById = online .GroupBy(p => p.Id) .ToDictionary(g => g.Key, g => g.First()); var joined = new List(); var left = new List(); // Joins, and heartbeat for anyone still connected. foreach (var (id, player) in onlineById) { _missing.Remove(id); if (openById.TryGetValue(id, out var existing)) { existing.LastSeenUtc = now; existing.Name = player.Name; // keep up with renames } else { db.PlaySessions.Add(new PlaySession { PlayerId = id, Name = player.Name, StartedUtc = now, LastSeenUtc = now }); joined.Add(new PlayerEvent(id, player.Name, TimeSpan.Zero)); } } // Leaves, only after enough consecutive absences. foreach (var session in openSessions) { if (onlineById.ContainsKey(session.PlayerId)) continue; var misses = _missing.GetValueOrDefault(session.PlayerId) + 1; _missing[session.PlayerId] = misses; if (misses < _options.MissedPollsBeforeLeave) continue; // Credit them only up to when we last actually saw them. session.EndedUtc = session.LastSeenUtc; _missing.Remove(session.PlayerId); left.Add(new PlayerEvent( session.PlayerId, session.Name, session.EndedUtc.Value - session.StartedUtc)); } await db.SaveChangesAsync(ct); foreach (var e in joined) await RaiseAsync(PlayerJoined, e); foreach (var e in left) await RaiseAsync(PlayerLeft, e); } private async Task RaiseAsync(Func? handler, PlayerEvent e) { if (handler is null) return; try { await handler(e); } catch (Exception ex) { // A broken notification must not stop tracking. _logger.LogError(ex, "Player event handler failed for {Player}.", e.Name); } } /// Currently open sessions, for the /players command. public async Task> GetOpenSessionsAsync(CancellationToken ct = default) { await using var db = await _dbFactory.CreateDbContextAsync(ct); return await db.PlaySessions .Where(s => s.EndedUtc == null) .OrderBy(s => s.StartedUtc) .ToListAsync(ct); } }