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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj" />
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
Expand All @@ -34,6 +35,7 @@
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj" />
<Project Path="tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
Expand Down
2,985 changes: 2,985 additions & 0 deletions docs/superpowers/plans/2026-07-15-wipe-detection.md

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions docs/superpowers/specs/2026-07-15-wipe-detection-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Server Wipe Detection — Design

**Date:** 2026-07-15
**Status:** Approved (brainstorm gate passed)
**Branch (planned):** feat/wipe-detection off develop

## Problem

When the Rust server wipes (forced/map wipe), the world is reset: every smart
device (switch, alarm, storage monitor) is destroyed in game and its entity id
becomes permanently invalid. Today the bot is blind to this:

- Device rows and their embeds stay in #switches/#alarms/#storagemonitors
forever, pointing at entities that no longer exist.
- Nobody is told the server wiped; the team discovers it by joining.
- Stale alarms linger as dead entries (they can never trigger again, but they
clutter the channel and look "armed").

`ServerInfoSnapshot.WipeTimeUtc` (from `getInfo`) and `WorldSnapshot.Seed` /
`WorldSize` (from `getInfo` via `GetWorldAsync`) already expose the signals —
but nothing persists a baseline to diff against, so a wipe is invisible.

**Goal:** detect the wipe when the server comes back up, announce it in the
per-server **#events** channel (optionally pinging @everyone via a global
setting), and purge all paired devices so no stale state or notifications
survive the wipe.

## Decisions (user-confirmed)

1. **Auto-delete all devices on wipe.** Entity ids are permanently invalid
after a wipe, so switches/alarms/storage monitors (and generic
`PairedEntity` rows) are deleted outright — DB rows and Discord embeds.
Users re-pair on the new map. No confirmation prompt, no "wiped" tombstone
state.
2. **Announcement goes to the per-server #events channel**, alongside the
existing cargo/heli/rig event posts.
3. **@everyone ping is a global guild setting** (`PingEveryoneOnWipe`) surfaced
as a toggle in the **#settings** message, **default OFF**.
4. **Detection is event-driven, not supervisor-inline** (approach B below):
a new `Features.Wipes` project reacts to the existing
`ConnectionStatusChangedEvent`; `ConnectionSupervisor` is untouched.

## Detection model

A wipe always implies a server restart, which the bot always observes as
disconnect → reconnect (`ConnectionStatusChangedEvent` with
`IsConnected && !WasConnected`). Checking on that transition is sufficient —
no polling needed. Because the baseline is persisted, a wipe that happens
while the **bot** is down is still caught on the next connect.

Approaches considered:

- **A — inline in `ConnectionSupervisor`:** rejected; fattens an already-large
class and couples it to wipe semantics.
- **B — event-driven feature (chosen):** mirrors how Alarms/Switches/Events
already consume connection events; fully testable in isolation.
- **C — periodic poll loop:** rejected; wipes cannot happen without a restart,
so connect-time checks cover every case at zero steady-state cost.

### Baseline persistence

Three nullable columns added to `RustServer` (`Domain/Servers/RustServer.cs`):

| Column | Type | Source |
| ----------------- | ----------------- | ---------------------------------- |
| `LastWipeTimeUtc` | `DateTimeOffset?` | `ServerInfoSnapshot.WipeTimeUtc` |
| `LastMapSeed` | `uint?` | `WorldSnapshot.Seed` |
| `LastMapSize` | `uint?` | `WorldSnapshot.WorldSize` |

Plus `GuildSettings.PingEveryoneOnWipe` (`bool`, default `false`). One EF
migration (`WipeDetection`) covers all four.

### Diff rules (WipeDetector)

On each transition to connected, query `IRustServerQuery.GetServerInfoAsync`
and `GetWorldAsync` (either returning null — socket dropped mid-check — aborts
silently; the next reconnect retries):

1. **Empty baseline** (all three columns null — first connect ever, or first
connect after this feature deploys): store the observed values silently.
No event. This prevents a false wipe announcement on upgrade.
2. **Wiped** when any of:
- `newWipeTime > LastWipeTimeUtc + 60s` (tolerance absorbs clock jitter),
- `Seed != LastMapSeed`,
- `WorldSize != LastMapSize`.
Null observed values never match a rule (skipped, not treated as change).
3. On wipe: persist the new baseline **first**, then publish
`ServerWipedEvent` (consistent with persist-then-publish elsewhere; the
in-process bus makes the crash window negligible, and purge is idempotent).
4. Otherwise: no-op (plain reconnect).

## Architecture

### New event (Abstractions/Events)

```csharp
public sealed record ServerWipedEvent(
ulong GuildId,
Guid ServerId,
DateTimeOffset? PreviousWipeTimeUtc,
DateTimeOffset? NewWipeTimeUtc,
uint Seed,
uint WorldSize);
```

### New project: `RustPlusBot.Features.Wipes` (+ mirrored test project)

- `Hosting/WipesHostedService` — subscribes to `ConnectionStatusChangedEvent`
(connected transitions → detector) and to `ServerWipedEvent` (→ announcer).
- `Detection/WipeDetector` (`IWipeDetector`) — the diff rules above.
- `Posting/DiscordWipeChannelPoster` (`IWipeChannelPoster`) — posts to
#events via the existing `IEventChannelLocator` (Features.Workspace) and
`DiscordChannelMessenger` pattern; reads `PingEveryoneOnWipe` to decide
whether the message content carries `@everyone`.
- `Rendering/WipeEmbedRenderer` — the announcement embed: title
("🧹 Server wiped"), wiped-at as a Discord relative timestamp, new map size
and seed fields, and a line stating all paired devices were removed and must
be re-paired in game. Localized (en/fr resx keys under `wipe.*`).

### Persistence

- `Persistence/Wipes/WipeBaselineStore` (`IWipeBaselineStore`) — reads/updates
the three `RustServer` baseline columns (single-purpose store, keeps
`ServerService` focused).
- `PairedEntity` store gains `RemoveByServerAsync(guildId, serverId)`.
- Device stores already expose `ListByServerAsync` + `RemoveAsync` — the wipe
purge is their first caller outside pairing flows.
- `GuildSettings` store gains the `PingEveryoneOnWipe` accessor/mutator beside
the existing culture handling.

### Purge fan-out (modular ownership)

Each feature cleans up its own state by subscribing to `ServerWipedEvent` in
its existing hosted service:

- **Features.Alarms / Switches / StorageMonitors** — list rows for the wiped
server; for each, delete the Discord embed
(`IWorkspaceGateway.DeleteMessageAsync` via the feature's channel locator,
missing-message tolerant) then `RemoveAsync` the row. Deleting rows is also
the stale-notification guarantee: a late `SmartDeviceTriggeredEvent` for a
dead entity finds no row and is dropped (existing relay behavior — verify
and cover with a test).
- **Features.Pairing** — `RemoveByServerAsync` on `PairedEntity` rows.
- **Features.Map / Events** — nothing: RustMaps generation keys on
`(size, seed)`, so the new seed already produces a fresh map; in-memory
event/rig state is already cleared on disconnect.

Purge handlers are idempotent (re-running on an already-purged server is a
no-op), so a duplicate `ServerWipedEvent` is harmless.

### Settings toggle (Features.Workspace)

- `SettingsMessageRenderer` adds a toggle button
(`workspace:settings:wipeping`) under the language selector, label showing
the current state (e.g. "🔔 Ping @everyone on wipe: Off").
- The settings component module (the one handling `LanguageSelectId`) gains a
handler that flips `GuildSettings.PingEveryoneOnWipe` and re-renders the
message.

### Ordering note

`ConnectionSupervisor.PrimeDevicesAsync` may prime dead entity ids on the
first post-wipe connect before the purge lands — harmless: dead entities never
broadcast, and priming publishes only no-ping `SmartDeviceStateObservedEvent`s
which drop once the rows are gone.

## Edge cases

- **Bot offline during the wipe:** baseline is persisted → caught on next
connect.
- **Existing deployments:** first post-deploy connect backfills the baseline
silently (rule 1).
- **Reconnect without wipe** (network blip, bot restart): all values match →
no-op.
- **Server rotates IP/endpoint for the new wipe:** out of scope — that arrives
as a new server pairing, already handled by the pairing confirmation flow.
- **Wipe announcement channel missing** (user deleted #events): poster logs
and skips, same tolerance as existing event posting.

## Testing

- `Features.Wipes.Tests` — detector: empty-baseline backfill, wipe-time
advance beyond/within tolerance, seed change, size change, null snapshots
abort, persist-then-publish order; renderer output; poster ping on/off from
the guild setting.
- Device feature test projects — purge removes rows + messages, idempotency,
late trigger for a purged entity is dropped.
- Workspace tests — settings toggle round-trip and renderer state.

## Non-goals

- Predicting or scheduling upcoming wipes (no forced-wipe calendar).
- Automatic device re-pairing after a wipe.
- Per-server (rather than per-guild) ping configuration.
- Blueprint-wipe detection distinct from map wipe (a BP-only wipe without a
map change and without `WipeTime` moving is indistinguishable and ignored).
16 changes: 16 additions & 0 deletions src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>Published when a reconnected server's wipe baseline no longer matches (the server wiped while we were away or restarting).</summary>
/// <param name="GuildId">The owning guild snowflake.</param>
/// <param name="ServerId">The wiped server id.</param>
/// <param name="PreviousWipeTimeUtc">The baseline wipe time before this wipe, or null if never observed.</param>
/// <param name="NewWipeTimeUtc">The freshly observed wipe time, or null when the server does not report one.</param>
/// <param name="Seed">The new procedural map seed.</param>
/// <param name="WorldSize">The new world size (game units).</param>
public sealed record ServerWipedEvent(
ulong GuildId,
Guid ServerId,
DateTimeOffset? PreviousWipeTimeUtc,
DateTimeOffset? NewWipeTimeUtc,
uint Seed,
uint WorldSize);
3 changes: 3 additions & 0 deletions src/RustPlusBot.Domain/Guilds/GuildSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ public sealed class GuildSettings

/// <summary>BCP-47 culture for localized output (e.g. "en", "fr").</summary>
public string Culture { get; set; } = "en";

/// <summary>When true, the server-wiped announcement pings @everyone in #events. Off by default.</summary>
public bool PingEveryoneOnWipe { get; set; }
}
9 changes: 9 additions & 0 deletions src/RustPlusBot.Domain/Servers/RustServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@ public sealed class RustServer

/// <summary>The Facepunch server GUID from FCM pairings, backfilled on server pairing; null until first seen. Used to attribute entity pairings.</summary>
public Guid? FacepunchServerId { get; set; }

/// <summary>Baseline: the last observed wipe time (UTC) from getInfo, or null before first observation.</summary>
public DateTimeOffset? LastWipeTimeUtc { get; set; }

/// <summary>Baseline: the last observed procedural map seed, or null before first observation.</summary>
public uint? LastMapSeed { get; set; }

/// <summary>Baseline: the last observed world size (game units), or null before first observation.</summary>
public uint? LastMapSize { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Alarms;
/// <summary>DI registration for the Smart Alarms feature.</summary>
public static class AlarmServiceCollectionExtensions
{
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service.</summary>
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, wipe purger, modules, and hosted service.</summary>
/// <param name="services">The service collection to add to.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddAlarms(this IServiceCollection services)
Expand All @@ -26,6 +26,7 @@ public static IServiceCollection AddAlarms(this IServiceCollection services)
services.AddSingleton<AlarmPairingCoordinator>();
services.AddSingleton<AlarmRelayChannels>();
services.AddSingleton<AlarmStateRelay>();
services.AddSingleton<AlarmWipePurger>();
services.AddHostedService<AlarmsHostedService>();

// Contribute this assembly's interaction modules to the Discord layer.
Expand Down
33 changes: 31 additions & 2 deletions src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

namespace RustPlusBot.Features.Alarms.Hosting;

/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, and the observed-state sync loop.</summary>
/// <summary>Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, the observed-state sync loop, and the wipe-purge loop.</summary>
/// <param name="eventBus">The in-process event bus.</param>
/// <param name="coordinator">Handles paired alarms.</param>
/// <param name="relay">Re-renders alarms on trigger/connection/reachability/observed-state changes.</param>
/// <param name="purger">Purges alarms when a server wipes.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class AlarmsHostedService(
IEventBus eventBus,
AlarmPairingCoordinator coordinator,
AlarmStateRelay relay,
AlarmWipePurger purger,
ILogger<AlarmsHostedService> logger) : IHostedService, IDisposable
{
private readonly CancellationTokenSource _cts = new();
Expand All @@ -23,6 +25,7 @@ internal sealed partial class AlarmsHostedService(
private Task? _reachabilityLoop;
private Task? _statusLoop;
private Task? _triggeredLoop;
private Task? _wipedLoop;

/// <inheritdoc />
public void Dispose() => _cts.Dispose();
Expand All @@ -35,6 +38,7 @@ public Task StartAsync(CancellationToken cancellationToken)
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
_observedLoop = Task.Run(() => ConsumeStateObservedAsync(_cts.Token), CancellationToken.None);
_wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}

Expand All @@ -44,7 +48,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
await _cts.CancelAsync().ConfigureAwait(false);
foreach (var loop in new[]
{
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop, _wipedLoop
}.Where(t => t is not null))
{
try
Expand Down Expand Up @@ -170,6 +174,28 @@ private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken
}
}

private async Task ConsumeWipedAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var evt in eventBus.SubscribeAsync<ServerWipedEvent>(cancellationToken)
.ConfigureAwait(false))
{
await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
catch (Exception ex)
#pragma warning restore CA1031
{
LogWipedLoopFaulted(logger, ex);
}
}

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")]
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);

Expand All @@ -184,4 +210,7 @@ private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm observed-state sync loop faulted.")]
private static partial void LogObservedLoopFaulted(ILogger logger, Exception exception);

[LoggerMessage(Level = LogLevel.Error, Message = "Alarm wipe-purge loop faulted.")]
private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception);
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,43 @@ await channel.SendMessageAsync(
}
}

/// <inheritdoc />
public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken)
{
try
{
var options = new RequestOptions
{
CancelToken = cancellationToken
};
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
is not ITextChannel channel)
{
return;
}

await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw; // Shutdown — let the loop unwind.
}
#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge.
catch (Exception ex)
#pragma warning restore CA1031
{
LogDeleteFailed(logger, ex, messageId, channelId);
}
}

[LoggerMessage(Level = LogLevel.Warning,
Message = "Sending @everyone ping in channel {ChannelId} failed.")]
private static partial void LogPingFailed(ILogger logger, Exception exception, ulong channelId);

[LoggerMessage(Level = LogLevel.Debug,
Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")]
private static partial void LogDeleteFailed(ILogger logger,
Exception exception,
ulong messageId,
ulong channelId);
}
Loading
Loading