diff --git a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs new file mode 100644 index 00000000..107bb7ae --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// One named monument observed in a GetMap response. +/// The monument token (e.g. oilrig_1, large_oil_rig). +/// World X coordinate. +/// World Y coordinate. +public sealed record MonumentSnapshot(string Token, float X, float Y); diff --git a/src/RustPlusBot.Abstractions/Events/RigEventKind.cs b/src/RustPlusBot.Abstractions/Events/RigEventKind.cs new file mode 100644 index 00000000..d094d0f6 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/RigEventKind.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Abstractions.Events; + +/// The rig lifecycle boundary an event marks. +public enum RigEventKind +{ + /// A CH47 reached the rig — the combat (unlocking) phase began. + Activated = 0, + + /// The combat phase ended — the crate is now lootable. + CrateLootable = 1, + + /// The dormant window ended — the crate respawned and the rig is armed again. + Respawned = 2, +} diff --git a/src/RustPlusBot.Abstractions/Events/RigKind.cs b/src/RustPlusBot.Abstractions/Events/RigKind.cs new file mode 100644 index 00000000..de640849 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/RigKind.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Which oil rig a rig event refers to. +public enum RigKind +{ + /// The small oil rig (monument token oilrig_1). + Small = 0, + + /// The large oil rig (monument token large_oil_rig). + Large = 1, +} diff --git a/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs new file mode 100644 index 00000000..fb7e9ab5 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs @@ -0,0 +1,20 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Abstractions.Events; + +/// Published when an oil rig crosses a lifecycle boundary (activated / crate lootable / respawned). +/// The owning guild snowflake. +/// The target server id. +/// Which rig. +/// The boundary that was crossed. +/// The rig monument's world X coordinate. +/// The rig monument's world Y coordinate. +/// Map dimensions for grid-reference rendering, or null if unavailable. +public sealed record RigStateChangedEvent( + ulong GuildId, + Guid ServerId, + RigKind Rig, + RigEventKind Kind, + float X, + float Y, + MapDimensions? Dimensions); diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 0f83eb0b..dc315283 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -42,6 +42,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); diff --git a/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs new file mode 100644 index 00000000..14b67da3 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs @@ -0,0 +1,22 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !large — reports the large oil rig's status. +/// The rig state reader. +/// The reply localizer. +internal sealed class LargeCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler +{ + /// + public string Name => "large"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + return Task.FromResult(RigReply.For(rigState, context, RigKind.Large, "command.large", localizer)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs new file mode 100644 index 00000000..40b9d18c --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs @@ -0,0 +1,37 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// Shared rig-status reply formatting for the !small/!large handlers. +internal static class RigReply +{ + /// Formats the localized rig-status reply for a rig. + /// The rig state reader. + /// The command context. + /// Which rig. + /// The localization key prefix ("command.small" / "command.large"). + /// The reply localizer. + /// The localized reply. + public static string For( + IRigState rigState, + CommandContext context, + RigKind rig, + string prefix, + ICommandLocalizer localizer) + { + var state = rigState.Get(context.GuildId, context.ServerId, rig); + return state.Status switch + { + RigStatus.Online => localizer.Get($"{prefix}.online", context.Culture), + RigStatus.Active => localizer.Get($"{prefix}.active", context.Culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + RigStatus.Offline => localizer.Get($"{prefix}.offline", context.Culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + _ => localizer.Get($"{prefix}.online", context.Culture), + }; + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs new file mode 100644 index 00000000..d1f83a7d --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs @@ -0,0 +1,22 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !small — reports the small oil rig's status. +/// The rig state reader. +/// The reply localizer. +internal sealed class SmallCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler +{ + /// + public string Name => "small"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + return Task.FromResult(RigReply.For(rigState, context, RigKind.Small, "command.small", localizer)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index ffb906d1..013b944c 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -51,6 +51,12 @@ internal sealed class CommandLocalizationCatalog ["command.event.helientered"] = "heli in {0}", ["command.event.helileft"] = "heli left {0}", ["command.event.chinookspawned"] = "chinook in {0}", + ["command.small.online"] = "Small Oil Rig: crate ready, waiting for activation.", + ["command.small.active"] = "Small Oil Rig: combat phase — crate lootable in {0}.", + ["command.small.offline"] = "Small Oil Rig: looted / dormant — respawns in {0}.", + ["command.large.online"] = "Large Oil Rig: crate ready, waiting for activation.", + ["command.large.active"] = "Large Oil Rig: combat phase — crate lootable in {0}.", + ["command.large.offline"] = "Large Oil Rig: looted / dormant — respawns in {0}.", ["command.server.none"] = "No server is set up yet.", ["command.server.specify"] = "Multiple servers are set up — choose one with the server option.", ["command.server.unknown"] = "That server isn't set up.", @@ -125,6 +131,12 @@ internal sealed class CommandLocalizationCatalog ["command.event.helientered"] = "heli en {0}", ["command.event.helileft"] = "heli parti de {0}", ["command.event.chinookspawned"] = "chinook en {0}", + ["command.small.online"] = "Petite plateforme : caisse prête, en attente d'activation.", + ["command.small.active"] = "Petite plateforme : phase de combat — caisse lootable dans {0}.", + ["command.small.offline"] = "Petite plateforme : pillée / dormante — réapparaît dans {0}.", + ["command.large.online"] = "Grande plateforme : caisse prête, en attente d'activation.", + ["command.large.active"] = "Grande plateforme : phase de combat — caisse lootable dans {0}.", + ["command.large.offline"] = "Grande plateforme : pillée / dormante — réapparaît dans {0}.", ["command.server.none"] = "Aucun serveur n'est encore configuré.", ["command.server.specify"] = "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", diff --git a/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs b/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs index b8c91bd0..99c87525 100644 --- a/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs @@ -70,6 +70,22 @@ public Task AliveAsync( [Autocomplete(typeof(ServerAutocompleteHandler))] string? server = null) => RunAsync("alive", server); + /// Shows the small oil rig's status. + /// The target server (only needed if more than one is registered). + [SlashCommand("small", "Show the small oil rig status")] + public Task SmallAsync( + [Summary("server", "Which server (only needed if more than one)")] + [Autocomplete(typeof(ServerAutocompleteHandler))] + string? server = null) => RunAsync("small", server); + + /// Shows the large oil rig's status. + /// The target server (only needed if more than one is registered). + [SlashCommand("large", "Show the large oil rig status")] + public Task LargeAsync( + [Summary("server", "Which server (only needed if more than one)")] + [Autocomplete(typeof(ServerAutocompleteHandler))] + string? server = null) => RunAsync("large", server); + private async Task RunAsync(string commandName, string? server) { if (Context.Guild is null) diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs index 5c02f68d..1fbdabcf 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs @@ -18,6 +18,21 @@ public sealed class ConnectionOptions /// How long a single heartbeat may take before the socket is considered unreachable. public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10); - /// How often to poll map markers for live-event detection. Default 10s. - public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(10); + /// How often to poll map markers for live-event detection. Default 5s. + public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(5); + + /// Faster poll cadence used while a CH47 marker is live (to catch its brief oil-rig visit). Default 2s. + public TimeSpan MarkerPollFastInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// Distance (world units) within which a CH47 is considered to be "at" an oil rig. Default 150. + public float RigRadius { get; set; } = 150f; + + /// How long an oil rig stays in the combat (Active) phase before the crate becomes lootable. Default 15m. + public TimeSpan RigActiveWindow { get; set; } = TimeSpan.FromMinutes(15); + + /// How long an oil rig stays dormant (Offline) before the crate respawns (Online). Default 15m. + public TimeSpan RigOfflineWindow { get; set; } = TimeSpan.FromMinutes(15); + + /// How often the rig-timer tick advances rig phases and emits timed boundary events. Default 30s. + public TimeSpan RigTickInterval { get; set; } = TimeSpan.FromSeconds(30); } diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 1b6092ae..21a6edc2 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -60,6 +60,13 @@ Task> GetMapMarkersAsync(TimeSpan timeout, /// The map dimensions, or null on failure/timeout. Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Gets the map monuments (for locating oil rigs). Throws on failure. + /// How long to wait for the response. + /// A cancellation token. + /// The map monuments (token + position). + Task> GetMonumentsAsync(TimeSpan timeout, + CancellationToken cancellationToken = default); + /// Raised for every in-game team chat line received on this socket. event EventHandler? TeamMessageReceived; } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index c6035aaa..2833d92e 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -56,6 +56,10 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task> GetMonumentsAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + public event EventHandler? TeamMessageReceived { add { _ = value; } @@ -374,6 +378,36 @@ public async Task> GetMapMarkersAsync( } } + public async Task> GetMonumentsAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. + // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oilrig_1"), + // Nullable X/Y. We surface (token, x, y) and skip monuments with incomplete coordinates. + var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!response.IsSuccess || response.Data is null) + { + throw new InvalidOperationException("GetMap returned no data."); + } + + var monuments = new List(); + foreach (var m in response.Data.Monuments ?? []) + { + if (m.Name is null || m.X is not { } x || m.Y is not { } y) + { + continue; + } + + monuments.Add(new MonumentSnapshot(m.Name, x, y)); + } + + return monuments; + } + public async ValueTask DisposeAsync() { _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 2987eccc..7c4a91ed 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -351,11 +351,13 @@ void OnTeamMessage(object? sender, TeamChatLine line) #pragma warning restore RCS1163 var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false); connection.TeamMessageReceived += OnTeamMessage; _liveSockets[key] = new LiveSocket(connection, activeSteamId); using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, pollCts.Token), CancellationToken.None); + var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, pollCts.Token), + CancellationToken.None); try { while (!ct.IsCancellationRequested) @@ -400,14 +402,19 @@ private async Task PollMarkersAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, MapDimensions? dims, + IReadOnlyList rigs, CancellationToken ct) { IReadOnlyList? previous = null; + var rigsInRadius = new HashSet(); while (!ct.IsCancellationRequested) { + var anyCh47 = false; try { var current = await connection.GetMapMarkersAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + anyCh47 = current.Any(m => m.Kind == MarkerKind.Chinook); + if (previous is null) { previous = current; // first poll: silent baseline @@ -424,6 +431,8 @@ await eventBus.PublishAsync( .ConfigureAwait(false); } } + + await DetectRigActivationsAsync(key, current, rigs, dims, rigsInRadius, ct).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -434,10 +443,94 @@ await eventBus.PublishAsync( #pragma warning restore CA1031 { LogMarkerPollFailed(logger, ex, key.Server); - // previous is retained, so a transient failure does not produce a spurious despawn/respawn diff. } - await Task.Delay(_options.MarkerPollInterval, ct).ConfigureAwait(false); + var delay = anyCh47 ? _options.MarkerPollFastInterval : _options.MarkerPollInterval; + await Task.Delay(delay, ct).ConfigureAwait(false); + } + } + + private async Task DetectRigActivationsAsync( + (ulong Guild, Guid Server) key, + IReadOnlyList current, + IReadOnlyList rigs, + MapDimensions? dims, + HashSet rigsInRadius, + CancellationToken ct) + { + if (rigs.Count == 0) + { + return; + } + + var radiusSquared = _options.RigRadius * _options.RigRadius; + var nowInRadius = new HashSet(); + foreach (var rig in rigs) + { + foreach (var m in current) + { + if (m.Kind != MarkerKind.Chinook) + { + continue; + } + + var dx = m.X - rig.X; + var dy = m.Y - rig.Y; + if ((dx * dx) + (dy * dy) <= radiusSquared) + { + nowInRadius.Add(rig.Kind); + if (!rigsInRadius.Contains(rig.Kind)) + { + await eventBus.PublishAsync( + new RigStateChangedEvent(key.Guild, key.Server, rig.Kind, RigEventKind.Activated, + rig.X, rig.Y, dims), ct) + .ConfigureAwait(false); + } + + break; // one CH47 in radius is enough for this rig + } + } + } + + rigsInRadius.Clear(); + rigsInRadius.UnionWith(nowInRadius); + } + + private async Task> GetRigPositionsAsync( + Guid serverId, + IRustServerConnection connection, + CancellationToken ct) + { + try + { + var monuments = await connection.GetMonumentsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + var rigs = new List(); + foreach (var m in monuments) + { + RigKind? kind = m.Token switch + { + "oilrig_1" => RigKind.Small, + "large_oil_rig" => RigKind.Large, + _ => null, + }; + if (kind is { } k) + { + rigs.Add(new RigPosition(k, m.X, m.Y)); + } + } + + return rigs; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a monuments-fetch failure just disables rig detection this window. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogMonumentsFetchFailed(logger, ex, serverId); // rig detection degrades gracefully for this window + return []; } } @@ -572,6 +665,11 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong [LoggerMessage(Level = LogLevel.Warning, Message = "Marker poll for server {ServerId} failed.")] private static partial void LogMarkerPollFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, + Message = + "Fetching monuments for oil-rig detection on server {ServerId} failed; rig detection disabled for this connection.")] + private static partial void LogMonumentsFetchFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Relaying a message to team chat for server {ServerId} failed.")] private static partial void LogSendFailed(ILogger logger, Exception exception, Guid serverId); @@ -591,6 +689,8 @@ private enum ReconnectReason AuthRejected = 2, } + private readonly record struct RigPosition(RigKind Kind, float X, float Y); + private readonly record struct Prepared( string Ip, int Port, diff --git a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs index 659b16be..78b7fb99 100644 --- a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs @@ -20,6 +20,8 @@ public static IServiceCollection AddEvents(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(EventLocalizationCatalog.Default); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs index 617704b2..5d8966b9 100644 --- a/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs +++ b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs @@ -1,30 +1,41 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Connections; using RustPlusBot.Features.Events.Relaying; using RustPlusBot.Features.Events.State; using RustPlusBot.Persistence.Connections; namespace RustPlusBot.Features.Events.Hosting; -/// Runs the marker-change relay loop and the disconnect-clear loop. +/// Runs the marker relay loop, the rig-event relay loop, the rig-timer tick, and the disconnect-clear loop. /// The in-process event bus. -/// Relays marker deltas into Discord #events. -/// Cleared on disconnect. +/// Relays marker + rig events into Discord #events and in-game chat. +/// The marker state store, cleared on disconnect. +/// The rig state store, advanced by the tick and cleared on disconnect. +/// Supplies the current time for the tick. +/// Supplies the rig-tick interval. /// Opens scopes to read connection state. /// The logger. internal sealed partial class EventsHostedService( IEventBus eventBus, EventRelay relay, EventStateStore store, + RigStateStore rigStore, + IClock clock, + IOptions options, IServiceScopeFactory scopeFactory, ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); private Task? _disconnectLoop; private Task? _relayLoop; + private Task? _rigLoop; + private Task? _tickLoop; /// public void Dispose() => _cts.Dispose(); @@ -33,6 +44,8 @@ internal sealed partial class EventsHostedService( public Task StartAsync(CancellationToken cancellationToken) { _relayLoop = Task.Run(() => ConsumeMarkerEventsAsync(_cts.Token), CancellationToken.None); + _rigLoop = Task.Run(() => ConsumeRigEventsAsync(_cts.Token), CancellationToken.None); + _tickLoop = Task.Run(() => RunRigTickAsync(_cts.Token), CancellationToken.None); _disconnectLoop = Task.Run(() => ConsumeConnectionStatusEventsAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -43,12 +56,12 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _relayLoop, _disconnectLoop + _relayLoop, _rigLoop, _tickLoop, _disconnectLoop }.Where(t => t is not null)) { try { -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop. +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. await loop!.ConfigureAwait(false); #pragma warning restore VSTHRD003 } @@ -59,6 +72,42 @@ public async Task StopAsync(CancellationToken cancellationToken) } } + /// Runs one rig-timer tick: advances rig phases and publishes any timed boundary crossings. + /// A cancellation token. + /// A task that completes when the tick has published all crossings. + internal async Task TickOnceAsync(CancellationToken cancellationToken) + { + foreach (var c in rigStore.Advance(clock.UtcNow)) + { + await eventBus.PublishAsync( + new RigStateChangedEvent(c.GuildId, c.ServerId, c.Rig, c.Kind, c.X, c.Y, c.Dimensions), + cancellationToken) + .ConfigureAwait(false); + } + } + + private async Task RunRigTickAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await TickOnceAsync(cancellationToken).ConfigureAwait(false); + await Task.Delay(options.Value.RigTickInterval, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting tick must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRigTickFaulted(logger, ex); + } + } + private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken) { try @@ -81,6 +130,28 @@ private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken) } } + private async Task ConsumeRigEventsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.RelayRigAsync(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 + { + LogRigLoopFaulted(logger, ex); + } + } + private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken) { try @@ -114,6 +185,7 @@ private async Task ClearIfDisconnectedAsync(ConnectionStatusChangedEvent evt, Ca if (state is null || state.Status != ConnectionStatus.Connected) { store.Clear(evt.GuildId, evt.ServerId); + rigStore.Clear(evt.GuildId, evt.ServerId); } } } @@ -121,6 +193,12 @@ private async Task ClearIfDisconnectedAsync(ConnectionStatusChangedEvent evt, Ca [LoggerMessage(Level = LogLevel.Error, Message = "Event relay loop faulted.")] private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Error, Message = "Rig relay loop faulted.")] + private static partial void LogRigLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Rig tick loop faulted.")] + private static partial void LogRigTickFaulted(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Error, Message = "Disconnect-clear loop faulted.")] private static partial void LogDisconnectLoopFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs index de148ca1..f034dba5 100644 --- a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs +++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Posting; using RustPlusBot.Features.Events.Rendering; @@ -9,12 +10,14 @@ namespace RustPlusBot.Features.Events.Relaying; -/// Classifies one marker delta, updates state, and posts one embed per event to #events. +/// Posts every live event to #events AND in-game team chat; tracks rig state. /// Classifies raw marker deltas into domain events. /// Tracks active markers and recent events per server. -/// Renders events as Discord embeds. +/// Renders events 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. +/// Tracks oil-rig state (Apply on Activated). /// Opens scopes to read guild culture. internal sealed class EventRelay( MarkerEventClassifier classifier, @@ -22,9 +25,11 @@ internal sealed class EventRelay( EventEmbedRenderer renderer, IEventChannelLocator locator, IEventChannelPoster poster, + ITeamChatSender teamChatSender, + RigStateStore rigStore, IServiceScopeFactory scopeFactory) { - /// Handles one . + /// Handles one : updates state, posts embeds, broadcasts in-game. /// The marker delta. /// A cancellation token. /// A task that completes when the delta has been processed. @@ -38,18 +43,44 @@ public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cance return; } + var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); - if (channelId is null) + + foreach (var e in events) { - return; + await teamChatSender + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture), cancellationToken) + .ConfigureAwait(false); + if (channelId is { } id) + { + await poster.PostAsync(id, renderer.Render(e, culture), cancellationToken).ConfigureAwait(false); + } + } + } + + /// Handles one : applies activation, posts an embed, broadcasts in-game. + /// The rig boundary event. + /// A cancellation token. + /// A task that completes when the rig event has been processed. + public async Task RelayRigAsync(RigStateChangedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + if (evt.Kind == RigEventKind.Activated) + { + rigStore.Apply(evt); } var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); - foreach (var e in events) + await teamChatSender + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture), cancellationToken) + .ConfigureAwait(false); + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is { } id) { - var embed = renderer.Render(e, culture); - await poster.PostAsync(channelId.Value, embed, cancellationToken).ConfigureAwait(false); + await poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken).ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index 67faccbb..d100d159 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -1,4 +1,5 @@ using Discord; +using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Formatting; @@ -33,4 +34,63 @@ public Embed Render(RustMapEvent evt, string culture) .WithTimestamp(evt.AtUtc) .Build(); } + + /// Renders a rig boundary event as a Discord embed. + /// The rig event. + /// The guild culture. + /// The built embed. + public Embed RenderRig(RigStateChangedEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + return new EmbedBuilder() + .WithAuthor(localizer.Get("event.title", culture)) + .WithDescription(localizer.Get(RigKey(evt), culture, grid)) + .Build(); + } + + /// Renders the in-game team-chat line for a rig boundary event. + /// The rig event. + /// The guild culture. + /// The line text. + public string RenderRigLine(RigStateChangedEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + return localizer.Get(RigKey(evt) + ".line", culture, grid); + } + + /// Renders the in-game team-chat line for a cargo/heli/chinook event. + /// The map event. + /// The guild culture. + /// The line text. + /// The event kind is not a supported . + public string RenderLine(RustMapEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var key = evt.Kind switch + { + MapEventKind.CargoEntered => "event.cargo.entered.line", + MapEventKind.CargoLeft => "event.cargo.left.line", + MapEventKind.HeliEntered => "event.heli.entered.line", + MapEventKind.HeliLeft => "event.heli.left.line", + MapEventKind.ChinookSpawned => "event.chinook.spawned.line", + _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."), + }; + return localizer.Get(key, culture, grid); + } + + private static string RigKey(RigStateChangedEvent evt) + { + var rig = evt.Rig == RigKind.Small ? "small" : "large"; + var phase = evt.Kind switch + { + RigEventKind.Activated => "activated", + RigEventKind.CrateLootable => "lootable", + RigEventKind.Respawned => "respawned", + _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported rig event kind."), + }; + return $"event.rig.{rig}.{phase}"; + } } diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs index 54981cd4..c58ed031 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs @@ -19,6 +19,23 @@ internal sealed class EventLocalizationCatalog ["event.heli.left"] = "🚁 Patrol Helicopter left ({0})", ["event.chinook.spawned"] = "🚁 Chinook spawned at {0}", ["event.title"] = "Live event", + ["event.cargo.entered.line"] = "Cargo Ship entered at {0}", + ["event.cargo.left.line"] = "Cargo Ship left ({0})", + ["event.heli.entered.line"] = "Patrol Helicopter entered at {0}", + ["event.heli.left.line"] = "Patrol Helicopter left ({0})", + ["event.chinook.spawned.line"] = "Chinook spawned at {0}", + ["event.rig.small.activated"] = "🛢️ Small Oil Rig activated — combat phase, crate lootable soon ({0})", + ["event.rig.small.lootable"] = "🛢️ Small Oil Rig — crate is now LOOTABLE ({0})", + ["event.rig.small.respawned"] = "🛢️ Small Oil Rig — crate respawned, armed again ({0})", + ["event.rig.large.activated"] = "🛢️ Large Oil Rig activated — combat phase, crate lootable soon ({0})", + ["event.rig.large.lootable"] = "🛢️ Large Oil Rig — crate is now LOOTABLE ({0})", + ["event.rig.large.respawned"] = "🛢️ Large Oil Rig — crate respawned, armed again ({0})", + ["event.rig.small.activated.line"] = "Small Oil Rig activated — combat phase ({0})", + ["event.rig.small.lootable.line"] = "Small Oil Rig — crate is now lootable ({0})", + ["event.rig.small.respawned.line"] = "Small Oil Rig — crate respawned ({0})", + ["event.rig.large.activated.line"] = "Large Oil Rig activated — combat phase ({0})", + ["event.rig.large.lootable.line"] = "Large Oil Rig — crate is now lootable ({0})", + ["event.rig.large.respawned.line"] = "Large Oil Rig — crate respawned ({0})", }, ["fr"] = new Dictionary(StringComparer.Ordinal) { @@ -28,6 +45,25 @@ internal sealed class EventLocalizationCatalog ["event.heli.left"] = "🚁 Hélicoptère de patrouille parti ({0})", ["event.chinook.spawned"] = "🚁 Chinook apparu en {0}", ["event.title"] = "Événement", + ["event.cargo.entered.line"] = "Cargo Ship arrivé en {0}", + ["event.cargo.left.line"] = "Cargo Ship parti ({0})", + ["event.heli.entered.line"] = "Hélicoptère de patrouille arrivé en {0}", + ["event.heli.left.line"] = "Hélicoptère de patrouille parti ({0})", + ["event.chinook.spawned.line"] = "Chinook apparu en {0}", + ["event.rig.small.activated"] = + "🛢️ Petite plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0})", + ["event.rig.small.lootable"] = "🛢️ Petite plateforme pétrolière — caisse LOOTABLE ({0})", + ["event.rig.small.respawned"] = "🛢️ Petite plateforme pétrolière — caisse réapparue, réarmée ({0})", + ["event.rig.large.activated"] = + "🛢️ Grande plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0})", + ["event.rig.large.lootable"] = "🛢️ Grande plateforme pétrolière — caisse LOOTABLE ({0})", + ["event.rig.large.respawned"] = "🛢️ Grande plateforme pétrolière — caisse réapparue, réarmée ({0})", + ["event.rig.small.activated.line"] = "Petite plateforme activée — phase de combat ({0})", + ["event.rig.small.lootable.line"] = "Petite plateforme — caisse lootable ({0})", + ["event.rig.small.respawned.line"] = "Petite plateforme — caisse réapparue ({0})", + ["event.rig.large.activated.line"] = "Grande plateforme activée — phase de combat ({0})", + ["event.rig.large.lootable.line"] = "Grande plateforme — caisse lootable ({0})", + ["event.rig.large.respawned.line"] = "Grande plateforme — caisse réapparue ({0})", }, }, }; diff --git a/src/RustPlusBot.Features.Events/State/IRigState.cs b/src/RustPlusBot.Features.Events/State/IRigState.cs new file mode 100644 index 00000000..394fa51d --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/IRigState.cs @@ -0,0 +1,14 @@ +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Features.Events.State; + +/// Reads the current inferred oil-rig status for a server. +public interface IRigState +{ + /// Gets the current status + time remaining for one rig. An untracked rig reads as Online. + /// The owning guild snowflake. + /// The target server id. + /// Which rig. + /// The current rig state. + RigState Get(ulong guildId, Guid serverId, RigKind rig); +} diff --git a/src/RustPlusBot.Features.Events/State/RigState.cs b/src/RustPlusBot.Features.Events/State/RigState.cs new file mode 100644 index 00000000..6d7ee444 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/RigState.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Events.State; + +/// A point-in-time read of an oil rig's status and time remaining in the current phase. +/// The current status. +/// Time left in the current timed phase, or null when Online (no timer). +public sealed record RigState(RigStatus Status, TimeSpan? Remaining); diff --git a/src/RustPlusBot.Features.Events/State/RigStateStore.cs b/src/RustPlusBot.Features.Events/State/RigStateStore.cs new file mode 100644 index 00000000..80a80bd5 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/RigStateStore.cs @@ -0,0 +1,196 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Events.State; + +/// One timed boundary an oil rig crossed, ready to publish as a . +/// The owning guild snowflake. +/// The target server id. +/// Which rig. +/// The boundary crossed (CrateLootable or Respawned). +/// The rig monument's world X coordinate. +/// The rig monument's world Y coordinate. +/// Map dimensions for grid rendering, or null. +public readonly record struct RigCrossing( + ulong GuildId, + Guid ServerId, + RigKind Rig, + RigEventKind Kind, + float X, + float Y, + MapDimensions? Dimensions); + +/// +/// In-memory per-(guild, server, rig) oil-rig state machine. An untracked rig is Online by default; +/// a rig enters the map on its first (Activated) and is dropped again once it +/// respawns back to Online. Timed transitions are driven by (the tick) and +/// also settled lazily on . +/// +/// Supplies the current time. +/// Supplies the rig phase windows. +public sealed class RigStateStore(IClock clock, IOptions options) : IRigState +{ + private readonly ConcurrentDictionary<(ulong Guild, Guid Server, RigKind Rig), Entry> _rigs = new(); + + private object Gate { get; } = new(); + + /// + public RigState Get(ulong guildId, Guid serverId, RigKind rig) + { + var now = clock.UtcNow; + var key = (guildId, serverId, rig); + lock (Gate) + { + if (!_rigs.TryGetValue(key, out var entry)) + { + return new RigState(RigStatus.Online, null); + } + + // Settle any overdue transitions (without emitting crossings — Advance does that). + while (TrySettle(key, entry, now, out var next)) + { + if (next is null) + { + return new RigState(RigStatus.Online, null); // respawned -> dropped + } + + entry = next; + } + + var remaining = Remaining(entry, now); + return new RigState(entry.Status, remaining); + } + } + + /// Sets a rig Active (the CH47 ground-truth override from any state). + /// The activation event; its must be . + /// The event's kind is not (the timed kinds are produced by , not applied here). + public void Apply(RigStateChangedEvent activated) + { + ArgumentNullException.ThrowIfNull(activated); + if (activated.Kind != RigEventKind.Activated) + { + throw new ArgumentException( + $"Apply only accepts {nameof(RigEventKind.Activated)} events; got {activated.Kind}.", + nameof(activated)); + } + + var key = (activated.GuildId, activated.ServerId, activated.Rig); + lock (Gate) + { + _rigs[key] = new Entry(RigStatus.Active, clock.UtcNow, activated.X, activated.Y, activated.Dimensions); + } + } + + /// Advances every tracked rig whose timed window has elapsed, returning the crossings to publish. + /// The current time. + /// The boundary crossings (CrateLootable / Respawned) since the last advance. + public IReadOnlyList Advance(DateTimeOffset now) + { + var crossings = new List(); + lock (Gate) + { + foreach (var key in _rigs.Keys.ToList()) + { + if (!_rigs.TryGetValue(key, out var entry)) + { + continue; + } + + while (TrySettle(key, entry, now, out var next, recordInto: crossings)) + { + if (next is null) + { + break; // dropped + } + + entry = next; + } + } + } + + return crossings; + } + + /// Drops all rig state for a server (on disconnect). + /// The owning guild snowflake. + /// The target server id. + public void Clear(ulong guildId, Guid serverId) + { + lock (Gate) + { + foreach (var key in _rigs.Keys.Where(k => k.Guild == guildId && k.Server == serverId).ToList()) + { + _rigs.TryRemove(key, out _); + } + } + } + + /// Performs one due transition. Returns true if a transition happened; sets + /// to the new entry, or null if the rig respawned (and was dropped). Optionally records the crossing. + /// The rig key to settle. + /// The current entry for the rig. + /// The current time used to determine if a transition is due. + /// The updated entry after transition, or null if the rig was dropped. + /// If non-null, the crossing is appended here. + /// if a transition occurred; if none was due. + private bool TrySettle( + (ulong Guild, Guid Server, RigKind Rig) key, + Entry entry, + DateTimeOffset now, + out Entry? next, + List? recordInto = null) + { + var opts = options.Value; + switch (entry.Status) + { + case RigStatus.Active when now - entry.PhaseStart >= opts.RigActiveWindow: + next = entry with + { + Status = RigStatus.Offline, PhaseStart = entry.PhaseStart + opts.RigActiveWindow + }; + _rigs[key] = next; + recordInto?.Add(new RigCrossing(key.Guild, key.Server, key.Rig, RigEventKind.CrateLootable, + entry.X, entry.Y, entry.Dimensions)); + return true; + case RigStatus.Offline when now - entry.PhaseStart >= opts.RigOfflineWindow: + _rigs.TryRemove(key, out _); // respawn -> back to untracked Online + recordInto?.Add(new RigCrossing(key.Guild, key.Server, key.Rig, RigEventKind.Respawned, + entry.X, entry.Y, entry.Dimensions)); + next = null; + return true; + default: + next = entry; + return false; + } + } + + private TimeSpan? Remaining(Entry entry, DateTimeOffset now) + { + var opts = options.Value; + var window = entry.Status switch + { + RigStatus.Active => opts.RigActiveWindow, + RigStatus.Offline => opts.RigOfflineWindow, + _ => (TimeSpan?)null, + }; + if (window is not { } w) + { + return null; + } + + var remaining = w - (now - entry.PhaseStart); + return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining; + } + + private sealed record Entry( + RigStatus Status, + DateTimeOffset PhaseStart, + float X, + float Y, + MapDimensions? Dimensions); +} diff --git a/src/RustPlusBot.Features.Events/State/RigStatus.cs b/src/RustPlusBot.Features.Events/State/RigStatus.cs new file mode 100644 index 00000000..d87f821e --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/RigStatus.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Events.State; + +/// The inferred lifecycle status of an oil rig. +public enum RigStatus +{ + /// Crate present and armed; the resting state (also assumed before any activation is seen). + Online = 0, + + /// Combat phase — scientists deployed, crate unlocking (becomes lootable at the end). + Active = 1, + + /// Crate became lootable and (in practice) was looted; the rig is dormant until respawn. + Offline = 2, +} diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 514035de..79d07cde 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -52,6 +52,12 @@ .Validate(static o => o.HeartbeatTimeout < o.HeartbeatInterval, "Connections:HeartbeatTimeout must be less than HeartbeatInterval.") .Validate(static o => o.MarkerPollInterval > TimeSpan.Zero, "Connections:MarkerPollInterval must be positive.") + .Validate(static o => o.MarkerPollFastInterval > TimeSpan.Zero, + "Connections:MarkerPollFastInterval must be positive.") + .Validate(static o => o.RigRadius > 0f, "Connections:RigRadius must be positive.") + .Validate(static o => o.RigActiveWindow > TimeSpan.Zero, "Connections:RigActiveWindow must be positive.") + .Validate(static o => o.RigOfflineWindow > TimeSpan.Zero, "Connections:RigOfflineWindow must be positive.") + .Validate(static o => o.RigTickInterval > TimeSpan.Zero, "Connections:RigTickInterval must be positive.") .ValidateOnStart(); builder.Services.AddConnections(); builder.Services.AddChat(); diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs new file mode 100644 index 00000000..eb461db8 --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs @@ -0,0 +1,33 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Abstractions.Tests.Events; + +public sealed class RigStateChangedEventTests +{ + [Fact] + public void Carries_rig_kind_event_kind_position_and_dimensions() + { + var server = Guid.NewGuid(); + var dims = new MapDimensions(4000u, 4000u, 500); + + var evt = new RigStateChangedEvent(7UL, server, RigKind.Large, RigEventKind.CrateLootable, 1f, 2f, dims); + + Assert.Equal(7UL, evt.GuildId); + Assert.Equal(server, evt.ServerId); + Assert.Equal(RigKind.Large, evt.Rig); + Assert.Equal(RigEventKind.CrateLootable, evt.Kind); + Assert.Equal(1f, evt.X); + Assert.Equal(2f, evt.Y); + Assert.Equal(dims, evt.Dimensions); + } + + [Fact] + public void Monument_snapshot_carries_token_and_position() + { + var m = new MonumentSnapshot("oilrig_1", 10f, 20f); + Assert.Equal("oilrig_1", m.Token); + Assert.Equal(10f, m.X); + Assert.Equal(20f, m.Y); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 4cdbd6a9..8f2166a9 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -28,6 +28,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()); @@ -46,7 +47,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(16, handlers.Count); + Assert.Equal(18, handlers.Count); Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); @@ -61,6 +62,8 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "heli"); Assert.Contains(handlers, h => h.Name == "chinook"); Assert.Contains(handlers, h => h.Name == "events"); + Assert.Contains(handlers, h => h.Name == "small"); + Assert.Contains(handlers, h => h.Name == "large"); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } @@ -75,6 +78,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/Handlers/RigCommandHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs new file mode 100644 index 00000000..998703f8 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs @@ -0,0 +1,58 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Tests.Handlers; + +public sealed class RigCommandHandlersTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); + + private static ICommandLocalizer RealLocalizer() => + new CommandLocalizer(CommandLocalizationCatalog.Default); + + [Fact] + public async Task Small_reports_online_when_untracked() + { + var rig = Substitute.For(); + rig.Get(Guild, Server, RigKind.Small).Returns(new RigState(RigStatus.Online, null)); + var handler = new SmallCommandHandler(rig, RealLocalizer()); + + var reply = await handler.ExecuteAsync(Ctx(), CancellationToken.None); + + Assert.Equal("small", handler.Name); + Assert.False(string.IsNullOrWhiteSpace(reply)); + } + + [Fact] + public async Task Large_reports_active_with_remaining() + { + var rig = Substitute.For(); + rig.Get(Guild, Server, RigKind.Large) + .Returns(new RigState(RigStatus.Active, TimeSpan.FromMinutes(8))); + var handler = new LargeCommandHandler(rig, RealLocalizer()); + + var reply = await handler.ExecuteAsync(Ctx(), CancellationToken.None); + + Assert.Equal("large", handler.Name); + Assert.False(string.IsNullOrWhiteSpace(reply)); + } + + [Fact] + public async Task Small_reports_offline_with_remaining() + { + var rig = Substitute.For(); + rig.Get(Guild, Server, RigKind.Small) + .Returns(new RigState(RigStatus.Offline, TimeSpan.FromMinutes(6))); + var handler = new SmallCommandHandler(rig, RealLocalizer()); + + var reply = await handler.ExecuteAsync(Ctx(), CancellationToken.None); + Assert.False(string.IsNullOrWhiteSpace(reply)); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionOptionsTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionOptionsTests.cs new file mode 100644 index 00000000..023c8252 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionOptionsTests.cs @@ -0,0 +1,21 @@ +using RustPlusBot.Features.Connections; + +namespace RustPlusBot.Features.Connections.Tests; + +/// Unit tests for default values. +public sealed class ConnectionOptionsTests +{ + /// Verifies that all defaults match the 2a-ii design specification. + [Fact] + public void Defaults_match_the_2a_ii_design() + { + var o = new ConnectionOptions(); + + Assert.Equal(TimeSpan.FromSeconds(5), o.MarkerPollInterval); + Assert.Equal(TimeSpan.FromSeconds(2), o.MarkerPollFastInterval); + Assert.Equal(150f, o.RigRadius); + Assert.Equal(TimeSpan.FromMinutes(15), o.RigActiveWindow); + Assert.Equal(TimeSpan.FromMinutes(15), o.RigOfflineWindow); + Assert.Equal(TimeSpan.FromSeconds(30), o.RigTickInterval); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 1edf5c05..392d0539 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -65,6 +65,7 @@ private static Harness CreateHarness(FakeRustSocketSource source) HeartbeatInterval = TimeSpan.FromMilliseconds(20), HeartbeatTimeout = TimeSpan.FromMilliseconds(200), MarkerPollInterval = TimeSpan.FromMilliseconds(20), + MarkerPollFastInterval = TimeSpan.FromMilliseconds(20), })); services.AddSingleton(); @@ -418,6 +419,119 @@ public async Task Failed_marker_poll_retains_previous_snapshot() } } + [Fact] + public async Task Ch47_entering_rig_radius_publishes_activated_once_per_visit() + { + // Rig at (1000, 1000). Poll 1: CH47 far away (no event). Poll 2: CH47 within radius (Activated). + // Poll 3: CH47 still within radius (no re-fire). Poll 4: CH47 gone (no event). + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 1: far + source.EnqueueMarkers([ + new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 1010f, 1010f, null) + ]); // poll 2: in radius + source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 1005f, 1005f, null)]); // poll 3: still in + source.EnqueueMarkers([]); // poll 4: gone + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var rigEvents = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + rigEvents.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + // Wait for the Activated event — its arrival proves poll 2 completed. + await WaitUntilAsync(() => !rigEvents.IsEmpty, cts.Token); + + // Give a little more time so poll 3 can complete (should NOT fire again). + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + + Assert.Single(rigEvents); + Assert.True(rigEvents.TryPeek(out var evt)); + Assert.NotNull(evt); + Assert.Equal(RigKind.Small, evt!.Rig); + Assert.Equal(RigEventKind.Activated, evt.Kind); + Assert.Equal(1000f, evt.X); + Assert.Equal(1000f, evt.Y); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try { await subTask; } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Ch47_far_from_rig_publishes_chinook_event_but_no_rig_event() + { + // Rig at (1000, 1000). CH47 spawns far away at (0, 0) — no rig activation. + // Expects: a MapMarkersChangedEvent with an Added Chinook, and zero RigStateChangedEvents. + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.EnqueueMarkers([]); // poll 1: baseline + source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 2: CH47 far + source.EnqueueMarkers([]); // poll 3: gone + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var markerEvents = new System.Collections.Concurrent.ConcurrentQueue(); + var rigEvents = new System.Collections.Concurrent.ConcurrentQueue(); + var markerSub = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + markerEvents.Enqueue(e); + } + }, CancellationToken.None); + var rigSub = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + rigEvents.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + // Wait for the Chinook-added marker event — definite signal that poll 2 completed. + await WaitUntilAsync( + () => markerEvents.Any(e => e.Added.Any(m => m.Kind == MarkerKind.Chinook)), cts.Token); + + // Give a little more time so poll 3 can complete — still no rig event. + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + + Assert.Contains(markerEvents, e => e.Added.Any(m => m.Kind == MarkerKind.Chinook)); + Assert.Empty(rigEvents); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try { await markerSub; } + catch (OperationCanceledException) + { + /* expected */ + } + + try { await rigSub; } + catch (OperationCanceledException) + { + /* expected */ + } + } + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) { while (!condition()) diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 494a1efe..76713ed1 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -23,6 +23,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private int _createCount; private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); + private IReadOnlyList _pendingMonuments = []; /// Number of times has been called. Safe to read from any thread. public int CreateCount => Volatile.Read(ref _createCount); @@ -49,6 +50,11 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p connection.EnqueueMarkers(markers); } + // Transfer any pre-staged monuments so they are available before the supervisor fetches them on connect. + // Reset after transfer so the staging applies to the NEXT connection only (no leak across connections). + connection.MonumentsResult = _pendingMonuments; + _pendingMonuments = []; + LastConnection = connection; return connection; } @@ -67,6 +73,15 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p public void EnqueueMarkers(IReadOnlyList markers) => _pendingMarkerScript.Enqueue(markers); + /// + /// Pre-stages the monument list returned by for the NEXT + /// connection created by . The list is transferred to the new connection at creation + /// time, before the supervisor fetches monuments on connect, eliminating the setup race. + /// Call this before . + /// + /// The monument list to return from . + public void SetMonuments(IReadOnlyList monuments) => _pendingMonuments = monuments; + internal HeartbeatResult NextHeartbeat() { if (_heartbeats.TryDequeue(out var next)) @@ -115,6 +130,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The dimensions returned by . Defaults to a non-null snapshot. public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500); + /// The monuments returned by . Defaults to empty. + public IReadOnlyList MonumentsResult { get; set; } = []; + /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; @@ -172,6 +190,10 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(DimensionsResult); + public Task> GetMonumentsAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => + Task.FromResult(MonumentsResult); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs index 17fcc3fa..03dc9ffc 100644 --- a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs @@ -1,9 +1,12 @@ using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using NSubstitute; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.Hosting; using RustPlusBot.Features.Events.Relaying; using RustPlusBot.Features.Events.State; @@ -39,6 +42,8 @@ private static ServiceProvider BuildProvider() services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Options.Create(new ConnectionOptions())); services.AddEvents(); return services.BuildServiceProvider(validateScopes: true); diff --git a/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs b/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs new file mode 100644 index 00000000..827ecd73 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Hosting/EventsHostedServiceTickTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Events.Hosting; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Events.Tests.Hosting; + +public sealed class EventsHostedServiceTickTests +{ + [Fact] + public async Task TickOnce_publishes_a_crate_lootable_event_when_active_window_elapsed() + { + var clock = new TestClock(); + var options = Options.Create(new ConnectionOptions()); + var rigStore = new RigStateStore(clock, options); + var bus = Substitute.For(); + var server = Guid.NewGuid(); + + rigStore.Apply(new RigStateChangedEvent(1UL, server, RigKind.Small, RigEventKind.Activated, 1f, 2f, null)); + clock.UtcNow = clock.UtcNow.Add(options.Value.RigActiveWindow); + + var service = EventsHostedServiceTestAccess.Create(bus, rigStore, clock, options); + await service.TickOnceAsync(CancellationToken.None); + + await bus.Received(1).PublishAsync( + Arg.Is(e => e.Kind == RigEventKind.CrateLootable && e.Rig == RigKind.Small), + Arg.Any()); + } + + private sealed class TestClock : IClock + { + public DateTimeOffset UtcNow { get; set; } = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + } +} + +/// Test-only factory that constructs with minimal real dependencies. +internal static class EventsHostedServiceTestAccess +{ + /// Creates an wired to the provided bus, rig store, clock, and options. + /// Dependencies not exercised by are stubbed. + /// The event bus to publish crossings on. + /// The rig state store to advance. + /// Supplies the current time. + /// Supplies rig timing windows. + /// A constructed . + internal static EventsHostedService Create( + IEventBus eventBus, + RigStateStore rigStore, + IClock clock, + IOptions options) + { + var eventStateStore = new EventStateStore(clock); + var scopeFactory = Substitute.For(); + var logger = NullLogger.Instance; + + return new EventsHostedService( + eventBus, + relay: null!, + eventStateStore, + rigStore, + clock, + options, + scopeFactory, + logger); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index 263158ed..29ec3928 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -1,8 +1,10 @@ using Discord; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using NSubstitute; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Posting; @@ -19,8 +21,8 @@ public sealed class EventRelayTests private const ulong Guild = 1UL; private static readonly Guid Server = Guid.NewGuid(); - private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Poster) - CreateRelay(ulong? channelId = 999UL) + private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Poster, + ITeamChatSender Sender, RigStateStore RigStore) CreateRelay(ulong? channelId = 999UL) { var clock = Substitute.For(); clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 17, 12, 0, 0, TimeSpan.Zero)); @@ -39,21 +41,27 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos services.AddScoped(_ => workspaceStore); var provider = services.BuildServiceProvider(); + var sender = Substitute.For(); + var rigStore = new RigStateStore(clock, Options.Create(new ConnectionOptions())); + var renderer = new EventEmbedRenderer(new EventLocalizer(EventLocalizationCatalog.Default)); + var relay = new EventRelay( new MarkerEventClassifier(clock), store, - new EventEmbedRenderer(new EventLocalizer(EventLocalizationCatalog.Default)), + renderer, locator, poster, + sender, + rigStore, provider.GetRequiredService()); - return (relay, store, poster); + return (relay, store, poster, sender, rigStore); } [Fact] public async Task Posts_one_embed_per_classified_event_and_updates_state() { - var (relay, store, poster) = CreateRelay(); + var (relay, store, poster, _, _) = CreateRelay(); await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, @@ -68,7 +76,7 @@ [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], [Fact] public async Task When_channel_missing_updates_state_but_does_not_post() { - var (relay, store, poster) = CreateRelay(channelId: null); + var (relay, store, poster, _, _) = CreateRelay(channelId: null); await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, @@ -84,7 +92,7 @@ [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], [Fact] public async Task Empty_classification_posts_nothing() { - var (relay, _, poster) = CreateRelay(); + var (relay, _, poster, _, _) = CreateRelay(); // Other markers produce no domain events from the classifier. await relay.RelayAsync( @@ -95,4 +103,47 @@ [new MapMarkerSnapshot(2, MarkerKind.Other, 0f, 0f, null)], await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Relay_broadcasts_each_event_in_game_in_addition_to_discord() + { + var (relay, _, poster, sender, _) = CreateRelay(); + + await relay.RelayAsync( + new MapMarkersChangedEvent(Guild, Server, null, + [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], + []), + CancellationToken.None); + + await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); + await sender.Received(1).SendAsync(Guild, Server, Arg.Is(s => !string.IsNullOrWhiteSpace(s)), + Arg.Any()); + } + + [Fact] + public async Task RelayRig_activated_applies_state_posts_embed_and_broadcasts() + { + var (relay, _, poster, sender, rigStore) = CreateRelay(); + var evt = new RigStateChangedEvent(Guild, Server, RigKind.Small, RigEventKind.Activated, 100f, 200f, null); + + await relay.RelayRigAsync(evt, CancellationToken.None); + + Assert.Equal(RigStatus.Active, rigStore.Get(Guild, Server, RigKind.Small).Status); + await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); + await sender.Received(1).SendAsync(Guild, Server, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RelayRig_lootable_does_not_apply_but_still_alerts() + { + var (relay, _, poster, sender, rigStore) = CreateRelay(); + var evt = new RigStateChangedEvent(Guild, Server, RigKind.Small, RigEventKind.CrateLootable, 100f, 200f, null); + + await relay.RelayRigAsync(evt, CancellationToken.None); + + // Not Active — the tick already advanced state; relay must not flip it back to Active. + Assert.Equal(RigStatus.Online, rigStore.Get(Guild, Server, RigKind.Small).Status); + await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); + await sender.Received(1).SendAsync(Guild, Server, Arg.Any(), Arg.Any()); + } } diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs new file mode 100644 index 00000000..f71b51bd --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs @@ -0,0 +1,39 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Rendering; + +namespace RustPlusBot.Features.Events.Tests.Rendering; + +public sealed class RigRenderingTests +{ + private static EventEmbedRenderer Renderer() => + new(new EventLocalizer(EventLocalizationCatalog.Default)); + + [Theory] + [InlineData(RigKind.Small, RigEventKind.Activated, "en")] + [InlineData(RigKind.Small, RigEventKind.CrateLootable, "fr")] + [InlineData(RigKind.Large, RigEventKind.Respawned, "en")] + public void RenderRig_produces_a_nonempty_embed(RigKind rig, RigEventKind kind, string culture) + { + var evt = new RigStateChangedEvent(1UL, Guid.NewGuid(), rig, kind, 100f, 200f, null); + var embed = Renderer().RenderRig(evt, culture); + Assert.False(string.IsNullOrWhiteSpace(embed.Description)); + } + + [Fact] + public void RenderRigLine_includes_a_grid_reference() + { + var evt = new RigStateChangedEvent(1UL, Guid.NewGuid(), RigKind.Small, RigEventKind.Activated, 100f, 200f, + null); + var line = Renderer().RenderRigLine(evt, "en"); + Assert.False(string.IsNullOrWhiteSpace(line)); + } + + [Fact] + public void RenderLine_for_cargo_is_nonempty() + { + var evt = new RustMapEvent(MapEventKind.CargoEntered, 0f, 0f, null, DateTimeOffset.UtcNow); + var line = Renderer().RenderLine(evt, "fr"); + Assert.False(string.IsNullOrWhiteSpace(line)); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/State/RigStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/RigStateStoreTests.cs new file mode 100644 index 00000000..aa56fb96 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/State/RigStateStoreTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Events.Tests.State; + +public sealed class RigStateStoreTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + private static (RigStateStore Store, TestClock Clock) Create() + { + var clock = new TestClock(); + var options = Options.Create(new ConnectionOptions + { + RigActiveWindow = TimeSpan.FromMinutes(15), RigOfflineWindow = TimeSpan.FromMinutes(15), + }); + return (new RigStateStore(clock, options), clock); + } + + private static RigStateChangedEvent Activated(RigKind rig) => + new(Guild, Server, rig, RigEventKind.Activated, 100f, 200f, null); + + [Fact] + public void Untracked_rig_reads_online_with_no_timer() + { + var (store, _) = Create(); + var state = store.Get(Guild, Server, RigKind.Small); + Assert.Equal(RigStatus.Online, state.Status); + Assert.Null(state.Remaining); + } + + [Fact] + public void Apply_activated_sets_active_with_remaining() + { + var (store, _) = Create(); + store.Apply(Activated(RigKind.Small)); + var state = store.Get(Guild, Server, RigKind.Small); + Assert.Equal(RigStatus.Active, state.Status); + Assert.Equal(TimeSpan.FromMinutes(15), state.Remaining); + } + + [Theory] + [InlineData(RigEventKind.CrateLootable)] + [InlineData(RigEventKind.Respawned)] + public void Apply_rejects_non_activated_events(RigEventKind kind) + { + var (store, _) = Create(); + var evt = new RigStateChangedEvent(Guild, Server, RigKind.Small, kind, 100f, 200f, null); + Assert.Throws(() => store.Apply(evt)); + } + + [Fact] + public void Advance_active_past_window_yields_crate_lootable_and_goes_offline() + { + var (store, clock) = Create(); + store.Apply(Activated(RigKind.Small)); + clock.UtcNow = clock.UtcNow.AddMinutes(15); + + var crossings = store.Advance(clock.UtcNow); + + Assert.Single(crossings); + Assert.Equal(RigEventKind.CrateLootable, crossings[0].Kind); + Assert.Equal(RigKind.Small, crossings[0].Rig); + Assert.Equal(100f, crossings[0].X); + Assert.Equal(RigStatus.Offline, store.Get(Guild, Server, RigKind.Small).Status); + } + + [Fact] + public void Advance_offline_past_window_yields_respawned_and_returns_to_online_untracked() + { + var (store, clock) = Create(); + store.Apply(Activated(RigKind.Small)); + clock.UtcNow = clock.UtcNow.AddMinutes(15); + store.Advance(clock.UtcNow); // -> Offline + clock.UtcNow = clock.UtcNow.AddMinutes(15); + + var crossings = store.Advance(clock.UtcNow); // -> Online (Respawned), then dropped + + Assert.Single(crossings); + Assert.Equal(RigEventKind.Respawned, crossings[0].Kind); + Assert.Equal(RigStatus.Online, store.Get(Guild, Server, RigKind.Small).Status); + } + + [Fact] + public void Get_settles_overdue_transition_on_read() + { + var (store, clock) = Create(); + store.Apply(Activated(RigKind.Small)); + clock.UtcNow = clock.UtcNow.AddMinutes(15); + + // No Advance call; reading must settle Active -> Offline. + Assert.Equal(RigStatus.Offline, store.Get(Guild, Server, RigKind.Small).Status); + } + + [Fact] + public void Activated_in_any_state_resets_to_active() + { + var (store, clock) = Create(); + store.Apply(Activated(RigKind.Small)); + clock.UtcNow = clock.UtcNow.AddMinutes(15); + store.Advance(clock.UtcNow); // Offline + store.Apply(Activated(RigKind.Small)); // ground-truth override + Assert.Equal(RigStatus.Active, store.Get(Guild, Server, RigKind.Small).Status); + } + + [Fact] + public void Clear_drops_tracked_rigs() + { + var (store, _) = Create(); + store.Apply(Activated(RigKind.Small)); + store.Clear(Guild, Server); + Assert.Equal(RigStatus.Online, store.Get(Guild, Server, RigKind.Small).Status); + } + + [Fact] + public void Advance_returns_nothing_for_untracked_or_within_window() + { + var (store, clock) = Create(); + Assert.Empty(store.Advance(clock.UtcNow)); + store.Apply(Activated(RigKind.Small)); + Assert.Empty(store.Advance(clock.UtcNow)); // still within active window + } + + private sealed class TestClock : IClock + { + public DateTimeOffset UtcNow { get; set; } = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + } +}