Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
Expand All @@ -18,6 +19,7 @@
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
</Folder>
</Solution>
6 changes: 6 additions & 0 deletions docs/development/running-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ overwritten on every startup, so a normal run after the reset leaves only the cu
5. In Development (`Workspace:EnableDangerCommands = true`), use
`/workspace simulate-server name:<n> ip:<host> port:<port>` to create a server category, and
`/workspace reset` to delete the whole workspace.

Each provisioned server category also includes an `#events` channel, which receives live alerts when a
**cargo ship** or **patrol helicopter** enters or leaves the map and when a **chinook** spawns. No new
Discord gateway intent is required — event markers are detected by polling the Rust+ socket, not the
Discord gateway; only the existing **Send Messages** and **Embed Links** permissions on the provisioned
channel are used.
7 changes: 7 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/MapDimensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>The static-per-wipe map size needed to convert world coordinates to a grid reference.</summary>
/// <param name="Width">Map image width.</param>
/// <param name="Height">Map image height.</param>
/// <param name="OceanMargin">The ocean margin around the playable area.</param>
public sealed record MapDimensions(uint Width, uint Height, int OceanMargin);
9 changes: 9 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>One map marker observed in a <c>GetMapMarkers</c> poll.</summary>
/// <param name="Id">The stable marker id (used to diff polls).</param>
/// <param name="Kind">The classified marker kind.</param>
/// <param name="X">World X coordinate.</param>
/// <param name="Y">World Y coordinate.</param>
/// <param name="Name">The marker name, if any.</param>
public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name);
20 changes: 20 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace RustPlusBot.Features.Connections.Listening;

/// <summary>The subset of Rust map-marker types this bot reasons about; everything else is <see cref="Other"/>.</summary>
public enum MarkerKind
{
/// <summary>A marker type the bot does not classify (player, vending, explosion, generic radius, vendor).</summary>
Other = 0,

/// <summary>A cargo ship.</summary>
CargoShip = 1,

/// <summary>A patrol helicopter.</summary>
PatrolHelicopter = 2,

/// <summary>A Chinook (CH-47).</summary>
Chinook = 3,

/// <summary>A locked crate.</summary>
Crate = 4,
}
16 changes: 16 additions & 0 deletions src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using RustPlusBot.Features.Connections.Listening;

namespace RustPlusBot.Abstractions.Events;

/// <summary>Published when a marker poll detects markers that appeared or disappeared since the previous poll.</summary>
/// <param name="GuildId">The owning guild snowflake.</param>
/// <param name="ServerId">The target server id.</param>
/// <param name="Dimensions">Map dimensions for grid-reference rendering, or null if unavailable.</param>
/// <param name="Added">Markers present now that were absent in the previous poll.</param>
/// <param name="Removed">Markers absent now that were present in the previous poll.</param>
public sealed record MapMarkersChangedEvent(
ulong GuildId,
Guid ServerId,
MapDimensions? Dimensions,
IReadOnlyList<MapMarkerSnapshot> Added,
IReadOnlyList<MapMarkerSnapshot> Removed);
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, SteamIdCommandHandler>();
services.AddScoped<ICommandHandler, AliveCommandHandler>();
services.AddScoped<ICommandHandler, ProxCommandHandler>();
services.AddScoped<ICommandHandler, CargoCommandHandler>();
services.AddScoped<ICommandHandler, HeliCommandHandler>();
services.AddScoped<ICommandHandler, ChinookCommandHandler>();
services.AddScoped<ICommandHandler, EventsCommandHandler>();

services.AddScoped<CommandDispatcher>();
services.AddHostedService<CommandsHostedService>();
Expand Down
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!cargo — reports the cargo ship's current grid position, if any.</summary>
/// <param name="state">The live event state.</param>
/// <param name="localizer">The reply localizer.</param>
/// <param name="clock">For "how long ago".</param>
internal sealed class CargoCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "cargo";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.CargoShip);
if (markers.Count == 0)
{
return Task.FromResult<string?>(localizer.Get("command.cargo.none", context.Culture));
}

var m = markers[0];
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
return Task.FromResult<string?>(localizer.Get("command.cargo.ok", context.Culture, grid, ago));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!chinook — reports the Chinook helicopter's current grid position, if any.</summary>
/// <param name="state">The live event state.</param>
/// <param name="localizer">The reply localizer.</param>
/// <param name="clock">For "how long ago".</param>
internal sealed class ChinookCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "chinook";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.Chinook);
if (markers.Count == 0)
{
return Task.FromResult<string?>(localizer.Get("command.chinook.none", context.Culture));
}

var m = markers[0];
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
return Task.FromResult<string?>(localizer.Get("command.chinook.ok", context.Culture, grid, ago));
}
}
46 changes: 46 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Localization;
using RustPlusBot.Features.Events.Classifying;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!events — lists the most recent live events.</summary>
/// <param name="state">The live event state.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "events";

/// <inheritdoc />
/// <exception cref="ArgumentOutOfRangeException">A recent event has an unsupported <see cref="MapEventKind"/>.</exception>
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var events = state.GetRecentEvents(context.GuildId, context.ServerId);
if (events.Count == 0)
{
return Task.FromResult<string?>(localizer.Get("command.events.none", context.Culture));
}

var parts = events.Select(e =>
{
var grid = GridReference.From(e.X, e.Y, e.Dimensions);
var key = e.Kind switch
{
MapEventKind.CargoEntered => "command.event.cargoentered",
MapEventKind.CargoLeft => "command.event.cargoleft",
MapEventKind.HeliEntered => "command.event.helientered",
MapEventKind.HeliLeft => "command.event.helileft",
MapEventKind.ChinookSpawned => "command.event.chinookspawned",
_ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."),
};
return localizer.Get(key, context.Culture, grid);
});

return Task.FromResult<string?>(
localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)));
}
}
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Localization;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Formatting;
using RustPlusBot.Features.Events.State;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!heli — reports the patrol helicopter's current grid position, if any.</summary>
/// <param name="state">The live event state.</param>
/// <param name="localizer">The reply localizer.</param>
/// <param name="clock">For "how long ago".</param>
internal sealed class HeliCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "heli";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.PatrolHelicopter);
if (markers.Count == 0)
{
return Task.FromResult<string?>(localizer.Get("command.heli.none", context.Culture));
}

var m = markers[0];
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
return Task.FromResult<string?>(localizer.Get("command.heli.ok", context.Culture, grid, ago));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ internal sealed class CommandLocalizationCatalog
["command.prox.member"] = "{0} {1}m",
["command.prox.selfunknown"] = "Can't locate you.",
["command.prox.alone"] = "No teammates nearby.",
["command.cargo.ok"] = "Cargo Ship at {0} ({1} ago)",
["command.cargo.none"] = "No cargo ship on the map.",
["command.heli.ok"] = "Patrol Helicopter at {0} ({1} ago)",
["command.heli.none"] = "No patrol helicopter on the map.",
["command.chinook.ok"] = "Chinook at {0} ({1} ago)",
["command.chinook.none"] = "No chinook on the map.",
["command.events.ok"] = "Recent: {0}",
["command.events.none"] = "No recent events.",
["command.event.cargoentered"] = "cargo in {0}",
["command.event.cargoleft"] = "cargo left {0}",
["command.event.helientered"] = "heli in {0}",
["command.event.helileft"] = "heli left {0}",
["command.event.chinookspawned"] = "chinook 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 @@ -99,6 +112,19 @@ internal sealed class CommandLocalizationCatalog
["command.prox.member"] = "{0} {1}m",
["command.prox.selfunknown"] = "Impossible de vous localiser.",
["command.prox.alone"] = "Aucun coéquipier à proximité.",
["command.cargo.ok"] = "Cargo en {0} (il y a {1})",
["command.cargo.none"] = "Aucun cargo sur la carte.",
["command.heli.ok"] = "Hélicoptère en {0} (il y a {1})",
["command.heli.none"] = "Aucun hélicoptère sur la carte.",
["command.chinook.ok"] = "Chinook en {0} (il y a {1})",
["command.chinook.none"] = "Aucun chinook sur la carte.",
["command.events.ok"] = "Récent : {0}",
["command.events.none"] = "Aucun événement récent.",
["command.event.cargoentered"] = "cargo en {0}",
["command.event.cargoleft"] = "cargo parti de {0}",
["command.event.helientered"] = "heli en {0}",
["command.event.helileft"] = "heli parti de {0}",
["command.event.chinookspawned"] = "chinook en {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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ProjectReference Include="..\RustPlusBot.Abstractions\RustPlusBot.Abstractions.csproj" />
<ProjectReference Include="..\RustPlusBot.Persistence\RustPlusBot.Persistence.csproj" />
<ProjectReference Include="..\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
<ProjectReference Include="..\RustPlusBot.Features.Events\RustPlusBot.Features.Events.csproj" />
<ProjectReference Include="..\RustPlusBot.Discord\RustPlusBot.Discord.csproj" />
</ItemGroup>

Expand Down
3 changes: 3 additions & 0 deletions src/RustPlusBot.Features.Connections/ConnectionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ internal interface IRustServerConnection : IAsyncDisposable
/// <returns>True if the promotion succeeded; false on failure/timeout.</returns>
Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Polls the current map markers the bot tracks (cargo ship, patrol helicopter, chinook), for diffing by id. Throws on failure.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The current cargo-ship / patrol-helicopter / chinook markers. Other marker types are not surfaced (the mapped RustPlusApi facade exposes no others the bot reasons about; crates are no longer sent by the game).</returns>
Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
CancellationToken cancellationToken = default);

/// <summary>Gets the static map dimensions for grid-reference rendering, or null on failure/timeout.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The map dimensions, or null on failure/timeout.</returns>
Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

/// <summary>Raised for every in-game team chat line received on this socket.</summary>
event EventHandler<TeamChatLine>? TeamMessageReceived;
}
Loading
Loading