Skip to content

Commit 54263a4

Browse files
HandyS11claude
andauthored
Add server wipe detection (#53)
* Add design spec for server wipe detection Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add implementation plan for server wipe detection Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add wipe baseline columns and wipe-ping guild setting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add wipe baseline store and wipe-ping workspace accessors Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add Features.Wipes project with wipe detector and ServerWipedEvent Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add wipe announcement embed renderer and localization keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add wipe channel poster and announcer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wire wipe detection and announcement through a hosted service Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use at-least-once assertions in wipe hosted-service tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Purge smart alarms on server wipe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Purge smart switches on server wipe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Purge storage monitors on server wipe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add @everyone-on-wipe toggle to the settings channel Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restrict wipe checks to reconnects and preserve baseline wipe time Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Suppress mentions on embed-only wipe posts Only pass AllowedMentions.All when ping content is present; use AllowedMentions.None for embed-only posts so incidental mention-like text in the localized embed cannot ping users or roles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a3e21b9 commit 54263a4

66 files changed

Lines changed: 6285 additions & 16 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
1717
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
1818
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
19+
<Project Path="src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj" />
1920
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
2021
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
2122
</Folder>
@@ -34,6 +35,7 @@
3435
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
3536
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
3637
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
38+
<Project Path="tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj" />
3739
<Project Path="tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj" />
3840
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
3941
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />

docs/superpowers/plans/2026-07-15-wipe-detection.md

Lines changed: 2985 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Server Wipe Detection — Design
2+
3+
**Date:** 2026-07-15
4+
**Status:** Approved (brainstorm gate passed)
5+
**Branch (planned):** feat/wipe-detection off develop
6+
7+
## Problem
8+
9+
When the Rust server wipes (forced/map wipe), the world is reset: every smart
10+
device (switch, alarm, storage monitor) is destroyed in game and its entity id
11+
becomes permanently invalid. Today the bot is blind to this:
12+
13+
- Device rows and their embeds stay in #switches/#alarms/#storagemonitors
14+
forever, pointing at entities that no longer exist.
15+
- Nobody is told the server wiped; the team discovers it by joining.
16+
- Stale alarms linger as dead entries (they can never trigger again, but they
17+
clutter the channel and look "armed").
18+
19+
`ServerInfoSnapshot.WipeTimeUtc` (from `getInfo`) and `WorldSnapshot.Seed` /
20+
`WorldSize` (from `getInfo` via `GetWorldAsync`) already expose the signals —
21+
but nothing persists a baseline to diff against, so a wipe is invisible.
22+
23+
**Goal:** detect the wipe when the server comes back up, announce it in the
24+
per-server **#events** channel (optionally pinging @everyone via a global
25+
setting), and purge all paired devices so no stale state or notifications
26+
survive the wipe.
27+
28+
## Decisions (user-confirmed)
29+
30+
1. **Auto-delete all devices on wipe.** Entity ids are permanently invalid
31+
after a wipe, so switches/alarms/storage monitors (and generic
32+
`PairedEntity` rows) are deleted outright — DB rows and Discord embeds.
33+
Users re-pair on the new map. No confirmation prompt, no "wiped" tombstone
34+
state.
35+
2. **Announcement goes to the per-server #events channel**, alongside the
36+
existing cargo/heli/rig event posts.
37+
3. **@everyone ping is a global guild setting** (`PingEveryoneOnWipe`) surfaced
38+
as a toggle in the **#settings** message, **default OFF**.
39+
4. **Detection is event-driven, not supervisor-inline** (approach B below):
40+
a new `Features.Wipes` project reacts to the existing
41+
`ConnectionStatusChangedEvent`; `ConnectionSupervisor` is untouched.
42+
43+
## Detection model
44+
45+
A wipe always implies a server restart, which the bot always observes as
46+
disconnect → reconnect (`ConnectionStatusChangedEvent` with
47+
`IsConnected && !WasConnected`). Checking on that transition is sufficient —
48+
no polling needed. Because the baseline is persisted, a wipe that happens
49+
while the **bot** is down is still caught on the next connect.
50+
51+
Approaches considered:
52+
53+
- **A — inline in `ConnectionSupervisor`:** rejected; fattens an already-large
54+
class and couples it to wipe semantics.
55+
- **B — event-driven feature (chosen):** mirrors how Alarms/Switches/Events
56+
already consume connection events; fully testable in isolation.
57+
- **C — periodic poll loop:** rejected; wipes cannot happen without a restart,
58+
so connect-time checks cover every case at zero steady-state cost.
59+
60+
### Baseline persistence
61+
62+
Three nullable columns added to `RustServer` (`Domain/Servers/RustServer.cs`):
63+
64+
| Column | Type | Source |
65+
| ----------------- | ----------------- | ---------------------------------- |
66+
| `LastWipeTimeUtc` | `DateTimeOffset?` | `ServerInfoSnapshot.WipeTimeUtc` |
67+
| `LastMapSeed` | `uint?` | `WorldSnapshot.Seed` |
68+
| `LastMapSize` | `uint?` | `WorldSnapshot.WorldSize` |
69+
70+
Plus `GuildSettings.PingEveryoneOnWipe` (`bool`, default `false`). One EF
71+
migration (`WipeDetection`) covers all four.
72+
73+
### Diff rules (WipeDetector)
74+
75+
On each transition to connected, query `IRustServerQuery.GetServerInfoAsync`
76+
and `GetWorldAsync` (either returning null — socket dropped mid-check — aborts
77+
silently; the next reconnect retries):
78+
79+
1. **Empty baseline** (all three columns null — first connect ever, or first
80+
connect after this feature deploys): store the observed values silently.
81+
No event. This prevents a false wipe announcement on upgrade.
82+
2. **Wiped** when any of:
83+
- `newWipeTime > LastWipeTimeUtc + 60s` (tolerance absorbs clock jitter),
84+
- `Seed != LastMapSeed`,
85+
- `WorldSize != LastMapSize`.
86+
Null observed values never match a rule (skipped, not treated as change).
87+
3. On wipe: persist the new baseline **first**, then publish
88+
`ServerWipedEvent` (consistent with persist-then-publish elsewhere; the
89+
in-process bus makes the crash window negligible, and purge is idempotent).
90+
4. Otherwise: no-op (plain reconnect).
91+
92+
## Architecture
93+
94+
### New event (Abstractions/Events)
95+
96+
```csharp
97+
public sealed record ServerWipedEvent(
98+
ulong GuildId,
99+
Guid ServerId,
100+
DateTimeOffset? PreviousWipeTimeUtc,
101+
DateTimeOffset? NewWipeTimeUtc,
102+
uint Seed,
103+
uint WorldSize);
104+
```
105+
106+
### New project: `RustPlusBot.Features.Wipes` (+ mirrored test project)
107+
108+
- `Hosting/WipesHostedService` — subscribes to `ConnectionStatusChangedEvent`
109+
(connected transitions → detector) and to `ServerWipedEvent` (→ announcer).
110+
- `Detection/WipeDetector` (`IWipeDetector`) — the diff rules above.
111+
- `Posting/DiscordWipeChannelPoster` (`IWipeChannelPoster`) — posts to
112+
#events via the existing `IEventChannelLocator` (Features.Workspace) and
113+
`DiscordChannelMessenger` pattern; reads `PingEveryoneOnWipe` to decide
114+
whether the message content carries `@everyone`.
115+
- `Rendering/WipeEmbedRenderer` — the announcement embed: title
116+
("🧹 Server wiped"), wiped-at as a Discord relative timestamp, new map size
117+
and seed fields, and a line stating all paired devices were removed and must
118+
be re-paired in game. Localized (en/fr resx keys under `wipe.*`).
119+
120+
### Persistence
121+
122+
- `Persistence/Wipes/WipeBaselineStore` (`IWipeBaselineStore`) — reads/updates
123+
the three `RustServer` baseline columns (single-purpose store, keeps
124+
`ServerService` focused).
125+
- `PairedEntity` store gains `RemoveByServerAsync(guildId, serverId)`.
126+
- Device stores already expose `ListByServerAsync` + `RemoveAsync` — the wipe
127+
purge is their first caller outside pairing flows.
128+
- `GuildSettings` store gains the `PingEveryoneOnWipe` accessor/mutator beside
129+
the existing culture handling.
130+
131+
### Purge fan-out (modular ownership)
132+
133+
Each feature cleans up its own state by subscribing to `ServerWipedEvent` in
134+
its existing hosted service:
135+
136+
- **Features.Alarms / Switches / StorageMonitors** — list rows for the wiped
137+
server; for each, delete the Discord embed
138+
(`IWorkspaceGateway.DeleteMessageAsync` via the feature's channel locator,
139+
missing-message tolerant) then `RemoveAsync` the row. Deleting rows is also
140+
the stale-notification guarantee: a late `SmartDeviceTriggeredEvent` for a
141+
dead entity finds no row and is dropped (existing relay behavior — verify
142+
and cover with a test).
143+
- **Features.Pairing**`RemoveByServerAsync` on `PairedEntity` rows.
144+
- **Features.Map / Events** — nothing: RustMaps generation keys on
145+
`(size, seed)`, so the new seed already produces a fresh map; in-memory
146+
event/rig state is already cleared on disconnect.
147+
148+
Purge handlers are idempotent (re-running on an already-purged server is a
149+
no-op), so a duplicate `ServerWipedEvent` is harmless.
150+
151+
### Settings toggle (Features.Workspace)
152+
153+
- `SettingsMessageRenderer` adds a toggle button
154+
(`workspace:settings:wipeping`) under the language selector, label showing
155+
the current state (e.g. "🔔 Ping @everyone on wipe: Off").
156+
- The settings component module (the one handling `LanguageSelectId`) gains a
157+
handler that flips `GuildSettings.PingEveryoneOnWipe` and re-renders the
158+
message.
159+
160+
### Ordering note
161+
162+
`ConnectionSupervisor.PrimeDevicesAsync` may prime dead entity ids on the
163+
first post-wipe connect before the purge lands — harmless: dead entities never
164+
broadcast, and priming publishes only no-ping `SmartDeviceStateObservedEvent`s
165+
which drop once the rows are gone.
166+
167+
## Edge cases
168+
169+
- **Bot offline during the wipe:** baseline is persisted → caught on next
170+
connect.
171+
- **Existing deployments:** first post-deploy connect backfills the baseline
172+
silently (rule 1).
173+
- **Reconnect without wipe** (network blip, bot restart): all values match →
174+
no-op.
175+
- **Server rotates IP/endpoint for the new wipe:** out of scope — that arrives
176+
as a new server pairing, already handled by the pairing confirmation flow.
177+
- **Wipe announcement channel missing** (user deleted #events): poster logs
178+
and skips, same tolerance as existing event posting.
179+
180+
## Testing
181+
182+
- `Features.Wipes.Tests` — detector: empty-baseline backfill, wipe-time
183+
advance beyond/within tolerance, seed change, size change, null snapshots
184+
abort, persist-then-publish order; renderer output; poster ping on/off from
185+
the guild setting.
186+
- Device feature test projects — purge removes rows + messages, idempotency,
187+
late trigger for a purged entity is dropped.
188+
- Workspace tests — settings toggle round-trip and renderer state.
189+
190+
## Non-goals
191+
192+
- Predicting or scheduling upcoming wipes (no forced-wipe calendar).
193+
- Automatic device re-pairing after a wipe.
194+
- Per-server (rather than per-guild) ping configuration.
195+
- Blueprint-wipe detection distinct from map wipe (a BP-only wipe without a
196+
map change and without `WipeTime` moving is indistinguishable and ignored).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Published when a reconnected server's wipe baseline no longer matches (the server wiped while we were away or restarting).</summary>
4+
/// <param name="GuildId">The owning guild snowflake.</param>
5+
/// <param name="ServerId">The wiped server id.</param>
6+
/// <param name="PreviousWipeTimeUtc">The baseline wipe time before this wipe, or null if never observed.</param>
7+
/// <param name="NewWipeTimeUtc">The freshly observed wipe time, or null when the server does not report one.</param>
8+
/// <param name="Seed">The new procedural map seed.</param>
9+
/// <param name="WorldSize">The new world size (game units).</param>
10+
public sealed record ServerWipedEvent(
11+
ulong GuildId,
12+
Guid ServerId,
13+
DateTimeOffset? PreviousWipeTimeUtc,
14+
DateTimeOffset? NewWipeTimeUtc,
15+
uint Seed,
16+
uint WorldSize);

src/RustPlusBot.Domain/Guilds/GuildSettings.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ public sealed class GuildSettings
88

99
/// <summary>BCP-47 culture for localized output (e.g. "en", "fr").</summary>
1010
public string Culture { get; set; } = "en";
11+
12+
/// <summary>When true, the server-wiped announcement pings @everyone in #events. Off by default.</summary>
13+
public bool PingEveryoneOnWipe { get; set; }
1114
}

src/RustPlusBot.Domain/Servers/RustServer.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,13 @@ public sealed class RustServer
2323

2424
/// <summary>The Facepunch server GUID from FCM pairings, backfilled on server pairing; null until first seen. Used to attribute entity pairings.</summary>
2525
public Guid? FacepunchServerId { get; set; }
26+
27+
/// <summary>Baseline: the last observed wipe time (UTC) from getInfo, or null before first observation.</summary>
28+
public DateTimeOffset? LastWipeTimeUtc { get; set; }
29+
30+
/// <summary>Baseline: the last observed procedural map seed, or null before first observation.</summary>
31+
public uint? LastMapSeed { get; set; }
32+
33+
/// <summary>Baseline: the last observed world size (game units), or null before first observation.</summary>
34+
public uint? LastMapSize { get; set; }
2635
}

src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Alarms;
1212
/// <summary>DI registration for the Smart Alarms feature.</summary>
1313
public static class AlarmServiceCollectionExtensions
1414
{
15-
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service.</summary>
15+
/// <summary>Registers the localizer, renderer, poster, refresher, coordinator, relay, wipe purger, modules, and hosted service.</summary>
1616
/// <param name="services">The service collection to add to.</param>
1717
/// <returns>The same service collection, for chaining.</returns>
1818
public static IServiceCollection AddAlarms(this IServiceCollection services)
@@ -26,6 +26,7 @@ public static IServiceCollection AddAlarms(this IServiceCollection services)
2626
services.AddSingleton<AlarmPairingCoordinator>();
2727
services.AddSingleton<AlarmRelayChannels>();
2828
services.AddSingleton<AlarmStateRelay>();
29+
services.AddSingleton<AlarmWipePurger>();
2930
services.AddHostedService<AlarmsHostedService>();
3031

3132
// Contribute this assembly's interaction modules to the Discord layer.

src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66

77
namespace RustPlusBot.Features.Alarms.Hosting;
88

9-
/// <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>
9+
/// <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>
1010
/// <param name="eventBus">The in-process event bus.</param>
1111
/// <param name="coordinator">Handles paired alarms.</param>
1212
/// <param name="relay">Re-renders alarms on trigger/connection/reachability/observed-state changes.</param>
13+
/// <param name="purger">Purges alarms when a server wipes.</param>
1314
/// <param name="logger">The logger.</param>
1415
internal sealed partial class AlarmsHostedService(
1516
IEventBus eventBus,
1617
AlarmPairingCoordinator coordinator,
1718
AlarmStateRelay relay,
19+
AlarmWipePurger purger,
1820
ILogger<AlarmsHostedService> logger) : IHostedService, IDisposable
1921
{
2022
private readonly CancellationTokenSource _cts = new();
@@ -23,6 +25,7 @@ internal sealed partial class AlarmsHostedService(
2325
private Task? _reachabilityLoop;
2426
private Task? _statusLoop;
2527
private Task? _triggeredLoop;
28+
private Task? _wipedLoop;
2629

2730
/// <inheritdoc />
2831
public void Dispose() => _cts.Dispose();
@@ -35,6 +38,7 @@ public Task StartAsync(CancellationToken cancellationToken)
3538
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
3639
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
3740
_observedLoop = Task.Run(() => ConsumeStateObservedAsync(_cts.Token), CancellationToken.None);
41+
_wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None);
3842
return Task.CompletedTask;
3943
}
4044

@@ -44,7 +48,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
4448
await _cts.CancelAsync().ConfigureAwait(false);
4549
foreach (var loop in new[]
4650
{
47-
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop
51+
_pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop, _wipedLoop
4852
}.Where(t => t is not null))
4953
{
5054
try
@@ -170,6 +174,28 @@ private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken
170174
}
171175
}
172176

177+
private async Task ConsumeWipedAsync(CancellationToken cancellationToken)
178+
{
179+
try
180+
{
181+
await foreach (var evt in eventBus.SubscribeAsync<ServerWipedEvent>(cancellationToken)
182+
.ConfigureAwait(false))
183+
{
184+
await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false);
185+
}
186+
}
187+
catch (OperationCanceledException)
188+
{
189+
// Shutting down.
190+
}
191+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
192+
catch (Exception ex)
193+
#pragma warning restore CA1031
194+
{
195+
LogWipedLoopFaulted(logger, ex);
196+
}
197+
}
198+
173199
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")]
174200
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
175201

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

185211
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm observed-state sync loop faulted.")]
186212
private static partial void LogObservedLoopFaulted(ILogger logger, Exception exception);
213+
214+
[LoggerMessage(Level = LogLevel.Error, Message = "Alarm wipe-purge loop faulted.")]
215+
private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception);
187216
}

src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,43 @@ await channel.SendMessageAsync(
5656
}
5757
}
5858

59+
/// <inheritdoc />
60+
public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken)
61+
{
62+
try
63+
{
64+
var options = new RequestOptions
65+
{
66+
CancelToken = cancellationToken
67+
};
68+
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
69+
is not ITextChannel channel)
70+
{
71+
return;
72+
}
73+
74+
await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false);
75+
}
76+
catch (OperationCanceledException)
77+
{
78+
throw; // Shutdown — let the loop unwind.
79+
}
80+
#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge.
81+
catch (Exception ex)
82+
#pragma warning restore CA1031
83+
{
84+
LogDeleteFailed(logger, ex, messageId, channelId);
85+
}
86+
}
87+
5988
[LoggerMessage(Level = LogLevel.Warning,
6089
Message = "Sending @everyone ping in channel {ChannelId} failed.")]
6190
private static partial void LogPingFailed(ILogger logger, Exception exception, ulong channelId);
91+
92+
[LoggerMessage(Level = LogLevel.Debug,
93+
Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")]
94+
private static partial void LogDeleteFailed(ILogger logger,
95+
Exception exception,
96+
ulong messageId,
97+
ulong channelId);
6298
}

0 commit comments

Comments
 (0)