|
| 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`. | |
0 commit comments