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

Expand Down
4 changes: 4 additions & 0 deletions docs/development/running-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,7 @@ Each provisioned server category also includes an `#events` channel, which recei
Discord gateway intent is required — event markers are detected by polling the Rust+ socket, not the
Discord gateway; only the existing **Send Messages** and **Embed Links** permissions on the provisioned
channel are used.

A `#player-events` channel is also automatically created, read-only to players, and receives updates
when team members log in, disconnect, die, respawn, or transition AFK status. The same team-presence
lines are still broadcast to the in-game team chat.
575 changes: 575 additions & 0 deletions docs/superpowers/plans/2026-07-22-player-events-channel.md

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions docs/superpowers/specs/2026-07-22-player-events-channel-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Player Events Channel — Design

**Date:** 2026-07-22
**Status:** Approved

## Problem

Every live notification lands in the same per-server `#events` channel: map events (cargo
ship, patrol helicopter, chinook, oil rigs), wipe announcements, and team-player presence
transitions (login, logout, death, AFK). The player noise drowns the map events.

## Goal

Route team-player presence transitions to a new per-server `#player-events` channel. Map
events and wipe announcements stay in `#events`. In-game team-chat messaging is unchanged
for both feeds.

## Scope

**In scope:** `PlayerStateChangedEvent` (the presence transitions published by the team-info
poll) — the only event `Features.Players` relays.

**Out of scope:** `MapMarkersChangedEvent` / `RigStateChangedEvent` (`Features.Events`),
wipe announcements (`Features.Wipes`), the pinned `#info` team roster embed, smart alarms
and switches (already have their own channels). None of these change.

## Current Architecture

Three relays resolve the same locator to the same channel:

| Consumer | Feature project | Locator | Channel |
|---|---|---|---|
| `EventRelay` | `Features.Events` | `IEventChannelLocator` | `#events` |
| `WipeAnnouncer` | `Features.Wipes` | `IEventChannelLocator` | `#events` |
| `PlayerEventRelay` | `Features.Players` | `IEventChannelLocator` | `#events` |

Channels are declarative: `ServerWorkspaceSpecProvider` returns `ChannelSpec` records, and
`WorkspaceReconciler.EnsureChannelsAsync` creates any missing channel and enforces the
declared ordering via `EnsureChannelOrderAsync`. Locators are singletons subclassing
`CachingChannelLocator` (30s TTL over `IWorkspaceStore.GetChannelsByKeyAsync`).

Player events already live in their own feature project, so the split is a new channel key
plus a new locator — not a code move.

## Approach

Mirror the existing locator pattern, which the codebase already uses nine times over.

Rejected alternatives:

- **Parameterize one locator by channel key.** Would collapse nine near-identical locator
classes into one, but touches every locator and consumer. Unrelated refactor.
- **Single channel with Discord tags/threads.** Does not meet the goal.

## Changes

### 1. Channel key

`WorkspaceKeys.cs` — add to `WorkspaceChannelKeys`:

```csharp
/// <summary>Key for the per-server #player-events channel.</summary>
public const string ServerPlayerEvents = "playerevents";
```

### 2. Channel spec

`ServerWorkspaceSpecProvider.GetChannelSpecs()` — insert after `ServerEvents` at position 5,
shifting `ServerMap`, `ServerSwitches`, `ServerAlarms`, `ServerStorageMonitors` to 6–9:

```csharp
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerPlayerEvents, "channel.playerevents.name",
ChannelPermissionProfile.ReadOnly, 5),
```

Read-only, like `#events` — the bot posts, players do not.

### 3. Localized names

`Strings.resx`: `channel.playerevents.name` = `player-events`
`Strings.fr.resx`: `channel.playerevents.name` = `evenements-joueurs`

### 4. Locator

`Features.Workspace/Locating/IPlayerEventChannelLocator.cs` and
`PlayerEventChannelLocator.cs`, modelled exactly on `IEventChannelLocator` /
`EventChannelLocator`:

```csharp
internal sealed class PlayerEventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerPlayerEvents),
IPlayerEventChannelLocator;
```

Registered in `WorkspaceServiceCollectionExtensions` alongside the other locators:

```csharp
services.AddSingleton<IPlayerEventChannelLocator, PlayerEventChannelLocator>();
```

### 5. Routing

`PlayerEventRelay` swaps its `IEventChannelLocator locator` constructor parameter for
`IPlayerEventChannelLocator locator`. The XML doc comment updates from "#events" to
"#player-events". Nothing else in the class changes — in particular the
`teamChatSender.SendAsync(...)` call, the renderer calls, and the per-transition loop are
untouched, so the in-game team-chat output is byte-identical.

`EventRelay` and `WipeAnnouncer` are not modified.

## Data Flow

```
Team-info poll → PlayerStateChangedEvent → PlayersHostedService → PlayerEventRelay
├─ teamChatSender.SendAsync(...) → in-game team chat (unchanged)
└─ IPlayerEventChannelLocator.GetChannelIdAsync(...) → #player-events (new target)

Map markers → MapMarkersChangedEvent → EventRelay
├─ teamChatSender.SendAsync(...) → in-game team chat (unchanged)
└─ IEventChannelLocator.GetChannelIdAsync(...) → #events (unchanged)
```

## Migration

None required. On the next reconcile, `WorkspaceReconciler` creates `#player-events` in every
existing per-server category and reorders the category to match the declared positions.
Historical messages already in `#events` stay there.

## Error Handling

`PlayerEventRelay` already treats a null channel id as "skip the Discord post" while still
sending the in-game line. That covers the window between deploy and first reconcile, and any
case where a guild admin deletes the channel. No fallback to `#events` — a temporary gap
matches how every other channel behaves on introduction.

## Testing

| Test | Assertion |
|---|---|
| `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. |
| `PlayersHostedServiceTests`, `PlayerEventRegistrationTests` | Updated to the new interface; DI graph resolves. |
| `WorkspaceRegistrationTests` | `IPlayerEventChannelLocator` is registered. |
| `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. |
| `EventRelayTests`, `WipeAnnouncerTests` | Unchanged — regression proof that map events and wipe announcements still target `#events`. |
6 changes: 3 additions & 3 deletions src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@

namespace RustPlusBot.Features.Players.Relaying;

/// <summary>Posts every player transition to #events AND in-game team chat.</summary>
/// <summary>Posts every player transition to #player-events AND in-game team chat.</summary>
/// <param name="renderer">Renders transitions as embeds and in-game lines.</param>
/// <param name="locator">Resolves the #events Discord channel id.</param>
/// <param name="locator">Resolves the #player-events Discord channel id.</param>
/// <param name="poster">Posts embeds to the Discord channel.</param>
/// <param name="teamChatSender">Broadcasts the in-game team-chat line.</param>
/// <param name="scopeFactory">Opens scopes to read guild culture.</param>
internal sealed class PlayerEventRelay(
PlayerEventRenderer renderer,
IEventChannelLocator locator,
IPlayerEventChannelLocator locator,
IPlayerChannelPoster poster,
IBotTeamChatSender teamChatSender,
IServiceScopeFactory scopeFactory)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RustPlusBot.Features.Workspace.Locating;

/// <summary>Resolves the per-server #player-events channel (game-to-Discord direction only).</summary>
public interface IPlayerEventChannelLocator
{
/// <summary>Gets the Discord channel id of #player-events for (<paramref name="guildId"/>, <paramref name="serverId"/>), or null.</summary>
/// <param name="guildId">The guild snowflake.</param>
/// <param name="serverId">The server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The Discord channel id, or null if not provisioned.</returns>
Task<ulong?> GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Time;

namespace RustPlusBot.Features.Workspace.Locating;

/// <summary>Resolves the #player-events channel id for a (guild, server).</summary>
/// <param name="scopeFactory">Opens scopes for the scoped workspace store.</param>
/// <param name="clock">Drives the cache TTL.</param>
internal sealed class PlayerEventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
: CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerPlayerEvents),
IPlayerEventChannelLocator;
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ public IEnumerable<ChannelSpec> GetChannelSpecs() =>
ChannelPermissionProfile.ReadOnly, 3, WorkspaceCapabilities.Clan),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name",
ChannelPermissionProfile.ReadOnly, 4),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name",
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerPlayerEvents, "channel.playerevents.name",
ChannelPermissionProfile.ReadOnly, 5),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name",
ChannelPermissionProfile.ReadOnly, 6),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerSwitches, "channel.switches.name",
ChannelPermissionProfile.Interactive, 6),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
ChannelPermissionProfile.Interactive, 7),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name",
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
ChannelPermissionProfile.Interactive, 8),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name",
ChannelPermissionProfile.Interactive, 9),
];

/// <inheritdoc />
Expand Down
3 changes: 3 additions & 0 deletions src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ internal static class WorkspaceChannelKeys
/// <summary>Key for the per-server #events channel.</summary>
public const string ServerEvents = "events";

/// <summary>Key for the per-server #player-events channel (team presence: join, leave, death, AFK).</summary>
public const string ServerPlayerEvents = "playerevents";

/// <summary>The per-server rendered-map channel.</summary>
public const string ServerMap = "map";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
services.AddSingleton<IChatChannelLocator, ClanChatChannelLocator>();
services.AddSingleton<IClanInfoChannelLocator, ClanInfoChannelLocator>();
services.AddSingleton<IEventChannelLocator, EventChannelLocator>();
services.AddSingleton<IPlayerEventChannelLocator, PlayerEventChannelLocator>();
services.AddSingleton<IMapChannelLocator, MapChannelLocator>();
services.AddSingleton<ISwitchChannelLocator, SwitchChannelLocator>();
services.AddSingleton<IAlarmChannelLocator, AlarmChannelLocator>();
Expand Down
3 changes: 3 additions & 0 deletions src/RustPlusBot.Localization/Strings.fr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@
<data name="channel.events.name" xml:space="preserve">
<value>evenements</value>
</data>
<data name="channel.playerevents.name" xml:space="preserve">
<value>evenements-joueurs</value>
</data>
<data name="channel.info.name" xml:space="preserve">
<value>info</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/RustPlusBot.Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@
<data name="channel.events.name" xml:space="preserve">
<value>events</value>
</data>
<data name="channel.playerevents.name" xml:space="preserve">
<value>player-events</value>
</data>
<data name="channel.info.name" xml:space="preserve">
<value>info</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private static Harness Create()
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();

var locator = Substitute.For<IEventChannelLocator>();
var locator = Substitute.For<IPlayerEventChannelLocator>();
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((ulong?)null);
var poster = Substitute.For<IPlayerChannelPoster>();
Expand Down Expand Up @@ -122,6 +122,6 @@ private sealed record Harness(
PlayersHostedService Service,
InMemoryEventBus Bus,
IBotTeamChatSender Sender,
IEventChannelLocator Locator,
IPlayerEventChannelLocator Locator,
IPlayerChannelPoster Poster);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private static ServiceProvider BuildProvider()
services.AddSingleton(new DiscordSocketClient());
services.AddSingleton<RenderGate>();
services.AddSingleton<DiscordChannelMessenger>();
services.AddSingleton<IEventChannelLocator>(Substitute.For<IEventChannelLocator>());
services.AddSingleton<IPlayerEventChannelLocator>(Substitute.For<IPlayerEventChannelLocator>());
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
services.AddScoped<IMapSettingsStore>(_ => Substitute.For<IMapSettingsStore>());
services.AddSingleton<IBotTeamChatSender>(Substitute.For<IBotTeamChatSender>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace RustPlusBot.Features.Players.Tests;

public sealed class PlayerEventRelayTests
{
private readonly IEventChannelLocator _locator = Substitute.For<IEventChannelLocator>();
private readonly IPlayerEventChannelLocator _locator = Substitute.For<IPlayerEventChannelLocator>();
private readonly IPlayerChannelPoster _poster = Substitute.For<IPlayerChannelPoster>();
private readonly IBotTeamChatSender _sender = Substitute.For<IBotTeamChatSender>();
private readonly IWorkspaceStore _workspace = Substitute.For<IWorkspaceStore>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Servers;
using RustPlusBot.Domain.Workspace;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Persistence;
using RustPlusBot.Persistence.Workspace;

namespace RustPlusBot.Features.Workspace.Tests.Locating;

/// <summary>Covers that the locator resolves the "playerevents" key and not the "events" key.</summary>
public sealed class PlayerEventChannelLocatorTests
{
private static (PlayerEventChannelLocator Locator, ServiceProvider Provider, string ConnectionString)
CreateLocator()
{
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);

var cs = $"DataSource=player-event-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
var keepAlive = new SqliteConnection(cs);
keepAlive.Open();
using (var seed = new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options))
{
seed.Database.Migrate();
}

var services = new ServiceCollection();
services.AddSingleton(keepAlive);
services.AddSingleton(clock);
services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options));
services.AddScoped<IWorkspaceStore, WorkspaceStore>();
var provider = services.BuildServiceProvider();

var locator = new PlayerEventChannelLocator(provider.GetRequiredService<IServiceScopeFactory>(), clock);
return (locator, provider, cs);
}

private static async Task<Guid> SeedAsync(string connectionString)
{
await using var context =
new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(connectionString).Options);

var server = new RustServer
{
GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
};
context.RustServers.Add(server);
await context.SaveChangesAsync();

context.ProvisionedChannels.Add(new ProvisionedChannel
{
GuildId = 10UL,
RustServerId = server.Id,
ChannelKey = WorkspaceChannelKeys.ServerPlayerEvents,
DiscordChannelId = 777UL,
CreatedAt = DateTimeOffset.UnixEpoch,
});
context.ProvisionedChannels.Add(new ProvisionedChannel
{
GuildId = 10UL,
RustServerId = server.Id,
ChannelKey = WorkspaceChannelKeys.ServerEvents,
DiscordChannelId = 888UL,
CreatedAt = DateTimeOffset.UnixEpoch,
});
await context.SaveChangesAsync();

return server.Id;
}

[Fact]
public async Task GetChannelIdAsync_returns_the_player_events_channel_not_the_events_channel()
{
var (locator, provider, cs) = CreateLocator();
await using var _p = provider;
var serverId = await SeedAsync(cs);

var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None);

Assert.Equal(777UL, channelId);
}

[Fact]
public async Task GetChannelIdAsync_returns_null_when_not_provisioned()
{
var (locator, provider, _) = CreateLocator();
await using var _p = provider;

Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None));
}
}
Loading
Loading