AnsonBot/AnsonBot.Core/PlayerTrackerService.cs

188 lines
6 KiB
C#
Raw Normal View History

2026-07-30 19:47:39 +00:00
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;
/// <summary>
/// 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.
/// </summary>
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<BotDbContext> _dbFactory;
private readonly TrackerOptions _options;
private readonly ILogger<PlayerTrackerService> _logger;
// PlayerId -> consecutive polls missing.
private readonly Dictionary<string, int> _missing = new();
public event Func<PlayerEvent, Task>? PlayerJoined;
public event Func<PlayerEvent, Task>? PlayerLeft;
public PlayerTrackerService(
IRconService rcon,
IDbContextFactory<BotDbContext> dbFactory,
IOptions<TrackerOptions> options,
ILogger<PlayerTrackerService> 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));
}
/// <summary>
/// If the bot died without closing sessions, settle them at the last time
/// we actually saw the player rather than leaving them open forever.
/// </summary>
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<PlayerEvent>();
var left = new List<PlayerEvent>();
// 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<PlayerEvent, Task>? 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);
}
}
/// <summary>Currently open sessions, for the /players command.</summary>
public async Task<IReadOnlyList<PlaySession>> 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);
}
}