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