diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index fea52606..032a6480 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -16,6 +16,7 @@
+
@@ -34,6 +35,7 @@
+
diff --git a/docs/superpowers/plans/2026-07-15-wipe-detection.md b/docs/superpowers/plans/2026-07-15-wipe-detection.md
new file mode 100644
index 00000000..46482734
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-15-wipe-detection.md
@@ -0,0 +1,2985 @@
+# Server Wipe Detection 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:** Detect a Rust server wipe on reconnect (persisted WipeTime/Seed/WorldSize baseline), announce it in the per-server #events channel (optionally pinging @everyone via a new global setting, default off), and auto-purge all paired smart devices (rows + Discord embeds) so no stale devices or alarm notifications survive the wipe.
+
+**Architecture:** A new `RustPlusBot.Features.Wipes` feature project subscribes to the existing `ConnectionStatusChangedEvent`; on each transition to connected it reads `IRustServerQuery.GetServerInfoAsync`/`GetWorldAsync`, diffs against a baseline persisted on `RustServer` (new columns), and publishes a new `ServerWipedEvent` on the in-process bus. Alarms/Switches/StorageMonitors each subscribe and purge their own rows and channel messages. A `PingEveryoneOnWipe` flag on `GuildSettings` is surfaced as a toggle button in the #settings message.
+
+**Tech Stack:** C# / .NET 10, EF Core 10 (SQLite), Discord.Net, xUnit + NSubstitute, resx localization (en/fr).
+
+**Spec:** `docs/superpowers/specs/2026-07-15-wipe-detection-design.md`
+
+**Spec deviation (agreed):** the spec's "Features.Pairing purges `PairedEntity` rows" step is DROPPED — nothing in production code ever inserts `PairedEntity` rows (verified: the only writer is a test; the only reader is `GuildPurgeService`), so a per-server wipe purge of that table would be dead code.
+
+## Global Constraints
+
+- Solution file is `RustPlusBot.slnx` (no `.sln`). Build with `dotnet build RustPlusBot.slnx`.
+- **`-maxcpucount:1` is MANDATORY on EVERY `dotnet build` AND `dotnet test`.** A `ConfigureGitHooks BeforeTargets="Build"` target races on `.git/config` under parallel builds; a failed build silently DROPS an assembly's tests (they report 0 and look "passing"). Always read the per-assembly test counts, never just "passed".
+- Run `dotnet tool restore` once before the first build (jb / ef / stryker / docfx are local manifest tools).
+- Build is `-warnaserror` (`TreatWarningsAsErrors=true` in `Directory.Build.props`): every public/internal type and member needs XML `///` docs; `CA1305/CA1307/CA1310` → pass `CultureInfo.InvariantCulture` / `StringComparison.Ordinal`; `CA2007` → `.ConfigureAwait(false)` on every awaited task in `src/` (tests don't need it).
+- Tests are plain xUnit `Assert.*` + NSubstitute. NO FluentAssertions. `using Xunit` is a global using — do not add it per-file.
+- `dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx` is a hard CI gate that fails on any diff — run it before every commit (local tool, `dotnet jb`). It is slow; running it once per task right before `git commit` is fine.
+- EF migrations use the local tool: `dotnet ef` (dotnet-ef 10.0.9). Migrations live in `src/RustPlusBot.Persistence/Migrations`; `--project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host`. If `dotnet ef` errors with "Unable to retrieve project metadata", build the solution first and retry.
+- Event-bus consumer loops follow the exact `AlarmsHostedService` shape: one `Task.Run` loop per event type, `await foreach (… in eventBus.SubscribeAsync(ct))`, `OperationCanceledException` swallowed, broad `catch (Exception)` with `#pragma warning disable CA1031` + a `[LoggerMessage]` log.
+- The localization parity test (`tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs`) fails if `Strings.resx` and `Strings.fr.resx` key sets differ — always add new keys to BOTH.
+- Commit messages are plain imperative sentences (repo style, no `feat:` prefixes), each ending with the trailer line `Co-Authored-By: Claude Fable 5 `.
+- `docs/superpowers/**` is tracked in git (NOT ignored) — commit the spec/plan normally.
+- Feature branch: `feat/wipe-detection` off `develop`. Create it before Task 1: `git checkout -b feat/wipe-detection`.
+
+---
+
+### Task 1: Wipe baseline columns + wipe-ping guild setting (Domain + migration)
+
+Add the persisted state everything else diffs against: three nullable baseline columns on `RustServer` and a `PingEveryoneOnWipe` bool on `GuildSettings`, in one EF migration.
+
+**Files:**
+- Modify: `src/RustPlusBot.Domain/Servers/RustServer.cs`
+- Modify: `src/RustPlusBot.Domain/Guilds/GuildSettings.cs`
+- Modify: `src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs`
+- Create (generated): `src/RustPlusBot.Persistence/Migrations/*_WipeDetection.cs` (+ `.Designer.cs`, snapshot update)
+- Test: `tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs` (create)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `RustServer.LastWipeTimeUtc` (`DateTimeOffset?`), `RustServer.LastMapSeed` (`uint?`), `RustServer.LastMapSize` (`uint?`), `GuildSettings.PingEveryoneOnWipe` (`bool`, default `false`). Task 2's store reads/writes these.
+
+- [ ] **Step 1: Write the failing round-trip test**
+
+Create `tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs` (the `Servers/` test folder already exists; `SqliteContextFixture` applies the real migrations, so this fails until the migration exists):
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Guilds;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Tests.Servers;
+
+/// Round-trips the wipe-baseline columns and the wipe-ping guild flag through the migrated schema.
+public sealed class RustServerWipeColumnsTests
+{
+ [Fact]
+ public async Task Wipe_baseline_columns_round_trip()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = connection;
+ await using var __ = context;
+
+ var server = new RustServer
+ {
+ GuildId = 10UL,
+ Name = "S",
+ Ip = "1.1.1.1",
+ Port = 28015,
+ LastWipeTimeUtc = new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero),
+ LastMapSeed = 123456u,
+ LastMapSize = 4250u,
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+
+ var loaded = await context.RustServers.FindAsync(server.Id);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), loaded.LastWipeTimeUtc);
+ Assert.Equal(123456u, loaded.LastMapSeed);
+ Assert.Equal(4250u, loaded.LastMapSize);
+ }
+
+ [Fact]
+ public async Task New_server_has_empty_baseline_and_guild_ping_defaults_false()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = connection;
+ await using var __ = context;
+
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ context.GuildSettings.Add(new GuildSettings
+ {
+ GuildId = 10UL
+ });
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+
+ var loadedServer = await context.RustServers.FindAsync(server.Id);
+ var loadedGuild = await context.GuildSettings.SingleOrDefaultAsync(g => g.GuildId == 10UL);
+
+ Assert.NotNull(loadedServer);
+ Assert.Null(loadedServer.LastWipeTimeUtc);
+ Assert.Null(loadedServer.LastMapSeed);
+ Assert.Null(loadedServer.LastMapSize);
+ Assert.NotNull(loadedGuild);
+ Assert.False(loadedGuild.PingEveryoneOnWipe);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1 --filter RustServerWipeColumnsTests`
+Expected: FAIL — compile error (`LastWipeTimeUtc` does not exist).
+
+- [ ] **Step 3: Add the domain properties**
+
+In `src/RustPlusBot.Domain/Servers/RustServer.cs`, append inside the class (after `FacepunchServerId`):
+
+```csharp
+ /// Baseline: the last observed wipe time (UTC) from getInfo, or null before first observation.
+ public DateTimeOffset? LastWipeTimeUtc { get; set; }
+
+ /// Baseline: the last observed procedural map seed, or null before first observation.
+ public uint? LastMapSeed { get; set; }
+
+ /// Baseline: the last observed world size (game units), or null before first observation.
+ public uint? LastMapSize { get; set; }
+```
+
+In `src/RustPlusBot.Domain/Guilds/GuildSettings.cs`, append inside the class:
+
+```csharp
+ /// When true, the server-wiped announcement pings @everyone in #events. Off by default.
+ public bool PingEveryoneOnWipe { get; set; }
+```
+
+In `src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs`, add explicit `long` conversions for the `uint?` columns (keeps the model portable to providers without native uint support), after the existing `Property` lines:
+
+```csharp
+ builder.Property(s => s.LastMapSeed).HasConversion();
+ builder.Property(s => s.LastMapSize).HasConversion();
+```
+
+- [ ] **Step 4: Generate the migration**
+
+Run (from repo root):
+
+```bash
+dotnet build RustPlusBot.slnx -maxcpucount:1
+dotnet ef migrations add WipeDetection --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host
+```
+
+Expected: a new `Migrations/_WipeDetection.cs` whose `Up` adds nullable `LastWipeTimeUtc` (TEXT), `LastMapSeed` (INTEGER), `LastMapSize` (INTEGER) to `RustServers`, and non-nullable `PingEveryoneOnWipe` (INTEGER, `defaultValue: false`) to `GuildSettings`. Inspect the file to confirm exactly these four columns and nothing else.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1`
+Expected: PASS, including the 2 new tests (check per-assembly counts).
+
+Also verify no model drift: `dotnet ef migrations has-pending-model-changes --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host`
+Expected: "No changes have been made to the model since the last migration."
+
+- [ ] **Step 6: Cleanup + commit**
+
+```bash
+dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx
+git add -A
+git commit -m "Add wipe baseline columns and wipe-ping guild setting
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 2: WipeBaselineStore + wipe-ping accessors on IWorkspaceStore
+
+Persistence seams the detector and announcer will use: a single-purpose baseline store over the new `RustServer` columns, and `Get/SetPingEveryoneOnWipeAsync` beside the existing culture accessors.
+
+**Files:**
+- Create: `src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs`
+- Create: `src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs`
+- Create: `src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs`
+- Modify: `src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs` (two methods, next to `GetCultureAsync`/`SetCultureAsync`)
+- Modify: `src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs`
+- Modify: `src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs` (register the store)
+- Test: `tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs` (create)
+- Test: `tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs` (create)
+
+**Interfaces:**
+- Consumes: Task 1's columns.
+- Produces (used by Tasks 3 and 5):
+
+```csharp
+public sealed record WipeBaseline(DateTimeOffset? WipeTimeUtc, uint? MapSeed, uint? MapSize);
+
+public interface IWipeBaselineStore
+{
+ Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+ Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default);
+}
+
+// added to IWorkspaceStore:
+Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default);
+Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default);
+```
+
+`GetAsync` returns **null when the server row does not exist** (server removed mid-check) and a `WipeBaseline` with all-null members for a never-observed server — callers distinguish the two.
+
+- [ ] **Step 1: Write the failing store tests**
+
+Create `tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs`:
+
+```csharp
+using Microsoft.Data.Sqlite;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Persistence.Wipes;
+
+namespace RustPlusBot.Persistence.Tests.Wipes;
+
+/// Unit tests for .
+public sealed class WipeBaselineStoreTests
+{
+ private static (WipeBaselineStore Store, BotDbContext Context, SqliteConnection Conn) Create()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ return (new WipeBaselineStore(context), context, connection);
+ }
+
+ private static async Task SeedServerAsync(BotDbContext context)
+ {
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+ return server.Id;
+ }
+
+ [Fact]
+ public async Task Get_returns_null_for_unknown_server()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+
+ var baseline = await store.GetAsync(10UL, Guid.NewGuid());
+
+ Assert.Null(baseline);
+ }
+
+ [Fact]
+ public async Task Get_returns_empty_baseline_for_new_server()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var serverId = await SeedServerAsync(context);
+
+ var baseline = await store.GetAsync(10UL, serverId);
+
+ Assert.Equal(new WipeBaseline(null, null, null), baseline);
+ }
+
+ [Fact]
+ public async Task Set_then_Get_round_trips()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var serverId = await SeedServerAsync(context);
+ var baseline = new WipeBaseline(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 42u, 3500u);
+
+ await store.SetAsync(10UL, serverId, baseline);
+
+ Assert.Equal(baseline, await store.GetAsync(10UL, serverId));
+ }
+
+ [Fact]
+ public async Task Set_is_noop_for_unknown_server()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+
+ await store.SetAsync(10UL, Guid.NewGuid(), new WipeBaseline(null, 42u, null));
+
+ // No throw; nothing persisted.
+ Assert.Null(await store.GetAsync(10UL, Guid.NewGuid()));
+ }
+
+ [Fact]
+ public async Task Get_is_guild_scoped()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var serverId = await SeedServerAsync(context);
+
+ var baseline = await store.GetAsync(999UL, serverId);
+
+ Assert.Null(baseline);
+ }
+}
+```
+
+Create `tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs` (mirror the `WorkspaceStore` construction used by the existing tests in that folder — it takes the context; check the neighboring test file's `Create` helper and copy its constructor arguments exactly if it differs):
+
+```csharp
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Persistence.Tests.Workspace;
+
+/// Wipe-ping accessor tests for .
+public sealed class WorkspaceStoreWipePingTests
+{
+ [Fact]
+ public async Task Ping_defaults_false_without_settings_row()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = connection;
+ await using var __ = context;
+ var store = new WorkspaceStore(context);
+
+ Assert.False(await store.GetPingEveryoneOnWipeAsync(10UL));
+ }
+
+ [Fact]
+ public async Task Set_true_then_get_round_trips_and_upserts_row()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = connection;
+ await using var __ = context;
+ var store = new WorkspaceStore(context);
+
+ await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true);
+
+ Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL));
+ // The upserted row keeps the default culture.
+ Assert.Equal("en", await store.GetCultureAsync(10UL));
+ }
+
+ [Fact]
+ public async Task Set_preserves_existing_culture()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = connection;
+ await using var __ = context;
+ var store = new WorkspaceStore(context);
+ await store.SetCultureAsync(10UL, "fr");
+
+ await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true);
+
+ Assert.Equal("fr", await store.GetCultureAsync(10UL));
+ Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL));
+ }
+}
+```
+
+> If `WorkspaceStore`'s constructor takes more than the context, copy the construction from the existing `Workspace/` tests.
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1 --filter "WipeBaselineStoreTests|WorkspaceStoreWipePingTests"`
+Expected: FAIL — compile errors (types/methods missing).
+
+- [ ] **Step 3: Implement**
+
+Create `src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs`:
+
+```csharp
+namespace RustPlusBot.Persistence.Wipes;
+
+/// The persisted wipe baseline for a server: the last observed wipe time and world identity. All-null before the first observation.
+/// The last observed wipe time (UTC), or null when the server never reported one.
+/// The last observed procedural map seed, or null before first observation.
+/// The last observed world size (game units), or null before first observation.
+public sealed record WipeBaseline(DateTimeOffset? WipeTimeUtc, uint? MapSeed, uint? MapSize);
+```
+
+Create `src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs`:
+
+```csharp
+namespace RustPlusBot.Persistence.Wipes;
+
+/// Reads/writes the per-server wipe baseline stored on the RustServer row.
+public interface IWipeBaselineStore
+{
+ /// Gets the baseline, or null when the server row does not exist.
+ /// The owning guild snowflake.
+ /// The server id.
+ /// A cancellation token.
+ /// The baseline (all-null members before first observation), or null for an unknown server.
+ Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ /// Overwrites the baseline (no-op when the server row does not exist).
+ /// The owning guild snowflake.
+ /// The server id.
+ /// The new baseline values.
+ /// A cancellation token.
+ /// A task that completes when the baseline has been persisted.
+ Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default);
+}
+```
+
+Create `src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs`:
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+
+namespace RustPlusBot.Persistence.Wipes;
+
+/// Default over the RustServer baseline columns.
+/// The bot database context.
+internal sealed class WipeBaselineStore(BotDbContext context) : IWipeBaselineStore
+{
+ ///
+ public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default)
+ {
+ var server = await context.RustServers
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken)
+ .ConfigureAwait(false);
+ return server is null
+ ? null
+ : new WipeBaseline(server.LastWipeTimeUtc, server.LastMapSeed, server.LastMapSize);
+ }
+
+ ///
+ public async Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(baseline);
+ var server = await context.RustServers
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken)
+ .ConfigureAwait(false);
+ if (server is null)
+ {
+ return;
+ }
+
+ server.LastWipeTimeUtc = baseline.WipeTimeUtc;
+ server.LastMapSeed = baseline.MapSeed;
+ server.LastMapSize = baseline.MapSize;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
+```
+
+> If the compiler rejects `internal` here because `Persistence.Tests` can't see it, check `src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj` for an `InternalsVisibleTo` — `AlarmStore` is constructed directly by its tests, so the mechanism already exists; mirror `AlarmStore`'s access modifiers exactly.
+
+In `src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs`, add right after `SetCultureAsync`:
+
+```csharp
+ /// True when the guild wants the server-wiped announcement to ping @everyone. Defaults to false.
+ /// The guild snowflake.
+ /// A cancellation token.
+ /// The current flag value.
+ Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default);
+
+ /// Sets the @everyone-on-wipe flag (upserting the GuildSettings row).
+ /// The guild snowflake.
+ /// The new flag value.
+ /// A cancellation token.
+ /// A task that completes when the flag has been persisted.
+ Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default);
+```
+
+In `src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs`, add right after the `SetCultureAsync` implementation (mirroring its upsert shape):
+
+```csharp
+ ///
+ public async Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default)
+ {
+ var settings = await context.GuildSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken)
+ .ConfigureAwait(false);
+ return settings?.PingEveryoneOnWipe ?? false;
+ }
+
+ ///
+ public async Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default)
+ {
+ var settings = await context.GuildSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (settings is null)
+ {
+ context.GuildSettings.Add(new GuildSettings
+ {
+ GuildId = guildId, PingEveryoneOnWipe = enabled
+ });
+ }
+ else
+ {
+ settings.PingEveryoneOnWipe = enabled;
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+```
+
+In `src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs`, add a using `RustPlusBot.Persistence.Wipes;` and register after the `IMapSettingsStore` line:
+
+```csharp
+ services.AddScoped();
+```
+
+Note: adding methods to `IWorkspaceStore` is safe for existing NSubstitute mocks (substitutes auto-implement new members returning defaults — `false` here, which is the correct default).
+
+- [ ] **Step 4: Run the tests**
+
+Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1`
+Expected: PASS (8 new tests green, no regressions).
+
+- [ ] **Step 5: Cleanup + commit**
+
+```bash
+dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx
+git add -A
+git commit -m "Add wipe baseline store and wipe-ping workspace accessors
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 3: Features.Wipes project + ServerWipedEvent + WipeDetector
+
+The new feature project, the bus event, and the diff logic. The project is registered in the solution and Host now; renderer/poster/announcer/hosted-service arrive in Tasks 4–6.
+
+**Files:**
+- Create: `src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs`
+- Create: `src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj`
+- Create: `src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs`
+- Create: `src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs`
+- Create: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs`
+- Create: `tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj`
+- Modify: `RustPlusBot.slnx` (two `` entries)
+- Modify: `src/RustPlusBot.Host/Program.cs` (`AddWipes()`)
+- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs` (create)
+
+**Interfaces:**
+- Consumes: `IRustServerQuery.GetServerInfoAsync/GetWorldAsync` (singleton), `IWipeBaselineStore` (scoped, Task 2), `IEventBus.PublishAsync`.
+- Produces:
+
+```csharp
+public sealed record ServerWipedEvent(
+ ulong GuildId,
+ Guid ServerId,
+ DateTimeOffset? PreviousWipeTimeUtc,
+ DateTimeOffset? NewWipeTimeUtc,
+ uint Seed,
+ uint WorldSize);
+
+internal interface IWipeDetector
+{
+ Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
+```
+
+- [ ] **Step 1: Create the projects and wire them up**
+
+Create `src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs`:
+
+```csharp
+namespace RustPlusBot.Abstractions.Events;
+
+/// Published when a reconnected server's wipe baseline no longer matches (the server wiped while we were away or restarting).
+/// The owning guild snowflake.
+/// The wiped server id.
+/// The baseline wipe time before this wipe, or null if never observed.
+/// The freshly observed wipe time, or null when the server does not report one.
+/// The new procedural map seed.
+/// The new world size (game units).
+public sealed record ServerWipedEvent(
+ ulong GuildId,
+ Guid ServerId,
+ DateTimeOffset? PreviousWipeTimeUtc,
+ DateTimeOffset? NewWipeTimeUtc,
+ uint Seed,
+ uint WorldSize);
+```
+
+Create `src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj`:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Create `tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj`:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+In `RustPlusBot.slnx`, add inside the `/src/` folder (after the `Features.Workspace` line):
+
+```xml
+
+```
+
+and inside the `/tests/` folder (after the `Features.Workspace.Tests` line):
+
+```xml
+
+```
+
+- [ ] **Step 2: Write the failing detector tests**
+
+Create `tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs`:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Connections;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Wipes.Detection;
+using RustPlusBot.Persistence.Wipes;
+
+namespace RustPlusBot.Features.Wipes.Tests;
+
+/// Unit tests for 's diff rules.
+public sealed class WipeDetectorTests
+{
+ private static readonly DateTimeOffset OldWipe = new(2026, 6, 4, 18, 0, 0, TimeSpan.Zero);
+ private static readonly Guid ServerId = Guid.NewGuid();
+
+ private sealed record Harness(WipeDetector Detector, IRustServerQuery Query, IWipeBaselineStore Store, IEventBus Bus);
+
+ private static Harness Create(
+ ServerInfoSnapshot? info,
+ WorldSnapshot? world,
+ WipeBaseline? baseline)
+ {
+ var query = Substitute.For();
+ query.GetServerInfoAsync(10UL, ServerId, Arg.Any()).Returns(info);
+ query.GetWorldAsync(10UL, ServerId, Arg.Any()).Returns(world);
+
+ var store = Substitute.For();
+ store.GetAsync(10UL, ServerId, Arg.Any()).Returns(baseline);
+
+ var services = new ServiceCollection();
+ services.AddScoped(_ => store);
+ var provider = services.BuildServiceProvider();
+ var scopeFactory = provider.GetRequiredService();
+
+ var bus = Substitute.For();
+ var detector = new WipeDetector(scopeFactory, query, bus, NullLogger.Instance);
+ return new Harness(detector, query, store, bus);
+ }
+
+ private static ServerInfoSnapshot Info(DateTimeOffset? wipeTime) => new(5, 100, 0, wipeTime);
+
+ [Fact]
+ public async Task Null_info_snapshot_aborts_silently()
+ {
+ var h = Create(info: null, world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default);
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Null_world_snapshot_aborts_silently()
+ {
+ var h = Create(info: Info(OldWipe), world: null, baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Missing_server_row_aborts_silently()
+ {
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: null);
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default);
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Empty_baseline_backfills_without_event()
+ {
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(null, null, null));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), Arg.Any());
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Unchanged_baseline_is_a_noop()
+ {
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default);
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Wipe_time_advance_beyond_tolerance_publishes_event()
+ {
+ var newWipe = OldWipe.AddDays(7);
+ var h = Create(info: Info(newWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(newWipe, 42u, 3500u), Arg.Any());
+ await h.Bus.Received(1).PublishAsync(
+ new ServerWipedEvent(10UL, ServerId, OldWipe, newWipe, 42u, 3500u),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Wipe_time_jitter_within_tolerance_refreshes_baseline_without_event()
+ {
+ var jittered = OldWipe.AddSeconds(30);
+ var h = Create(info: Info(jittered), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(jittered, 42u, 3500u), Arg.Any());
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+
+ [Fact]
+ public async Task Seed_change_publishes_event()
+ {
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 999u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Bus.Received(1).PublishAsync(
+ new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 999u, 3500u),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Size_change_publishes_event()
+ {
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(4250u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Bus.Received(1).PublishAsync(
+ new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 42u, 4250u),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Wipe_time_appearing_from_null_backfills_without_event()
+ {
+ // Baseline had seed/size but no wipe time (server started reporting it): not a wipe.
+ var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(null, 42u, 3500u));
+
+ await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None);
+
+ await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), Arg.Any());
+ await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default);
+ }
+}
+```
+
+- [ ] **Step 3: Run to verify failure**
+
+Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1`
+Expected: FAIL — `WipeDetector` does not exist.
+
+- [ ] **Step 4: Implement the detector**
+
+Create `src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs`:
+
+```csharp
+namespace RustPlusBot.Features.Wipes.Detection;
+
+/// Checks a freshly connected server against its persisted wipe baseline.
+internal interface IWipeDetector
+{
+ /// Reads live info/world, diffs against the baseline, and publishes on a wipe.
+ /// The owning guild snowflake.
+ /// The server that just connected.
+ /// A cancellation token.
+ /// A task that completes when the check (and any event publication) has finished.
+ Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
+```
+
+Create `src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs`:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Connections;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Persistence.Wipes;
+
+namespace RustPlusBot.Features.Wipes.Detection;
+
+///
+/// Default . A wipe always implies a server restart, so the check runs on each
+/// transition to connected: the server wiped when the wipe time advanced beyond a small jitter tolerance
+/// or the map seed/size changed. The first-ever observation backfills the baseline silently so existing
+/// deployments never see a false wipe on upgrade.
+///
+/// Opens scopes for the scoped baseline store.
+/// Reads live server info from the socket.
+/// Publishes .
+/// The logger.
+internal sealed partial class WipeDetector(
+ IServiceScopeFactory scopeFactory,
+ IRustServerQuery query,
+ IEventBus eventBus,
+ ILogger logger) : IWipeDetector
+{
+ /// Absorbs clock jitter in the reported wipe time across restarts; anything larger counts as a wipe.
+ private static readonly TimeSpan _wipeTimeTolerance = TimeSpan.FromMinutes(1);
+
+ ///
+ public async Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
+ {
+ var info = await query.GetServerInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (info is null || world is null)
+ {
+ return; // Socket dropped mid-check; the next reconnect retries.
+ }
+
+ var observed = new WipeBaseline(info.WipeTimeUtc, world.Seed, world.WorldSize);
+ WipeBaseline? baseline;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ baseline = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (baseline is null || baseline == observed)
+ {
+ return; // Server removed mid-check, or plain reconnect with nothing changed.
+ }
+
+ await store.SetAsync(guildId, serverId, observed, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (baseline is { WipeTimeUtc: null, MapSeed: null, MapSize: null })
+ {
+ LogBaselineStored(logger, guildId, serverId);
+ return; // First observation ever: backfill silently.
+ }
+
+ var wiped =
+ (info.WipeTimeUtc is { } newWipe && baseline.WipeTimeUtc is { } oldWipe
+ && newWipe > oldWipe + _wipeTimeTolerance)
+ || (baseline.MapSeed is { } oldSeed && world.Seed != oldSeed)
+ || (baseline.MapSize is { } oldSize && world.WorldSize != oldSize);
+ if (!wiped)
+ {
+ return; // Jitter within tolerance or a null field backfilled — baseline refreshed silently.
+ }
+
+ await eventBus.PublishAsync(
+ new ServerWipedEvent(guildId, serverId, baseline.WipeTimeUtc, info.WipeTimeUtc, world.Seed,
+ world.WorldSize),
+ cancellationToken)
+ .ConfigureAwait(false);
+ LogWipeDetected(logger, guildId, serverId, world.Seed, world.WorldSize);
+ }
+
+ [LoggerMessage(Level = LogLevel.Information,
+ Message = "Stored first wipe baseline for guild {GuildId} server {ServerId}.")]
+ private static partial void LogBaselineStored(ILogger logger, ulong guildId, Guid serverId);
+
+ [LoggerMessage(Level = LogLevel.Information,
+ Message = "Wipe detected for guild {GuildId} server {ServerId}: seed {Seed}, size {WorldSize}.")]
+ private static partial void LogWipeDetected(ILogger logger, ulong guildId, Guid serverId, uint seed,
+ uint worldSize);
+}
+```
+
+Create `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs`:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Wipes.Detection;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.Wipes;
+
+/// DI registration for the wipe-detection feature.
+public static class WipeServiceCollectionExtensions
+{
+ /// Registers the wipe detector (announcer/renderer/poster/hosted service are added by later slices).
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddWipes(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddRustPlusBotLocalization();
+ services.AddSingleton();
+
+ return services;
+ }
+}
+```
+
+In `src/RustPlusBot.Host/Program.cs`, add `using RustPlusBot.Features.Wipes;` with the other feature usings and, right after `builder.Services.AddStorageMonitors();`:
+
+```csharp
+builder.Services.AddWipes();
+```
+
+- [ ] **Step 5: Build + run the tests**
+
+Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` then `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1`
+Expected: build clean, 10 detector tests PASS.
+
+- [ ] **Step 6: Cleanup + commit**
+
+```bash
+dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx
+git add -A
+git commit -m "Add Features.Wipes project with wipe detector and ServerWipedEvent
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 4: Wipe announcement embed renderer + localization keys
+
+**Files:**
+- Create: `src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs`
+- Modify: `src/RustPlusBot.Localization/Strings.resx`
+- Modify: `src/RustPlusBot.Localization/Strings.fr.resx`
+- Modify: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs` (register renderer)
+- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs` (create)
+
+**Interfaces:**
+- Consumes: `ServerWipedEvent` (Task 3), `ILocalizer`.
+- Produces: `internal sealed class WipeEmbedRenderer` with `Embed Render(ServerWipedEvent evt, string culture)` — Task 5's announcer calls it.
+
+- [ ] **Step 1: Write the failing renderer tests**
+
+Create `tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs`:
+
+```csharp
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Wipes.Rendering;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.Wipes.Tests;
+
+/// Unit tests for .
+public sealed class WipeEmbedRendererTests
+{
+ private static readonly DateTimeOffset WipedAt = new(2026, 7, 2, 18, 0, 0, TimeSpan.Zero);
+
+ private static ServerWipedEvent Event(DateTimeOffset? newWipe = null) =>
+ new(10UL, Guid.NewGuid(), WipedAt.AddDays(-7), newWipe ?? WipedAt, 999u, 4250u);
+
+ [Fact]
+ public void Render_includes_title_body_and_fields()
+ {
+ var renderer = new WipeEmbedRenderer(new ResxLocalizer());
+
+ var embed = renderer.Render(Event(), "en");
+
+ Assert.Equal("🧹 Server wiped", embed.Title);
+ Assert.False(string.IsNullOrEmpty(embed.Description));
+ Assert.Contains(embed.Fields, f => f.Value == $"");
+ Assert.Contains(embed.Fields, f => f.Value == "4250");
+ Assert.Contains(embed.Fields, f => f.Value == "999");
+ }
+
+ [Fact]
+ public void Render_omits_wiped_at_field_when_wipe_time_unknown()
+ {
+ var renderer = new WipeEmbedRenderer(new ResxLocalizer());
+
+ var embed = renderer.Render(Event() with
+ {
+ NewWipeTimeUtc = null
+ }, "en");
+
+ Assert.Equal(2, embed.Fields.Length);
+ }
+
+ [Fact]
+ public void Render_localizes_to_french()
+ {
+ var renderer = new WipeEmbedRenderer(new ResxLocalizer());
+
+ var en = renderer.Render(Event(), "en");
+ var fr = renderer.Render(Event(), "fr");
+
+ Assert.NotEqual(en.Description, fr.Description);
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1 --filter WipeEmbedRendererTests`
+Expected: FAIL — `WipeEmbedRenderer` does not exist.
+
+- [ ] **Step 3: Add the localization keys**
+
+In `src/RustPlusBot.Localization/Strings.resx`, add before the closing `` (same `` shape as the existing entries):
+
+```xml
+
+ 🧹 Server wiped
+
+
+ The server has wiped. All paired smart devices were removed — pair them again in game to keep using your switches, alarms and storage monitors.
+
+
+ Wiped
+
+
+ Map size
+
+
+ Seed
+
+
+ 🔔 Ping @everyone on wipe: On
+
+
+ 🔕 Ping @everyone on wipe: Off
+
+```
+
+In `src/RustPlusBot.Localization/Strings.fr.resx`, add the same seven keys:
+
+```xml
+
+ 🧹 Serveur wipé
+
+
+ Le serveur a wipé. Tous les appareils connectés appairés ont été supprimés — appairez-les à nouveau en jeu pour continuer à utiliser vos interrupteurs, alarmes et moniteurs de stockage.
+
+
+ Wipe
+
+
+ Taille de la carte
+
+
+ Seed
+
+
+ 🔔 Ping @everyone au wipe : activé
+
+
+ 🔕 Ping @everyone au wipe : désactivé
+
+```
+
+(The `settings.wipeping.*` keys are consumed in Task 9; adding them now keeps the resx edits in one place and the parity test green throughout.)
+
+- [ ] **Step 4: Implement the renderer**
+
+Create `src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs`:
+
+```csharp
+using System.Globalization;
+using Discord;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.Wipes.Rendering;
+
+/// Renders the server-wiped announcement embed posted in #events.
+/// String resolution.
+internal sealed class WipeEmbedRenderer(ILocalizer localizer)
+{
+ /// Renders the announcement for a guild culture.
+ /// The wipe event.
+ /// The guild culture ("en"/"fr").
+ /// The built embed.
+ public Embed Render(ServerWipedEvent evt, string culture)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ var builder = new EmbedBuilder()
+ .WithTitle(localizer.Get("wipe.title", culture))
+ .WithDescription(localizer.Get("wipe.body", culture))
+ .WithColor(Color.Orange);
+
+ if (evt.NewWipeTimeUtc is { } wipedAt)
+ {
+ builder.AddField(localizer.Get("wipe.field.wipedat", culture),
+ $"", inline: true);
+ }
+
+ builder
+ .AddField(localizer.Get("wipe.field.mapsize", culture),
+ evt.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true)
+ .AddField(localizer.Get("wipe.field.seed", culture),
+ evt.Seed.ToString(CultureInfo.InvariantCulture), inline: true);
+ return builder.Build();
+ }
+}
+```
+
+In `WipeServiceCollectionExtensions.AddWipes`, add (with `using RustPlusBot.Features.Wipes.Rendering;`):
+
+```csharp
+ services.AddSingleton();
+```
+
+- [ ] **Step 5: Run the tests**
+
+Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` and `dotnet test tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj -maxcpucount:1`
+Expected: PASS (renderer tests + resx parity green).
+
+- [ ] **Step 6: Cleanup + commit**
+
+```bash
+dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx
+git add -A
+git commit -m "Add wipe announcement embed renderer and localization keys
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 5: Wipe channel poster + announcer
+
+Posting a one-off message (optional `@everyone` content + embed) to the per-server #events channel.
+
+**Files:**
+- Create: `src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs`
+- Create: `src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs`
+- Create: `src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs`
+- Create: `src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs`
+- Modify: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs`
+- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs` (create)
+
+**Interfaces:**
+- Consumes: `IEventChannelLocator` (Workspace singleton), `IWorkspaceStore.GetCultureAsync`/`GetPingEveryoneOnWipeAsync` (scoped), `WipeEmbedRenderer` (Task 4), `ServerWipedEvent`.
+- Produces:
+
+```csharp
+internal interface IWipeChannelPoster
+{
+ Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken);
+}
+
+internal interface IWipeAnnouncer
+{
+ Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken);
+}
+```
+
+- [ ] **Step 1: Write the failing announcer tests**
+
+Create `tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs`:
+
+```csharp
+using Discord;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Wipes.Announcing;
+using RustPlusBot.Features.Wipes.Posting;
+using RustPlusBot.Features.Wipes.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Localization;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Wipes.Tests;
+
+/// Unit tests for .
+public sealed class WipeAnnouncerTests
+{
+ private static readonly Guid ServerId = Guid.NewGuid();
+
+ private sealed record Harness(WipeAnnouncer Announcer, IEventChannelLocator Locator, IWipeChannelPoster Poster);
+
+ private static Harness Create(ulong? channelId = 777UL, bool ping = false, string culture = "en")
+ {
+ var workspace = Substitute.For();
+ workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns(culture);
+ workspace.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(ping);
+
+ var services = new ServiceCollection();
+ services.AddScoped(_ => workspace);
+ var provider = services.BuildServiceProvider();
+ var scopeFactory = provider.GetRequiredService();
+
+ var locator = Substitute.For();
+ locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(channelId);
+ var poster = Substitute.For();
+
+ var announcer = new WipeAnnouncer(
+ scopeFactory,
+ locator,
+ new WipeEmbedRenderer(new ResxLocalizer()),
+ poster,
+ NullLogger.Instance);
+ return new Harness(announcer, locator, poster);
+ }
+
+ private static ServerWipedEvent Event() =>
+ new(10UL, ServerId, null, new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 999u, 4250u);
+
+ [Fact]
+ public async Task Missing_events_channel_skips_posting()
+ {
+ var h = Create(channelId: null);
+
+ await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None);
+
+ await h.Poster.DidNotReceiveWithAnyArgs().PostAsync(default, default, null!, default);
+ }
+
+ [Fact]
+ public async Task Posts_embed_without_ping_by_default()
+ {
+ var h = Create();
+
+ await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None);
+
+ await h.Poster.Received(1).PostAsync(
+ 777UL,
+ null,
+ Arg.Is