Skip to content

Commit c6ad400

Browse files
HandyS11claude
andauthored
Split player presence events into a #player-events channel (#62)
* docs: design for splitting player events into a #player-events channel Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for the #player-events channel split Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(workspace): declare the per-server #player-events channel * feat(workspace): add the #player-events channel locator * feat(players): route presence events to #player-events Swap PlayerEventRelay's injected locator from IEventChannelLocator to IPlayerEventChannelLocator so player transitions (login/logout/death/AFK) resolve the #player-events channel instead of #events. Constructor arity, RelayAsync body, and the in-game team-chat send path are unchanged. Also bumps the StringsResourceParityTests sentinel count (365 -> 366) to account for the channel.playerevents.name key already added in Task 1; the sentinel was left stale and was failing dtk dotnet test RustPlusBot.slnx. * test(players): drop vacuous Never_posts_to_the_events_channel * docs: document the #player-events channel * fix: drop stray blank line and document #player-events in the dev guide Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 00039cc commit c6ad400

19 files changed

Lines changed: 915 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Once paired, each Rust server gets its own Discord category — every channel be
2828
| `#teamchat` | Two-way bridge with in-game team chat |
2929
| `#clanchat` / `#claninfo` | Clan bridge, roster/overview/invites embeds and a live change feed — appear only while the paired player is in a clan |
3030
| `#events` | Live feed for Cargo, Patrol Heli, Chinook, and oil-rig activity |
31+
| `#player-events` | Team presence: joins, disconnects, deaths, respawns, and AFK transitions. Read-only. The same lines are still broadcast to in-game team chat. |
3132
| `#map` | Rendered live map with toggleable layers |
3233
| `#switches` / `#alarms` / `#storagemonitors` | One embed per paired smart device |
3334

docs/development/running-locally.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,7 @@ Each provisioned server category also includes an `#events` channel, which recei
6969
Discord gateway intent is required — event markers are detected by polling the Rust+ socket, not the
7070
Discord gateway; only the existing **Send Messages** and **Embed Links** permissions on the provisioned
7171
channel are used.
72+
73+
A `#player-events` channel is also automatically created, read-only to players, and receives updates
74+
when team members log in, disconnect, die, respawn, or transition AFK status. The same team-presence
75+
lines are still broadcast to the in-game team chat.

docs/superpowers/plans/2026-07-22-player-events-channel.md

Lines changed: 575 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Player Events Channel — Design
2+
3+
**Date:** 2026-07-22
4+
**Status:** Approved
5+
6+
## Problem
7+
8+
Every live notification lands in the same per-server `#events` channel: map events (cargo
9+
ship, patrol helicopter, chinook, oil rigs), wipe announcements, and team-player presence
10+
transitions (login, logout, death, AFK). The player noise drowns the map events.
11+
12+
## Goal
13+
14+
Route team-player presence transitions to a new per-server `#player-events` channel. Map
15+
events and wipe announcements stay in `#events`. In-game team-chat messaging is unchanged
16+
for both feeds.
17+
18+
## Scope
19+
20+
**In scope:** `PlayerStateChangedEvent` (the presence transitions published by the team-info
21+
poll) — the only event `Features.Players` relays.
22+
23+
**Out of scope:** `MapMarkersChangedEvent` / `RigStateChangedEvent` (`Features.Events`),
24+
wipe announcements (`Features.Wipes`), the pinned `#info` team roster embed, smart alarms
25+
and switches (already have their own channels). None of these change.
26+
27+
## Current Architecture
28+
29+
Three relays resolve the same locator to the same channel:
30+
31+
| Consumer | Feature project | Locator | Channel |
32+
|---|---|---|---|
33+
| `EventRelay` | `Features.Events` | `IEventChannelLocator` | `#events` |
34+
| `WipeAnnouncer` | `Features.Wipes` | `IEventChannelLocator` | `#events` |
35+
| `PlayerEventRelay` | `Features.Players` | `IEventChannelLocator` | `#events` |
36+
37+
Channels are declarative: `ServerWorkspaceSpecProvider` returns `ChannelSpec` records, and
38+
`WorkspaceReconciler.EnsureChannelsAsync` creates any missing channel and enforces the
39+
declared ordering via `EnsureChannelOrderAsync`. Locators are singletons subclassing
40+
`CachingChannelLocator` (30s TTL over `IWorkspaceStore.GetChannelsByKeyAsync`).
41+
42+
Player events already live in their own feature project, so the split is a new channel key
43+
plus a new locator — not a code move.
44+
45+
## Approach
46+
47+
Mirror the existing locator pattern, which the codebase already uses nine times over.
48+
49+
Rejected alternatives:
50+
51+
- **Parameterize one locator by channel key.** Would collapse nine near-identical locator
52+
classes into one, but touches every locator and consumer. Unrelated refactor.
53+
- **Single channel with Discord tags/threads.** Does not meet the goal.
54+
55+
## Changes
56+
57+
### 1. Channel key
58+
59+
`WorkspaceKeys.cs` — add to `WorkspaceChannelKeys`:
60+
61+
```csharp
62+
/// <summary>Key for the per-server #player-events channel.</summary>
63+
public const string ServerPlayerEvents = "playerevents";
64+
```
65+
66+
### 2. Channel spec
67+
68+
`ServerWorkspaceSpecProvider.GetChannelSpecs()` — insert after `ServerEvents` at position 5,
69+
shifting `ServerMap`, `ServerSwitches`, `ServerAlarms`, `ServerStorageMonitors` to 6–9:
70+
71+
```csharp
72+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerPlayerEvents, "channel.playerevents.name",
73+
ChannelPermissionProfile.ReadOnly, 5),
74+
```
75+
76+
Read-only, like `#events` — the bot posts, players do not.
77+
78+
### 3. Localized names
79+
80+
`Strings.resx`: `channel.playerevents.name` = `player-events`
81+
`Strings.fr.resx`: `channel.playerevents.name` = `evenements-joueurs`
82+
83+
### 4. Locator
84+
85+
`Features.Workspace/Locating/IPlayerEventChannelLocator.cs` and
86+
`PlayerEventChannelLocator.cs`, modelled exactly on `IEventChannelLocator` /
87+
`EventChannelLocator`:
88+
89+
```csharp
90+
internal sealed class PlayerEventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
91+
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerPlayerEvents),
92+
IPlayerEventChannelLocator;
93+
```
94+
95+
Registered in `WorkspaceServiceCollectionExtensions` alongside the other locators:
96+
97+
```csharp
98+
services.AddSingleton<IPlayerEventChannelLocator, PlayerEventChannelLocator>();
99+
```
100+
101+
### 5. Routing
102+
103+
`PlayerEventRelay` swaps its `IEventChannelLocator locator` constructor parameter for
104+
`IPlayerEventChannelLocator locator`. The XML doc comment updates from "#events" to
105+
"#player-events". Nothing else in the class changes — in particular the
106+
`teamChatSender.SendAsync(...)` call, the renderer calls, and the per-transition loop are
107+
untouched, so the in-game team-chat output is byte-identical.
108+
109+
`EventRelay` and `WipeAnnouncer` are not modified.
110+
111+
## Data Flow
112+
113+
```
114+
Team-info poll → PlayerStateChangedEvent → PlayersHostedService → PlayerEventRelay
115+
├─ teamChatSender.SendAsync(...) → in-game team chat (unchanged)
116+
└─ IPlayerEventChannelLocator.GetChannelIdAsync(...) → #player-events (new target)
117+
118+
Map markers → MapMarkersChangedEvent → EventRelay
119+
├─ teamChatSender.SendAsync(...) → in-game team chat (unchanged)
120+
└─ IEventChannelLocator.GetChannelIdAsync(...) → #events (unchanged)
121+
```
122+
123+
## Migration
124+
125+
None required. On the next reconcile, `WorkspaceReconciler` creates `#player-events` in every
126+
existing per-server category and reorders the category to match the declared positions.
127+
Historical messages already in `#events` stay there.
128+
129+
## Error Handling
130+
131+
`PlayerEventRelay` already treats a null channel id as "skip the Discord post" while still
132+
sending the in-game line. That covers the window between deploy and first reconcile, and any
133+
case where a guild admin deletes the channel. No fallback to `#events` — a temporary gap
134+
matches how every other channel behaves on introduction.
135+
136+
## Testing
137+
138+
| Test | Assertion |
139+
|---|---|
140+
| `PlayerEventRelayTests` | Substitutes `IPlayerEventChannelLocator`; player embeds post to the player-events channel id and never to `#events`. In-game line still sent when the locator returns null. |
141+
| `PlayersHostedServiceTests`, `PlayerEventRegistrationTests` | Updated to the new interface; DI graph resolves. |
142+
| `WorkspaceRegistrationTests` | `IPlayerEventChannelLocator` is registered. |
143+
| `ServerWorkspaceSpecProvider` spec test | `playerevents` spec exists at position 5, read-only; `events` remains at position 4 and the four following channels are at 6–9. |
144+
| `EventRelayTests`, `WipeAnnouncerTests` | Unchanged — regression proof that map events and wipe announcements still target `#events`. |

src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010

1111
namespace RustPlusBot.Features.Players.Relaying;
1212

13-
/// <summary>Posts every player transition to #events AND in-game team chat.</summary>
13+
/// <summary>Posts every player transition to #player-events AND in-game team chat.</summary>
1414
/// <param name="renderer">Renders transitions as embeds and in-game lines.</param>
15-
/// <param name="locator">Resolves the #events Discord channel id.</param>
15+
/// <param name="locator">Resolves the #player-events Discord channel id.</param>
1616
/// <param name="poster">Posts embeds to the Discord channel.</param>
1717
/// <param name="teamChatSender">Broadcasts the in-game team-chat line.</param>
1818
/// <param name="scopeFactory">Opens scopes to read guild culture.</param>
1919
internal sealed class PlayerEventRelay(
2020
PlayerEventRenderer renderer,
21-
IEventChannelLocator locator,
21+
IPlayerEventChannelLocator locator,
2222
IPlayerChannelPoster poster,
2323
IBotTeamChatSender teamChatSender,
2424
IServiceScopeFactory scopeFactory)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Features.Workspace.Locating;
2+
3+
/// <summary>Resolves the per-server #player-events channel (game-to-Discord direction only).</summary>
4+
public interface IPlayerEventChannelLocator
5+
{
6+
/// <summary>Gets the Discord channel id of #player-events for (<paramref name="guildId"/>, <paramref name="serverId"/>), or null.</summary>
7+
/// <param name="guildId">The guild snowflake.</param>
8+
/// <param name="serverId">The server id.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>The Discord channel id, or null if not provisioned.</returns>
11+
Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Time;
3+
4+
namespace RustPlusBot.Features.Workspace.Locating;
5+
6+
/// <summary>Resolves the #player-events channel id for a (guild, server).</summary>
7+
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
8+
/// <param name="clock">Drives the cache TTL.</param>
9+
internal sealed class PlayerEventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
10+
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerPlayerEvents),
11+
IPlayerEventChannelLocator;

src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ public IEnumerable<ChannelSpec> GetChannelSpecs() =>
1818
ChannelPermissionProfile.ReadOnly, 3, WorkspaceCapabilities.Clan),
1919
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name",
2020
ChannelPermissionProfile.ReadOnly, 4),
21-
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name",
21+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerPlayerEvents, "channel.playerevents.name",
2222
ChannelPermissionProfile.ReadOnly, 5),
23+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name",
24+
ChannelPermissionProfile.ReadOnly, 6),
2325
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerSwitches, "channel.switches.name",
24-
ChannelPermissionProfile.Interactive, 6),
25-
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
2626
ChannelPermissionProfile.Interactive, 7),
27-
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name",
27+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
2828
ChannelPermissionProfile.Interactive, 8),
29+
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name",
30+
ChannelPermissionProfile.Interactive, 9),
2931
];
3032

3133
/// <inheritdoc />

src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ internal static class WorkspaceChannelKeys
2727
/// <summary>Key for the per-server #events channel.</summary>
2828
public const string ServerEvents = "events";
2929

30+
/// <summary>Key for the per-server #player-events channel (team presence: join, leave, death, AFK).</summary>
31+
public const string ServerPlayerEvents = "playerevents";
32+
3033
/// <summary>The per-server rendered-map channel.</summary>
3134
public const string ServerMap = "map";
3235

src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
6969
services.AddSingleton<IChatChannelLocator, ClanChatChannelLocator>();
7070
services.AddSingleton<IClanInfoChannelLocator, ClanInfoChannelLocator>();
7171
services.AddSingleton<IEventChannelLocator, EventChannelLocator>();
72+
services.AddSingleton<IPlayerEventChannelLocator, PlayerEventChannelLocator>();
7273
services.AddSingleton<IMapChannelLocator, MapChannelLocator>();
7374
services.AddSingleton<ISwitchChannelLocator, SwitchChannelLocator>();
7475
services.AddSingleton<IAlarmChannelLocator, AlarmChannelLocator>();

0 commit comments

Comments
 (0)