diff --git a/README.md b/README.md index c64de0de..80ec0790 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,16 @@ recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are nex server's channels. - **Credential pool per server** — multiple accounts can pair with the same server; the bot keeps one live socket per `(guild, server)` with **hot-swap** of the - active player (select menu on `#info`) and **auto-failover** to a standby + active player (`/server player`) and **auto-failover** to a standby credential when one goes invalid (owner is DM'd). - **Lifecycle controls** — "Remove server" (on `#info`) and "Disconnect account" (on `#setup`), both Manage-Server-gated with a confirmation step; cascade cleanup of channels, credentials, and live state. -- **Live status** — `#info` shows connection status, player count, and a team - summary (online/total + leader), refreshed on connection-state changes. +- **Live `#info` dashboard** — three auto-refreshing embeds below the map image: + **Server** (status, players/queue, in-game time, wipe age), **Events** (cargo / + heli / chinook / both oil rigs), and **Team** (per-member presence, grid, and + survival time). Refreshed on connection-state changes and on a configurable + interval (`Workspace:InfoRefreshInterval`, default 1 min). - **Bilingual** — every provisioned surface renders in **English or French**, switchable from a select menu in `#settings`. @@ -51,12 +54,13 @@ configurable per-server prefix (default `!`) and per-command cooldowns: - **Items** — `!item`, `!recycle`, `!craft`, `!research`, `!decay`, `!upkeep` (name or id) - **Control** — `!mute` / `!unmute` (gate all bot→game output) -### Slash commands +Live server data (population, in-game time, wipe, team, oil rigs) is no longer a +set of ephemeral commands — it renders continuously in the `#info` dashboard +above, and stays available in-game via the `!commands`. -- **Server data** (ephemeral, with a `server` selector when several are paired) — - `/pop`, `/time`, `/wipe`, `/online`, `/offline`, `/team`, `/alive`, `/small`, `/large` - **Items** (ephemeral) — `/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep` (name or id) - **Utility** — `/help`, `/uptime`, `/leader` (Manage Server — transfer in-game team leadership) +- **Server** — `/server player` (Manage Server — hot-swap the active paired account) - **Admin** — `/setup`, `/workspace reset`, `/workspace simulate-server` ### Live map events diff --git a/docs/superpowers/plans/2026-07-20-info-embeds-server-data.md b/docs/superpowers/plans/2026-07-20-info-embeds-server-data.md new file mode 100644 index 00000000..03e3d5ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-info-embeds-server-data.md @@ -0,0 +1,2772 @@ +# #info Server-Data Embeds Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete the nine server-data slash commands and surface their data — plus more — as three auto-refreshing embeds (Server / Events / Team) in each server's `#info` channel. + +**Architecture:** The three embeds are `IMessageRenderer` implementations wired into the existing Workspace reconciler via `MessageSpec` declarations. Each renderer lives in the project that owns its data source. A new hosted service ticks on a configurable interval and drives a narrow, render-gated refresh path that bypasses full channel reconciliation. The "switch active player" select moves off the embed into a new `/server player` command. + +**Tech Stack:** .NET 10, C# 13, Discord.Net (`Discord.Interactions`), EF Core 10, xUnit + NSubstitute, Serilog. + +## Global Constraints + +- Solution file is `RustPlusBot.slnx` — there is **no** `.sln`. +- **`-maxcpucount:1` is MANDATORY on every build and test invocation.** `ConfigureGitHooks` races on `.git/config` under parallel MSBuild; a broken build then **silently drops an entire test assembly**, which reports 0 tests and looks like a pass. Read the per-assembly count, never just the green tick. +- `dtk` wraps `dotnet` — the form is `dtk dotnet build …` / `dtk dotnet test …`, **not** `dtk build …`. +- **Baseline at branch point: 1028 passed, 1 skipped, across 18 test assemblies.** A run reporting fewer than 18 assemblies means a build break swallowed one — investigate before trusting green. +- Build is `-warnaserror`. Every public type and member needs an XML doc comment; match the surrounding density. +- CA1305 / CA1307 / CA1310 are errors: `CultureInfo.InvariantCulture` for formatting, `StringComparison.Ordinal` for comparisons. +- Tests are plain xUnit `Assert.*` plus NSubstitute. **No FluentAssertions.** `using Xunit;` is a global using — omit it from test files. +- Format gate, run before the final commit of each task (after `dotnet tool restore`): + `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR` + Hard CI gate; fails on any diff. +- Every new user-facing string is a resx key present in **both** `src/RustPlusBot.Localization/Strings.resx` and `Strings.fr.resx`. `StringsResourceParityTests` enforces parity and a hard-coded key count. +- The key count assertion lives at `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` and currently reads `Assert.Equal(281, EnglishKeys().Count);`. Update it in whichever task changes the catalog. +- No new NuGet packages. Never bump ImageSharp to v4 or SixLabors.Drawing to v3 — the paid license hard-fails the build. +- Every commit message ends with: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- All 18 test projects already exist — including `tests/RustPlusBot.Abstractions.Tests` and `tests/RustPlusBot.Features.Connections.Tests` (which already grants `InternalsVisibleTo`). No test project needs creating. +- Work happens on branch `feat/info-embeds`, already cut off `develop` with the spec committed. +- Spec: `docs/superpowers/specs/2026-07-20-info-embeds-server-data-design.md`. + +--- + +## File Structure + +**Task 1 — shared formatting primitives (unblocks everything else)** + +- Create `src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs` — public duration formatting. +- Create `src/RustPlusBot.Abstractions/Connections/Daylight.cs` — day/night + next-transition over `ServerTimeSnapshot`. +- Delete `src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs`. + +**Task 2 — open the renderer seam** + +- Modify `src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs`, `Registry/MessageRenderContext.cs`, `Gateway/MessagePayload.cs` — `internal` → `public`. + +**Task 3 — Server embed** + +- Modify `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs`. + +**Task 4 — Events embed** + +- Create `src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs`. + +**Task 5 — Team embed** + +- Create `src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs`. + +**Task 6 — declare the specs** + +- Modify `src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs`, `Specs/ServerWorkspaceSpecProvider.cs`. + +**Task 7 — gated refresh path** + +- Create `src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs` (+ `IServerInfoRefresher`). + +**Task 8 — refresh hosted service** + +- Create `src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs`. +- Modify `src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs`, `src/RustPlusBot.Host/appsettings.json`. + +**Task 9 — delete the slash commands, move the resolver** + +- Delete `src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs`, `Servers/ServerQueryService.cs`. +- Move `Servers/ServerResolver.cs`, `Servers/ServerResolution.cs`, `Servers/ServerAutocompleteHandler.cs` → `src/RustPlusBot.Features.Connections/Servers/`. + +**Task 10 — `/server player`** + +- Create `src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs`. +- Modify `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs`. + +--- + +### Task 1: Shared formatting primitives + +`DurationFormat` is `internal` to Features.Commands — the highest project in the reference graph — but all three renderers sit below it. Move it to Abstractions. Same for the day/night math currently inlined in `TimeCommandHandler`. + +**Files:** + +- Create: `src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs` +- Create: `src/RustPlusBot.Abstractions/Connections/Daylight.cs` +- Delete: `src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs` +- Modify: `src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs` +- Test: `tests/RustPlusBot.Abstractions.Tests/Connections/DaylightTests.cs` + +**Interfaces:** + +- Consumes: `ServerTimeSnapshot(float TimeOfDay, float Sunrise, float Sunset)` from `RustPlusBot.Abstractions.Connections`. +- Produces: + - `RustPlusBot.Abstractions.Formatting.DurationFormat.Compact(TimeSpan) -> string` + - `RustPlusBot.Abstractions.Formatting.DurationFormat.Seconds(double) -> string` + - `RustPlusBot.Abstractions.Connections.Daylight.IsDay(ServerTimeSnapshot) -> bool` + - `RustPlusBot.Abstractions.Connections.Daylight.Clock(ServerTimeSnapshot) -> string` (e.g. `"14:32"`) + - `RustPlusBot.Abstractions.Connections.Daylight.HoursUntilTransition(ServerTimeSnapshot) -> float` (in-game hours until the next sunrise/sunset) + - `RustPlusBot.Abstractions.Connections.Daylight.UntilTransition(ServerTimeSnapshot) -> TimeSpan` (the same interval as a `TimeSpan`, for `DurationFormat.Compact`) + +- [ ] **Step 1: Write the failing test** + +Create `tests/RustPlusBot.Abstractions.Tests/Connections/DaylightTests.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Tests.Connections; + +public sealed class DaylightTests +{ + [Theory] + [InlineData(12.0f, true)] // midday, between sunrise and sunset + [InlineData(7.5f, true)] // just after sunrise + [InlineData(2.0f, false)] // pre-dawn + [InlineData(21.0f, false)] // after sunset + [InlineData(7.0f, true)] // exactly sunrise counts as day + [InlineData(20.0f, false)] // exactly sunset counts as night + public void IsDay_bins_against_sunrise_and_sunset(float timeOfDay, bool expected) + { + var snapshot = new ServerTimeSnapshot(timeOfDay, 7.0f, 20.0f); + + Assert.Equal(expected, Daylight.IsDay(snapshot)); + } + + [Theory] + [InlineData(14.53f, "14:31")] + [InlineData(0.0f, "00:00")] + [InlineData(9.5f, "09:30")] + [InlineData(23.99f, "23:59")] + public void Clock_formats_as_zero_padded_hours_and_minutes(float timeOfDay, string expected) + { + var snapshot = new ServerTimeSnapshot(timeOfDay, 7.0f, 20.0f); + + Assert.Equal(expected, Daylight.Clock(snapshot)); + } + + [Fact] + public void HoursUntilTransition_during_day_counts_to_sunset() + { + var snapshot = new ServerTimeSnapshot(14.0f, 7.0f, 20.0f); + + Assert.Equal(6.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void HoursUntilTransition_before_sunrise_counts_to_sunrise() + { + var snapshot = new ServerTimeSnapshot(2.0f, 7.0f, 20.0f); + + Assert.Equal(5.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void HoursUntilTransition_after_sunset_wraps_past_midnight_to_sunrise() + { + var snapshot = new ServerTimeSnapshot(22.0f, 7.0f, 20.0f); + + // 2h to midnight + 7h to sunrise. + Assert.Equal(9.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void UntilTransition_matches_HoursUntilTransition() + { + var snapshot = new ServerTimeSnapshot(14.0f, 7.0f, 20.0f); + + Assert.Equal(TimeSpan.FromHours(6.0), Daylight.UntilTransition(snapshot), TimeSpan.FromSeconds(1)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Abstractions.Tests -maxcpucount:1 --filter FullyQualifiedName~DaylightTests` +Expected: FAIL — compile error, `The name 'Daylight' does not exist in the namespace`. + +`tests/RustPlusBot.Abstractions.Tests` already exists and is already in `RustPlusBot.slnx` — add the file to it, do not create a project. + +- [ ] **Step 3: Create the Daylight helper** + +Create `src/RustPlusBot.Abstractions/Connections/Daylight.cs`: + +```csharp +using System.Globalization; + +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Day/night reasoning over a . Shared by the in-game !time +/// handler and the #info server embed so the two can never disagree. All intervals are in +/// in-game hours: Rust's day length is server-configurable and the API never reports +/// it, so these values must not be presented as real-world minutes. +/// +public static class Daylight +{ + private const float HoursPerDay = 24f; + + /// True when the in-game clock sits between sunrise (inclusive) and sunset (exclusive). + /// The in-game time snapshot. + /// True during daylight; false at night. + public static bool IsDay(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + return snapshot.TimeOfDay >= snapshot.Sunrise && snapshot.TimeOfDay < snapshot.Sunset; + } + + /// Renders the in-game clock as zero-padded "HH:mm". + /// The in-game time snapshot. + /// The clock text, e.g. "14:32". + public static string Clock(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var hours = (int)snapshot.TimeOfDay; + var minutes = (int)((snapshot.TimeOfDay - hours) * 60f); + return string.Create(CultureInfo.InvariantCulture, $"{hours:00}:{minutes:00}"); + } + + /// In-game hours until the next sunrise or sunset, wrapping past midnight. + /// The in-game time snapshot. + /// The interval in in-game hours; never negative. + public static float HoursUntilTransition(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var target = IsDay(snapshot) ? snapshot.Sunset : snapshot.Sunrise; + var delta = target - snapshot.TimeOfDay; + return delta >= 0f ? delta : delta + HoursPerDay; + } + + /// The same interval as , as a . + /// The in-game time snapshot. + /// The interval in in-game hours expressed as a TimeSpan. + public static TimeSpan UntilTransition(ServerTimeSnapshot snapshot) + => TimeSpan.FromHours(HoursUntilTransition(snapshot)); +} +``` + +- [ ] **Step 4: Move DurationFormat to Abstractions** + +Create `src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs` with the exact body of the existing file, changing only the namespace and visibility: + +```csharp +using System.Globalization; + +namespace RustPlusBot.Abstractions.Formatting; + +/// Formats durations compactly for in-game replies and Discord embeds. +public static class DurationFormat +{ + /// Renders a duration as "Xd Yh" / "Yh Zm" / "Zm". + /// The duration to render. + /// A compact duration string. + public static string Compact(TimeSpan span) + { + if (span.TotalDays >= 1) + { + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h"); + } + + if (span.TotalHours >= 1) + { + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m"); + } + + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m"); + } + + /// Renders a sub-minute-aware duration: "<n>s" under a minute, else "<m>m <s>s". + /// The duration in seconds. + /// A compact duration string. + public static string Seconds(double seconds) + { + if (seconds < 60) + { + return string.Create(CultureInfo.InvariantCulture, $"{seconds:0.#}s"); + } + + var span = TimeSpan.FromSeconds(seconds); + var minutes = (int)span.TotalMinutes; + return span.Seconds == 0 + ? string.Create(CultureInfo.InvariantCulture, $"{minutes}m") + : string.Create(CultureInfo.InvariantCulture, $"{minutes}m {span.Seconds}s"); + } +} +``` + +Then delete the old file: + +```bash +rm src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs +``` + +- [ ] **Step 5: Repoint the thirteen call sites** + +Every consumer is inside Features.Commands and needs only a `using` swap. In each of these files, replace `using RustPlusBot.Features.Commands.Formatting;` with `using RustPlusBot.Abstractions.Formatting;` — or, where the file needs *both* namespaces (it uses another helper from `Features.Commands.Formatting` too), add the Abstractions using alongside it: + +``` +src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs +src/RustPlusBot.Features.Commands/Formatting/DecayLine.cs +src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs +src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs +src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs +src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs +src/RustPlusBot.Features.Commands/Handlers/RigReply.cs +src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs +src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs +src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs +src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs +src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs +``` + +Note the four files under `Formatting/` are themselves in namespace `RustPlusBot.Features.Commands.Formatting`, so they resolved `DurationFormat` with no using at all — those need the Abstractions using **added**. + +Verify nothing was missed: + +```bash +grep -rn "DurationFormat" src --include='*.cs' | grep -v "/obj/\|/bin/" | grep -v "Abstractions/Formatting" +``` + +Expected: every hit is a call site, none is a namespace declaration. + +- [ ] **Step 6: Rewrite TimeCommandHandler over the shared helper** + +Replace the body of `src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !time — reports the in-game clock and whether it is day or night. +/// The live server query. +/// The reply localizer. +internal sealed class TimeCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler +{ + /// + public string Name => "time"; + + /// + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var time = await query.GetTimeAsync(context.GuildId, context.ServerId, cancellationToken).ConfigureAwait(false); + if (time is null) + { + return localizer.Get("command.notconnected", context.Culture); + } + + var phase = localizer.Get(Daylight.IsDay(time) ? "command.time.day" : "command.time.night", context.Culture); + + return localizer.Get("command.time.ok", context.Culture, Daylight.Clock(time), phase); + } +} +``` + +- [ ] **Step 7: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Abstractions.Tests -maxcpucount:1 --filter FullyQualifiedName~DaylightTests` +Expected: PASS — all 13 test cases. + +Run: `dtk dotnet test tests/RustPlusBot.Features.Commands.Tests -maxcpucount:1` +Expected: PASS — the existing `!time` and duration-formatting tests still pass unchanged, proving the move was behaviour-preserving. + +- [ ] **Step 8: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "refactor: move DurationFormat to Abstractions and extract Daylight helper + +Both are needed by the #info renderers, which sit below Features.Commands +in the project graph. Behaviour is unchanged; TimeCommandHandler now shares +its day/night math with the forthcoming server embed." +``` + +--- + +### Task 2: Open the renderer seam + +The Events and Team renderers live outside Features.Workspace, so the three contracts they implement must become public. + +**Files:** + +- Modify: `src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs` + +**Interfaces:** + +- Consumes: nothing from earlier tasks. +- Produces: `public interface IMessageRenderer { string MessageKey { get; } ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken); }`, `public sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture)`, `public sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components)` — all in their existing namespaces (`RustPlusBot.Features.Workspace.Registry` and `...Gateway`). + +- [ ] **Step 1: Widen the three declarations** + +In `src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs`, change: + +```csharp +internal interface IMessageRenderer +``` + +to: + +```csharp +public interface IMessageRenderer +``` + +In `src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs`, change: + +```csharp +internal sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture); +``` + +to: + +```csharp +public sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture); +``` + +In `src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs`, change: + +```csharp +internal sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components); +``` + +to: + +```csharp +public sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components); +``` + +- [ ] **Step 2: Build to surface accessibility fallout** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: PASS. A public interface may not expose less-accessible types in its signature — `MessagePayload` and `MessageRenderContext` are widened in the same step, so `IMessageRenderer` is consistent. + +If the build reports CS0051/CS0053 ("inconsistent accessibility") on any *other* type reachable from these signatures, widen that type too and note it in the commit message. + +- [ ] **Step 3: Run the Workspace tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1` +Expected: PASS — visibility widening is behaviour-neutral. + +- [ ] **Step 4: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "refactor: make the workspace message-renderer contracts public + +Lets Features.Events and Features.Players contribute renderers for the new +#info embeds without moving the state interfaces into Abstractions." +``` + +--- + +### Task 3: Server embed + +Rewrite the existing `#info` status embed: add Players / Time / Wipe, drop the swap select, keep the Remove button. + +**Files:** + +- Modify: `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `src/RustPlusBot.Localization/Strings.fr.resx` +- Modify: `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` + +**Interfaces:** + +- Consumes: `Daylight.IsDay/Clock/UntilTransition`, `DurationFormat.Compact` (Task 1); `MessagePayload`, `MessageRenderContext`, `IMessageRenderer` (Task 2). +- Produces: `ServerInfoMessageRenderer(IServerService servers, IConnectionStore connections, IRustServerQuery query, IClock clock, ILocalizer localizer)` — note the **added `IClock` parameter**, which Tasks 4 and 5 mirror. + +- [ ] **Step 1: Add the resx keys** + +Add to `src/RustPlusBot.Localization/Strings.resx`: + +| Key | Value | +| --- | ----- | +| `server.info.players.value` | `{0} / {1} · {2} queued` | +| `server.info.time.label` | `Time` | +| `server.info.time.day` | `☀️` | +| `server.info.time.night` | `🌙` | +| `server.info.time.value` | `{0} {1} · {2} of in-game time to {3}` | +| `server.info.time.to.day` | `sunrise` | +| `server.info.time.to.night` | `nightfall` | +| `server.info.wipe.label` | `Wipe` | +| `server.info.wipe.value` | `{0} ago` | +| `server.info.wipe.unknown` | `Unknown` | + +And the French counterparts in `Strings.fr.resx`: + +| Key | Value | +| --- | ----- | +| `server.info.players.value` | `{0} / {1} · {2} en file` | +| `server.info.time.label` | `Heure` | +| `server.info.time.day` | `☀️` | +| `server.info.time.night` | `🌙` | +| `server.info.time.value` | `{0} {1} · {2} de temps en jeu avant {3}` | +| `server.info.time.to.day` | `le lever du soleil` | +| `server.info.time.to.night` | `la tombée de la nuit` | +| `server.info.wipe.label` | `Wipe` | +| `server.info.wipe.value` | `il y a {0}` | +| `server.info.wipe.unknown` | `Inconnu` | + +That is **10 new keys**. Update `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` from `Assert.Equal(281, EnglishKeys().Count);` to `Assert.Equal(291, EnglishKeys().Count);`. + +- [ ] **Step 2: Write the failing tests** + +Add to `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs`. These use the existing file's `Loc` field and NSubstitute idiom: + +```csharp + [Fact] + public async Task ServerInfo_Connected_ShowsPlayersTimeAndWipe() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var now = new DateTimeOffset(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 + }); + + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 187, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns([ + new() + { + Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, + SteamId = 76561198000000000UL, Status = CredentialStatus.Active + }, + ]); + + var query = Substitute.For(); + query.GetServerInfoAsync(1, serverId, Arg.Any()) + .Returns(new ServerInfoSnapshot(187, 200, 12, now.AddDays(-4).AddHours(-6))); + query.GetTimeAsync(1, serverId, Arg.Any()) + .Returns(new ServerTimeSnapshot(14.0f, 7.0f, 20.0f)); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + + var clock = Substitute.For(); + clock.UtcNow.Returns(now); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var values = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("187 / 200", values, StringComparison.Ordinal); + Assert.Contains("12 queued", values, StringComparison.Ordinal); + Assert.Contains("14:00", values, StringComparison.Ordinal); + Assert.Contains("4d 6h ago", values, StringComparison.Ordinal); + } + + [Fact] + public async Task ServerInfo_NoLongerRendersSwapSelect() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 + }); + + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, PlayerCount = 1, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns([ + new() + { + Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, + SteamId = 76561198000000000UL, Status = CredentialStatus.Active + }, + ]); + + var query = Substitute.For(); + query.GetServerInfoAsync(1, serverId, Arg.Any()).Returns((ServerInfoSnapshot?)null); + query.GetTimeAsync(1, serverId, Arg.Any()).Returns((ServerTimeSnapshot?)null); + query.GetTeamInfoAsync(1, serverId, Arg.Any()).Returns((TeamInfoSnapshot?)null); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var selects = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Empty(selects); + + var buttons = payload.Components.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); + } + + [Fact] + public async Task ServerInfo_Disconnected_OmitsLiveFields() + { + var serverId = Guid.NewGuid(); + + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 + }); + + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.Unreachable, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()).Returns([]); + + var query = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + // Disconnected: never queries the socket, and shows only Status + Active player. + await query.DidNotReceive().GetServerInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()); + Assert.Equal(2, payload.Embed!.Fields.Length); + } +``` + +Add these usings to the top of the file if not already present: + +```csharp +using RustPlusBot.Abstractions.Time; +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~RendererTests` +Expected: FAIL — compile error, `ServerInfoMessageRenderer` has no 5-parameter constructor. + +- [ ] **Step 4: Rewrite the renderer** + +Replace `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` entirely: + +```csharp +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders a server's #info status embed: connection, population, in-game time and wipe age. +/// Server lookup. +/// Live connection state + pool. +/// Live server query. +/// Supplies the current time for the wipe age. +/// String resolution. +internal sealed class ServerInfoMessageRenderer( + IServerService servers, + IConnectionStore connections, + IRustServerQuery query, + IClock clock, + ILocalizer localizer) : IMessageRenderer +{ + /// + public string MessageKey => WorkspaceMessageKeys.ServerInfo; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var server = await servers.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (server is null) + { + return new MessagePayload(null, null, null); + } + + var state = await connections.GetStateAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var pool = await connections.ListPoolAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var status = state?.Status ?? ConnectionStatus.NoCredentials; + + var active = pool.FirstOrDefault(c => state?.ActiveCredentialId is Guid id && c.Id == id) + ?? pool.FirstOrDefault(c => c.Status == CredentialStatus.Active); + var none = localizer.Get("server.info.none", context.Culture); + var port = server.Port.ToString(CultureInfo.InvariantCulture); + + var embed = new EmbedBuilder() + .WithTitle($"{Glyph(status)} {server.Name}") + .WithDescription(localizer.Get("server.info.endpoint", context.Culture, server.Ip, port)) + .WithColor(ColorFor(status)) + .AddField(localizer.Get("server.info.status.label", context.Culture), StatusText(status, context.Culture)) + .AddField(localizer.Get("server.info.player.label", context.Culture), + active is null ? none : active.SteamId.ToString(CultureInfo.InvariantCulture)); + + if (status == ConnectionStatus.Connected) + { + await AddLiveFieldsAsync(embed, context, serverId, cancellationToken).ConfigureAwait(false); + } + + // The swap select moved to /server player; the remove button now owns row 0 alone. + var builder = new ComponentBuilder() + .WithButton( + localizer.Get("server.info.remove.button", context.Culture), + $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", + ButtonStyle.Danger, + row: 0); + + return new MessagePayload(null, embed.Build(), builder.Build()); + } + + private async ValueTask AddLiveFieldsAsync( + EmbedBuilder embed, + MessageRenderContext context, + Guid serverId, + CancellationToken cancellationToken) + { + var info = await query.GetServerInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (info is not null) + { + embed.AddField( + localizer.Get("server.info.players.label", context.Culture), + localizer.Get("server.info.players.value", context.Culture, + info.Players.ToString(CultureInfo.InvariantCulture), + info.MaxPlayers.ToString(CultureInfo.InvariantCulture), + info.QueuedPlayers.ToString(CultureInfo.InvariantCulture))); + } + + var time = await query.GetTimeAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (time is not null) + { + var isDay = Daylight.IsDay(time); + embed.AddField( + localizer.Get("server.info.time.label", context.Culture), + localizer.Get("server.info.time.value", context.Culture, + Daylight.Clock(time), + localizer.Get(isDay ? "server.info.time.day" : "server.info.time.night", context.Culture), + DurationFormat.Compact(Daylight.UntilTransition(time)), + localizer.Get(isDay ? "server.info.time.to.night" : "server.info.time.to.day", context.Culture))); + } + + if (info is not null) + { + embed.AddField( + localizer.Get("server.info.wipe.label", context.Culture), + info.WipeTimeUtc is { } wiped + ? localizer.Get("server.info.wipe.value", context.Culture, + DurationFormat.Compact(clock.UtcNow - wiped)) + : localizer.Get("server.info.wipe.unknown", context.Culture)); + } + } + + private static string Glyph(ConnectionStatus status) => status switch + { + ConnectionStatus.Connected => "🟢", + ConnectionStatus.Connecting or ConnectionStatus.Unreachable => "🟡", + _ => "🔴", + }; + + private static Color ColorFor(ConnectionStatus status) => status switch + { + ConnectionStatus.Connected => Color.Green, + ConnectionStatus.Connecting or ConnectionStatus.Unreachable => Color.Gold, + _ => Color.Red, + }; + + private string StatusText(ConnectionStatus status, string culture) => status switch + { + ConnectionStatus.Connecting => localizer.Get("server.info.status.connecting", culture), + ConnectionStatus.Connected => localizer.Get("server.info.status.connected", culture), + ConnectionStatus.Unreachable => localizer.Get("server.info.status.unreachable", culture), + _ => localizer.Get("server.info.status.nocredentials", culture), + }; +} +``` + +The old `AddTeamFieldAsync` is gone — the team moves to its own embed in Task 5. + +- [ ] **Step 5: Delete the now-stale assertions** + +Existing tests in `RendererTests.cs` assert the swap select and the two-row layout. Find them: + +```bash +grep -n "swap\|SwapSelect\|SeparateActionRows\|HasRemoveServerButton" \ + tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +``` + +Delete every test method that grep implicates. At time of writing those are +`ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSelect`, +`ServerInfo_HasRemoveServerButton`, and the method asserting the select and button sit in separate +action rows. `ServerInfo_NoLongerRendersSwapSelect` from Step 2 covers the remove button, so no +coverage is lost. + +Every remaining `new ServerInfoMessageRenderer(...)` call in the file needs the new `clock` argument added. + +- [ ] **Step 6: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~RendererTests` +Expected: PASS. + +Run: `dtk dotnet test tests/RustPlusBot.Localization.Tests -maxcpucount:1` +Expected: PASS — parity holds and the count is 291. + +- [ ] **Step 7: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: add players, time and wipe to the #info server embed + +Renders the MaxPlayers/QueuedPlayers/WipeTimeUtc already carried by +ServerInfoSnapshot but never shown, replacing /pop, /time and /wipe. +Drops the swap select ahead of /server player replacing it." +``` + +--- + +### Task 4: Events embed + +**Files:** + +- Create: `src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs` +- Modify: `src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `Strings.fr.resx` +- Modify: `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` +- Test: `tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs` + +**Interfaces:** + +- Consumes: `DurationFormat.Compact` (Task 1); `IMessageRenderer`, `MessagePayload`, `MessageRenderContext` (Task 2); `IEventState.GetActiveMarkers(ulong, Guid, MarkerKind) -> IReadOnlyList`; `IRigState.Get(ulong, Guid, RigKind) -> RigState`; `GridReference.From(float, float, MapDimensions?, MapGridStyle) -> string`; `IConnectionStore.GetStateAsync`. +- Produces: `ServerEventsMessageRenderer(IEventState events, IRigState rigs, IMapSettingsStore mapSettings, IConnectionStore connections, IClock clock, ILocalizer localizer)` with `MessageKey => "server.events"`. + +Note the `IConnectionStore` dependency: marker and rig state are in-process and are cleared on +disconnect, so a disconnected server would otherwise render as a confident "Not out / Online" wall. +The renderer stamps the disconnected notice into the embed description instead. + +- [ ] **Step 1: Add the resx keys** + +Add to `Strings.resx`: + +| Key | Value | +| --- | ----- | +| `server.events.title` | `⚡ Events` | +| `server.events.cargo.label` | `🚢 Cargo Ship` | +| `server.events.heli.label` | `🚁 Patrol Helicopter` | +| `server.events.chinook.label` | `🚁 Chinook` | +| `server.events.small.label` | `🛢️ Small Oil Rig` | +| `server.events.large.label` | `🛢️ Large Oil Rig` | +| `server.events.out` | `Out · {0} · {1} ago` | +| `server.events.notout` | `Not out` | +| `server.events.rig.online` | `Online` | +| `server.events.rig.active` | `Crate unlocking · {0} left` | +| `server.events.rig.offline` | `Looted · respawns in {0}` | +| `server.events.disconnected` | `Not connected to the server.` | + +And `Strings.fr.resx`: + +| Key | Value | +| --- | ----- | +| `server.events.title` | `⚡ Événements` | +| `server.events.cargo.label` | `🚢 Cargo` | +| `server.events.heli.label` | `🚁 Hélicoptère de patrouille` | +| `server.events.chinook.label` | `🚁 Chinook` | +| `server.events.small.label` | `🛢️ Petite plateforme` | +| `server.events.large.label` | `🛢️ Grande plateforme` | +| `server.events.out` | `Présent · {0} · il y a {1}` | +| `server.events.notout` | `Absent` | +| `server.events.rig.online` | `En ligne` | +| `server.events.rig.active` | `Caisse en déverrouillage · {0} restant` | +| `server.events.rig.offline` | `Pillée · réapparaît dans {0}` | +| `server.events.disconnected` | `Non connecté au serveur.` | + +That is **12 new keys**: update the count assertion from `291` to `303`. + +- [ ] **Step 2: Write the failing test** + +Create `tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs`: + +```csharp +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Events.Messages; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Events.Tests.Messages; + +public sealed class ServerEventsMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly Guid ServerId = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + + private static ServerEventsMessageRenderer Build( + IEventState events, + IRigState rigs, + ConnectionStatus status = ConnectionStatus.Connected) + { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(1, ServerId, Arg.Any()).Returns(MapLayerSettings.AllOn); + var connections = Substitute.For(); + connections.GetStateAsync(1, ServerId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = 1, RustServerId = ServerId, Status = status + }); + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new ServerEventsMessageRenderer(events, rigs, mapSettings, connections, clock, Loc); + } + + [Fact] + public async Task Renders_all_five_rows() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Equal(5, payload.Embed!.Fields.Length); + } + + [Fact] + public async Task Absent_marker_renders_not_out() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var cargo = payload.Embed!.Fields.Single(f => f.Name.Contains("Cargo", StringComparison.Ordinal)); + Assert.Equal("Not out", cargo.Value); + } + + [Fact] + public async Task Present_marker_renders_grid_and_age() + { + var dims = new MapDimensions(4000, 0, 0, 4000); + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + events.GetActiveMarkers(1, ServerId, MarkerKind.CargoShip) + .Returns([ + new ActiveMarker(9UL, MarkerKind.CargoShip, 2000f, 2000f, dims, Now.AddMinutes(-12), [], null), + ]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var cargo = payload.Embed!.Fields.Single(f => f.Name.Contains("Cargo", StringComparison.Ordinal)); + Assert.StartsWith("Out ·", cargo.Value, StringComparison.Ordinal); + Assert.Contains("12m ago", cargo.Value, StringComparison.Ordinal); + } + + [Theory] + [InlineData(RigStatus.Online, "Online")] + [InlineData(RigStatus.Active, "Crate unlocking · 4m left")] + [InlineData(RigStatus.Offline, "Looted · respawns in 4m")] + public async Task Rig_phases_render_distinctly(RigStatus status, string expected) + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, RigKind.Small).Returns(new RigState(status, TimeSpan.FromMinutes(4))); + rigs.Get(1, ServerId, RigKind.Large).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var small = payload.Embed!.Fields.Single(f => f.Name.Contains("Small", StringComparison.Ordinal)); + Assert.Equal(expected, small.Value); + } + + [Fact] + public async Task No_server_scope_renders_empty_payload() + { + var events = Substitute.For(); + var rigs = Substitute.For(); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, null, "en"), default); + + Assert.Null(payload.Embed); + Assert.Null(payload.Text); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Disconnected_says_so_instead_of_reporting_stale_state() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs, ConnectionStatus.Unreachable) + .RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + // In-process marker and rig state is cleared on disconnect, so an unqualified + // "Not out / Online" wall would read as a confident live report. + Assert.Equal("Not connected to the server.", payload.Embed!.Description); + } +} +``` + +Add `using RustPlusBot.Domain.Connections;` and `using RustPlusBot.Persistence.Connections;` to the test file's usings. + +Confirm the `MapDimensions` constructor arity before running — if it differs from `(uint WorldSize, ...)` above, adjust the test's `dims` construction to match `src/RustPlusBot.Abstractions/Connections/MapDimensions.cs`. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Events.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerEventsMessageRendererTests` +Expected: FAIL — `ServerEventsMessageRenderer` does not exist. + +- [ ] **Step 4: Write the renderer** + +Create `src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs`: + +```csharp +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Events.Messages; + +/// +/// Renders the #info events embed: cargo / patrol heli / chinook presence plus both oil rigs' +/// lifecycle phase. Reads only in-process state, so it costs no Rust+ calls. +/// +/// Active map-marker state. +/// Inferred oil-rig state. +/// Supplies the per-server grid convention. +/// Live connection status, to avoid reporting cleared state as live. +/// Supplies the current time for marker ages. +/// String resolution. +public sealed class ServerEventsMessageRenderer( + IEventState events, + IRigState rigs, + IMapSettingsStore mapSettings, + IConnectionStore connections, + IClock clock, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "server.events"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var state = await connections.GetStateAsync(context.GuildId, serverId, cancellationToken) + .ConfigureAwait(false); + var connected = state?.Status == ConnectionStatus.Connected; + var culture = context.Culture; + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("server.events.title", culture)) + .WithColor(connected ? Color.DarkerGrey : Color.Red) + // Marker and rig state are in-process and cleared on disconnect, so without this the rows + // would read as a confident live report of an empty world. + .WithDescription(connected ? null : localizer.Get("server.events.disconnected", culture)) + .AddField(localizer.Get("server.events.cargo.label", culture), + Marker(context.GuildId, serverId, MarkerKind.CargoShip, settings.GridStyle, culture), inline: true) + .AddField(localizer.Get("server.events.heli.label", culture), + Marker(context.GuildId, serverId, MarkerKind.PatrolHelicopter, settings.GridStyle, culture), + inline: true) + .AddField(localizer.Get("server.events.chinook.label", culture), + Marker(context.GuildId, serverId, MarkerKind.Chinook, settings.GridStyle, culture), inline: true) + .AddField(localizer.Get("server.events.small.label", culture), + Rig(context.GuildId, serverId, RigKind.Small, culture), inline: true) + .AddField(localizer.Get("server.events.large.label", culture), + Rig(context.GuildId, serverId, RigKind.Large, culture), inline: true); + + return new MessagePayload(null, embed.Build(), null); + } + + private string Marker(ulong guildId, Guid serverId, MarkerKind kind, MapGridStyle style, string culture) + { + var active = events.GetActiveMarkers(guildId, serverId, kind); + if (active.Count == 0) + { + return localizer.Get("server.events.notout", culture); + } + + // Newest-first: the freshest sighting is the one worth reporting. + var marker = active[0]; + return localizer.Get("server.events.out", culture, + GridReference.From(marker.X, marker.Y, marker.Dimensions, style), + DurationFormat.Compact(clock.UtcNow - marker.SeenAtUtc)); + } + + private string Rig(ulong guildId, Guid serverId, RigKind rig, string culture) + { + var state = rigs.Get(guildId, serverId, rig); + return state.Status switch + { + RigStatus.Active => localizer.Get("server.events.rig.active", culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + RigStatus.Offline => localizer.Get("server.events.rig.offline", culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + _ => localizer.Get("server.events.rig.online", culture), + }; + } +} +``` + +- [ ] **Step 5: Register the renderer** + +In `src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs`, inside the `AddEvents` method body before `return services;`, add: + +```csharp + // Contributes the #info events embed to the Workspace reconciler. + services.AddScoped(); +``` + +with these usings at the top of the file: + +```csharp +using RustPlusBot.Features.Events.Messages; +using RustPlusBot.Features.Workspace.Registry; +``` + +- [ ] **Step 6: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Events.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerEventsMessageRendererTests` +Expected: PASS — 8 test cases. + +Run: `dtk dotnet test tests/RustPlusBot.Localization.Tests -maxcpucount:1` +Expected: PASS at count 303. + +- [ ] **Step 7: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: add the #info events embed + +Cargo, patrol heli, chinook and both oil rigs in one embed, replacing +/small and /large. Reads in-process marker and rig state only." +``` + +--- + +### Task 5: Team embed + +**Files:** + +- Create: `src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs` +- Modify: `src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `Strings.fr.resx` +- Modify: `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` +- Test: `tests/RustPlusBot.Features.Players.Tests/Messages/ServerTeamMessageRendererTests.cs` + +**Interfaces:** + +- Consumes: `DurationFormat.Compact` (Task 1); `IMessageRenderer`, `MessagePayload`, `MessageRenderContext` (Task 2); `IRustServerQuery.GetTeamInfoAsync`, `GetMapDimensionsAsync`; `IAfkState.GetAfkMembersAsync`; `GridReference.From`. +- Produces: `ServerTeamMessageRenderer(IRustServerQuery query, IAfkState afk, IMapSettingsStore mapSettings, IClock clock, ILocalizer localizer)` with `MessageKey => "server.team"`. + +- [ ] **Step 1: Add the resx keys** + +Add to `Strings.resx`: + +| Key | Value | +| --- | ----- | +| `server.team.title` | `👥 Team — {0}/{1} online` | +| `server.team.line` | `{0}{1} **{2}** · {3} · {4}` | +| `server.team.alive` | `alive {0}` | +| `server.team.afk` | `AFK {0}` | +| `server.team.dead` | `dead {0}` | +| `server.team.offline` | `offline` | +| `server.team.nogrid` | `—` | +| `server.team.empty` | `No team members.` | +| `server.team.disconnected` | `Not connected to the server.` | + +`server.team.offline` deliberately carries **no duration placeholder**: +`TeamMemberSnapshot` has `LastSpawnTimeUtc` and `LastDeathTimeUtc` but no disconnect timestamp, so +any "offline for X" figure would be fabricated from the wrong field. + +And `Strings.fr.resx`: + +| Key | Value | +| --- | ----- | +| `server.team.title` | `👥 Équipe — {0}/{1} en ligne` | +| `server.team.line` | `{0}{1} **{2}** · {3} · {4}` | +| `server.team.alive` | `en vie {0}` | +| `server.team.afk` | `AFK {0}` | +| `server.team.dead` | `mort {0}` | +| `server.team.offline` | `hors ligne` | +| `server.team.nogrid` | `—` | +| `server.team.empty` | `Aucun membre d'équipe.` | +| `server.team.disconnected` | `Non connecté au serveur.` | + +That is **9 new keys**: update the count assertion from `303` to `312`. + +- [ ] **Step 2: Write the failing test** + +Create `tests/RustPlusBot.Features.Players.Tests/Messages/ServerTeamMessageRendererTests.cs`: + +```csharp +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Players.Tests.Messages; + +public sealed class ServerTeamMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly Guid ServerId = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + + private static TeamMemberSnapshot Member( + ulong steamId, + string name, + bool online, + bool alive, + double spawnedHoursAgo = 2, + double diedMinutesAgo = 0) + => new(steamId, name, 2000f, 2000f, online, alive, + Now.AddHours(-spawnedHoursAgo), Now.AddMinutes(-diedMinutesAgo)); + + private static ServerTeamMessageRenderer Build(IRustServerQuery query, IAfkState afk) + { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(1, ServerId, Arg.Any()).Returns(MapLayerSettings.AllOn); + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new ServerTeamMessageRenderer(query, afk, mapSettings, clock, Loc); + } + + private static IAfkState NoAfk() + { + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>([])); + return afk; + } + + [Fact] + public async Task Title_counts_online_over_total() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [ + Member(1UL, "Alice", online: true, alive: true), + Member(2UL, "Bob", online: true, alive: true), + Member(3UL, "Carol", online: false, alive: true), + ])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("2/3 online", payload.Embed!.Title, StringComparison.Ordinal); + } + + [Fact] + public async Task Leader_gets_a_crown() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(2UL, [ + Member(1UL, "Alice", online: true, alive: true), + Member(2UL, "Bob", online: true, alive: true), + ])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var bobLine = payload.Embed!.Description.Split('\n').Single(l => l.Contains("Bob", StringComparison.Ordinal)); + Assert.Contains("👑", bobLine, StringComparison.Ordinal); + } + + [Fact] + public async Task Afk_member_renders_afk_not_alive() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(1UL, "Alice", online: true, alive: true)])); + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>( + [new AfkMember(1UL, "Alice", TimeSpan.FromMinutes(7))])); + + var payload = await Build(query, afk).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("AFK 7m", payload.Embed!.Description, StringComparison.Ordinal); + Assert.Contains("😴", payload.Embed.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Dead_member_renders_death_age() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, + [Member(1UL, "Alice", online: true, alive: false, diedMinutesAgo: 6)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("dead 6m", payload.Embed!.Description, StringComparison.Ordinal); + Assert.Contains("💀", payload.Embed.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Offline_member_hides_grid() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(1UL, "Alice", online: false, alive: true)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var line = payload.Embed!.Description.Split('\n').Single(l => l.Contains("Alice", StringComparison.Ordinal)); + Assert.Contains("—", line, StringComparison.Ordinal); + Assert.Contains("⚫", line, StringComparison.Ordinal); + } + + [Fact] + public async Task Empty_name_falls_back_to_steam_id() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(76561198000000000UL, "", online: true, alive: true)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("76561198000000000", payload.Embed!.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Zero_members_renders_empty_notice() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(0UL, [])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("0/0 online", payload.Embed!.Title, StringComparison.Ordinal); + Assert.Equal("No team members.", payload.Embed.Description); + } + + [Fact] + public async Task Null_snapshot_renders_disconnected_notice() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Equal("Not connected to the server.", payload.Embed!.Description); + } + + [Fact] + public async Task Online_alive_sort_before_afk_dead_and_offline() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(99UL, [ + Member(4UL, "Dave", online: false, alive: true), + Member(3UL, "Carol", online: true, alive: false, diedMinutesAgo: 6), + Member(2UL, "Bob", online: true, alive: true, spawnedHoursAgo: 1), + Member(1UL, "Alice", online: true, alive: true, spawnedHoursAgo: 3), + ])); + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>( + [new AfkMember(2UL, "Bob", TimeSpan.FromMinutes(7))])); + + var payload = await Build(query, afk).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var names = payload.Embed!.Description.Split('\n') + .Select(l => new[] { "Alice", "Bob", "Carol", "Dave" }.First(n => l.Contains(n, StringComparison.Ordinal))) + .ToArray(); + Assert.Equal(["Alice", "Bob", "Carol", "Dave"], names); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Players.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerTeamMessageRendererTests` +Expected: FAIL — `ServerTeamMessageRenderer` does not exist. + +- [ ] **Step 4: Write the renderer** + +Create `src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs`: + +```csharp +using System.Globalization; +using System.Text; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Players.Messages; + +/// +/// Renders the #info team embed: one line per member with presence, grid reference and how long +/// they have held their current state. Replaces the /team, /online, /offline and /alive commands. +/// Rust caps a team at eight members, so a description list never risks truncation. +/// +/// Live team query. +/// Live AFK state. +/// Supplies the per-server grid convention. +/// Supplies the current time for durations. +/// String resolution. +public sealed class ServerTeamMessageRenderer( + IRustServerQuery query, + IAfkState afk, + IMapSettingsStore mapSettings, + IClock clock, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "server.team"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var culture = context.Culture; + var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is null) + { + // Honest over stale: an empty payload would make the reconciler skip the edit and leave + // the previous roster on screen as though it were current. + var offline = new EmbedBuilder() + .WithTitle(localizer.Get("server.team.title", culture, "0", "0")) + .WithDescription(localizer.Get("server.team.disconnected", culture)) + .WithColor(Color.Red) + .Build(); + return new MessagePayload(null, offline, null); + } + + var online = team.Members.Count(m => m.IsOnline); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("server.team.title", culture, + online.ToString(CultureInfo.InvariantCulture), + team.Members.Count.ToString(CultureInfo.InvariantCulture))) + .WithColor(Color.Blue); + + if (team.Members.Count == 0) + { + return new MessagePayload(null, + embed.WithDescription(localizer.Get("server.team.empty", culture)).Build(), null); + } + + var afkIds = await ResolveAfkAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var dims = await query.GetMapDimensionsAsync(context.GuildId, serverId, cancellationToken) + .ConfigureAwait(false); + + var body = new StringBuilder(); + foreach (var member in team.Members.OrderBy(m => RankOf(m, afkIds)).ThenByDescending(SurvivedFor)) + { + body.Append(LineFor(member, team.LeaderSteamId, afkIds, dims, settings.GridStyle, culture)).Append('\n'); + } + + return new MessagePayload(null, embed.WithDescription(body.ToString().TrimEnd('\n')).Build(), null); + } + + private async ValueTask> ResolveAfkAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken) + { + var members = await afk.GetAfkMembersAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return members is null + ? [] + : members.ToDictionary(m => m.SteamId, m => m.StillFor); + } + + /// Sort bucket: online-alive, then AFK, then dead, then offline. + private static int RankOf(TeamMemberSnapshot member, Dictionary afkIds) + { + if (!member.IsOnline) + { + return 3; + } + + if (!member.IsAlive) + { + return 2; + } + + return afkIds.ContainsKey(member.SteamId) ? 1 : 0; + } + + private TimeSpan SurvivedFor(TeamMemberSnapshot member) => clock.UtcNow - member.LastSpawnTimeUtc; + + private string LineFor( + TeamMemberSnapshot member, + ulong leaderSteamId, + Dictionary afkIds, + MapDimensions? dims, + MapGridStyle style, + string culture) + { + var crown = member.SteamId == leaderSteamId ? "👑" : string.Empty; + + // The API can report a member with no display name; fall back to the id rather than a blank. + var name = string.IsNullOrWhiteSpace(member.Name) + ? member.SteamId.ToString(CultureInfo.InvariantCulture) + : member.Name; + + // Offline members report their last-known position, which would read as current. + var grid = member.IsOnline + ? GridReference.From(member.X, member.Y, dims, style) + : localizer.Get("server.team.nogrid", culture); + + var (glyph, state) = StateFor(member, afkIds, culture); + + return localizer.Get("server.team.line", culture, crown, glyph, name, grid, state); + } + + private (string Glyph, string State) StateFor( + TeamMemberSnapshot member, + Dictionary afkIds, + string culture) + { + if (!member.IsOnline) + { + // No disconnect timestamp exists on the snapshot, so no duration is reported here. + return ("⚫", localizer.Get("server.team.offline", culture)); + } + + if (!member.IsAlive) + { + return ("💀", localizer.Get("server.team.dead", culture, + DurationFormat.Compact(clock.UtcNow - member.LastDeathTimeUtc))); + } + + if (afkIds.TryGetValue(member.SteamId, out var stillFor)) + { + return ("😴", localizer.Get("server.team.afk", culture, DurationFormat.Compact(stillFor))); + } + + return ("🟢", localizer.Get("server.team.alive", culture, DurationFormat.Compact(SurvivedFor(member)))); + } +} +``` + +- [ ] **Step 5: Register the renderer** + +In `src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs`, before `return services;`, add: + +```csharp + // Contributes the #info team embed to the Workspace reconciler. + services.AddScoped(); +``` + +with these usings: + +```csharp +using RustPlusBot.Features.Players.Messages; +using RustPlusBot.Features.Workspace.Registry; +``` + +- [ ] **Step 6: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Players.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerTeamMessageRendererTests` +Expected: PASS — 9 test cases. + +Run: `dtk dotnet test tests/RustPlusBot.Localization.Tests -maxcpucount:1` +Expected: PASS at count 312. + +- [ ] **Step 7: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: add the #info team embed + +One line per member with presence glyph, grid reference and state age, +replacing /team, /online, /offline and /alive. Sorted online-alive, AFK, +dead, offline." +``` + +--- + +### Task 6: Declare the message specs + +Wire the two new renderers into the reconciler and fix their in-channel order. + +**Files:** + +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs` + +**Interfaces:** + +- Consumes: `ServerEventsMessageRenderer.Key == "server.events"` (Task 4), `ServerTeamMessageRenderer.Key == "server.team"` (Task 5). +- Produces: `WorkspaceMessageKeys.ServerEvents == "server.events"`, `WorkspaceMessageKeys.ServerTeam == "server.team"`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs`: + +```csharp +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Specs; + +namespace RustPlusBot.Features.Workspace.Tests.Specs; + +public sealed class ServerWorkspaceSpecProviderTests +{ + [Fact] + public void Info_channel_messages_are_declared_in_render_order() + { + var specs = new ServerWorkspaceSpecProvider().GetMessageSpecs() + .Where(s => s.ChannelKey == "info") + .Select(s => s.Key) + .ToArray(); + + // Discord orders by creation time, so declaration order is the on-screen order: + // map image, then status, then events, then team. + Assert.Equal(["server.info.map", "server.info", "server.events", "server.team"], specs); + } + + [Fact] + public void Every_per_server_message_targets_a_declared_channel() + { + var provider = new ServerWorkspaceSpecProvider(); + var channels = provider.GetChannelSpecs().Select(c => c.Key).ToHashSet(StringComparer.Ordinal); + + Assert.All(provider.GetMessageSpecs(), s => Assert.Contains(s.ChannelKey, channels)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerWorkspaceSpecProviderTests` +Expected: FAIL — the order assertion reports only `["server.info.map", "server.info"]`. + +If `ServerWorkspaceSpecProvider` is `internal`, the test resolves it via the existing `InternalsVisibleTo` entry for `RustPlusBot.Features.Workspace.Tests` declared in the csproj — no visibility change needed. + +- [ ] **Step 3: Add the message keys** + +In `src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs`, inside `WorkspaceMessageKeys`, after the `ServerInfo` constant, add: + +```csharp + /// Key for the per-server #info events embed (cargo/heli/chinook/rigs). Rendered by Features.Events. + public const string ServerEvents = "server.events"; + + /// Key for the per-server #info team embed (roster + presence). Rendered by Features.Players. + public const string ServerTeam = "server.team"; +``` + +- [ ] **Step 4: Declare the specs** + +In `src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs`, replace the `GetMessageSpecs` body: + +```csharp + /// + public IEnumerable GetMessageSpecs() => + [ + // Declaration order IS the on-screen order (Discord orders messages by creation time), and + // the reconciler re-posts later messages to repair drift. Keep: map image, status, events, team. + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfoMap, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerEvents, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerTeam, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), + ]; +``` + +- [ ] **Step 5: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1` +Expected: PASS. + +Note the reconciler filters specs by `_renderers.ContainsKey(s.Key)` (`WorkspaceReconciler.cs:239`), so a host that composes Workspace without Events or Players simply skips the corresponding embed rather than failing. + +- [ ] **Step 6: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: declare the events and team message specs in #info + +Declaration order fixes the on-screen order: map, status, events, team." +``` + +--- + +### Task 7: Gated refresh path + +A narrow refresh that skips channel provisioning and suppresses no-op edits. + +**Files:** + +- Create: `src/RustPlusBot.Features.Workspace/Reconciler/IServerInfoRefresher.cs` +- Create: `src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs` +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs` + +**Interfaces:** + +- Consumes: `IWorkspaceStore.GetMessageAsync/GetCultureAsync`, `IWorkspaceGateway.EditMessageAsync`, `IWorkspaceReconciler.ReconcileServerAsync`, `RenderGate.ShouldSend/Commit/Invalidate`, `RenderCanonicalizer.Canonicalize`, `IMessageRenderer` (Task 2), the three message keys (Task 6). +- Produces: `internal interface IServerInfoRefresher { Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); }`, implemented by `ServerInfoRefresher`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs`: + +```csharp +using Discord; +using NSubstitute; +using RustPlusBot.Discord.Posting; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class ServerInfoRefresherTests +{ + private const ulong GuildId = 1; + private const ulong ChannelId = 500; + private const ulong MessageId = 900; + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed class StubRenderer(string key, string title) : IMessageRenderer + { + public string MessageKey => key; + + public int Calls { get; private set; } + + public string Title { get; set; } = title; + + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + Calls++; + var embed = new EmbedBuilder().WithTitle(Title).Build(); + return ValueTask.FromResult(new MessagePayload(null, embed, null)); + } + } + + private static IWorkspaceStore StoreWith(params string[] keys) + { + var store = Substitute.For(); + store.GetCultureAsync(GuildId, Arg.Any()).Returns("en"); + foreach (var key in keys) + { + store.GetMessageAsync(GuildId, ServerId, key, Arg.Any()) + .Returns(new ProvisionedMessage + { + GuildId = GuildId, + RustServerId = ServerId, + MessageKey = key, + DiscordChannelId = ChannelId, + DiscordMessageId = MessageId, + }); + } + + return store; + } + + [Fact] + public async Task First_refresh_edits_the_message() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.Received(1).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Unchanged_render_is_not_sent_twice() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + await refresher.RefreshAsync(GuildId, ServerId, default); + + Assert.Equal(2, renderer.Calls); + await gateway.Received(1).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Changed_render_is_sent_again() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + renderer.Title = "v2"; + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.Received(2).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Missing_message_row_escalates_to_full_reconcile() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var store = Substitute.For(); + store.GetCultureAsync(GuildId, Arg.Any()).Returns("en"); + store.GetMessageAsync(GuildId, ServerId, Arg.Any(), Arg.Any()) + .Returns((ProvisionedMessage?)null); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await reconciler.Received(1).ReconcileServerAsync(GuildId, ServerId, Arg.Any()); + await gateway.DidNotReceive().EditMessageAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Edit_failure_escalates_and_invalidates_the_gate() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + gateway.EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("404"))); + var reconciler = Substitute.For(); + var gate = new RenderGate(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], gate, + reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await reconciler.Received(1).ReconcileServerAsync(GuildId, ServerId, Arg.Any()); + // The gate must not remember a render that never landed. + Assert.True(gate.ShouldSend(MessageId, "anything")); + } + + [Fact] + public async Task Empty_payload_is_skipped_without_editing() + { + var renderer = Substitute.For(); + renderer.MessageKey.Returns(WorkspaceMessageKeys.ServerInfo); + renderer.RenderAsync(Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromResult(new MessagePayload(null, null, null))); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.DidNotReceive().EditMessageAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + await reconciler.DidNotReceive().ReconcileServerAsync(Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Refreshes_all_three_info_messages() + { + var renderers = new IMessageRenderer[] + { + new StubRenderer(WorkspaceMessageKeys.ServerInfo, "a"), + new StubRenderer(WorkspaceMessageKeys.ServerEvents, "b"), + new StubRenderer(WorkspaceMessageKeys.ServerTeam, "c"), + }; + var store = StoreWith(WorkspaceMessageKeys.ServerInfo, WorkspaceMessageKeys.ServerEvents, + WorkspaceMessageKeys.ServerTeam); + var gateway = Substitute.For(); + var refresher = new ServerInfoRefresher(store, gateway, renderers, new RenderGate(), + Substitute.For()); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + Assert.All(renderers.Cast(), r => Assert.Equal(1, r.Calls)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerInfoRefresherTests` +Expected: FAIL — `ServerInfoRefresher` does not exist. + +- [ ] **Step 3: Write the interface** + +Create `src/RustPlusBot.Features.Workspace/Reconciler/IServerInfoRefresher.cs`: + +```csharp +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// +/// Re-renders a server's #info messages in place without re-walking channel provisioning. +/// The full takes the per-guild +/// provisioning lock and re-checks every category and channel — far too heavy for a +/// minute-by-minute pulse. This path reads the message ids straight from the store and edits. +/// +internal interface IServerInfoRefresher +{ + /// Re-renders the server's #info messages, editing only those whose render changed. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// A task that completes when every message has been considered. + Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} +``` + +- [ ] **Step 4: Write the implementation** + +Create `src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs`: + +```csharp +using RustPlusBot.Discord.Posting; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Render-gated in-place refresh of the three #info messages. +/// Supplies the provisioned message ids and the guild culture. +/// Performs the Discord edit. +/// All registered message renderers. +/// Suppresses PATCHes for renders that did not change. +/// The full reconcile, used to self-heal a missing or stale message. +internal sealed class ServerInfoRefresher( + IWorkspaceStore store, + IWorkspaceGateway gateway, + IEnumerable renderers, + RenderGate gate, + IWorkspaceReconciler reconciler) : IServerInfoRefresher +{ + /// The #info message keys this path refreshes, in declaration order. + private static readonly string[] Keys = + [ + WorkspaceMessageKeys.ServerInfo, + WorkspaceMessageKeys.ServerEvents, + WorkspaceMessageKeys.ServerTeam, + ]; + + private readonly Dictionary _renderers = + renderers.ToDictionary(r => r.MessageKey, StringComparer.Ordinal); + + /// + public async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var context = new MessageRenderContext(guildId, serverId, culture); + + foreach (var key in Keys) + { + if (!_renderers.TryGetValue(key, out var renderer)) + { + // The host did not compose the feature that owns this embed; nothing to refresh. + continue; + } + + var payload = await renderer.RenderAsync(context, cancellationToken).ConfigureAwait(false); + if (payload.Text is null && payload.Embed is null && payload.Components is null) + { + // Nothing to show (e.g. the server row vanished mid-tick). Leave the message alone. + continue; + } + + var record = await store.GetMessageAsync(guildId, serverId, key, cancellationToken).ConfigureAwait(false); + if (record is null) + { + // Never posted (or wiped): the full reconcile owns creating it. + await reconciler.ReconcileServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return; + } + + var canonical = RenderCanonicalizer.Canonicalize(payload.Embed, payload.Components); + if (!gate.ShouldSend(record.DiscordMessageId, canonical)) + { + continue; + } + + try + { + await gateway + .EditMessageAsync(guildId, record.DiscordChannelId, record.DiscordMessageId, payload, + cancellationToken) + .ConfigureAwait(false); + gate.Commit(record.DiscordMessageId, canonical); + } +#pragma warning disable CA1031 // Broad catch: any edit failure (deleted message, permissions, 5xx) heals the same way. + catch (Exception) +#pragma warning restore CA1031 + { + // Never remember a render that did not land, or the next tick would skip it too. + gate.Invalidate(record.DiscordMessageId); + await reconciler.ReconcileServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return; + } + } + } +} +``` + +- [ ] **Step 5: Register it** + +In `src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs`, immediately after the `IWorkspaceReconciler` registration, add: + +```csharp + services.AddScoped(); +``` + +`RenderGate` is already a DI singleton registered by the Discord layer. If `dtk build` reports it cannot be resolved from the Workspace container, add `services.TryAddSingleton();` alongside, with `using Microsoft.Extensions.DependencyInjection.Extensions;`. + +- [ ] **Step 6: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerInfoRefresherTests` +Expected: PASS — 7 test cases. + +- [ ] **Step 7: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: add a render-gated in-place refresh for #info + +Skips the provisioning lock and channel walk that ReconcileServerAsync +performs, and suppresses PATCHes for unchanged renders. Any edit failure +invalidates the gate and escalates to a full reconcile." +``` + +--- + +### Task 8: Refresh hosted service + +**Files:** + +- Create: `src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs` +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs` +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Host/appsettings.json` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Hosting/ServerInfoRefreshHostedServiceTests.cs` + +**Interfaces:** + +- Consumes: `IServerInfoRefresher.RefreshAsync` (Task 7); `IEventBus.SubscribeAsync`; `ConnectionStatusChangedEvent` with its `GuildId`, `ServerId`, `IsConnected` members. +- Produces: `WorkspaceOptions.InfoRefreshInterval` (`TimeSpan`, default 1 minute). + +- [ ] **Step 1: Confirm the event shape** + +Run: `cat src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs` + +Note the exact property names. The implementation below assumes `GuildId`, `ServerId` and `IsConnected`; adjust the two references in Step 4 if they differ. + +- [ ] **Step 2: Add the option** + +Replace `src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs`: + +```csharp +namespace RustPlusBot.Features.Workspace; + +/// Workspace feature configuration, bound from the "Workspace" config section. +public sealed class WorkspaceOptions +{ + /// Enables dangerous developer commands (/workspace reset, /workspace simulate-server). + public bool EnableDangerCommands { get; set; } + + /// + /// How often every connected server's #info embeds are re-rendered. Unchanged renders are + /// suppressed by the render gate, so the steady-state cost is three Rust+ calls per server + /// per tick and at most three Discord edits. + /// + public TimeSpan InfoRefreshInterval { get; set; } = TimeSpan.FromMinutes(1); +} +``` + +And in `src/RustPlusBot.Host/appsettings.json`, change the `Workspace` section to: + +```json + "Workspace": { + "EnableDangerCommands": false, + "InfoRefreshInterval": "00:01:00" + }, +``` + +- [ ] **Step 3: Write the failing test** + +Create `tests/RustPlusBot.Features.Workspace.Tests/Hosting/ServerInfoRefreshHostedServiceTests.cs`: + +```csharp +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Features.Workspace.Hosting; + +namespace RustPlusBot.Features.Workspace.Tests.Hosting; + +public sealed class ServerInfoRefreshHostedServiceTests +{ + [Fact] + public void Interval_is_clamped_to_a_one_second_floor() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.Zero + }); + + Assert.Equal(TimeSpan.FromSeconds(1), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public void Interval_is_clamped_when_negative() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.FromSeconds(-30) + }); + + Assert.Equal(TimeSpan.FromSeconds(1), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public void Configured_interval_passes_through() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.FromMinutes(5) + }); + + Assert.Equal(TimeSpan.FromMinutes(5), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public void Connected_servers_are_tracked_and_dropped_on_disconnect() + { + var tracker = new ConnectedServerSet(); + var serverId = Guid.NewGuid(); + + tracker.Set(1, serverId, connected: true); + Assert.Contains((1UL, serverId), tracker.Snapshot()); + + tracker.Set(1, serverId, connected: false); + Assert.DoesNotContain((1UL, serverId), tracker.Snapshot()); + } +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~ServerInfoRefreshHostedServiceTests` +Expected: FAIL — neither `ServerInfoRefreshHostedService` nor `ConnectedServerSet` exists. + +- [ ] **Step 5: Write the hosted service** + +Create `src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs`: + +```csharp +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Workspace.Reconciler; + +namespace RustPlusBot.Features.Workspace.Hosting; + +/// The set of servers currently believed connected, maintained from connection status events. +internal sealed class ConnectedServerSet +{ + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new(); + + /// Adds or removes a server from the refresh rotation. + /// The owning guild snowflake. + /// The server id. + /// True to track the server; false to drop it. + public void Set(ulong guildId, Guid serverId, bool connected) + { + if (connected) + { + _connected[(guildId, serverId)] = 0; + } + else + { + _connected.TryRemove((guildId, serverId), out _); + } + } + + /// A point-in-time copy of the tracked servers. + /// The currently-tracked (guild, server) pairs. + public IReadOnlyList<(ulong Guild, Guid Server)> Snapshot() => [.. _connected.Keys]; +} + +/// +/// Re-renders every connected server's #info embeds on a steady interval. In-game time, population +/// and team state change continuously, and the workspace reconciler is purely event-driven — without +/// this tick the embeds would only update on connect, disconnect, wipe or credential change. +/// +/// The in-process event bus. +/// Supplies the refresh interval. +/// Opens a scope per refresh (the refresher is scoped). +/// The logger. +internal sealed partial class ServerInfoRefreshHostedService( + IEventBus eventBus, + IOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + private readonly ConnectedServerSet _connected = new(); + private readonly CancellationTokenSource _cts = new(); + private Task? _statusLoop; + private Task? _tickLoop; + + /// Resolves the effective interval, flooring it so a misconfiguration cannot spin the loop. + /// The bound workspace options. + /// The interval to sleep between ticks. + public static TimeSpan ResolveInterval(WorkspaceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var floor = TimeSpan.FromSeconds(1); + return options.InfoRefreshInterval < floor ? floor : options.InfoRefreshInterval; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _statusLoop = Task.Run(() => ConsumeStatusEventsAsync(_cts.Token), CancellationToken.None); + _tickLoop = Task.Run(() => RunPeriodicRefreshAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] { _statusLoop, _tickLoop }) + { + if (loop is null) + { + continue; + } + + try + { + await loop.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } + } + } + + /// + public void Dispose() => _cts.Dispose(); + + private async Task ConsumeStatusEventsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + _connected.Set(evt.GuildId, evt.ServerId, evt.IsConnected); + } + } + 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 + { + LogStatusLoopFaulted(logger, ex); + } + } + + private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(ResolveInterval(options.Value), cancellationToken).ConfigureAwait(false); + foreach (var (guildId, serverId) in _connected.Snapshot()) + { + await RefreshAsync(guildId, serverId, 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 + { + LogTickLoopFaulted(logger, ex); + } + } + + private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + try + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var refresher = scope.ServiceProvider.GetRequiredService(); + await refresher.RefreshAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // Broad catch: one server's failure must not stop the rotation. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRefreshFaulted(logger, ex, serverId); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "#info status loop faulted.")] + private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "#info refresh tick faulted.")] + private static partial void LogTickLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "#info refresh failed for server {ServerId}.")] + private static partial void LogRefreshFaulted(ILogger logger, Exception exception, Guid serverId); +} +``` + +- [ ] **Step 6: Register the hosted service** + +In `src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs`, directly after the existing `services.AddHostedService();`, add: + +```csharp + services.AddHostedService(); +``` + +- [ ] **Step 7: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1` +Expected: PASS. + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: PASS. + +- [ ] **Step 8: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: refresh #info on a configurable interval + +Tracks connected servers off the connection status event and re-renders +their #info embeds every Workspace:InfoRefreshInterval (default 1m), +floored at one second so a misconfiguration cannot spin the loop." +``` + +--- + +### Task 9: Delete the slash commands, move the resolver + +**Files:** + +- Delete: `src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs` +- Delete: `src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs` +- Delete: `tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs` +- Move: `src/RustPlusBot.Features.Commands/Servers/{ServerResolver,ServerResolution,ServerAutocompleteHandler}.cs` → `src/RustPlusBot.Features.Connections/Servers/` +- Move: `tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs` → `tests/RustPlusBot.Features.Connections.Tests/Servers/` +- Modify: `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs` +- Modify: `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` + +**Interfaces:** + +- Consumes: nothing new. +- Produces: `RustPlusBot.Features.Connections.Servers.ServerResolver`, `.ServerResolution(Guid? ServerId, string? Name, string? ErrorMessage)`, `.ServerAutocompleteHandler`. `ServerResolver` stays `internal`; `ServerAutocompleteHandler` stays `public` (Discord.Net instantiates it reflectively). + +- [ ] **Step 1: Move the three files** + +```bash +mkdir -p src/RustPlusBot.Features.Connections/Servers +git mv src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs src/RustPlusBot.Features.Connections/Servers/ +git mv src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs src/RustPlusBot.Features.Connections/Servers/ +git mv src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs src/RustPlusBot.Features.Connections/Servers/ +``` + +In all three files, change: + +```csharp +namespace RustPlusBot.Features.Commands.Servers; +``` + +to: + +```csharp +namespace RustPlusBot.Features.Connections.Servers; +``` + +- [ ] **Step 2: Delete the dead code** + +```bash +git rm src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs +git rm src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs +git rm tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs +rmdir src/RustPlusBot.Features.Commands/Servers 2>/dev/null || true +``` + +- [ ] **Step 3: Move the registration** + +In `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs`, delete these two lines: + +```csharp + services.AddScoped(); + services.AddScoped(); +``` + +and remove the now-unused `using RustPlusBot.Features.Commands.Servers;` if the file has no other reference to that namespace. + +In `src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs`, add before the `InteractionModuleAssembly` registration: + +```csharp + services.AddScoped(); +``` + +with `using RustPlusBot.Features.Connections.Servers;` at the top. + +- [ ] **Step 4: Move the resolver test** + +```bash +mkdir -p tests/RustPlusBot.Features.Connections.Tests/Servers +git mv tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs tests/RustPlusBot.Features.Connections.Tests/Servers/ +``` + +In the moved file, change the namespace to `RustPlusBot.Features.Connections.Tests.Servers` and the `using RustPlusBot.Features.Commands.Servers;` to `using RustPlusBot.Features.Connections.Servers;`. + +`src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj:4` already grants +``, so the internal +`ServerResolver` is visible to the moved test with no csproj change. + +- [ ] **Step 5: Fix CommandRegistrationTests** + +In `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs`, delete these two assertions from `Dispatcher_and_handlers_resolve`: + +```csharp + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); +``` + +and remove `using RustPlusBot.Features.Commands.Servers;`. + +Leave `Assert.Equal(28, handlers.Count);` **unchanged** — no `ICommandHandler` is removed by this task, which is exactly the guard that the in-game surface survived. + +- [ ] **Step 6: Add the mirrored Connections assertion** + +Add to `tests/RustPlusBot.Features.Connections.Tests/` a registration test (extend the existing one if present, otherwise create `ConnectionRegistrationTests.cs`): + +```csharp + using var scope = provider.CreateScope(); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); +``` + +- [ ] **Step 7: Run the tests** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: PASS — no dangling references to the deleted types. + +Run: `dtk dotnet test tests/RustPlusBot.Features.Commands.Tests -maxcpucount:1` +Expected: PASS — including `QueryHandlersTests` **unmodified**, proving every `!command` still works. + +Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` +Expected: PASS — including the moved `ServerResolverTests`. + +- [ ] **Step 8: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "refactor: delete the nine server-data slash commands + +Their data now lives in the #info embeds. The in-game !pop/!time/!wipe/ +!online/!offline/!team/!alive/!small/!large handlers are untouched. +ServerResolver moves to Features.Connections, which /server player needs +because Features.Commands cannot see WorkspaceComponentIds." +``` + +--- + +### Task 10: `/server player` + +**Files:** + +- Create: `src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs` +- Modify: `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx`, `Strings.fr.resx` +- Modify: `tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44` + +**Interfaces:** + +- Consumes: `ServerResolver.ResolveAsync(ulong, string?, string, CancellationToken) -> ServerResolution` and `ServerAutocompleteHandler` (Task 9); `WorkspaceComponentIds.ServerInfoSwapPrefix`; `IConnectionStore.ListPoolAsync`, `GetStateAsync`; the retained `server.info.swap.placeholder` key. +- Produces: the `/server player` slash command. No new types are consumed downstream. + +- [ ] **Step 1: Add the resx keys** + +Add to `Strings.resx`: + +| Key | Value | +| --- | ----- | +| `command.server.player.pick` | `Choose which paired player drives the connection.` | +| `command.server.player.nopool` | `No paired players are available for that server.` | +| `help.slash.server.player` | `Switch which paired player drives a server's connection` | + +And `Strings.fr.resx`: + +| Key | Value | +| --- | ----- | +| `command.server.player.pick` | `Choisissez quel joueur associé pilote la connexion.` | +| `command.server.player.nopool` | `Aucun joueur associé n'est disponible pour ce serveur.` | +| `help.slash.server.player` | `Changer le joueur associé qui pilote la connexion d'un serveur` | + +That is **3 new keys**: update the count assertion from `312` to `315`. + +- [ ] **Step 2: Write the module** + +Create `src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs`: + +```csharp +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Connections.Servers; +using RustPlusBot.Features.Workspace; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Connections.Modules; + +/// +/// /server player — replaces the "switch active player" select that used to sit on the #info +/// embed. It replies ephemerally with that same select (same custom id), so +/// handles the choice with no new interaction logic. +/// +/// Creates a short-lived DI scope per interaction. +[Group("server", "Server administration")] +public sealed class ServerPlayerModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Shows the credential picker for a server. + /// The target server (only needed if more than one is registered). + /// A task that completes when the reply has been sent. + [SlashCommand("player", "Switch which paired player drives a server's connection")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task PlayerAsync( + [Summary("server", "Which server (only needed if more than one)")] + [Autocomplete(typeof(ServerAutocompleteHandler))] + string? server = null) + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var resolver = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + var connections = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var resolution = await resolver + .ResolveAsync(Context.Guild.Id, server, culture, CancellationToken.None).ConfigureAwait(false); + if (resolution.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var serverId = resolution.ServerId!.Value; + var state = await connections.GetStateAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + var pool = await connections.ListPoolAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + var eligible = pool + .Where(c => c.Status != CredentialStatus.Invalid) + .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. + .ToList(); + if (eligible.Count == 0) + { + await FollowupAsync(localizer.Get("command.server.player.nopool", culture), ephemeral: true) + .ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId($"{WorkspaceComponentIds.ServerInfoSwapPrefix}{serverId}") + .WithPlaceholder(localizer.Get("server.info.swap.placeholder", culture)); + foreach (var credential in eligible) + { + select.AddOption( + credential.SteamId.ToString(CultureInfo.InvariantCulture), + credential.Id.ToString(), + isDefault: credential.Id == state?.ActiveCredentialId); + } + + await FollowupAsync( + localizer.Get("command.server.player.pick", culture), + components: new ComponentBuilder().WithSelectMenu(select).Build(), + ephemeral: true) + .ConfigureAwait(false); + } + } +} +``` + +- [ ] **Step 3: List it in /help** + +In `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs`, add to the `Slash` list immediately after the `leader` entry: + +```csharp + new("server player", CommandGroup.Bot, "help.slash.server.player"), +``` + +- [ ] **Step 4: Build and run the suite** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: PASS. + +Run: `dtk dotnet test RustPlusBot.slnx -maxcpucount:1` +Expected: PASS — the whole suite. + +If a help-catalog drift guard asserts every `Slash` entry maps to a registered slash command, it may need the `"server player"` name spelled as the framework reports it (Discord groups render as `server player`). Adjust to match the guard's expectation rather than weakening the guard. + +- [ ] **Step 5: Format and commit** + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR +git add -A +git commit -m "feat: add /server player to replace the #info swap select + +Replies ephemerally with the same select and custom id the embed used to +carry, so ConnectionComponentModule handles it unchanged." +``` + +--- + +## Final verification + +- [ ] **Full build and suite** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1 && dtk dotnet test RustPlusBot.slnx -maxcpucount:1` +Expected: PASS, with a test count higher than the pre-branch baseline by roughly 45 (13 Daylight + 3 server embed + 8 events + 9 team + 2 spec + 7 refresher + 4 hosted service, less the 3 deleted `ServerInfo` renderer tests and the deleted `ServerQueryServiceTests`). + +- [ ] **Format gate** + +Run: `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" --no-build --verbosity=ERROR && git diff --exit-code` +Expected: exit 0, no diff. This is the hard CI gate. + +- [ ] **Live smoke (user gate — do not mark complete without the user confirming)** + +1. Start the bot against a live paired server. +2. Confirm `#info` shows four messages in order: map image, server status, events, team. +3. Wait one interval; confirm the server embed's in-game time advances. +4. Confirm the nine slash commands no longer appear in Discord's picker. +5. In-game, run `!pop`, `!time`, `!wipe`, `!online`, `!offline`, `!team`, `!alive`, `!small`, `!large` — all must still answer. +6. Run `/server player` and confirm it swaps the active credential. +7. Check the log for repeated `#info refresh failed` warnings — there should be none. diff --git a/docs/superpowers/specs/2026-07-20-info-embeds-server-data-design.md b/docs/superpowers/specs/2026-07-20-info-embeds-server-data-design.md new file mode 100644 index 00000000..28f24a9a --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-info-embeds-server-data-design.md @@ -0,0 +1,374 @@ +# #info Server-Data Embeds — Design + +**Date:** 2026-07-20 +**Status:** Approved (brainstorm gate passed) +**Branch (planned):** feat/info-embeds off develop + +## Problem + +Nine slash commands — `/pop`, `/time`, `/wipe`, `/online`, `/offline`, `/team`, +`/alive`, `/small`, `/large` — exist only as thin wrappers around the in-game +`!command` handlers. Each builds a synthetic `CommandContext`, calls the same +`ICommandHandler`, and returns the handler's single plain localized line as an +ephemeral text followup (`Modules/ServerCommandModule.cs:89-128`). They produce +no embed at all. + +That surface is poor on three counts: + +- **Pull, not push.** The data is live and constantly changing, but a user has + to ask for each fact one command at a time. +- **Multi-server friction.** With more than one paired server and no `server` + argument, `ServerResolver` hard-errors with `command.server.specify` rather + than offering a picker (`Servers/ServerResolver.cs:17-49`). +- **Thin output.** `ServerInfoSnapshot` already carries `MaxPlayers`, + `QueuedPlayers` and `WipeTimeUtc`, and `#info` renders none of them + (`Messages/ServerInfoMessageRenderer.cs:53-70`). + +Meanwhile the per-server **#info** channel shows only connection status, the +active credential's SteamId, a bare player count and a one-line team summary. + +**Goal:** delete the nine slash commands and surface the same data — plus more — +as three auto-refreshing embeds in **#info**, at the density rustplusplus +achieves. The in-game `!command` surface is untouched. + +## Decisions (user-confirmed) + +1. **Three embeds, not one** — Server / Events / Team, below the existing map + image embed. Maps 1:1 onto the removed commands, keeps each readable, and + lets each refresh independently. +2. **Periodic refresh with a configurable interval**, on top of the existing + event-driven triggers. +3. **The "Switch active player" select comes off the embed** and is replaced by + a `/server player` slash command. +4. **The "Remove server" button stays** on the embed — the nearest existing + commands (`/workspace purge`, `/admin reset-database`) are guild-wide, not + per-server, so removing it would lose a capability. +5. **In-game `!pop` / `!time` / `!wipe` / `!online` / `!offline` / `!team` / + `!alive` / `!small` / `!large` all stay.** Discord is not visible from inside + Rust; that surface is the reason the handlers exist. + +### The server selector disappears + +The original framing asked for "a server selector when several are paired". +That requirement dissolves: **#info is already per-server**, one channel per +`RustServer` under its own category (`Specs/ServerWorkspaceSpecProvider.cs:11`). +Each server's data renders in its own channel, so nothing needs disambiguating. +Only `/server player` still needs to resolve a server, and it reuses the +existing autocomplete. + +## Scope + +### Deleted + +| Path | Reason | +| ---- | ------ | +| `Features.Commands/Modules/ServerCommandModule.cs` | the nine `[SlashCommand]`s | +| `Features.Commands/Servers/ServerQueryService.cs` | only caller was the module | +| `Features.Commands.Tests/Servers/ServerQueryServiceTests.cs` | tests deleted code | + +Plus the DI registration of `ServerQueryService` +(`CommandServiceCollectionExtensions.cs:66`). + +### Moved + +`ServerResolver.cs`, `ServerResolution.cs` and `ServerAutocompleteHandler.cs` +move from `Features.Commands/Servers/` to `Features.Connections/Servers/`, with +their namespace changed and their DI registration relocated. + +The new `/server player` command must live in `Features.Connections`, because it +references `WorkspaceComponentIds.ServerInfoSwapPrefix` and **`Features.Commands` +does not reference `Features.Workspace`** (its refs are Abstractions, +Localization, Persistence, Features.Connections, Features.Events, +Features.ItemData, Discord). Since deleting `ServerCommandModule` leaves all +three files with no remaining consumer in Commands, moving beats duplicating. +`Features.Commands` references `Features.Connections`, so nothing there breaks. + +Their three resx keys — `command.server.none`, `command.server.specify`, +`command.server.unknown` — are therefore **retained**, not deleted: +`ServerResolver` still resolves them for `/server player`. + +### Kept + +- All nine `ICommandHandler`s in `Features.Commands/Handlers/` and every + `command.*` resx key they resolve, including `command.notconnected`. +- `ServerResolverTests`, updated only for the new namespace. + +## The three embeds + +Declaration order in `ServerWorkspaceSpecProvider.GetMessageSpecs()` controls +in-channel order, so the specs are declared map → server → events → team. + +```text +[1] 🗺️ map image embed server.info.map (unchanged) + +[2] 🟢 My Rust Server server.info (rewritten) + rust.io:28082 + Status Connected + Players 187 / 200 · 12 queued ← /pop + Time 14:32 ☀️ · 3h12m to night ← /time + Wipe 4d 6h ago ← /wipe + Active player 76561198012345678 + [Remove server] + +[3] ⚡ Events server.events (new) + 🚢 Cargo Ship Not out + 🚁 Patrol Heli Not out + 🚁 Chinook Out · G12 + 🛢️ Small Oil Rig Crate unlocking · 4m left ← /small + 🛢️ Large Oil Rig Online ← /large + +[4] 👥 Team — 3/5 online server.team (new) + 👑🟢 Alice G12 2h14m + 🟢 Bob H9 AFK 7m + 💀 Carol G12 dead 6m + ⚫ Dave — offline +``` + +### Server embed (`server.info`, rewritten) + +Keeps the existing title glyph, endpoint description, status colour and Status / +Active-player fields. Adds three fields: + +| Field | Source | Replaces | +| ----- | ------ | -------- | +| Players | `ServerInfoSnapshot.Players` / `.MaxPlayers` / `.QueuedPlayers` | `/pop` | +| Time | `ServerTimeSnapshot.TimeOfDay` / `.Sunrise` / `.Sunset` | `/time` | +| Wipe | `ServerInfoSnapshot.WipeTimeUtc` + `IClock.UtcNow`, via `DurationFormat.Compact` | `/wipe` | + +The bare `state.PlayerCount` field is replaced by the richer Players field. + +**Map size and seed stay out** — they are already rendered by +`ServerInfoMapMessageRenderer` in the embed directly above +(`map.info.size`, `map.info.seed`). No duplication. + +**Day/night computation is extracted** into the shared `Daylight` helper in +Abstractions described under "Renderer placement" below, consumed by both +`TimeCommandHandler` and this renderer so `!time` and the embed cannot drift +apart. The helper returns raw values; each caller formats with its own resx keys. + +### Events embed (`server.events`, new) + +Five inline fields. Cargo / heli / chinook come from +`IEventState.GetActiveMarkers(guildId, serverId, kind)` — present means out, +rendered with its grid reference and time since `SeenAtUtc`; absent means +"Not out". Small and large rigs come from `IRigState.Get(...)`, mapping +`RigStatus.Online | Active | Offline`, showing `RigState.Remaining` when the +phase is timed. + +Cargo, heli and chinook are **not** in the removed-command list, but their state +is already tracked in-process at zero marginal cost, and a two-row Events embed +would not justify a message slot. + +`RigReply` (the existing status→text mapper) lives in `Features.Commands`, which +references `Features.Events` — the wrong direction to reuse from. The renderer +implements its own mapping against its own `server.events.rig.*` keys. + +### Team embed (`server.team`, new) + +Rendered as an embed **description list**, not fields: Rust caps a team at 8 +members so there is no truncation risk, and a list reads far better than +Discord's three-column inline grid. + +- One line per member: leader crown, status glyph, name, grid reference, duration. +- Status glyphs — 🟢 online and alive, 😴 AFK, 💀 dead, ⚫ offline. +- AFK membership comes from `IAfkState.GetAfkMembersAsync`, which also supplies + `StillFor` for the duration. +- Grid references use `GridReference.From(x, y, dims, style)` with the + per-server `MapGridStyle` read from `IMapSettingsStore`, matching every other + grid reference the bot prints. +- Offline members render `—` rather than a grid, since their reported + coordinates are last-known and would read as current. They also carry **no + duration**: `TeamMemberSnapshot` exposes `LastSpawnTimeUtc` and + `LastDeathTimeUtc` but no disconnect timestamp, so any "offline for X" figure + would be fabricated from the wrong field. +- Sort order: online-alive by survival time descending (the `/alive` ordering), + then AFK, then dead, then offline. +- Durations use `DurationFormat.Compact`. + +Together this replaces `/team` (the roster), `/online` and `/offline` (the +glyphs and the `n/m online` title), and `/alive` (the ordering and survival +durations). + +## Renderer placement + +`IMessageRenderer`, `MessageRenderContext` and `MessagePayload` are currently +`internal` to `Features.Workspace`. **All three become public**, and each new +renderer lives next to the data it renders: + +| Renderer | Project | Dependencies | +| -------- | ------- | ------------ | +| `ServerInfoMessageRenderer` (rewritten) | Features.Workspace | `IRustServerQuery` (Abstractions), `IConnectionStore`, `IServerService` | +| `ServerEventsMessageRenderer` (new) | Features.Events | `IEventState`, `IRigState`, `GridReference` | +| `ServerTeamMessageRenderer` (new) | Features.Players | `IRustServerQuery`, `IAfkState` (Connections), `GridReference` (Events), `IMapSettingsStore` | + +The Team renderer lands in **Features.Players**, not Features.Connections. It +needs both `IAfkState` (Features.Connections) and `GridReference` +(Features.Events), and Connections sits *below* Events in the reference graph — +`Features.Events → Features.Connections`, not the reverse. Features.Players is +the lowest project that already references Workspace, Connections **and** Events, +so it is the only existing home that sees every dependency. + +`Features.Events` and `Features.Players` already reference `Features.Workspace` +one-way, so this adds **no new project references**. Each feature registers its +own renderer in its existing `IServiceCollection` extension. + +### Prerequisite: `DurationFormat` moves down + +All three renderers format durations, but `DurationFormat` is currently +`internal` to `Features.Commands` (`Formatting/DurationFormat.cs`) — the +*highest* project in the graph, invisible to all three renderer homes. It moves +to `RustPlusBot.Abstractions/Formatting/DurationFormat.cs` as `public static`, +which every project already references. Its thirteen existing call sites are all +inside Features.Commands and need only a `using` swap. + +`GridReference` is already `public` in `Features.Events.Formatting` and does not +move. + +### Prerequisite: a shared daylight helper + +`TimeCommandHandler` computes day-vs-night inline +(`Handlers/TimeCommandHandler.cs:27`), and it lives in Features.Commands where +the Server renderer cannot reach it. The computation moves to +`RustPlusBot.Abstractions/Connections/Daylight.cs` as a `public static` helper +over `ServerTimeSnapshot`, consumed by both the handler and the renderer so +`!time` and the embed cannot drift apart. + +The helper reports the interval to the next sunrise/sunset in **in-game hours**. +Rust's day length is server-configurable and the API does not report it, so the +render must not imply real-world minutes; the resx string says so explicitly. + +The alternative — keeping all three renderers in Workspace — would require +promoting `IEventState`, `IRigState` and `IAfkState` into Abstractions along +with `ActiveMarker`, `TrailPoint`, `RigState`, `RigStatus`, `AfkMember` and +`GridReference`. Strictly more churn than moving one formatting helper, and it +would invert the current layering where Workspace depends on no feature project. + +`MessageSpec` declarations stay centralised in `ServerWorkspaceSpecProvider` so +the ordering invariant remains visible in one file. + +## Refresh + +### Hosted service + +New `Hosting/ServerInfoRefreshHostedService` in `Features.Workspace`, mirroring +`MapHostedService.RunPeriodicRefreshAsync` (`Features.Map/Hosting/MapHostedService.cs:156-179`) +for its guild/server enumeration. Each tick, for every server whose +`ConnectionState.Status` is `Connected`, it calls the refresher below. + +Interval comes from a new `WorkspaceOptions.InfoRefreshInterval` +(`Features.Workspace/WorkspaceOptions.cs`, alongside `EnableDangerCommands`), +default `00:01:00`, surfaced in `appsettings.json` under the existing +`Workspace` section. + +The existing event triggers in `WorkspaceHostedService` are unchanged and keep +firing an immediate full reconcile on connect/disconnect, credential change, +map-ready and server-registered. + +### Narrow refresh path + +The tick must **not** call `ReconcileServerAsync`: that acquires the per-guild +`ProvisioningLock` and re-walks channel provisioning and ordering repair +(`Reconciler/WorkspaceReconciler.cs:162-331`) — far too heavy for a +minute-by-minute pulse. + +A new `RefreshServerMessagesAsync(guildId, serverId, ct)` on +`IWorkspaceReconciler` does only: + +1. Read the provisioned message ids for the server's #info channel from + `IWorkspaceStore` (a DB read, no Discord call). +2. Render the three payloads. +3. Canonicalize each via `RenderCanonicalizer.Canonicalize`. +4. `RenderGate.ShouldSend` → skip when unchanged; otherwise + `EditMessageAsync` then `RenderGate.Commit`. +5. On a missing message id, or a 404 from the edit, fall back to the full + `ReconcileServerAsync` for that server — the existing self-heal. + +### Render gating is new here + +`WorkspaceReconciler.EnsureMessagesAsync` edits **unconditionally** today; it +has no gate. Introducing `RenderGate` (`Discord/Posting/RenderGate.cs`, already +a DI singleton) on this path means the Events embed — which changes rarely — +stops burning a PATCH every minute. The Server embed will almost always change, +since in-game time advances every tick. + +Steady-state cost per connected server per tick: three rustplus calls +(`GetServerInfoAsync`, `GetTimeAsync`, `GetTeamInfoAsync`; event and rig state +are in-process and free) and zero to three PATCHes. + +## Replacing the swap select + +`/server player [server]` replies **ephemerally with the same select menu** the +embed carries today — same `workspace:info:swap:{serverId}` custom id, handled +by the unchanged `ConnectionComponentModule` (`Features.Connections/Modules/ConnectionComponentModule.cs:19-21`). +No new interaction logic; the select is simply relocated from a persistent +message to an ephemeral response. + +- The command lives in `Features.Connections/Modules/`, beside its handler. +- `server` is optional, resolved by `ServerResolver`: auto-selected when the + guild has exactly one server, autocompleted otherwise. +- The `server.info.swap.placeholder` resx key is retained and reused. + +`CommandHelpCatalog.Slash` gains an entry for it. The in-game list is unchanged. + +## Edge cases + +- **Not connected.** The Server embed renders Status and Active player only, + omitting the live fields. Events and Team render an explicit "Not connected" + body rather than an empty payload — an empty payload makes the reconciler skip + the edit, which would leave stale data presented as current. +- **Snapshot returns null mid-tick** (socket dropped between the status check + and the query). Distinct from a known disconnect: that embed's edit is skipped + for this tick, preserving the previous render. The next tick recovers. +- **Server removed mid-tick.** `IServerService.GetAsync` returns null → the + refresher skips the server; the deletion flow already tears down the channel. +- **Team query succeeds with zero members.** Title renders `0/0 online` and the + body renders a localized "No team members" line. +- **Member with an empty display name.** Falls back to the SteamId, matching the + existing leader-name handling (`ServerInfoMessageRenderer.cs:116-118`). +- **Deleted slash commands lingering in Discord.** Guild command registration + bulk-overwrites, so the nine disappear on the next registration pass. Confirm + during the live smoke rather than assuming. +- **Interval set absurdly low.** The interval is clamped to a one-second floor + when read, so a misconfiguration cannot spin the loop. + +## Testing + +- **Renderer unit tests, per embed** — mirroring + `Features.Workspace.Tests/Messages/RendererTests.cs`: connected and + disconnected, null snapshot, zero-member team, AFK member, dead member, + offline member, leader crown, each of the three rig phases, marker present + and absent, and grid style honoured. +- **Refresher tests** — an unchanged render produces no `EditMessageAsync`; a + changed render produces exactly one; a 404 escalates to `ReconcileServerAsync`; + a missing message id escalates likewise. +- **Hosted service test** — only `Connected` servers are refreshed. +- **Existing Workspace tests updated** — `RendererTests` currently asserts the + swap select is present (`:66`, `:118`) and that the select and button occupy + separate action rows (`:246`); those assertions change to expect the button + alone. +- **Command tests** — `ServerQueryServiceTests` deleted. + `CommandRegistrationTests` drops its `ServerResolver` and `ServerQueryService` + resolution assertions (the former moves to the Connections container, the + latter is gone); its `Assert.Equal(28, handlers.Count)` is **unchanged**, + since no `ICommandHandler` is removed. A matching assertion that + `ServerResolver` resolves is added to the Connections registration test. + The in-game `QueryHandlersTests` must keep passing unmodified — that is the + guard that the `!command` surface survived intact. +- **Localization** — `StringsResourceParityTests.Catalog_has_expected_key_count` + hard-codes `281` (`tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs:44`); + update to the new total, with every added key present in both `Strings.resx` + and `Strings.fr.resx`. +- **Live smoke (user gate)** — confirm the four messages render in order in + #info, that they refresh on the configured interval, that the nine slash + commands are gone from Discord's picker, that the in-game `!commands` still + answer, and that `/server player` swaps the active credential. + +## Non-goals + +- Battlemetrics-sourced player data (subsystem 7). +- A per-member player-profile embed or Steam avatars. +- Historical charts (population over time, wipe-cycle trends). +- Making the Events embed interactive (per-event mute toggles). +- Per-server refresh intervals — the interval is process-wide. +- Restoring a slash-command equivalent for `/pop` and friends. The in-game + handlers remain the only on-demand surface; #info is the Discord surface. diff --git a/src/RustPlusBot.Abstractions/Connections/Daylight.cs b/src/RustPlusBot.Abstractions/Connections/Daylight.cs new file mode 100644 index 00000000..0e9a7400 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/Daylight.cs @@ -0,0 +1,51 @@ +using System.Globalization; + +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Day/night reasoning over a . Shared by the in-game !time +/// handler and the #info server embed so the two can never disagree. All intervals are in +/// in-game hours: Rust's day length is server-configurable and the API never reports +/// it, so these values must not be presented as real-world minutes. +/// +public static class Daylight +{ + private const float HoursPerDay = 24f; + + /// True when the in-game clock sits between sunrise (inclusive) and sunset (exclusive). + /// The in-game time snapshot. + /// True during daylight; false at night. + public static bool IsDay(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + return snapshot.TimeOfDay >= snapshot.Sunrise && snapshot.TimeOfDay < snapshot.Sunset; + } + + /// Renders the in-game clock as zero-padded "HH:mm". + /// The in-game time snapshot. + /// The clock text, e.g. "14:32". + public static string Clock(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var hours = (int)snapshot.TimeOfDay; + var minutes = (int)((snapshot.TimeOfDay - hours) * 60f); + return string.Create(CultureInfo.InvariantCulture, $"{hours:00}:{minutes:00}"); + } + + /// In-game hours until the next sunrise or sunset, wrapping past midnight. + /// The in-game time snapshot. + /// The interval in in-game hours; never negative. + public static float HoursUntilTransition(ServerTimeSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var target = IsDay(snapshot) ? snapshot.Sunset : snapshot.Sunrise; + var delta = target - snapshot.TimeOfDay; + return delta >= 0f ? delta : delta + HoursPerDay; + } + + /// The same interval as , as a . + /// The in-game time snapshot. + /// The interval in in-game hours expressed as a TimeSpan. + public static TimeSpan UntilTransition(ServerTimeSnapshot snapshot) + => TimeSpan.FromHours(HoursUntilTransition(snapshot)); +} diff --git a/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs b/src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs similarity index 89% rename from src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs rename to src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs index 251d7d12..800dc1a8 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs +++ b/src/RustPlusBot.Abstractions/Formatting/DurationFormat.cs @@ -1,9 +1,9 @@ using System.Globalization; -namespace RustPlusBot.Features.Commands.Formatting; +namespace RustPlusBot.Abstractions.Formatting; -/// Formats durations compactly for in-game replies. -internal static class DurationFormat +/// Formats durations compactly for in-game replies and Discord embeds. +public static class DurationFormat { /// Renders a duration as "Xd Yh" / "Yh Zm" / "Zm". /// The duration to render. diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 1755a4c7..5e00372f 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using RustPlusBot.Features.Commands.Help; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Servers; using RustPlusBot.Features.ItemData; using RustPlusBot.Localization; @@ -62,8 +61,6 @@ public static IServiceCollection AddCommands(this IServiceCollection services) // Discord surfaces (3c): help renderer, leader service, and the interaction modules. services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly)); return services; diff --git a/src/RustPlusBot.Features.Commands/Formatting/DecayLine.cs b/src/RustPlusBot.Features.Commands/Formatting/DecayLine.cs index 62162844..191dca8a 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/DecayLine.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/DecayLine.cs @@ -1,4 +1,5 @@ using System.Globalization; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.ItemData.Data; namespace RustPlusBot.Features.Commands.Formatting; diff --git a/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs b/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs index 1154c9b3..08e06122 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs @@ -1,4 +1,5 @@ using System.Globalization; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.ItemData.Data; using RustPlusBot.Features.ItemData.Naming; diff --git a/src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs b/src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs index 0ee675ca..e6ddb44d 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs @@ -1,4 +1,5 @@ using System.Globalization; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.ItemData.Data; namespace RustPlusBot.Features.Commands.Formatting; diff --git a/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs b/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs index 7231e4ab..d4d218ac 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs @@ -1,4 +1,5 @@ using System.Globalization; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.ItemData.Data; using RustPlusBot.Features.ItemData.Naming; diff --git a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs index 8d0e1798..6b2d4409 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs @@ -1,5 +1,5 @@ +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Localization; diff --git a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs index c9537023..1a951575 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs @@ -1,7 +1,7 @@ using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; diff --git a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs index cf4a49e2..e0e1d1bd 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs @@ -1,7 +1,7 @@ using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; diff --git a/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs index e83c9299..fa62f241 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs @@ -1,6 +1,6 @@ using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; diff --git a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs index 0b0361ec..b8c732fd 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs @@ -1,4 +1,3 @@ -using System.Globalization; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Localization; @@ -23,13 +22,8 @@ internal sealed class TimeCommandHandler(IRustServerQuery query, ILocalizer loca return localizer.Get("command.notconnected", context.Culture); } - var isDay = time.TimeOfDay >= time.Sunrise && time.TimeOfDay < time.Sunset; - var phase = localizer.Get(isDay ? "command.time.day" : "command.time.night", context.Culture); + var phase = localizer.Get(Daylight.IsDay(time) ? "command.time.day" : "command.time.night", context.Culture); - var hours = (int)time.TimeOfDay; - var minutes = (int)((time.TimeOfDay - hours) * 60f); - var clockText = string.Create(CultureInfo.InvariantCulture, $"{hours:00}:{minutes:00}"); - - return localizer.Get("command.time.ok", context.Culture, clockText, phase); + return localizer.Get("command.time.ok", context.Culture, Daylight.Clock(time), phase); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs index 68553ae0..4819415b 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs @@ -1,5 +1,5 @@ +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Localization; diff --git a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs index e6ce1f60..9a3ecbf5 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs @@ -1,7 +1,7 @@ using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs index 0af5acf7..dfa80739 100644 --- a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs @@ -42,6 +42,7 @@ internal static class CommandHelpCatalog new("help", CommandGroup.Bot, "help.slash.help"), new("uptime", CommandGroup.Bot, "help.slash.uptime"), new("leader", CommandGroup.Bot, "help.slash.leader"), + new("server player", CommandGroup.Bot, "help.slash.server.player"), new("item", CommandGroup.ItemDb, "help.slash.item"), new("recycle", CommandGroup.ItemDb, "help.slash.recycle"), new("craft", CommandGroup.ItemDb, "help.slash.craft"), diff --git a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs index 1e99ac8a..e6bfe0c9 100644 --- a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs @@ -2,7 +2,7 @@ using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.Commands.Help; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Leader; diff --git a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs index 04ba1fc7..63145a03 100644 --- a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs @@ -3,7 +3,7 @@ using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Abstractions.Formatting; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; diff --git a/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs b/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs deleted file mode 100644 index 99c87525..00000000 --- a/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Discord.Interactions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using RustPlusBot.Features.Commands.Servers; -using RustPlusBot.Persistence.Workspace; - -namespace RustPlusBot.Features.Commands.Modules; - -/// Read-only live-data slash commands (/pop, /time, /wipe, /online, /offline, /team, /alive). -/// Creates a short-lived DI scope per interaction. -/// The logger. -public sealed partial class ServerCommandModule( - IServiceScopeFactory scopeFactory, - ILogger logger) - : InteractionModuleBase -{ - /// Shows the current player count. - /// The target server (only needed if more than one is registered). - [SlashCommand("pop", "Show the current player count")] - public Task PopAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("pop", server); - - /// Shows the in-game time. - /// The target server (only needed if more than one is registered). - [SlashCommand("time", "Show the in-game time")] - public Task TimeAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("time", server); - - /// Shows how long ago the server wiped. - /// The target server (only needed if more than one is registered). - [SlashCommand("wipe", "Show how long ago the server wiped")] - public Task WipeAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("wipe", server); - - /// Lists online teammates. - /// The target server (only needed if more than one is registered). - [SlashCommand("online", "List online teammates")] - public Task OnlineAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("online", server); - - /// Lists offline teammates. - /// The target server (only needed if more than one is registered). - [SlashCommand("offline", "List offline teammates")] - public Task OfflineAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("offline", server); - - /// Lists all team members. - /// The target server (only needed if more than one is registered). - [SlashCommand("team", "List all team members")] - public Task TeamAsync( - [Summary("server", "Which server (only needed if more than one)")] - [Autocomplete(typeof(ServerAutocompleteHandler))] - string? server = null) => RunAsync("team", server); - - /// Shows how long each teammate has survived. - /// The target server (only needed if more than one is registered). - [SlashCommand("alive", "Show how long each teammate has survived")] - public Task AliveAsync( - [Summary("server", "Which server (only needed if more than one)")] - [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) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - await DeferAsync(ephemeral: true).ConfigureAwait(false); - try - { - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var resolver = scope.ServiceProvider.GetRequiredService(); - var query = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - var resolution = await resolver.ResolveAsync(Context.Guild.Id, server, culture, CancellationToken.None) - .ConfigureAwait(false); - if (resolution.ErrorMessage is { } error) - { - await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - - var reply = await query.RunAsync(commandName, Context.Guild.Id, resolution.ServerId!.Value, culture, - CancellationToken.None).ConfigureAwait(false); - await FollowupAsync(reply ?? "Something went wrong.", ephemeral: true).ConfigureAwait(false); - } - } -#pragma warning disable CA1031 // Broad catch: an interaction failure must not surface as a Discord "interaction failed". - catch (Exception ex) -#pragma warning restore CA1031 - { - LogCommandFaulted(logger, ex, commandName); - await FollowupAsync("Something went wrong.", ephemeral: true).ConfigureAwait(false); - } - } - - [LoggerMessage(Level = LogLevel.Error, Message = "Slash data command '{Command}' faulted.")] - private static partial void LogCommandFaulted(ILogger logger, Exception exception, string command); -} diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs b/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs deleted file mode 100644 index 7e452c6d..00000000 --- a/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using RustPlusBot.Features.Commands.Dispatching; - -namespace RustPlusBot.Features.Commands.Servers; - -/// -/// Runs an in-game command handler for the slash-command path. Builds a synthetic context (no in-game -/// caller, no args) and delegates to the existing handler so slash and in-game replies are identical. -/// -internal sealed class ServerQueryService -{ - private readonly Dictionary _handlers; - - /// Initializes the service from the registered handlers. - /// The available in-game command handlers. - public ServerQueryService(IEnumerable handlers) - { - ArgumentNullException.ThrowIfNull(handlers); - _handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal); - } - - /// Runs the named handler against a server and returns its localized reply. - /// The handler name (e.g. "pop"). - /// The owning guild snowflake. - /// The target server id. - /// The guild culture. - /// A cancellation token. - /// The reply text, or null if the command is unknown or the handler produced no reply. - public async Task RunAsync( - string commandName, - ulong guildId, - Guid serverId, - string culture, - CancellationToken cancellationToken) - { - if (!_handlers.TryGetValue(commandName, out var handler)) - { - return null; - } - - var context = new CommandContext(guildId, serverId, culture, 0UL, string.Empty, []); - return await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index 7736a409..641fc0c3 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -4,7 +4,9 @@ using RustPlusBot.Features.Connections.Hosting; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Connections.Servers; using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Connections; @@ -18,6 +20,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services { ArgumentNullException.ThrowIfNull(services); + services.AddRustPlusBotLocalization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -27,6 +30,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly)); diff --git a/src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs b/src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs new file mode 100644 index 00000000..f4fa0276 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Modules/ServerPlayerModule.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Connections.Servers; +using RustPlusBot.Features.Workspace; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Connections.Modules; + +/// +/// /server player — replaces the "switch active player" select that used to sit on the #info +/// embed. It replies ephemerally with that same select (same custom id), so +/// handles the choice with no new interaction logic. +/// +/// Creates a short-lived DI scope per interaction. +[Group("server", "Server administration")] +public sealed class ServerPlayerModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Shows the credential picker for a server. + /// The target server (only needed if more than one is registered). + /// A task that completes when the reply has been sent. + [SlashCommand("player", "Switch which paired player drives a server's connection")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task PlayerAsync( + [Summary("server", "Which server (only needed if more than one)")] + [Autocomplete(typeof(ServerAutocompleteHandler))] + string? server = null) + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var resolver = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + var connections = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var resolution = await resolver + .ResolveAsync(Context.Guild.Id, server, culture, CancellationToken.None).ConfigureAwait(false); + if (resolution.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var serverId = resolution.ServerId!.Value; + var state = await connections.GetStateAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + var pool = await connections.ListPoolAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + var eligible = pool + .Where(c => c.Status != CredentialStatus.Invalid) + .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. + .ToList(); + if (eligible.Count == 0) + { + await FollowupAsync(localizer.Get("command.server.player.nopool", culture), ephemeral: true) + .ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId($"{WorkspaceComponentIds.ServerInfoSwapPrefix}{serverId}") + .WithPlaceholder(localizer.Get("server.info.swap.placeholder", culture)); + foreach (var credential in eligible) + { + select.AddOption( + credential.SteamId.ToString(CultureInfo.InvariantCulture), + credential.Id.ToString(), + isDefault: credential.Id == state?.ActiveCredentialId); + } + + await FollowupAsync( + localizer.Get("command.server.player.pick", culture), + ephemeral: true, + components: new ComponentBuilder().WithSelectMenu(select).Build()) + .ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs b/src/RustPlusBot.Features.Connections/Servers/ServerAutocompleteHandler.cs similarity index 96% rename from src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs rename to src/RustPlusBot.Features.Connections/Servers/ServerAutocompleteHandler.cs index 32c46897..5589b5e7 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs +++ b/src/RustPlusBot.Features.Connections/Servers/ServerAutocompleteHandler.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Persistence.Servers; -namespace RustPlusBot.Features.Commands.Servers; +namespace RustPlusBot.Features.Connections.Servers; /// Offers the guild's registered servers (by name) as choices for the slash server option. public sealed class ServerAutocompleteHandler : AutocompleteHandler diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs b/src/RustPlusBot.Features.Connections/Servers/ServerResolution.cs similarity index 89% rename from src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs rename to src/RustPlusBot.Features.Connections/Servers/ServerResolution.cs index 0eed93fe..df765820 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs +++ b/src/RustPlusBot.Features.Connections/Servers/ServerResolution.cs @@ -1,4 +1,4 @@ -namespace RustPlusBot.Features.Commands.Servers; +namespace RustPlusBot.Features.Connections.Servers; /// The outcome of resolving a slash command's target server. /// The resolved server id, or null on error. diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs b/src/RustPlusBot.Features.Connections/Servers/ServerResolver.cs similarity index 97% rename from src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs rename to src/RustPlusBot.Features.Connections/Servers/ServerResolver.cs index 46743793..7a9e1804 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs +++ b/src/RustPlusBot.Features.Connections/Servers/ServerResolver.cs @@ -1,7 +1,7 @@ using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; -namespace RustPlusBot.Features.Commands.Servers; +namespace RustPlusBot.Features.Connections.Servers; /// Picks the target server for a slash command from an optional (autocompleted) server argument. /// The server service used to list the guild's servers. diff --git a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs index 95cc8243..21e5b0c6 100644 --- a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Hosting; +using RustPlusBot.Features.Events.Messages; using RustPlusBot.Features.Events.Posting; using RustPlusBot.Features.Events.Relaying; using RustPlusBot.Features.Events.Rendering; using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; namespace RustPlusBot.Features.Events; @@ -32,6 +34,9 @@ public static IServiceCollection AddEvents(this IServiceCollection services) services.AddSingleton(); services.AddHostedService(); + // Contributes the #info events embed to the Workspace reconciler. + services.AddScoped(); + return services; } } diff --git a/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs b/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs new file mode 100644 index 00000000..d8b57f2d --- /dev/null +++ b/src/RustPlusBot.Features.Events/Messages/ServerEventsMessageRenderer.cs @@ -0,0 +1,105 @@ +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Events.Messages; + +/// +/// Renders the #info events embed: cargo / patrol heli / chinook presence plus both oil rigs' +/// lifecycle phase. Reads only in-process state, so it costs no Rust+ calls. +/// +/// Active map-marker state. +/// Inferred oil-rig state. +/// Supplies the per-server grid convention. +/// Live connection status, to avoid reporting cleared state as live. +/// Supplies the current time for marker ages. +/// String resolution. +public sealed class ServerEventsMessageRenderer( + IEventState events, + IRigState rigs, + IMapSettingsStore mapSettings, + IConnectionStore connections, + IClock clock, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "server.events"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var state = await connections.GetStateAsync(context.GuildId, serverId, cancellationToken) + .ConfigureAwait(false); + var connected = state?.Status == ConnectionStatus.Connected; + var culture = context.Culture; + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("server.events.title", culture)) + .WithColor(connected ? Color.DarkerGrey : Color.Red) + // Marker and rig state are in-process and cleared on disconnect, so without this the rows + // would read as a confident live report of an empty world. + .WithDescription(connected ? null : localizer.Get("server.events.disconnected", culture)) + .AddField(localizer.Get("server.events.cargo.label", culture), + Marker(context.GuildId, serverId, MarkerKind.CargoShip, settings.GridStyle, culture), inline: true) + .AddField(localizer.Get("server.events.heli.label", culture), + Marker(context.GuildId, serverId, MarkerKind.PatrolHelicopter, settings.GridStyle, culture), + inline: true) + .AddField(localizer.Get("server.events.chinook.label", culture), + Marker(context.GuildId, serverId, MarkerKind.Chinook, settings.GridStyle, culture), inline: true) + .AddField(localizer.Get("server.events.small.label", culture), + Rig(context.GuildId, serverId, RigKind.Small, culture), inline: true) + .AddField(localizer.Get("server.events.large.label", culture), + Rig(context.GuildId, serverId, RigKind.Large, culture), inline: true); + + return new MessagePayload(null, embed.Build(), null); + } + + private string Marker(ulong guildId, Guid serverId, MarkerKind kind, MapGridStyle style, string culture) + { + var active = events.GetActiveMarkers(guildId, serverId, kind); + if (active.Count == 0) + { + return localizer.Get("server.events.notout", culture); + } + + // Newest-first: the freshest sighting is the one worth reporting. + var marker = active[0]; + return localizer.Get("server.events.out", culture, + GridReference.From(marker.X, marker.Y, marker.Dimensions, style), + DurationFormat.Compact(clock.UtcNow - marker.SeenAtUtc)); + } + + private string Rig(ulong guildId, Guid serverId, RigKind rig, string culture) + { + var state = rigs.Get(guildId, serverId, rig); + return state.Status switch + { + RigStatus.Active => localizer.Get("server.events.rig.active", culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + RigStatus.Offline => localizer.Get("server.events.rig.offline", culture, + DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)), + _ => localizer.Get("server.events.rig.online", culture), + }; + } +} diff --git a/src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs b/src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs new file mode 100644 index 00000000..7d33a713 --- /dev/null +++ b/src/RustPlusBot.Features.Players/Messages/ServerTeamMessageRenderer.cs @@ -0,0 +1,171 @@ +using System.Globalization; +using System.Text; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Players.Messages; + +/// +/// Renders the #info team embed: one line per member with presence, grid reference and how long +/// they have held their current state. Replaces the /team, /online, /offline and /alive commands. +/// Rust caps a team at eight members, so a description list never risks truncation. +/// +/// Live team query. +/// Live AFK state. +/// Supplies the per-server grid convention. +/// Supplies the current time for durations. +/// String resolution. +public sealed class ServerTeamMessageRenderer( + IRustServerQuery query, + IAfkState afk, + IMapSettingsStore mapSettings, + IClock clock, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "server.team"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var culture = context.Culture; + var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is null) + { + // Honest over stale: an empty payload would make the reconciler skip the edit and leave + // the previous roster on screen as though it were current. + var offline = new EmbedBuilder() + .WithTitle(localizer.Get("server.team.title", culture, "0", "0")) + .WithDescription(localizer.Get("server.team.disconnected", culture)) + .WithColor(Color.Red) + .Build(); + return new MessagePayload(null, offline, null); + } + + var online = team.Members.Count(m => m.IsOnline); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("server.team.title", culture, + online.ToString(CultureInfo.InvariantCulture), + team.Members.Count.ToString(CultureInfo.InvariantCulture))) + .WithColor(Color.Blue); + + if (team.Members.Count == 0) + { + return new MessagePayload(null, + embed.WithDescription(localizer.Get("server.team.empty", culture)).Build(), null); + } + + var afkIds = await ResolveAfkAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + var dims = await query.GetMapDimensionsAsync(context.GuildId, serverId, cancellationToken) + .ConfigureAwait(false); + + var body = new StringBuilder(); + foreach (var member in team.Members.OrderBy(m => RankOf(m, afkIds)).ThenByDescending(SurvivedFor)) + { + body.Append(LineFor(member, team.LeaderSteamId, afkIds, dims, settings.GridStyle, culture)).Append('\n'); + } + + return new MessagePayload(null, embed.WithDescription(body.ToString().TrimEnd('\n')).Build(), null); + } + + private async ValueTask> ResolveAfkAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken) + { + var members = await afk.GetAfkMembersAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return members is null + ? [] + : members.ToDictionary(m => m.SteamId, m => m.StillFor); + } + + /// Sort bucket: online-alive, then AFK, then dead, then offline. + /// The member to rank. + /// Currently-AFK members, keyed by Steam id. + /// 0 for online-alive, 1 for AFK, 2 for dead, 3 for offline. + private static int RankOf(TeamMemberSnapshot member, Dictionary afkIds) + { + if (!member.IsOnline) + { + return 3; + } + + if (!member.IsAlive) + { + return 2; + } + + return afkIds.ContainsKey(member.SteamId) ? 1 : 0; + } + + private TimeSpan SurvivedFor(TeamMemberSnapshot member) => clock.UtcNow - member.LastSpawnTimeUtc; + + private string LineFor( + TeamMemberSnapshot member, + ulong leaderSteamId, + Dictionary afkIds, + MapDimensions? dims, + MapGridStyle style, + string culture) + { + var crown = member.SteamId == leaderSteamId ? "👑" : string.Empty; + + // The API can report a member with no display name; fall back to the id rather than a blank. + var name = string.IsNullOrWhiteSpace(member.Name) + ? member.SteamId.ToString(CultureInfo.InvariantCulture) + : member.Name; + + // Offline members report their last-known position, which would read as current. + var grid = member.IsOnline + ? GridReference.From(member.X, member.Y, dims, style) + : localizer.Get("server.team.nogrid", culture); + + var (glyph, state) = StateFor(member, afkIds, culture); + + return localizer.Get("server.team.line", culture, crown, glyph, name, grid, state); + } + + private (string Glyph, string State) StateFor( + TeamMemberSnapshot member, + Dictionary afkIds, + string culture) + { + if (!member.IsOnline) + { + // No disconnect timestamp exists on the snapshot, so no duration is reported here. + return ("⚫", localizer.Get("server.team.offline", culture)); + } + + if (!member.IsAlive) + { + return ("💀", localizer.Get("server.team.dead", culture, + DurationFormat.Compact(clock.UtcNow - member.LastDeathTimeUtc))); + } + + if (afkIds.TryGetValue(member.SteamId, out var stillFor)) + { + return ("😴", localizer.Get("server.team.afk", culture, DurationFormat.Compact(stillFor))); + } + + return ("🟢", localizer.Get("server.team.alive", culture, DurationFormat.Compact(SurvivedFor(member)))); + } +} diff --git a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs index 5642eedc..f13deded 100644 --- a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Features.Players.Hosting; +using RustPlusBot.Features.Players.Messages; using RustPlusBot.Features.Players.Posting; using RustPlusBot.Features.Players.Relaying; using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; namespace RustPlusBot.Features.Players; @@ -23,6 +25,9 @@ public static IServiceCollection AddPlayers(this IServiceCollection services) services.AddSingleton(); services.AddHostedService(); + // Contributes the #info team embed to the Workspace reconciler. + services.AddScoped(); + return services; } } diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index c43110f7..837c2019 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -125,11 +125,8 @@ public async Task EditMessageAsync(ulong guildId, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(payload); - var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); - if (channel is null) - { - return; - } + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId) + ?? throw new InvalidOperationException($"Channel {channelId} not found in guild {guildId}."); await channel.ModifyMessageAsync(messageId, props => { diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index 2e8cce00..aef06424 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -91,6 +91,7 @@ Task PostMessageAsync(ulong guildId, /// The snowflake ID of the message to edit. /// The new content to apply to the message. /// Token to cancel the operation. + /// The channel no longer exists in the guild. Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, diff --git a/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs b/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs index 6b9d998f..410c45a6 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs @@ -6,4 +6,4 @@ namespace RustPlusBot.Features.Workspace.Gateway; /// Plain content, or null. /// An embed, or null. /// Message components (buttons/selects), or null. -internal sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components); +public sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components); diff --git a/src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs new file mode 100644 index 00000000..ffb9bb83 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Hosting/ServerInfoRefreshHostedService.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Workspace.Hosting; + +/// +/// Re-renders every connected server's #info embeds on a steady interval. In-game time, population +/// and team state change continuously, and the workspace reconciler is purely event-driven — without +/// this tick the embeds would only update on connect, disconnect, wipe or credential change. +/// Each tick enumerates the connectable servers from the store (the source of truth) rather than +/// tracking connections from ConnectionStatusChangedEvent: the connection can publish its +/// "connected" event before this service has subscribed on startup, so an event-only set would miss +/// it and that server's embeds would never refresh. A disconnected server renders cheaply — the live +/// queries return null without a socket round-trip, producing its "not connected" embeds. +/// +/// Supplies the refresh interval. +/// Opens a scope per tick (the store) and per refresh (the refresher is scoped). +/// The logger. +internal sealed partial class ServerInfoRefreshHostedService( + IOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _tickLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _tickLoop = Task.Run(() => RunPeriodicRefreshAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + if (_tickLoop is null) + { + return; + } + + try + { + await _tickLoop.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } + } + + /// Resolves the effective interval, flooring it so a misconfiguration cannot spin the loop. + /// The bound workspace options. + /// The interval to sleep between ticks. + public static TimeSpan ResolveInterval(WorkspaceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var floor = TimeSpan.FromSeconds(1); + return options.InfoRefreshInterval < floor ? floor : options.InfoRefreshInterval; + } + + /// + /// Enumerates the connectable servers from the store and refreshes each. A transient enumeration + /// failure is logged and swallowed so the next tick still runs; per-server failures are isolated + /// inside . + /// + /// A cancellation token. + /// A task that completes when every connectable server has been refreshed. + internal async Task RefreshDueServersAsync(CancellationToken cancellationToken) + { + IReadOnlyList<(ulong GuildId, Guid ServerId)> servers; + try + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + servers = await store.ListConnectableServersAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a transient store failure must not stop future ticks. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogTickLoopFaulted(logger, ex); + return; + } + + foreach (var (guildId, serverId) in servers) + { + await RefreshAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(ResolveInterval(options.Value), cancellationToken).ConfigureAwait(false); + await RefreshDueServersAsync(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 + { + LogTickLoopFaulted(logger, ex); + } + } + + private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + try + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var refresher = scope.ServiceProvider.GetRequiredService(); + await refresher.RefreshAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: one server's failure must not stop the rotation. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRefreshFaulted(logger, ex, serverId); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "#info refresh tick faulted.")] + private static partial void LogTickLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "#info refresh failed for server {ServerId}.")] + private static partial void LogRefreshFaulted(ILogger logger, Exception exception, Guid serverId); +} diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index 2d357e68..eb889070 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -1,6 +1,8 @@ using System.Globalization; using Discord; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Formatting; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Features.Workspace.Gateway; @@ -11,15 +13,17 @@ namespace RustPlusBot.Features.Workspace.Messages; -/// Renders a server's #info embed with live connection status and the ManageGuild swap select. +/// Renders a server's #info status embed: connection, population, in-game time and wipe age. /// Server lookup. /// Live connection state + pool. -/// Live team query. +/// Live server query. +/// Supplies the current time for the wipe age. /// String resolution. internal sealed class ServerInfoMessageRenderer( IServerService servers, IConnectionStore connections, IRustServerQuery query, + IClock clock, ILocalizer localizer) : IMessageRenderer { /// @@ -58,70 +62,61 @@ public async ValueTask RenderAsync(MessageRenderContext context, .AddField(localizer.Get("server.info.player.label", context.Culture), active is null ? none : active.SteamId.ToString(CultureInfo.InvariantCulture)); - if (status == ConnectionStatus.Connected && state?.PlayerCount is int count) - { - embed.AddField(localizer.Get("server.info.players.label", context.Culture), - count.ToString(CultureInfo.InvariantCulture)); - } - if (status == ConnectionStatus.Connected) { - await AddTeamFieldAsync(embed, context, serverId, cancellationToken).ConfigureAwait(false); - } - - var eligible = pool - .Where(c => c.Status != CredentialStatus.Invalid) - .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. - .ToList(); - var builder = new ComponentBuilder(); - if (eligible.Count > 0) - { - var select = new SelectMenuBuilder() - .WithCustomId($"{WorkspaceComponentIds.ServerInfoSwapPrefix}{serverId}") - .WithPlaceholder(localizer.Get("server.info.swap.placeholder", context.Culture)); - foreach (var credential in eligible) - { - var label = credential.SteamId.ToString(CultureInfo.InvariantCulture); - select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id); - } - - builder.WithSelectMenu(select, row: 0); + await AddLiveFieldsAsync(embed, context, serverId, cancellationToken).ConfigureAwait(false); } - // Keep the remove button on its own row: Discord rejects an action row that mixes a select with buttons. - builder.WithButton( - localizer.Get("server.info.remove.button", context.Culture), - $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", - ButtonStyle.Danger, - row: eligible.Count > 0 ? 1 : 0); + // The swap select moved to /server player; the remove button now owns row 0 alone. + var builder = new ComponentBuilder() + .WithButton( + localizer.Get("server.info.remove.button", context.Culture), + $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", + ButtonStyle.Danger, + row: 0); return new MessagePayload(null, embed.Build(), builder.Build()); } - private async ValueTask AddTeamFieldAsync( + private async ValueTask AddLiveFieldsAsync( EmbedBuilder embed, MessageRenderContext context, Guid serverId, CancellationToken cancellationToken) { - var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); - if (team is null) + var info = await query.GetServerInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (info is not null) { - return; + embed.AddField( + localizer.Get("server.info.players.label", context.Culture), + localizer.Get("server.info.players.value", context.Culture, + info.Players.ToString(CultureInfo.InvariantCulture), + info.MaxPlayers.ToString(CultureInfo.InvariantCulture), + info.QueuedPlayers.ToString(CultureInfo.InvariantCulture))); } - var online = team.Members.Count(m => m.IsOnline); - var leaderEntry = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId); - // The API can report a member with no display name, so treat an empty name as missing. - var leaderName = string.IsNullOrWhiteSpace(leaderEntry?.Name) - ? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture) - : leaderEntry.Name; - embed.AddField( - localizer.Get("server.info.team.label", context.Culture), - localizer.Get("server.info.team.value", context.Culture, - online.ToString(CultureInfo.InvariantCulture), - team.Members.Count.ToString(CultureInfo.InvariantCulture), - leaderName)); + var time = await query.GetTimeAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (time is not null) + { + var isDay = Daylight.IsDay(time); + embed.AddField( + localizer.Get("server.info.time.label", context.Culture), + localizer.Get("server.info.time.value", context.Culture, + Daylight.Clock(time), + localizer.Get(isDay ? "server.info.time.day" : "server.info.time.night", context.Culture), + DurationFormat.Compact(Daylight.UntilTransition(time)), + localizer.Get(isDay ? "server.info.time.to.night" : "server.info.time.to.day", context.Culture))); + } + + if (info is not null) + { + embed.AddField( + localizer.Get("server.info.wipe.label", context.Culture), + info.WipeTimeUtc is { } wiped + ? localizer.Get("server.info.wipe.value", context.Culture, + DurationFormat.Compact(clock.UtcNow - wiped)) + : localizer.Get("server.info.wipe.unknown", context.Culture)); + } } private static string Glyph(ConnectionStatus status) => status switch diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/IServerInfoRefresher.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IServerInfoRefresher.cs new file mode 100644 index 00000000..44165218 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/IServerInfoRefresher.cs @@ -0,0 +1,17 @@ +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// +/// Re-renders a server's #info messages in place without re-walking channel provisioning. +/// The full takes the per-guild +/// provisioning lock and re-checks every category and channel — far too heavy for a +/// minute-by-minute pulse. This path reads the message ids straight from the store and edits. +/// +internal interface IServerInfoRefresher +{ + /// Re-renders the server's #info messages, editing only those whose render changed. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// A task that completes when every message has been considered. + Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs new file mode 100644 index 00000000..4ac40a67 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs @@ -0,0 +1,90 @@ +using RustPlusBot.Discord.Posting; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Render-gated in-place refresh of the three #info messages. +/// Supplies the provisioned message ids and the guild culture. +/// Performs the Discord edit. +/// All registered message renderers. +/// Suppresses PATCHes for renders that did not change. +/// The full reconcile, used to self-heal a missing or stale message. +internal sealed class ServerInfoRefresher( + IWorkspaceStore store, + IWorkspaceGateway gateway, + IEnumerable renderers, + RenderGate gate, + IWorkspaceReconciler reconciler) : IServerInfoRefresher +{ + /// The #info message keys this path refreshes, in declaration order. + private static readonly string[] Keys = + [ + WorkspaceMessageKeys.ServerInfo, + WorkspaceMessageKeys.ServerEvents, + WorkspaceMessageKeys.ServerTeam, + ]; + + private readonly Dictionary _renderers = + renderers.ToDictionary(r => r.MessageKey, StringComparer.Ordinal); + + /// + public async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var context = new MessageRenderContext(guildId, serverId, culture); + + foreach (var key in Keys) + { + if (!_renderers.TryGetValue(key, out var renderer)) + { + // The host did not compose the feature that owns this embed; nothing to refresh. + continue; + } + + var payload = await renderer.RenderAsync(context, cancellationToken).ConfigureAwait(false); + if (payload.Text is null && payload.Embed is null && payload.Components is null) + { + // Nothing to show (e.g. the server row vanished mid-tick). Leave the message alone. + continue; + } + + var record = await store.GetMessageAsync(guildId, serverId, key, cancellationToken).ConfigureAwait(false); + if (record is null) + { + // Never posted (or wiped): the full reconcile owns creating it. + await reconciler.ReconcileServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return; + } + + var canonical = RenderCanonicalizer.Canonicalize(payload.Embed, payload.Components); + if (!gate.ShouldSend(record.DiscordMessageId, canonical)) + { + continue; + } + + try + { + await gateway + .EditMessageAsync(guildId, record.DiscordChannelId, record.DiscordMessageId, payload, + cancellationToken) + .ConfigureAwait(false); + gate.Commit(record.DiscordMessageId, canonical); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: any edit failure (deleted message, permissions, 5xx) heals the same way. + catch (Exception) +#pragma warning restore CA1031 + { + // Never remember a render that did not land, or the next tick would skip it too. + gate.Invalidate(record.DiscordMessageId); + await reconciler.ReconcileServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return; + } + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs index 8f09def0..0597ba0b 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Renders the payload for one message key. Looked up by . -internal interface IMessageRenderer +public interface IMessageRenderer { /// The message key this renderer produces (matches ). string MessageKey { get; } diff --git a/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs b/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs index 50270bf1..20c010c9 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs @@ -4,4 +4,4 @@ namespace RustPlusBot.Features.Workspace.Registry; /// The guild being rendered for. /// The server scope, or null for global messages. /// The guild's BCP-47 culture. -internal sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture); +public sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture); diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index ce92fafc..ca22ea62 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -27,9 +27,12 @@ public IEnumerable GetChannelSpecs() => /// public IEnumerable GetMessageSpecs() => [ - // Declared FIRST so it posts above the status embed (within-channel order = declaration order). + // Declaration order IS the on-screen order (Discord orders messages by creation time), and + // the reconciler re-posts later messages to repair drift. Keep: map image, status, events, team. new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfoMap, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerEvents, WorkspaceChannelKeys.ServerInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerTeam, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), ]; } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index e950adfe..9653bac7 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -52,6 +52,12 @@ internal static class WorkspaceMessageKeys /// Key for the per-server info message. public const string ServerInfo = "server.info"; + /// Key for the per-server #info events embed (cargo/heli/chinook/rigs). Rendered by Features.Events. + public const string ServerEvents = "server.events"; + + /// Key for the per-server #info team embed (roster + presence). Rendered by Features.Players. + public const string ServerTeam = "server.team"; + /// Key for the per-server map control message (layer toggles). public const string ServerMap = "server.map"; } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs index 9b896750..5eb2763d 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs @@ -5,4 +5,13 @@ public sealed class WorkspaceOptions { /// Enables dangerous developer commands (/workspace reset, /workspace simulate-server). public bool EnableDangerCommands { get; set; } + + /// + /// How often every connected server's #info embeds are re-rendered. Unchanged renders are + /// suppressed by the render gate, so the steady-state cost is up to four Rust+ calls per + /// connected server per tick (server info, time, team info and map dimensions) and at most + /// three Discord edits. A disconnected server costs no round-trips — its live queries return + /// null immediately. + /// + public TimeSpan InfoRefreshInterval { get; set; } = TimeSpan.FromMinutes(1); } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 3ef020e2..06a9d2d4 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -52,6 +52,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // off the same scoped instance, so resolving either does not create a second instance. services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); @@ -73,6 +74,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(); services.AddHostedService(); + services.AddHostedService(); return services; } diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 4b7a3215..30630a41 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -30,7 +30,8 @@ "ConnectionString": "DataSource=rustplusbot.db" }, "Workspace": { - "EnableDangerCommands": false + "EnableDangerCommands": false, + "InfoRefreshInterval": "00:01:00" }, "Pairing": { "ProbeTimeout": "00:00:08", diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 432e00e0..26889e45 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -213,6 +213,12 @@ Aucun serveur n'est encore configuré. + + Aucun joueur associé n'est disponible pour ce serveur. + + + Choisissez quel joueur associé pilote la connexion. + Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur. @@ -465,6 +471,9 @@ Transférer le rôle de chef d'équipe en jeu. + + Changer le joueur associé qui pilote la connexion d'un serveur. + Afficher la recette de fabrication d'un objet. @@ -624,6 +633,42 @@ Événement d'équipe + + 🚢 Cargo + + + 🚁 Chinook + + + Non connecté au serveur. + + + 🚁 Hélicoptère de patrouille + + + 🛢️ Grande plateforme + + + Absent + + + Présent · {0} · il y a {1} + + + Caisse en déverrouillage · {0} restant + + + Pillée · réapparaît dans {0} + + + En ligne + + + 🛢️ Petite plateforme + + + ⚡ Événements + Adresse : {0}:{1} @@ -636,6 +681,9 @@ Joueurs en ligne + + {0} / {1} · {2} en file + Supprimer le serveur @@ -663,6 +711,33 @@ {0}/{1} en ligne · chef {2} + + ☀️ + + + Heure + + + 🌙 + + + le lever du soleil + + + la tombée de la nuit + + + {0} {1} · {2} de temps en jeu avant {3} + + + Wipe + + + Inconnu + + + il y a {0} + Accepter @@ -681,6 +756,33 @@ Nouveau serveur détecté + + AFK {0} + + + en vie {0} + + + mort {0} + + + Non connecté au serveur. + + + Aucun membre d'équipe. + + + {0}{1} **{2}** · {3} · {4} + + + + + + hors ligne + + + 👥 Équipe — {0}/{1} en ligne + Configurez le bot pour ce serveur. diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 51f3735f..70195253 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -213,6 +213,12 @@ No server is set up yet. + + No paired players are available for that server. + + + Choose which paired player drives the connection. + Multiple servers are set up — choose one with the server option. @@ -465,6 +471,9 @@ Transfer in-game team leadership. + + Switch which paired player drives a server's connection. + Show an item's craft recipe. @@ -624,6 +633,42 @@ Team event + + 🚢 Cargo Ship + + + 🚁 Chinook + + + Not connected to the server. + + + 🚁 Patrol Helicopter + + + 🛢️ Large Oil Rig + + + Not out + + + Out · {0} · {1} ago + + + Crate unlocking · {0} left + + + Looted · respawns in {0} + + + Online + + + 🛢️ Small Oil Rig + + + ⚡ Events + Endpoint: {0}:{1} @@ -636,6 +681,9 @@ Players online + + {0} / {1} · {2} queued + Remove server @@ -663,6 +711,33 @@ {0}/{1} online · leader {2} + + ☀️ + + + Time + + + 🌙 + + + sunrise + + + nightfall + + + {0} {1} · {2} of in-game time to {3} + + + Wipe + + + Unknown + + + {0} ago + Accept @@ -681,6 +756,33 @@ New server detected + + AFK {0} + + + alive {0} + + + dead {0} + + + Not connected to the server. + + + No team members. + + + {0}{1} **{2}** · {3} · {4} + + + + + + offline + + + 👥 Team — {0}/{1} online + Configure the bot for this server. diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/DaylightTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/DaylightTests.cs new file mode 100644 index 00000000..00a2d30b --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/DaylightTests.cs @@ -0,0 +1,65 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Tests.Connections; + +public sealed class DaylightTests +{ + [Theory] + [InlineData(12.0f, true)] // midday, between sunrise and sunset + [InlineData(7.5f, true)] // just after sunrise + [InlineData(2.0f, false)] // pre-dawn + [InlineData(21.0f, false)] // after sunset + [InlineData(7.0f, true)] // exactly sunrise counts as day + [InlineData(20.0f, false)] // exactly sunset counts as night + public void IsDay_bins_against_sunrise_and_sunset(float timeOfDay, bool expected) + { + var snapshot = new ServerTimeSnapshot(timeOfDay, 7.0f, 20.0f); + + Assert.Equal(expected, Daylight.IsDay(snapshot)); + } + + [Theory] + [InlineData(14.53f, "14:31")] + [InlineData(0.0f, "00:00")] + [InlineData(9.5f, "09:30")] + [InlineData(23.99f, "23:59")] + public void Clock_formats_as_zero_padded_hours_and_minutes(float timeOfDay, string expected) + { + var snapshot = new ServerTimeSnapshot(timeOfDay, 7.0f, 20.0f); + + Assert.Equal(expected, Daylight.Clock(snapshot)); + } + + [Fact] + public void HoursUntilTransition_during_day_counts_to_sunset() + { + var snapshot = new ServerTimeSnapshot(14.0f, 7.0f, 20.0f); + + Assert.Equal(6.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void HoursUntilTransition_before_sunrise_counts_to_sunrise() + { + var snapshot = new ServerTimeSnapshot(2.0f, 7.0f, 20.0f); + + Assert.Equal(5.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void HoursUntilTransition_after_sunset_wraps_past_midnight_to_sunrise() + { + var snapshot = new ServerTimeSnapshot(22.0f, 7.0f, 20.0f); + + // 2h to midnight + 7h to sunrise. + Assert.Equal(9.0f, Daylight.HoursUntilTransition(snapshot), 3); + } + + [Fact] + public void UntilTransition_matches_HoursUntilTransition() + { + var snapshot = new ServerTimeSnapshot(14.0f, 7.0f, 20.0f); + + Assert.Equal(TimeSpan.FromHours(6.0).TotalSeconds, Daylight.UntilTransition(snapshot).TotalSeconds, 1.0); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 1068b591..eb672bd5 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -7,7 +7,6 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Hosting; -using RustPlusBot.Features.Commands.Servers; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.ItemData; @@ -76,8 +75,6 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "durability"); Assert.Contains(handlers, h => h.Name == "smelt"); Assert.Contains(handlers, h => h.Name == "cctv"); - Assert.NotNull(scope.ServiceProvider.GetRequiredService()); - Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } [Fact] diff --git a/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs index 9710fec8..6abef74f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs @@ -1,4 +1,4 @@ -using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Abstractions.Formatting; namespace RustPlusBot.Features.Commands.Tests.Formatting; diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs deleted file mode 100644 index 3b9e846e..00000000 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Servers; - -namespace RustPlusBot.Features.Commands.Tests.Servers; - -public sealed class ServerQueryServiceTests -{ - [Fact] - public async Task RunsMatchingHandler_AndReturnsReply() - { - var handler = new StubHandler("pop", "Pop: 5/100 (0 queued)"); - var service = new ServerQueryService([handler]); - var server = Guid.NewGuid(); - - var reply = await service.RunAsync("pop", 1UL, server, "en", CancellationToken.None); - - Assert.Equal("Pop: 5/100 (0 queued)", reply); - Assert.NotNull(handler.Seen); - Assert.Equal(1UL, handler.Seen!.GuildId); - Assert.Equal(server, handler.Seen.ServerId); - Assert.Equal("en", handler.Seen.Culture); - Assert.Empty(handler.Seen.Args); - Assert.Equal(0UL, handler.Seen.SenderSteamId); - Assert.Equal(string.Empty, handler.Seen.SenderName); - } - - [Fact] - public async Task ReturnsNull_WhenNoHandlerMatches() - { - var service = new ServerQueryService([new StubHandler("pop", "x")]); - - var reply = await service.RunAsync("time", 1UL, Guid.NewGuid(), "en", CancellationToken.None); - - Assert.Null(reply); - } - - private sealed class StubHandler(string name, string? reply) : ICommandHandler - { - public CommandContext? Seen { get; private set; } - public string Name => name; - - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - Seen = context; - return Task.FromResult(reply); - } - } -} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs index fdd761fe..568020d0 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Connections.Servers; using RustPlusBot.Features.Connections.Supervisor; using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; @@ -41,5 +42,6 @@ public async Task Services_Resolve() await using var scope = provider.CreateAsyncScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs b/tests/RustPlusBot.Features.Connections.Tests/Servers/ServerResolverTests.cs similarity index 96% rename from tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs rename to tests/RustPlusBot.Features.Connections.Tests/Servers/ServerResolverTests.cs index 188c25f6..dd342d88 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Servers/ServerResolverTests.cs @@ -1,10 +1,10 @@ using NSubstitute; using RustPlusBot.Domain.Servers; -using RustPlusBot.Features.Commands.Servers; +using RustPlusBot.Features.Connections.Servers; using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; -namespace RustPlusBot.Features.Commands.Tests.Servers; +namespace RustPlusBot.Features.Connections.Tests.Servers; public sealed class ServerResolverTests { diff --git a/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs new file mode 100644 index 00000000..93be0d48 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Messages/ServerEventsMessageRendererTests.cs @@ -0,0 +1,135 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Events.Messages; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Events.Tests.Messages; + +public sealed class ServerEventsMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly Guid ServerId = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + + private static ServerEventsMessageRenderer Build( + IEventState events, + IRigState rigs, + ConnectionStatus status = ConnectionStatus.Connected) + { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(1, ServerId, Arg.Any()).Returns(MapLayerSettings.AllOn); + var connections = Substitute.For(); + connections.GetStateAsync(1, ServerId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = 1, RustServerId = ServerId, Status = status + }); + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new ServerEventsMessageRenderer(events, rigs, mapSettings, connections, clock, Loc); + } + + [Fact] + public async Task Renders_all_five_rows() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Equal(5, payload.Embed!.Fields.Length); + } + + [Fact] + public async Task Absent_marker_renders_not_out() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var cargo = payload.Embed!.Fields.Single(f => f.Name.Contains("Cargo", StringComparison.Ordinal)); + Assert.Equal("Not out", cargo.Value); + } + + [Fact] + public async Task Present_marker_renders_grid_and_age() + { + // MapDimensions ctor is (Width, Height, OceanMargin, WorldSize) in this repo, not + // (WorldSize, ...) — WorldSize is the last argument and is what GridReference.From reads. + var dims = new MapDimensions(4000, 4000, 0, 4000); + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + events.GetActiveMarkers(1, ServerId, MarkerKind.CargoShip) + .Returns([ + new ActiveMarker(9UL, MarkerKind.CargoShip, 2000f, 2000f, dims, Now.AddMinutes(-12), [], null), + ]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var cargo = payload.Embed!.Fields.Single(f => f.Name.Contains("Cargo", StringComparison.Ordinal)); + Assert.StartsWith("Out ·", cargo.Value, StringComparison.Ordinal); + Assert.Contains("12m ago", cargo.Value, StringComparison.Ordinal); + } + + [Theory] + [InlineData(RigStatus.Online, "Online")] + [InlineData(RigStatus.Active, "Crate unlocking · 4m left")] + [InlineData(RigStatus.Offline, "Looted · respawns in 4m")] + public async Task Rig_phases_render_distinctly(RigStatus status, string expected) + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, RigKind.Small).Returns(new RigState(status, TimeSpan.FromMinutes(4))); + rigs.Get(1, ServerId, RigKind.Large).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var small = payload.Embed!.Fields.Single(f => f.Name.Contains("Small", StringComparison.Ordinal)); + Assert.Equal(expected, small.Value); + } + + [Fact] + public async Task No_server_scope_renders_empty_payload() + { + var events = Substitute.For(); + var rigs = Substitute.For(); + + var payload = await Build(events, rigs).RenderAsync(new MessageRenderContext(1, null, "en"), default); + + Assert.Null(payload.Embed); + Assert.Null(payload.Text); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Disconnected_says_so_instead_of_reporting_stale_state() + { + var events = Substitute.For(); + events.GetActiveMarkers(1, ServerId, Arg.Any()).Returns([]); + var rigs = Substitute.For(); + rigs.Get(1, ServerId, Arg.Any()).Returns(new RigState(RigStatus.Online, null)); + + var payload = await Build(events, rigs, ConnectionStatus.Unreachable) + .RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + // In-process marker and rig state is cleared on disconnect, so an unqualified + // "Not out / Online" wall would read as a confident live report. + Assert.Equal("Not connected to the server.", payload.Embed!.Description); + } +} diff --git a/tests/RustPlusBot.Features.Players.Tests/Messages/ServerTeamMessageRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/Messages/ServerTeamMessageRendererTests.cs new file mode 100644 index 00000000..4b1115d9 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/Messages/ServerTeamMessageRendererTests.cs @@ -0,0 +1,202 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Players.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; + +namespace RustPlusBot.Features.Players.Tests.Messages; + +public sealed class ServerTeamMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + private static readonly Guid ServerId = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + private static readonly string[] ExpectedOrder = ["Alice", "Bob", "Carol", "Dave"]; + private static readonly string[] SurvivalOrder = ["Erin", "Frank"]; + + private static TeamMemberSnapshot Member( + ulong steamId, + string name, + bool online, + bool alive, + double spawnedHoursAgo = 2, + double diedMinutesAgo = 0) + => new(steamId, name, 2000f, 2000f, online, alive, + Now.AddHours(-spawnedHoursAgo), Now.AddMinutes(-diedMinutesAgo)); + + private static ServerTeamMessageRenderer Build(IRustServerQuery query, IAfkState afk) + { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(1, ServerId, Arg.Any()).Returns(MapLayerSettings.AllOn); + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new ServerTeamMessageRenderer(query, afk, mapSettings, clock, Loc); + } + + private static IAfkState NoAfk() + { + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>([])); + return afk; + } + + [Fact] + public async Task Title_counts_online_over_total() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [ + Member(1UL, "Alice", online: true, alive: true), + Member(2UL, "Bob", online: true, alive: true), + Member(3UL, "Carol", online: false, alive: true), + ])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("2/3 online", payload.Embed!.Title, StringComparison.Ordinal); + } + + [Fact] + public async Task Leader_gets_a_crown() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(2UL, [ + Member(1UL, "Alice", online: true, alive: true), + Member(2UL, "Bob", online: true, alive: true), + ])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var bobLine = payload.Embed!.Description.Split('\n').Single(l => l.Contains("Bob", StringComparison.Ordinal)); + Assert.Contains("👑", bobLine, StringComparison.Ordinal); + } + + [Fact] + public async Task Afk_member_renders_afk_not_alive() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(1UL, "Alice", online: true, alive: true)])); + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>( + [new AfkMember(1UL, "Alice", TimeSpan.FromMinutes(7))])); + + var payload = await Build(query, afk).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("AFK 7m", payload.Embed!.Description, StringComparison.Ordinal); + Assert.Contains("😴", payload.Embed.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Dead_member_renders_death_age() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, + [Member(1UL, "Alice", online: true, alive: false, diedMinutesAgo: 6)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("dead 6m", payload.Embed!.Description, StringComparison.Ordinal); + Assert.Contains("💀", payload.Embed.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Offline_member_hides_grid() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(1UL, "Alice", online: false, alive: true)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var line = payload.Embed!.Description.Split('\n').Single(l => l.Contains("Alice", StringComparison.Ordinal)); + Assert.Contains("—", line, StringComparison.Ordinal); + Assert.Contains("⚫", line, StringComparison.Ordinal); + } + + [Fact] + public async Task Empty_name_falls_back_to_steam_id() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(1UL, [Member(76561198000000000UL, "", online: true, alive: true)])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("76561198000000000", payload.Embed!.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Zero_members_renders_empty_notice() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(0UL, [])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Contains("0/0 online", payload.Embed!.Title, StringComparison.Ordinal); + Assert.Equal("No team members.", payload.Embed.Description); + } + + [Fact] + public async Task Null_snapshot_renders_disconnected_notice() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + Assert.Equal("Not connected to the server.", payload.Embed!.Description); + } + + [Fact] + public async Task Online_alive_sort_before_afk_dead_and_offline() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(99UL, [ + Member(4UL, "Dave", online: false, alive: true), + Member(3UL, "Carol", online: true, alive: false, diedMinutesAgo: 6), + Member(2UL, "Bob", online: true, alive: true, spawnedHoursAgo: 1), + Member(1UL, "Alice", online: true, alive: true, spawnedHoursAgo: 3), + ])); + var afk = Substitute.For(); + afk.GetAfkMembersAsync(1, ServerId, Arg.Any()) + .Returns(Task.FromResult?>( + [new AfkMember(2UL, "Bob", TimeSpan.FromMinutes(7))])); + + var payload = await Build(query, afk).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var names = payload.Embed!.Description.Split('\n') + .Select(l => ExpectedOrder.First(n => l.Contains(n, StringComparison.Ordinal))) + .ToArray(); + Assert.Equal(ExpectedOrder, names); + } + + [Fact] + public async Task Online_alive_members_sort_by_survival_time_descending() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1, ServerId, Arg.Any()) + .Returns(new TeamInfoSnapshot(99UL, [ + Member(5UL, "Frank", online: true, alive: true, spawnedHoursAgo: 1), + Member(6UL, "Erin", online: true, alive: true, spawnedHoursAgo: 5), + ])); + + var payload = await Build(query, NoAfk()).RenderAsync(new MessageRenderContext(1, ServerId, "en"), default); + + var names = payload.Embed!.Description.Split('\n') + .Select(l => SurvivalOrder.First(n => l.Contains(n, StringComparison.Ordinal))) + .ToArray(); + Assert.Equal(SurvivalOrder, names); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index 9b5f4cb8..b52de9f2 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -104,6 +104,11 @@ public Task EditMessageAsync(ulong guildId, MessagePayload payload, CancellationToken cancellationToken) { + if (!_channels.ContainsKey(channelId)) + { + throw new InvalidOperationException($"Channel {channelId} not found in guild {guildId}."); + } + _messages[messageId] = new Message(messageId, channelId, payload); EditedMessages++; return Task.CompletedTask; diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/ServerInfoRefreshHostedServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/ServerInfoRefreshHostedServiceTests.cs new file mode 100644 index 00000000..ba18b144 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/ServerInfoRefreshHostedServiceTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Features.Workspace.Hosting; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Workspace.Tests.Hosting; + +public sealed class ServerInfoRefreshHostedServiceTests +{ + [Fact] + public void Interval_is_clamped_to_a_one_second_floor() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.Zero + }); + + Assert.Equal(TimeSpan.FromSeconds(1), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public void Interval_is_clamped_when_negative() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.FromSeconds(-30) + }); + + Assert.Equal(TimeSpan.FromSeconds(1), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public void Configured_interval_passes_through() + { + var options = Options.Create(new WorkspaceOptions + { + InfoRefreshInterval = TimeSpan.FromMinutes(5) + }); + + Assert.Equal(TimeSpan.FromMinutes(5), ServerInfoRefreshHostedService.ResolveInterval(options.Value)); + } + + [Fact] + public async Task RefreshDueServers_refreshes_every_connectable_server_from_the_store() + { + var serverA = Guid.NewGuid(); + var serverB = Guid.NewGuid(); + var store = Substitute.For(); + store.ListConnectableServersAsync(Arg.Any()) + .Returns>([(1UL, serverA), (2UL, serverB)]); + var refresher = Substitute.For(); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => refresher); + await using var provider = services.BuildServiceProvider(); + + var service = new ServerInfoRefreshHostedService( + Options.Create(new WorkspaceOptions()), + provider.GetRequiredService(), + NullLogger.Instance); + + await service.RefreshDueServersAsync(CancellationToken.None); + + await refresher.Received(1).RefreshAsync(1UL, serverA, Arg.Any()); + await refresher.Received(1).RefreshAsync(2UL, serverB, Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 55518ea8..c8201ace 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -1,6 +1,7 @@ using Discord; using NSubstitute; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Servers; @@ -63,10 +64,12 @@ public async Task Settings_HasLanguageSelectMenu() } [Fact] - public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSelect() + public async Task ServerInfo_Connected_ShowsPlayersTimeAndWipe() { var serverId = Guid.NewGuid(); var credId = Guid.NewGuid(); + var now = new DateTimeOffset(2026, 7, 20, 12, 0, 0, TimeSpan.Zero); + var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) .Returns(new RustServer @@ -86,11 +89,10 @@ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSe GuildId = 1, ActiveCredentialId = credId, Status = ConnectionStatus.Connected, - PlayerCount = 12, + PlayerCount = 187, }); connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns( - [ + .Returns([ new() { Id = credId, @@ -101,162 +103,46 @@ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSe Status = CredentialStatus.Active }, ]); - var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); - - var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - - Assert.Contains("Rustopia EU", payload.Embed!.Title, StringComparison.Ordinal); - Assert.Contains("12", string.Concat(payload.Embed.Fields.Select(f => f.Value)), StringComparison.Ordinal); - Assert.Contains("76561198000000000", string.Concat(payload.Embed.Fields.Select(f => f.Value)), - StringComparison.Ordinal); - var selects = payload.Components!.Components.OfType() - .SelectMany(r => r.Components).OfType(); - Assert.Contains(selects, s => s.CustomId == $"workspace:info:swap:{serverId}"); - } - [Fact] - public async Task ServerInfo_NoCredentials_ShowsNoCredentialsAndNoCount() - { - var serverId = Guid.NewGuid(); - var servers = Substitute.For(); - servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer - { - Id = serverId, - GuildId = 1, - Name = "S", - Ip = "1.2.3.4", - Port = 28015 - }); - var connections = Substitute.For(); - connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns(new DomainConnectionState - { - RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials - }); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns([]); - var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); - - var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - - Assert.Contains("No working credentials", - string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal); - } - - [Fact] - public async Task ServerInfo_NullState_DefaultsToNoCredentials() - { - var serverId = Guid.NewGuid(); - var servers = Substitute.For(); - servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer - { - Id = serverId, - GuildId = 1, - Name = "S", - Ip = "1.2.3.4", - Port = 28015 - }); - var connections = Substitute.For(); - connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns((DomainConnectionState?)null); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns([]); var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + query.GetServerInfoAsync(1, serverId, Arg.Any()) + .Returns(new ServerInfoSnapshot(187, 200, 12, now.AddDays(-4).AddHours(-6))); + query.GetTimeAsync(1, serverId, Arg.Any()) + .Returns(new ServerTimeSnapshot(14.0f, 7.0f, 20.0f)); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); - - var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - - Assert.Contains("No working credentials", - string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal); - } + var clock = Substitute.For(); + clock.UtcNow.Returns(now); - [Fact] - public async Task Setup_HasConnectAccountButton() - { - var renderer = new SetupMessageRenderer(Loc); - - var payload = await renderer.RenderAsync(Global, default); - - Assert.NotNull(payload.Components); - var buttons = payload.Components!.Components.OfType() - .SelectMany(r => r.Components).OfType(); - Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect"); - } - - [Fact] - public async Task Setup_HasDisconnectAccountButton() - { - var renderer = new SetupMessageRenderer(Loc); - - var payload = await renderer.RenderAsync(Global, default); - - var buttons = payload.Components!.Components.OfType() - .SelectMany(r => r.Components).OfType(); - Assert.Contains(buttons, b => b.CustomId == "workspace:setup:disconnect"); - } - - [Fact] - public async Task ServerInfo_HasRemoveServerButton() - { - var serverId = Guid.NewGuid(); - var servers = Substitute.For(); - servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer - { - Id = serverId, - GuildId = 1, - Name = "S", - Ip = "1.2.3.4", - Port = 28015 - }); - var connections = Substitute.For(); - connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns(new DomainConnectionState - { - RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials - }); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns([]); - var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - var buttons = payload.Components!.Components.OfType() - .SelectMany(r => r.Components).OfType(); - Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); + var values = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("187 / 200", values, StringComparison.Ordinal); + Assert.Contains("12 queued", values, StringComparison.Ordinal); + Assert.Contains("14:00", values, StringComparison.Ordinal); + Assert.Contains("4d 6h ago", values, StringComparison.Ordinal); } [Fact] - public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() + public async Task ServerInfo_NoLongerRendersSwapSelect() { var serverId = Guid.NewGuid(); var credId = Guid.NewGuid(); + var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) .Returns(new RustServer { Id = serverId, GuildId = 1, - Name = "S", + Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState @@ -268,95 +154,80 @@ public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() PlayerCount = 1, }); connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns( - [ + .Returns([ new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, - SteamId = 5UL, + SteamId = 76561198000000000UL, Status = CredentialStatus.Active }, ]); + var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + query.GetServerInfoAsync(1, serverId, Arg.Any()).Returns((ServerInfoSnapshot?)null); + query.GetTimeAsync(1, serverId, Arg.Any()).Returns((ServerTimeSnapshot?)null); + query.GetTeamInfoAsync(1, serverId, Arg.Any()).Returns((TeamInfoSnapshot?)null); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - var rows = payload.Components!.Components.OfType().ToList(); - // Discord rejects an action row that mixes a select menu with buttons. - Assert.DoesNotContain(rows, r => - r.Components.OfType().Any() && r.Components.OfType().Any()); - Assert.Contains(rows, r => r.Components.OfType().Any()); - Assert.Contains(rows, r => r.Components.OfType().Any()); + var selects = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Empty(selects); + + var buttons = payload.Components.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); } [Fact] - public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() + public async Task ServerInfo_Disconnected_OmitsLiveFields() { var serverId = Guid.NewGuid(); - var credId = Guid.NewGuid(); + var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) .Returns(new RustServer { Id = serverId, GuildId = 1, - Name = "S", + Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState { - RustServerId = serverId, - GuildId = 1, - ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, - PlayerCount = 5, + RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.Unreachable, }); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns( - [ - new() - { - Id = credId, - GuildId = 1, - RustServerId = serverId, - OwnerUserId = 7, - SteamId = 5UL, - Status = CredentialStatus.Active - }, - ]); + connections.ListPoolAsync(1, serverId, Arg.Any()).Returns([]); + var query = Substitute.For(); - query.GetTeamInfoAsync(1, serverId, Arg.Any()) - .Returns(new TeamInfoSnapshot( - 10UL, - [ - new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - new TeamMemberSnapshot(20UL, "bob", 0f, 0f, false, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - ])); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); - Assert.Contains("1/2 online", body, StringComparison.Ordinal); - Assert.Contains("alice", body, StringComparison.Ordinal); + // Disconnected: never queries the socket, and shows only Status + Active player. + await query.DidNotReceive().GetServerInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()); + Assert.Equal(2, payload.Embed!.Fields.Length); } [Fact] - public async Task ServerInfo_Connected_LeaderHasEmptyName_FallsBackToSteamId() + public async Task ServerInfo_NoCredentials_ShowsNoCredentialsAndNoCount() { var serverId = Guid.NewGuid(); - var credId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) .Returns(new RustServer @@ -371,47 +242,27 @@ public async Task ServerInfo_Connected_LeaderHasEmptyName_FallsBackToSteamId() connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState { - RustServerId = serverId, - GuildId = 1, - ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, - PlayerCount = 1, + RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials }); connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns( - [ - new() - { - Id = credId, - GuildId = 1, - RustServerId = serverId, - OwnerUserId = 7, - SteamId = 5UL, - Status = CredentialStatus.Active - }, - ]); + .Returns([]); var query = Substitute.For(); - query.GetTeamInfoAsync(1, serverId, Arg.Any()) - .Returns(new TeamInfoSnapshot( - 10UL, - [ - // Leader is present but the API reported no display name. - new TeamMemberSnapshot(10UL, string.Empty, 0f, 0f, true, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - ])); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var clock = Substitute.For(); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); - Assert.Contains("leader 10", body, StringComparison.Ordinal); + Assert.Contains("No working credentials", + string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal); } [Fact] - public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() + public async Task ServerInfo_NullState_DefaultsToNoCredentials() { var serverId = Guid.NewGuid(); - var credId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) .Returns(new RustServer @@ -424,35 +275,44 @@ public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() }); var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns(new DomainConnectionState - { - RustServerId = serverId, - GuildId = 1, - ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, - PlayerCount = 5, - }); + .Returns((DomainConnectionState?)null); connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns( - [ - new() - { - Id = credId, - GuildId = 1, - RustServerId = serverId, - OwnerUserId = 7, - SteamId = 5UL, - Status = CredentialStatus.Active - }, - ]); + .Returns([]); var query = Substitute.For(); - query.GetTeamInfoAsync(1, serverId, Arg.Any()) + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + var clock = Substitute.For(); + + var renderer = new ServerInfoMessageRenderer(servers, connections, query, clock, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - var teamLabel = Loc.Get("server.info.team.label", "en"); - Assert.DoesNotContain(payload.Embed!.Fields, f => f.Name == teamLabel); + Assert.Contains("No working credentials", + string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal); + } + + [Fact] + public async Task Setup_HasConnectAccountButton() + { + var renderer = new SetupMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + Assert.NotNull(payload.Components); + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect"); + } + + [Fact] + public async Task Setup_HasDisconnectAccountButton() + { + var renderer = new SetupMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == "workspace:setup:disconnect"); } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs new file mode 100644 index 00000000..93563d69 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs @@ -0,0 +1,196 @@ +using Discord; +using NSubstitute; +using RustPlusBot.Discord.Posting; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class ServerInfoRefresherTests +{ + private const ulong GuildId = 1; + private const ulong ChannelId = 500; + private const ulong MessageId = 900; + private static readonly Guid ServerId = Guid.NewGuid(); + + private static IWorkspaceStore StoreWith(params string[] keys) + { + var store = Substitute.For(); + store.GetCultureAsync(GuildId, Arg.Any()).Returns("en"); + foreach (var key in keys) + { + store.GetMessageAsync(GuildId, ServerId, key, Arg.Any()) + .Returns(new ProvisionedMessage + { + GuildId = GuildId, + RustServerId = ServerId, + MessageKey = key, + DiscordChannelId = ChannelId, + DiscordMessageId = MessageId, + }); + } + + return store; + } + + [Fact] + public async Task First_refresh_edits_the_message() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.Received(1).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Unchanged_render_is_not_sent_twice() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + await refresher.RefreshAsync(GuildId, ServerId, default); + + Assert.Equal(2, renderer.Calls); + await gateway.Received(1).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Changed_render_is_sent_again() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + renderer.Title = "v2"; + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.Received(2).EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Missing_message_row_escalates_to_full_reconcile() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var store = Substitute.For(); + store.GetCultureAsync(GuildId, Arg.Any()).Returns("en"); + store.GetMessageAsync(GuildId, ServerId, Arg.Any(), Arg.Any()) + .Returns((ProvisionedMessage?)null); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await reconciler.Received(1).ReconcileServerAsync(GuildId, ServerId, Arg.Any()); + await gateway.DidNotReceive().EditMessageAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Edit_failure_escalates_and_invalidates_the_gate() + { + // This also stands in as the regression test for the missing-channel case: DiscordWorkspaceGateway's + // EditMessageAsync throws InvalidOperationException("Channel {id} not found in guild {id}.") when the + // channel is absent from the socket cache (symmetric with PostMessageAsync), rather than silently + // returning. ServerInfoRefresher's catch below is exception-type-agnostic, so a channel-missing throw + // is handled by the exact same escalate-and-invalidate path exercised here; a separate test with only + // a different exception message would exercise no new code and assert nothing new. + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + gateway.EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()) + .Returns(Task.FromException( + new InvalidOperationException($"Channel {ChannelId} not found in guild {GuildId}."))); + var reconciler = Substitute.For(); + var gate = new RenderGate(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], gate, + reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await reconciler.Received(1).ReconcileServerAsync(GuildId, ServerId, Arg.Any()); + // Probe with the EXACT canonical of the render that was attempted (what the stub renderer built and + // ServerInfoRefresher tried to send), not an arbitrary literal. A dummy probe would pass even if the + // gate had wrongly committed the render before the edit landed — this one would not. + var attemptedCanonical = RenderCanonicalizer.Canonicalize(new EmbedBuilder().WithTitle("v1").Build(), null); + Assert.True(gate.ShouldSend(MessageId, attemptedCanonical)); + } + + [Fact] + public async Task Empty_payload_is_skipped_without_editing() + { + var renderer = new EmptyPayloadRenderer(WorkspaceMessageKeys.ServerInfo); + var gateway = Substitute.For(); + var reconciler = Substitute.For(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), reconciler); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + await gateway.DidNotReceive().EditMessageAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + await reconciler.DidNotReceive().ReconcileServerAsync(Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Refreshes_all_three_info_messages() + { + var renderers = new IMessageRenderer[] + { + new StubRenderer(WorkspaceMessageKeys.ServerInfo, "a"), + new StubRenderer(WorkspaceMessageKeys.ServerEvents, "b"), + new StubRenderer(WorkspaceMessageKeys.ServerTeam, "c"), + }; + var store = StoreWith(WorkspaceMessageKeys.ServerInfo, WorkspaceMessageKeys.ServerEvents, + WorkspaceMessageKeys.ServerTeam); + var gateway = Substitute.For(); + var refresher = new ServerInfoRefresher(store, gateway, renderers, new RenderGate(), + Substitute.For()); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + Assert.All(renderers.Cast(), r => Assert.Equal(1, r.Calls)); + } + + private sealed class StubRenderer(string key, string title) : IMessageRenderer + { + public int Calls { get; private set; } + + public string Title { get; set; } = title; + public string MessageKey => key; + + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + Calls++; + var embed = new EmbedBuilder().WithTitle(Title).Build(); + return ValueTask.FromResult(new MessagePayload(null, embed, null)); + } + } + + private sealed class EmptyPayloadRenderer(string key) : IMessageRenderer + { + public string MessageKey => key; + + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(new MessagePayload(null, null, null)); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs index da8bb7c8..3fa96b71 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs @@ -30,4 +30,26 @@ public void Contributes_storagemonitors_as_interactive_per_server_channel() Assert.Equal(ChannelPermissionProfile.Interactive, storagemonitors.Permissions); Assert.Equal("channel.storagemonitors.name", storagemonitors.NameKey); } + + [Fact] + public void Info_channel_messages_are_declared_in_render_order() + { + var specs = new ServerWorkspaceSpecProvider().GetMessageSpecs() + .Where(s => s.ChannelKey == "info") + .Select(s => s.Key) + .ToArray(); + + // Discord orders by creation time, so declaration order is the on-screen order: + // map image, then status, then events, then team. + Assert.Equal(["server.info.map", "server.info", "server.events", "server.team"], specs); + } + + [Fact] + public void Every_per_server_message_targets_a_declared_channel() + { + var provider = new ServerWorkspaceSpecProvider(); + var channels = provider.GetChannelSpecs().Select(c => c.Key).ToHashSet(StringComparer.Ordinal); + + Assert.All(provider.GetMessageSpecs(), s => Assert.Contains(s.ChannelKey, channels)); + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 3e72e6b0..23e5b1ef 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(281, EnglishKeys().Count); + Assert.Equal(315, EnglishKeys().Count); } }