diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index dbcf73ed..d3157bc0 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -10,6 +10,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs index e3b34742..9f2ca8dc 100644 --- a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs +++ b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs @@ -3,7 +3,11 @@ namespace RustPlusBot.Features.Connections.Listening; /// A point-in-time view of a team, decoupled from RustPlusApi types. /// Steam64 id of the current team leader. /// Status snapshots for all team members. -public sealed record TeamInfoSnapshot(ulong LeaderSteamId, IReadOnlyList Members); +/// The leader's death-note map coordinate, if one is set; null otherwise. +public sealed record TeamInfoSnapshot( + ulong LeaderSteamId, + IReadOnlyList Members, + (float X, float Y)? DeathNote = null); /// A point-in-time view of one team member. /// Steam64 id of the member. diff --git a/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs new file mode 100644 index 00000000..7e0b7504 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs @@ -0,0 +1,14 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Abstractions.Events; + +/// Published when a team-info poll detects presence transitions since the previous poll. +/// The owning guild snowflake. +/// The target server id. +/// Map dimensions for grid rendering, or null if unavailable. +/// The transitions detected this poll (never empty when published). +public sealed record PlayerStateChangedEvent( + ulong GuildId, + Guid ServerId, + MapDimensions? Dimensions, + IReadOnlyList Transitions); diff --git a/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs b/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs new file mode 100644 index 00000000..5f99d0a7 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Abstractions.Events; + +/// One team-member presence transition, with a pre-resolved optional map location. +/// The transition kind. +/// Steam64 id of the member. +/// In-game display name (empty when the game reports none). +/// Resolved map coordinate to show, or null when no location applies. +public sealed record PlayerTransition( + PlayerTransitionKind Kind, + ulong SteamId, + string Name, + (float X, float Y)? Location); diff --git a/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs b/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs new file mode 100644 index 00000000..4931d095 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs @@ -0,0 +1,23 @@ +namespace RustPlusBot.Abstractions.Events; + +/// The kind of team-member presence transition detected between two team snapshots. +public enum PlayerTransitionKind +{ + /// Member came online. + Connect = 0, + + /// Member went offline. + Disconnect = 1, + + /// Member died. + Death = 2, + + /// Member spawned/respawned. + Respawn = 3, + + /// Member crossed the AFK stillness threshold. + BecameAfk = 4, + + /// Member moved again after being AFK. + ReturnedFromAfk = 5, +} diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index dc315283..207eb838 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs new file mode 100644 index 00000000..2fcd2ccb --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs @@ -0,0 +1,38 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !afk — lists team members who have been still (online + alive) past the AFK threshold. +/// The live AFK state. +/// The reply localizer. +internal sealed class AfkCommandHandler(IAfkState afk, ICommandLocalizer localizer) : ICommandHandler +{ + /// + public string Name => "afk"; + + /// + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var members = await afk.GetAfkMembersAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); + if (members is null) + { + return localizer.Get("command.notconnected", context.Culture); + } + + if (members.Count == 0) + { + return localizer.Get("command.afk.none", context.Culture); + } + + var parts = members + .OrderByDescending(m => m.StillFor) + .Select(m => localizer.Get( + "command.afk.member", context.Culture, m.Name, DurationFormat.Compact(m.StillFor))); + return localizer.Get("command.afk.ok", context.Culture, string.Join(", ", parts)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs index 99f72afa..94e2a263 100644 --- a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs @@ -22,6 +22,7 @@ internal static class CommandHelpCatalog new("team", CommandGroup.TeamIntel, "help.team"), new("steamid", CommandGroup.TeamIntel, "help.steamid"), new("alive", CommandGroup.TeamIntel, "help.alive"), + new("afk", CommandGroup.TeamIntel, "help.afk"), new("prox", CommandGroup.TeamIntel, "help.prox"), new("uptime", CommandGroup.Bot, "help.uptime"), ]; diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 013b944c..cf74ea3f 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -31,6 +31,9 @@ internal sealed class CommandLocalizationCatalog ["command.team.none"] = "No team members.", ["command.team.nomatch"] = "No teammate matches '{0}'.", ["command.steamid.ok"] = "{0}", + ["command.afk.ok"] = "AFK: {0}", + ["command.afk.none"] = "Nobody is AFK.", + ["command.afk.member"] = "{0} ({1})", ["command.alive.ok"] = "Alive: {0}", ["command.alive.dead"] = "{0} dead", ["command.alive.member"] = "{0} {1}", @@ -77,6 +80,7 @@ internal sealed class CommandLocalizationCatalog ["help.team"] = "List all team members.", ["help.steamid"] = "Show teammates' Steam IDs.", ["help.alive"] = "Show how long each teammate has survived.", + ["help.afk"] = "List teammates who are AFK.", ["help.prox"] = "Show distance to each teammate.", ["help.uptime"] = "Show how long the bot has been running.", ["help.slash.help"] = "Show this help message.", @@ -111,6 +115,9 @@ internal sealed class CommandLocalizationCatalog ["command.team.none"] = "Aucun membre d'équipe.", ["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».", ["command.steamid.ok"] = "{0}", + ["command.afk.ok"] = "AFK : {0}", + ["command.afk.none"] = "Personne n'est AFK.", + ["command.afk.member"] = "{0} ({1})", ["command.alive.ok"] = "En vie : {0}", ["command.alive.dead"] = "{0} mort", ["command.alive.member"] = "{0} {1}", @@ -158,6 +165,7 @@ internal sealed class CommandLocalizationCatalog ["help.team"] = "Lister tous les membres de l'équipe.", ["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.", ["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.", + ["help.afk"] = "Lister les coéquipiers qui sont AFK.", ["help.prox"] = "Afficher la distance à chaque coéquipier.", ["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.", ["help.slash.help"] = "Afficher ce message d'aide.", diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs index 1fbdabcf..661a3499 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs @@ -35,4 +35,10 @@ public sealed class ConnectionOptions /// How often the rig-timer tick advances rig phases and emits timed boundary events. Default 30s. public TimeSpan RigTickInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// How long a member must be still (and online + alive) before being flagged AFK. Default 5m. + public TimeSpan AfkThreshold { get; set; } = TimeSpan.FromMinutes(5); + + /// Movement tolerance (world units) below which a member is considered still. Default 1. + public float AfkEpsilon { get; set; } = 1f; } diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index 31828268..6dc974bd 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. diff --git a/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs new file mode 100644 index 00000000..629f9dca --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// One currently-AFK team member. +/// Steam64 id. +/// In-game display name. +/// How long the member has been continuously still. +public sealed record AfkMember(ulong SteamId, string Name, TimeSpan StillFor); + +/// Reads the live AFK state computed by the connection poll loop. +public interface IAfkState +{ + /// Gets the currently-AFK members, or null when there is no live socket. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// The currently-AFK members, or null when there is no live socket. + Task?> GetAfkMembersAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 1cbf54c5..4d4bed85 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -264,7 +264,10 @@ public async Task GetInfoAsync(TimeSpan timeout, CancellationTo new DateTimeOffset(DateTime.SpecifyKind(m.LastSpawnTime, DateTimeKind.Utc)), new DateTimeOffset(DateTime.SpecifyKind(m.LastDeathTime, DateTimeKind.Utc)))) .ToList(); - return new TeamInfoSnapshot(response.Data.LeaderSteamId, members); + var deathNote = response.Data.DeathNote is { } dn + ? ((float X, float Y)?)(dn.X, dn.Y) + : null; + return new TeamInfoSnapshot(response.Data.LeaderSteamId, members, deathNote); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs new file mode 100644 index 00000000..2930d25a --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -0,0 +1,189 @@ +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Features.Connections.Listening; + +/// Diffs successive team snapshots into presence transitions. One instance per connected window. +internal sealed class TeamStateTracker +{ + private readonly HashSet _afk = new(); + private readonly object _gate = new(); + private readonly Dictionary _stillSince = new(); + private Dictionary? _baseline; + + /// Diffs against the previous one. First non-null call primes silently. + /// The latest team snapshot, or null when the poll returned no data. + /// Current wall-clock time (from IClock.UtcNow). + /// How long a member must be still before being flagged AFK. + /// Movement tolerance (world units) below which a member is considered still. + /// The transitions since the previous snapshot; empty on prime, null input, or no change. + public IReadOnlyList Diff( + TeamInfoSnapshot? snapshot, + DateTimeOffset now, + TimeSpan afkThreshold, + float afkEpsilon) + { + if (snapshot is null) + { + return []; + } + + var current = snapshot.Members.ToDictionary(m => m.SteamId); + + lock (_gate) + { + if (_baseline is null) + { + _baseline = current; + foreach (var m in current.Values) + { + _stillSince[m.SteamId] = now; + } + + return []; + } + + var transitions = new List(); + foreach (var (id, nowMember) in current) + { + if (!_baseline.TryGetValue(id, out var was)) + { + _stillSince[id] = now; // prime new member's stillness clock + continue; + } + + AddPresenceTransitions(transitions, id, was, nowMember, snapshot); + var diedThisPoll = nowMember.LastDeathTimeUtc > was.LastDeathTimeUtc; + UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon, diedThisPoll); + } + + PruneDepartedMembers(current); + _baseline = current; + return transitions; + } + } + + private static void AddPresenceTransitions( + List transitions, + ulong id, + TeamMemberSnapshot was, + TeamMemberSnapshot now, + TeamInfoSnapshot snapshot) + { + if (now.IsOnline && !was.IsOnline) + { + transitions.Add(new PlayerTransition(PlayerTransitionKind.Connect, id, now.Name, null)); + } + else if (!now.IsOnline && was.IsOnline) + { + transitions.Add(new PlayerTransition(PlayerTransitionKind.Disconnect, id, now.Name, null)); + } + + if (now.LastDeathTimeUtc > was.LastDeathTimeUtc) + { + transitions.Add(new PlayerTransition( + PlayerTransitionKind.Death, id, now.Name, ResolveDeathLocation(id, snapshot, was))); + } + + if (now.LastSpawnTimeUtc > was.LastSpawnTimeUtc) + { + transitions.Add(new PlayerTransition(PlayerTransitionKind.Respawn, id, now.Name, (now.X, now.Y))); + } + } + + private void UpdateAfk( + List transitions, + ulong id, + TeamMemberSnapshot was, + TeamMemberSnapshot now, + DateTimeOffset clock, + TimeSpan threshold, + float epsilon, + bool diedThisPoll) + { + // A member who goes offline, is dead, or died this poll (even if a slow poll already shows them + // respawned) can no longer be AFK. Clear the AFK latch SILENTLY — the disconnect/death transition + // already speaks for them, and a "back" line alongside "disconnected"/"died" would contradict it — + // and reset the stillness clock so AFK must be re-earned after the state change. + if (!now.IsOnline || !now.IsAlive || diedThisPoll) + { + _afk.Remove(id); + _stillSince[id] = clock; + return; + } + + // Squared-distance check so epsilon is a true movement radius (per-axis would treat diagonal + // movement of dx=dy=0.8, epsilon=1 — distance ≈ 1.13 — as still). + var dx = now.X - was.X; + var dy = now.Y - was.Y; + var moved = (dx * dx) + (dy * dy) > epsilon * epsilon; + if (moved) + { + _stillSince[id] = clock; + if (_afk.Remove(id)) + { + transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null)); + } + + return; + } + + var since = _stillSince.TryGetValue(id, out var s) ? s : clock; + _stillSince.TryAdd(id, since); + if (clock - since >= threshold && _afk.Add(id)) + { + transitions.Add(new PlayerTransition(PlayerTransitionKind.BecameAfk, id, now.Name, (now.X, now.Y))); + } + } + + /// Drops per-member AFK/stillness state for ids no longer present in the team snapshot. + /// The members in the latest snapshot, keyed by Steam id. + private void PruneDepartedMembers(Dictionary current) + { + if (_stillSince.Count == 0 && _afk.Count == 0) + { + return; + } + + foreach (var id in _stillSince.Keys.Where(id => !current.ContainsKey(id)).ToList()) + { + _stillSince.Remove(id); + } + + _afk.RemoveWhere(id => !current.ContainsKey(id)); + } + + /// The members currently flagged AFK and how long each has been still, as of . + /// The current wall-clock time used to compute each member's still duration. + public IReadOnlyList CurrentAfk(DateTimeOffset now) + { + lock (_gate) + { + var result = new List(); + foreach (var id in _afk) + { + if (_baseline is not null && _baseline.TryGetValue(id, out var m)) + { + var since = _stillSince.TryGetValue(id, out var s) ? s : now; + result.Add(new AfkMember(id, m.Name, now - since)); + } + } + + return result; + } + } + + private static (float X, float Y)? ResolveDeathLocation( + ulong steamId, + TeamInfoSnapshot snapshot, + TeamMemberSnapshot previous) + { + // Leader: the single DeathNote is the true death spot (player respawns elsewhere). + if (steamId == snapshot.LeaderSteamId && snapshot.DeathNote is { } note) + { + return note; + } + + // Everyone else: the previous poll's position (captured while alive) ≈ where they died. + return (previous.X, previous.Y); + } +} diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 7630ed6c..7e854cd8 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; @@ -20,6 +21,7 @@ namespace RustPlusBot.Features.Connections.Supervisor; /// DMs an owner when their credential is rejected. /// Unprotects stored tokens before connecting. /// Publishes ConnectionStatusChangedEvent on state changes. +/// Wall-clock source used for AFK hysteresis timestamps. /// Timeouts/backoff/heartbeat settings. /// The logger. internal sealed partial class ConnectionSupervisor( @@ -28,8 +30,10 @@ internal sealed partial class ConnectionSupervisor( IUserDmSender dmSender, ICredentialProtector protector, IEventBus eventBus, + IClock clock, IOptions options, - ILogger logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable + ILogger logger) + : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable { private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new(); private readonly SemaphoreSlim _gate = new(1, 1); @@ -38,6 +42,20 @@ internal sealed partial class ConnectionSupervisor( private readonly CancellationTokenSource _shutdown = new(); private bool _disposed; + /// + public Task?> GetAfkMembersAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken) + { + if (_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return Task.FromResult?>(live.Tracker.CurrentAfk(clock.UtcNow)); + } + + return Task.FromResult?>(null); + } + /// public async ValueTask DisposeAsync() { @@ -395,10 +413,11 @@ void OnTeamMessage(object? sender, TeamChatLine line) var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false); + var tracker = new TeamStateTracker(); connection.TeamMessageReceived += OnTeamMessage; - _liveSockets[key] = new LiveSocket(connection, activeSteamId); + _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker); using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, pollCts.Token), + var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token), CancellationToken.None); try { @@ -445,6 +464,7 @@ private async Task PollMarkersAsync( IRustServerConnection connection, MapDimensions? dims, IReadOnlyList rigs, + TeamStateTracker tracker, CancellationToken ct) { IReadOnlyList? previous = null; @@ -475,6 +495,15 @@ await eventBus.PublishAsync( } await DetectRigActivationsAsync(key, current, rigs, dims, rigsInRadius, ct).ConfigureAwait(false); + + var team = await connection.GetTeamInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + var transitions = tracker.Diff(team, clock.UtcNow, _options.AfkThreshold, _options.AfkEpsilon); + if (transitions.Count > 0) + { + await eventBus.PublishAsync( + new PlayerStateChangedEvent(key.Guild, key.Server, dims, transitions), ct) + .ConfigureAwait(false); + } } catch (OperationCanceledException) { @@ -742,7 +771,7 @@ private readonly record struct Prepared( ulong SteamId, string PlayerToken); - private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId); + private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId, TeamStateTracker Tracker); private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable { diff --git a/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs b/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs new file mode 100644 index 00000000..9de6ec17 --- /dev/null +++ b/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Players.Relaying; + +namespace RustPlusBot.Features.Players.Hosting; + +/// Consumes and relays each to #events + in-game chat. +/// The in-process event bus. +/// Relays player transitions. +/// The logger. +internal sealed partial class PlayersHostedService( + IEventBus eventBus, + PlayerEventRelay relay, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _loop = Task.Run(() => ConsumeAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + if (_loop is not null) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop task, joined on stop. + await _loop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumeAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRelayLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Player relay loop faulted.")] + private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs new file mode 100644 index 00000000..304e8408 --- /dev/null +++ b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Players.Hosting; +using RustPlusBot.Features.Players.Posting; +using RustPlusBot.Features.Players.Relaying; +using RustPlusBot.Features.Players.Rendering; + +namespace RustPlusBot.Features.Players; + +/// DI registration for the team-presence-events feature. +public static class PlayerEventServiceCollectionExtensions +{ + /// Registers the localizer, renderer, poster, relay, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddPlayers(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(PlayerLocalizationCatalog.Default); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs new file mode 100644 index 00000000..c6bbbb3b --- /dev/null +++ b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs @@ -0,0 +1,45 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Players.Posting; + +/// Posts player-event embeds to Discord text channels via the gateway client. +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordPlayerChannelPoster( + DiscordSocketClient client, + ILogger logger) + : IPlayerChannelPoster +{ + /// + public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) + { + return; + } + + await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a player-event embed to channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs new file mode 100644 index 00000000..51f931ed --- /dev/null +++ b/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs @@ -0,0 +1,13 @@ +using Discord; + +namespace RustPlusBot.Features.Players.Posting; + +/// Posts a player-event embed to a Discord channel. +internal interface IPlayerChannelPoster +{ + /// Posts to the channel. + /// The Discord channel snowflake. + /// The embed to post. + /// A cancellation token. + Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs new file mode 100644 index 00000000..433daf18 --- /dev/null +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Posting; +using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Players.Relaying; + +/// Posts every player transition to #events AND in-game team chat. +/// Renders transitions as embeds and in-game lines. +/// Resolves the #events Discord channel id. +/// Posts embeds to the Discord channel. +/// Broadcasts the in-game team-chat line. +/// Opens scopes to read guild culture. +internal sealed class PlayerEventRelay( + PlayerEventRenderer renderer, + IEventChannelLocator locator, + IPlayerChannelPoster poster, + ITeamChatSender teamChatSender, + IServiceScopeFactory scopeFactory) +{ + /// Handles one : posts embeds and broadcasts in-game. + /// The player state changed event. + /// A cancellation token. + public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + if (evt.Transitions.Count == 0) + { + return; + } + + var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + + foreach (var t in evt.Transitions) + { + await teamChatSender + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), + cancellationToken) + .ConfigureAwait(false); + if (channelId is { } id) + { + await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture), cancellationToken) + .ConfigureAwait(false); + } + } + } + + private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs new file mode 100644 index 00000000..db06fb3b --- /dev/null +++ b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Features.Players.Rendering; + +/// Resolves localized player-event strings by key and culture, falling back to English. +internal interface IPlayerLocalizer +{ + /// Gets the localized string for a key, or the key itself if not found. + /// The string key. + /// The BCP-47 culture tag (e.g. "en", "fr"). + string Get(string key, string culture); + + /// Gets the localized, format-applied string. + /// The string key. + /// The BCP-47 culture tag. + /// Format arguments. + string Get(string key, string culture, params object[] args); +} diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs new file mode 100644 index 00000000..ae86a78f --- /dev/null +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs @@ -0,0 +1,61 @@ +using Discord; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; + +namespace RustPlusBot.Features.Players.Rendering; + +/// Renders one as a Discord embed or an in-game line. +/// The reply localizer. +internal sealed class PlayerEventRenderer(IPlayerLocalizer localizer) +{ + /// Renders the transition for a guild culture as a Discord embed. + /// The player transition to render. + /// The map dimensions, or null if unavailable. + /// The BCP-47 culture tag. + public Embed Render(PlayerTransition transition, MapDimensions? dims, string culture) + { + ArgumentNullException.ThrowIfNull(transition); + return new EmbedBuilder() + .WithAuthor(localizer.Get("player.title", culture)) + .WithDescription(Describe(transition, dims, culture, suffix: string.Empty)) + .WithCurrentTimestamp() + .Build(); + } + + /// Renders the in-game team-chat line for the transition. + /// The player transition to render. + /// The map dimensions, or null if unavailable. + /// The BCP-47 culture tag. + public string RenderLine(PlayerTransition transition, MapDimensions? dims, string culture) + { + ArgumentNullException.ThrowIfNull(transition); + return Describe(transition, dims, culture, suffix: ".line"); + } + + private string Describe(PlayerTransition t, MapDimensions? dims, string culture, string suffix) + { + switch (t.Kind) + { + case PlayerTransitionKind.Connect: + return localizer.Get("player.connect" + suffix, culture, t.Name); + case PlayerTransitionKind.Disconnect: + return localizer.Get("player.disconnect" + suffix, culture, t.Name); + case PlayerTransitionKind.Respawn: + return localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims)); + case PlayerTransitionKind.ReturnedFromAfk: + return localizer.Get("player.afk.back" + suffix, culture, t.Name); + case PlayerTransitionKind.BecameAfk: + return localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims)); + case PlayerTransitionKind.Death: + return t.Location is null + ? localizer.Get("player.death.unknown" + suffix, culture, t.Name) + : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims)); + default: + throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind."); + } + } + + private static string Grid(PlayerTransition t, MapDimensions? dims) + => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims) : string.Empty; +} diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs new file mode 100644 index 00000000..9b946dda --- /dev/null +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs @@ -0,0 +1,52 @@ +namespace RustPlusBot.Features.Players.Rendering; + +/// The in-memory string catalog for player events: culture -> (key -> value). English is the fallback. +internal sealed class PlayerLocalizationCatalog +{ + /// culture -> key -> value. + public required IReadOnlyDictionary> Strings { get; init; } + + /// The built-in EN/FR catalog. + public static PlayerLocalizationCatalog Default { get; } = new() + { + Strings = new Dictionary>(StringComparer.Ordinal) + { + ["en"] = new Dictionary(StringComparer.Ordinal) + { + ["player.title"] = "Team event", + ["player.connect"] = "🟢 {0} connected", + ["player.connect.line"] = "{0} connected", + ["player.disconnect"] = "🔴 {0} disconnected", + ["player.disconnect.line"] = "{0} disconnected", + ["player.death"] = "💀 {0} died at {1}", + ["player.death.line"] = "{0} died at {1}", + ["player.death.unknown"] = "💀 {0} died", + ["player.death.unknown.line"] = "{0} died", + ["player.respawn"] = "✨ {0} respawned at {1}", + ["player.respawn.line"] = "{0} respawned at {1}", + ["player.afk"] = "💤 {0} is AFK ({1})", + ["player.afk.line"] = "{0} is AFK ({1})", + ["player.afk.back"] = "👋 {0} is back", + ["player.afk.back.line"] = "{0} is back", + }, + ["fr"] = new Dictionary(StringComparer.Ordinal) + { + ["player.title"] = "Événement d'équipe", + ["player.connect"] = "🟢 {0} s'est connecté", + ["player.connect.line"] = "{0} s'est connecté", + ["player.disconnect"] = "🔴 {0} s'est déconnecté", + ["player.disconnect.line"] = "{0} s'est déconnecté", + ["player.death"] = "💀 {0} est mort en {1}", + ["player.death.line"] = "{0} est mort en {1}", + ["player.death.unknown"] = "💀 {0} est mort", + ["player.death.unknown.line"] = "{0} est mort", + ["player.respawn"] = "✨ {0} a réapparu en {1}", + ["player.respawn.line"] = "{0} a réapparu en {1}", + ["player.afk"] = "💤 {0} est AFK ({1})", + ["player.afk.line"] = "{0} est AFK ({1})", + ["player.afk.back"] = "👋 {0} est de retour", + ["player.afk.back.line"] = "{0} est de retour", + }, + }, + }; +} diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs new file mode 100644 index 00000000..ddafa269 --- /dev/null +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs @@ -0,0 +1,61 @@ +using System.Globalization; + +namespace RustPlusBot.Features.Players.Rendering; + +/// Dictionary-backed with English fallback and region normalization. +/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor. +/// The string catalog. +internal sealed class PlayerLocalizer(PlayerLocalizationCatalog catalog) : IPlayerLocalizer +{ + private const string FallbackCulture = "en"; + + /// + public string Get(string key, string culture) + { + var normalized = Normalize(culture); + if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value)) + { + return value; + } + + if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) && + fallback.TryGetValue(key, out var fallbackValue)) + { + return fallbackValue; + } + + return key; + } + + /// + public string Get(string key, string culture, params object[] args) + { + var format = Get(key, culture); + var provider = ResolveFormatProvider(Normalize(culture)); + return string.Format(provider, format, args); + } + + private static string Normalize(string culture) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return FallbackCulture; + } + + var dash = culture.IndexOf('-', StringComparison.Ordinal); + var primary = dash >= 0 ? culture[..dash] : culture; + return primary.ToLowerInvariant(); + } + + private static CultureInfo ResolveFormatProvider(string culture) + { + try + { + return CultureInfo.GetCultureInfo(culture); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } +} diff --git a/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj new file mode 100644 index 00000000..41aa50f2 --- /dev/null +++ b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index be97c698..e2ce6899 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -11,6 +11,7 @@ using RustPlusBot.Features.Connections; using RustPlusBot.Features.Events; using RustPlusBot.Features.Map; +using RustPlusBot.Features.Players; using RustPlusBot.Features.Pairing; using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; @@ -68,6 +69,7 @@ .ValidateOnStart(); builder.Services.AddCommands(); builder.Services.AddEvents(); +builder.Services.AddPlayers(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Map")) .Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.") diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index b2456d02..313203a3 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -25,5 +25,6 @@ + diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs new file mode 100644 index 00000000..ab81386f --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs @@ -0,0 +1,48 @@ +using NSubstitute; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Tests; + +public sealed class AfkCommandHandlerTests +{ + private readonly IAfkState _afk = Substitute.For(); + private readonly ICommandLocalizer _localizer = new CommandLocalizer(CommandLocalizationCatalog.Default); + + private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 99, "Caller", []); + + [Fact] + public async Task Name_is_afk() => Assert.Equal("afk", new AfkCommandHandler(_afk, _localizer).Name); + + [Fact] + public async Task Reports_not_connected_when_state_null() + { + _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((IReadOnlyList?)null); + var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal(_localizer.Get("command.notconnected", "en"), reply); + } + + [Fact] + public async Task Reports_none_when_empty() + { + _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal(_localizer.Get("command.afk.none", "en"), reply); + } + + [Fact] + public async Task Lists_afk_members_with_durations() + { + _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new(1, "Bob", TimeSpan.FromMinutes(6)) + }); + var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Contains("Bob", reply, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 8f2166a9..00474a63 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -29,6 +29,7 @@ public void Dispatcher_and_handlers_resolve() services.AddSingleton(Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); @@ -47,7 +48,7 @@ public void Dispatcher_and_handlers_resolve() using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); var handlers = scope.ServiceProvider.GetServices().ToList(); - Assert.Equal(18, handlers.Count); + Assert.Equal(19, handlers.Count); Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); @@ -64,6 +65,7 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "events"); Assert.Contains(handlers, h => h.Name == "small"); Assert.Contains(handlers, h => h.Name == "large"); + Assert.Contains(handlers, h => h.Name == "afk"); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } @@ -79,6 +81,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddSingleton(Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs index 8478c445..e19fa6ca 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs @@ -5,11 +5,11 @@ namespace RustPlusBot.Features.Commands.Tests.Help; public sealed class CommandHelpCatalogTests { - /// The 12 registered in-game handler names (see AddCommands / CommandRegistrationTests). + /// The 13 registered in-game handler names (see AddCommands / CommandRegistrationTests). private static readonly string[] HandlerNames = [ "mute", "unmute", "uptime", "pop", "wipe", "time", - "online", "offline", "team", "steamid", "alive", "prox", + "online", "offline", "team", "steamid", "alive", "afk", "prox", ]; [Fact] diff --git a/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs new file mode 100644 index 00000000..6545985f --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs @@ -0,0 +1,31 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class AfkStateTests +{ + [Fact] + public void CurrentAfk_lists_member_with_still_duration() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0); + t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f); + t.Diff(new TeamInfoSnapshot(1, [m]), t0.AddMinutes(6), TimeSpan.FromMinutes(5), 1f); // BecameAfk + + var afk = t.CurrentAfk(t0.AddMinutes(6)); + var bob = Assert.Single(afk); + Assert.Equal(1UL, bob.SteamId); + Assert.Equal(TimeSpan.FromMinutes(6), bob.StillFor); + } + + [Fact] + public void CurrentAfk_empty_when_nobody_afk() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0); + t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f); + Assert.Empty(t.CurrentAfk(t0.AddMinutes(1))); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs new file mode 100644 index 00000000..b96fa7cd --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs @@ -0,0 +1,20 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class TeamInfoSnapshotTests +{ + [Fact] + public void DeathNote_defaults_to_null() + { + var snap = new TeamInfoSnapshot(123UL, []); + Assert.Null(snap.DeathNote); + } + + [Fact] + public void DeathNote_carries_coordinate_when_set() + { + var snap = new TeamInfoSnapshot(123UL, [], (100f, 200f)); + Assert.Equal((100f, 200f), snap.DeathNote); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs new file mode 100644 index 00000000..5bc8381e --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs @@ -0,0 +1,118 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class TeamStateTrackerAfkTests +{ + private const float Eps = 1f; + private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5); + + private static TeamMemberSnapshot Member( + ulong id, + float x, + float y, + bool online = true, + bool alive = true, + DateTimeOffset death = default) + => new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, death); + + private static TeamInfoSnapshot Team(params TeamMemberSnapshot[] m) => new(1, m); + + [Fact] + public void Still_for_threshold_emits_one_BecameAfk() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); // prime + Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(2), Threshold, Eps)); // still, < threshold + var afk = t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // crossed + Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk && x.Location == (0f, 0f)); + Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(9), Threshold, Eps)); // latched, no repeat + } + + [Fact] + public void Moving_after_afk_emits_ReturnedFromAfk() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); + t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk + var back = t.Diff(Team(Member(1, 50, 50)), t0.AddMinutes(7), Threshold, Eps); + Assert.Single(back, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk && x.Location == null); + } + + [Fact] + public void Going_offline_while_afk_clears_silently_without_ReturnedFromAfk() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); + t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk + var off = t.Diff(Team(Member(1, 0, 0, online: false)), t0.AddMinutes(7), Threshold, Eps); + // The Disconnect transition speaks for them; no contradictory "is back" line. + Assert.DoesNotContain(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk); + Assert.Contains(off, x => x.Kind == PlayerTransitionKind.Disconnect); + Assert.Empty(t.CurrentAfk(t0.AddMinutes(7))); + } + + [Fact] + public void Dying_while_afk_clears_silently_even_if_already_respawned() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); + t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk + // Slow poll: died and already respawned (IsAlive true), but LastDeathTime advanced. + var died = t.Diff( + Team(Member(1, 0, 0, alive: true, death: t0.AddMinutes(7))), t0.AddMinutes(8), Threshold, Eps); + Assert.DoesNotContain(died, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk); + Assert.Contains(died, x => x.Kind == PlayerTransitionKind.Death); + Assert.Empty(t.CurrentAfk(t0.AddMinutes(8))); + } + + [Fact] + public void Dead_member_is_never_afk() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0, alive: false)), t0, Threshold, Eps); + Assert.Empty(t.Diff(Team(Member(1, 0, 0, alive: false)), t0.AddMinutes(10), Threshold, Eps)); + } + + [Fact] + public void Small_jitter_below_epsilon_still_counts_as_still() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); + var afk = t.Diff(Team(Member(1, 0.5f, 0.5f)), t0.AddMinutes(6), Threshold, Eps); // < 1 unit move + Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk); + } + + [Fact] + public void Diagonal_move_beyond_epsilon_radius_counts_as_moved() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); + // dx=dy=0.8 → distance ≈ 1.13 > epsilon 1; per-axis would wrongly call this "still". + var afk = t.Diff(Team(Member(1, 0.8f, 0.8f)), t0.AddMinutes(6), Threshold, Eps); + Assert.DoesNotContain(afk, x => x.Kind == PlayerTransitionKind.BecameAfk); + } + + [Fact] + public void Departed_member_state_is_pruned() + { + var t = new TeamStateTracker(); + var t0 = DateTimeOffset.UnixEpoch; + t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0, Threshold, Eps); + t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // both BecameAfk + Assert.Equal(2, t.CurrentAfk(t0.AddMinutes(6)).Count); + // Member 2 leaves the team (absent from the snapshot entirely). + t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(7), Threshold, Eps); + var afk = t.CurrentAfk(t0.AddMinutes(7)); + Assert.Single(afk); + Assert.Equal(1UL, afk[0].SteamId); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs new file mode 100644 index 00000000..377515cf --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs @@ -0,0 +1,154 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class TeamStateTrackerTests +{ + private static TeamMemberSnapshot Member( + ulong id, + bool online = true, + bool alive = true, + float x = 0, + float y = 0, + DateTimeOffset spawn = default, + DateTimeOffset death = default) + => new(id, $"P{id}", x, y, online, alive, spawn, death); + + private static TeamInfoSnapshot Team(ulong leader, params TeamMemberSnapshot[] members) + => new(leader, members); + + [Fact] + public void First_snapshot_primes_silently() + { + var tracker = new TeamStateTracker(); + var result = tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + Assert.Empty(result); + } + + [Fact] + public void Null_snapshot_emits_nothing_and_keeps_baseline() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + Assert.Empty(tracker.Diff(null, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f)); + // After the null, an offline flip is still detected against the original baseline. + var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), + 1f); + Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect); + } + + [Fact] + public void Brand_new_member_is_primed_silently() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + var result = tracker.Diff(Team(1, Member(1), Member(2, online: true)), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + Assert.DoesNotContain(result, t => t.SteamId == 2); + } + + [Fact] + public void Connect_detected_on_offline_to_online() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + var result = tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), + 1f); + Assert.Single(result, t => t.Kind == PlayerTransitionKind.Connect && t.SteamId == 1); + } + + [Fact] + public void Disconnect_detected_on_online_to_offline() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), + 1f); + Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect && t.SteamId == 1); + } + + [Fact] + public void Death_detected_on_deathtime_advance_even_if_alive_again() + { + var t0 = DateTimeOffset.UnixEpoch; + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, alive: true, death: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + // Died and already respawned: IsAlive true both times, but LastDeathTime advanced. + var result = tracker.Diff(Team(1, Member(1, alive: true, death: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + Assert.Contains(result, t => t.Kind == PlayerTransitionKind.Death && t.SteamId == 1); + } + + [Fact] + public void Respawn_detected_on_spawntime_advance_with_current_location() + { + var t0 = DateTimeOffset.UnixEpoch; + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, spawn: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + var respawn = Assert.Single(result, t => t.Kind == PlayerTransitionKind.Respawn); + Assert.Equal((50f, 60f), respawn.Location); + } + + [Fact] + public void Unchanged_snapshot_emits_nothing() + { + var tracker = new TeamStateTracker(); + var m = Member(1, spawn: DateTimeOffset.UnixEpoch, death: DateTimeOffset.UnixEpoch); + tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f)); + } + + [Fact] + public void Connect_and_disconnect_have_no_location() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9)), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + Assert.Null(Assert.Single(result).Location); + } + + [Fact] + public void Leader_death_uses_deathnote_location() + { + var t0 = DateTimeOffset.UnixEpoch; + var tracker = new TeamStateTracker(); + tracker.Diff(new TeamInfoSnapshot(1, [Member(1, death: t0)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), + 1f); + var snap = new TeamInfoSnapshot(1, [Member(1, x: 1, y: 1, death: t0.AddMinutes(1))], (700f, 800f)); + var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), + t => t.Kind == PlayerTransitionKind.Death); + Assert.Equal((700f, 800f), death.Location); + } + + [Fact] + public void Nonleader_death_uses_previous_poll_position_not_current() + { + var t0 = DateTimeOffset.UnixEpoch; + var tracker = new TeamStateTracker(); + // Member 2 is alive at (10,10) on the baseline poll... + tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, x: 10, y: 10, death: t0)]), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + // ...then dies and respawns at (999,999); death must report the PRE-death (10,10). + var snap = new TeamInfoSnapshot(1, [Member(1), Member(2, x: 999, y: 999, death: t0.AddMinutes(1))], (5f, 5f)); + var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), + t => t.Kind == PlayerTransitionKind.Death); + Assert.Equal((10f, 10f), death.Location); // leader DeathNote (5,5) must NOT apply to a non-leader + } + + [Fact] + public void Death_with_no_prior_position_and_no_deathnote_has_null_location() + { + var t0 = DateTimeOffset.UnixEpoch; + var tracker = new TeamStateTracker(); + // Prime member 1 only. + tracker.Diff(new TeamInfoSnapshot(1, [Member(1)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); + // Member 2 appears already-dead-advanced in the same poll it is first seen → primed silently, no death. + var first = tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, death: t0)]), DateTimeOffset.UnixEpoch, + TimeSpan.FromMinutes(5), 1f); + Assert.DoesNotContain(first, t => t.SteamId == 2); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs new file mode 100644 index 00000000..1afdaa38 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -0,0 +1,25 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Rendering; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerEventEndToEndTests +{ + [Fact] + public void All_transition_kinds_render_without_throwing() + { + var renderer = new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default)); + var dims = new MapDimensions(3000, 3000, 0); + foreach (PlayerTransitionKind kind in Enum.GetValues()) + { + var loc = kind is PlayerTransitionKind.Death or PlayerTransitionKind.Respawn + or PlayerTransitionKind.BecameAfk + ? ((float, float)?)(10f, 10f) + : null; + var t = new PlayerTransition(kind, 1, "Bob", loc); + Assert.NotNull(renderer.Render(t, dims, "en")); + Assert.NotEmpty(renderer.RenderLine(t, dims, "fr")); + } + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs new file mode 100644 index 00000000..54d07746 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs @@ -0,0 +1,37 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players; +using RustPlusBot.Features.Players.Hosting; +using RustPlusBot.Features.Players.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerEventRegistrationTests +{ + [Fact] + public void Services_resolve() + { + using var provider = BuildProvider(); + Assert.Contains(provider.GetServices(), h => h is PlayersHostedService); + Assert.NotNull(provider.GetRequiredService()); + } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddPlayers(); + return services.BuildServiceProvider(validateScopes: true); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs new file mode 100644 index 00000000..3ce62955 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Posting; +using RustPlusBot.Features.Players.Relaying; +using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerEventRelayTests +{ + private readonly IEventChannelLocator _locator = Substitute.For(); + private readonly IPlayerChannelPoster _poster = Substitute.For(); + private readonly ITeamChatSender _sender = Substitute.For(); + private readonly IWorkspaceStore _workspace = Substitute.For(); + + private PlayerEventRelay BuildRelay() + { + var scopeFactory = BuildScopeFactory(_workspace); + _workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + return new PlayerEventRelay( + new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default)), + _locator, _poster, _sender, scopeFactory); + } + + private static IServiceScopeFactory BuildScopeFactory(IWorkspaceStore workspace) + { + var services = new ServiceCollection(); + services.AddScoped(_ => workspace); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static PlayerStateChangedEvent Evt(params PlayerTransition[] ts) + => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0), ts); + + [Fact] + public async Task Sends_ingame_line_for_each_transition() + { + var relay = BuildRelay(); + _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)null); + + await relay.RelayAsync( + Evt(new PlayerTransition(PlayerTransitionKind.Connect, 1, "Bob", null)), CancellationToken.None); + + await _sender.Received(1).SendAsync( + Arg.Any(), Arg.Any(), Arg.Is(s => s.Contains("Bob")), Arg.Any()); + } + + [Fact] + public async Task Skips_discord_post_when_no_channel_but_still_sends_ingame() + { + var relay = BuildRelay(); + _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)null); + + await relay.RelayAsync( + Evt(new PlayerTransition(PlayerTransitionKind.Disconnect, 1, "Bob", null)), CancellationToken.None); + + await _poster.DidNotReceive() + .PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _sender.ReceivedWithAnyArgs(1).SendAsync(default, Guid.Empty, default!, default); + } + + [Fact] + public async Task Posts_embed_when_channel_resolves() + { + var relay = BuildRelay(); + _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)555); + + await relay.RelayAsync( + Evt(new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", (10f, 10f))), CancellationToken.None); + + await _poster.Received(1).PostAsync(555UL, Arg.Any(), Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs new file mode 100644 index 00000000..547772f9 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs @@ -0,0 +1,43 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Rendering; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerEventRendererTests +{ + private static readonly PlayerEventRenderer Renderer = new(new PlayerLocalizer(PlayerLocalizationCatalog.Default)); + private static readonly MapDimensions Dims = new(3000, 3000, 0); + + [Fact] + public void Connect_line_names_member_without_location() + { + var line = Renderer.RenderLine( + new PlayerTransition(PlayerTransitionKind.Connect, 1, "Bob", null), Dims, "en"); + Assert.Equal("Bob connected", line); + } + + [Fact] + public void Death_with_location_includes_grid() + { + var line = Renderer.RenderLine( + new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", (0f, 2999f)), Dims, "en"); + Assert.StartsWith("Bob died at A", line, StringComparison.Ordinal); // top-left ≈ A0/A1 + } + + [Fact] + public void Death_without_location_uses_unknown_string() + { + var line = Renderer.RenderLine( + new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", null), Dims, "en"); + Assert.Equal("Bob died", line); + } + + [Fact] + public void Afk_back_has_no_location() + { + var line = Renderer.RenderLine( + new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, 1, "Bob", null), Dims, "en"); + Assert.Equal("Bob is back", line); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs new file mode 100644 index 00000000..e78ee5cd --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs @@ -0,0 +1,40 @@ +using RustPlusBot.Features.Players.Rendering; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerLocalizationCatalogTests +{ + private static readonly string[] Keys = + [ + "player.title", + "player.connect", "player.connect.line", + "player.disconnect", "player.disconnect.line", + "player.death", "player.death.line", + "player.death.unknown", "player.death.unknown.line", + "player.respawn", "player.respawn.line", + "player.afk", "player.afk.line", + "player.afk.back", "player.afk.back.line", + ]; + + [Theory] + [InlineData("en")] + [InlineData("fr")] + public void Every_key_present_for_culture(string culture) + { + var catalog = PlayerLocalizationCatalog.Default; + Assert.True(catalog.Strings.TryGetValue(culture, out var map)); + foreach (var key in Keys) + { + Assert.True(map!.ContainsKey(key), $"missing {key} for {culture}"); + } + } + + [Fact] + public void Localizer_formats_with_args_and_falls_back_to_english() + { + var localizer = new PlayerLocalizer(PlayerLocalizationCatalog.Default); + Assert.Contains("Bob", localizer.Get("player.connect", "en", "Bob"), StringComparison.Ordinal); + // Unknown culture falls back to English string (not the raw key). + Assert.Contains("Bob", localizer.Get("player.connect", "de", "Bob"), StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj b/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj new file mode 100644 index 00000000..b0ec8f25 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +