Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>One named monument observed in a <c>GetMap</c> response.</summary>
/// <param name="Token">The monument token (e.g. <c>oilrig_1</c>, <c>large_oil_rig</c>).</param>
/// <param name="X">World X coordinate.</param>
/// <param name="Y">World Y coordinate.</param>
public sealed record MonumentSnapshot(string Token, float X, float Y);
14 changes: 14 additions & 0 deletions src/RustPlusBot.Abstractions/Events/RigEventKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>The rig lifecycle boundary an event marks.</summary>
public enum RigEventKind
{
/// <summary>A CH47 reached the rig — the combat (unlocking) phase began.</summary>
Activated = 0,

/// <summary>The combat phase ended — the crate is now lootable.</summary>
CrateLootable = 1,

/// <summary>The dormant window ended — the crate respawned and the rig is armed again.</summary>
Respawned = 2,
}
11 changes: 11 additions & 0 deletions src/RustPlusBot.Abstractions/Events/RigKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>Which oil rig a rig event refers to.</summary>
public enum RigKind
{
/// <summary>The small oil rig (monument token <c>oilrig_1</c>).</summary>
Small = 0,

/// <summary>The large oil rig (monument token <c>large_oil_rig</c>).</summary>
Large = 1,
}
20 changes: 20 additions & 0 deletions src/RustPlusBot.Abstractions/Events/RigStateChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using RustPlusBot.Features.Connections.Listening;

namespace RustPlusBot.Abstractions.Events;

/// <summary>Published when an oil rig crosses a lifecycle boundary (activated / crate lootable / respawned).</summary>
/// <param name="GuildId">The owning guild snowflake.</param>
/// <param name="ServerId">The target server id.</param>
/// <param name="Rig">Which rig.</param>
/// <param name="Kind">The boundary that was crossed.</param>
/// <param name="X">The rig monument's world X coordinate.</param>
/// <param name="Y">The rig monument's world Y coordinate.</param>
/// <param name="Dimensions">Map dimensions for grid-reference rendering, or null if unavailable.</param>
public sealed record RigStateChangedEvent(
ulong GuildId,
Guid ServerId,
RigKind Rig,
RigEventKind Kind,
float X,
float Y,
MapDimensions? Dimensions);
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, HeliCommandHandler>();
services.AddScoped<ICommandHandler, ChinookCommandHandler>();
services.AddScoped<ICommandHandler, EventsCommandHandler>();
services.AddScoped<ICommandHandler, SmallCommandHandler>();
services.AddScoped<ICommandHandler, LargeCommandHandler>();

services.AddScoped<CommandDispatcher>();
services.AddHostedService<CommandsHostedService>();
Expand Down
22 changes: 22 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>!large — reports the large oil rig's status.</summary>
/// <param name="rigState">The rig state reader.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class LargeCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "large";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
return Task.FromResult<string?>(RigReply.For(rigState, context, RigKind.Large, "command.large", localizer));
}
}
37 changes: 37 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/RigReply.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Shared rig-status reply formatting for the !small/!large handlers.</summary>
internal static class RigReply
{
/// <summary>Formats the localized rig-status reply for a rig.</summary>
/// <param name="rigState">The rig state reader.</param>
/// <param name="context">The command context.</param>
/// <param name="rig">Which rig.</param>
/// <param name="prefix">The localization key prefix ("command.small" / "command.large").</param>
/// <param name="localizer">The reply localizer.</param>
/// <returns>The localized reply.</returns>
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),
};
}
}
22 changes: 22 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>!small — reports the small oil rig's status.</summary>
/// <param name="rigState">The rig state reader.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class SmallCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "small";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
return Task.FromResult<string?>(RigReply.For(rigState, context, RigKind.Small, "command.small", localizer));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand 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.",
Expand Down
16 changes: 16 additions & 0 deletions src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ public Task AliveAsync(
[Autocomplete(typeof(ServerAutocompleteHandler))]
string? server = null) => RunAsync("alive", server);

/// <summary>Shows the small oil rig's status.</summary>
/// <param name="server">The target server (only needed if more than one is registered).</param>
[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);

/// <summary>Shows the large oil rig's status.</summary>
/// <param name="server">The target server (only needed if more than one is registered).</param>
[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)
Expand Down
19 changes: 17 additions & 2 deletions src/RustPlusBot.Features.Connections/ConnectionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ public sealed class ConnectionOptions
/// <summary>How long a single heartbeat may take before the socket is considered unreachable.</summary>
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10);

/// <summary>How often to poll map markers for live-event detection. Default 10s.</summary>
public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>How often to poll map markers for live-event detection. Default 5s.</summary>
public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(5);

/// <summary>Faster poll cadence used while a CH47 marker is live (to catch its brief oil-rig visit). Default 2s.</summary>
public TimeSpan MarkerPollFastInterval { get; set; } = TimeSpan.FromSeconds(2);

/// <summary>Distance (world units) within which a CH47 is considered to be "at" an oil rig. Default 150.</summary>
public float RigRadius { get; set; } = 150f;

/// <summary>How long an oil rig stays in the combat (Active) phase before the crate becomes lootable. Default 15m.</summary>
public TimeSpan RigActiveWindow { get; set; } = TimeSpan.FromMinutes(15);

/// <summary>How long an oil rig stays dormant (Offline) before the crate respawns (Online). Default 15m.</summary>
public TimeSpan RigOfflineWindow { get; set; } = TimeSpan.FromMinutes(15);

/// <summary>How often the rig-timer tick advances rig phases and emits timed boundary events. Default 30s.</summary>
public TimeSpan RigTickInterval { get; set; } = TimeSpan.FromSeconds(30);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
/// <returns>The map dimensions, or null on failure/timeout.</returns>
Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

/// <summary>Gets the map monuments (for locating oil rigs). Throws on failure.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The map monuments (token + position).</returns>
Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default);

/// <summary>Raised for every in-game team chat line received on this socket.</summary>
event EventHandler<TeamChatLine>? TeamMessageReceived;
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeou
CancellationToken cancellationToken = default) =>
Task.FromResult<MapDimensions?>(null);

public Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<MonumentSnapshot>>([]);

public event EventHandler<TeamChatLine>? TeamMessageReceived
{
add { _ = value; }
Expand Down Expand Up @@ -374,6 +378,36 @@ public async Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(
}
}

public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
// CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task<Response<RustPlusApi.Data.ServerMap>>.
// ServerMap.Monuments is List<ServerMapMonument> with Name (= protobuf token, e.g. "oilrig_1"),
// Nullable<float> 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<MonumentSnapshot>();
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;
Expand Down
Loading
Loading