diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index dfe3e753..1fbdeffa 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -46,16 +46,19 @@ jobs:
- 'Directory.Build.props'
- '.github/workflows/ci.yaml'
- '.github/workflows/plugin-build.yaml'
- # each game-server image builds both its match plugin and the
- # practice plugin, so either source tree rebuilds it
+ # each game-server image builds its match plugin, the practice
+ # plugin and the player management plugin, so any of those source
+ # trees rebuilds it
css:
- *shared
- 'apps/counterstrikesharp/**'
- 'apps/utility-css/**'
+ - 'apps/player-management-css/**'
sw:
- *shared
- 'apps/swiftly/**'
- 'apps/utility-sw/**'
+ - 'apps/player-management-sw/**'
validator:
- 'apps/gamedata-validator/**'
- 'shared/gamedata/**'
@@ -75,7 +78,7 @@ jobs:
version_tag_regex: '^(?:css-)?v0\.0\.(\d+)$'
channel: ${{ needs.changes.outputs.channel }}
run_tests: true
- extra_test_dir: apps/utility-css
+ extra_test_dirs: apps/utility-css
secrets: inherit
swiftly:
@@ -91,7 +94,8 @@ jobs:
version_floor: 41
channel: ${{ needs.changes.outputs.channel }}
run_tests: true
- extra_test_dir: apps/utility-sw
+ # the player management suite covers the shared code both runtimes build
+ extra_test_dirs: apps/utility-sw apps/player-management-sw
secrets: inherit
gamedata-validator:
diff --git a/.github/workflows/plugin-build.yaml b/.github/workflows/plugin-build.yaml
index acef3fec..5cf9e4b3 100644
--- a/.github/workflows/plugin-build.yaml
+++ b/.github/workflows/plugin-build.yaml
@@ -29,8 +29,8 @@ on:
required: false
default: false
type: boolean
- extra_test_dir:
- description: "second app whose suite ships in this image (the practice plugin)"
+ extra_test_dirs:
+ description: "space-separated apps whose suites ship in this image (the practice and player management plugins)"
required: false
default: ""
type: string
@@ -54,12 +54,12 @@ jobs:
if: inputs.run_tests
env:
APP_DIR: ${{ inputs.app_dir }}
- EXTRA_TEST_DIR: ${{ inputs.extra_test_dir }}
+ EXTRA_TEST_DIRS: ${{ inputs.extra_test_dirs }}
run: |
dotnet test "$APP_DIR/test/FiveStack.Tests.csproj" -c Release
- if [ -n "$EXTRA_TEST_DIR" ]; then
- dotnet test "$EXTRA_TEST_DIR/test/FiveStack.Tests.csproj" -c Release
- fi
+ for dir in $EXTRA_TEST_DIRS; do
+ dotnet test "$dir/test/FiveStack.Tests.csproj" -c Release
+ done
- name: Resolve version and tags
id: resolve
@@ -87,6 +87,7 @@ jobs:
core.setOutput('release_tag', `${prefix}-${sha}`);
core.setOutput('release_name', `Build ${prefix} ${sha}`);
core.setOutput('zip', `FiveStack-${prefix}-${sha}.zip`);
+ core.setOutput('player_management_zip', `PlayerManagement-${prefix}-${sha}.zip`);
core.setOutput('draft', 'true');
core.setOutput('tags', [repoTag('beta'), repoTag(`beta-${sha}`)].join('\n'));
return;
@@ -111,6 +112,9 @@ jobs:
core.setOutput('release_tag', `${prefix}-v${version}`);
core.setOutput('release_name', `${prefix}-v${version}`);
core.setOutput('zip', `FiveStack-${prefix}-v${version}.zip`);
+ // the web links servers outside a node straight to this asset by
+ // tag, so its name must stay derivable from the release tag
+ core.setOutput('player_management_zip', `PlayerManagement-${prefix}-v${version}.zip`);
core.setOutput('draft', 'false');
// the package name already namespaces the image, so its version tag stays bare
core.setOutput('tags', [repoTag('latest'), repoTag(sha), repoTag(`v${version}`)].join('\n'));
@@ -140,6 +144,7 @@ jobs:
run: |
docker create --name temp mod-builder:latest
docker cp temp:/mod-release.zip ./${{ steps.resolve.outputs.zip }}
+ docker cp temp:/player-management-release.zip ./${{ steps.resolve.outputs.player_management_zip }}
docker rm temp
- name: Build and Push server Docker image
@@ -165,6 +170,7 @@ jobs:
prerelease: ${{ steps.resolve.outputs.draft }}
files: |
./${{ steps.resolve.outputs.zip }}
+ ./${{ steps.resolve.outputs.player_management_zip }}
# never prune the latest channel: game_server_nodes.pin_plugin_version resolves
# against its v0.0.N image tags
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index a58785d0..61511893 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- plugin: [counterstrikesharp, swiftly]
+ plugin: [counterstrikesharp, swiftly, player-management-sw]
steps:
- uses: actions/checkout@v7
@@ -32,3 +32,23 @@ jobs:
- name: Test
run: dotnet test apps/${{ matrix.plugin }}/test/FiveStack.Tests.csproj -c Release
+
+ # The player management plugins are only compiled by the image build after
+ # merge, and that build also releases the match plugins, so a broken plugin
+ # would block both; build them on every PR instead.
+ player-management-build:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ app: [player-management-css, player-management-sw]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Build
+ run: dotnet build apps/${{ matrix.app }}/src/PlayerManagement.csproj -c Release
diff --git a/README.md b/README.md
index 2f2d78ff..69e49822 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ This repo holds the CS2 game server images and the tooling that keeps them hones
| --- | --- | --- |
| [`apps/counterstrikesharp`](apps/counterstrikesharp) | CS2 server + 5stack plugin on CounterStrikeSharp | `ghcr.io/5stackgg/game-server-css` |
| [`apps/swiftly`](apps/swiftly) | the same plugin on SwiftlyS2 | `ghcr.io/5stackgg/game-server-sw` |
+| [`apps/player-management-css`](apps/player-management-css) / [`apps/player-management-sw`](apps/player-management-sw) | enforces panel bans, mutes and gags on community servers | ships inside both game-server images |
| [`apps/gamedata-validator`](apps/gamedata-validator) | checks our byte-pattern signatures still resolve after a CS2 update | `ghcr.io/5stackgg/gamedata-validator` |
`shared/` holds what the two plugins have in common: the server `cfg/`, the setup `scripts/`,
@@ -22,3 +23,22 @@ version independently and share one tag namespace, so pick by prefix:
- CounterStrikeSharp — `css-v0.0.N`, asset `FiveStack-css-v0.0.N.zip`
- SwiftlyS2 — `sw-v0.0.N`, asset `FiveStack-sw-v0.0.N.zip`
+
+# Player Management
+
+Community (non-Ranked) dedicated servers run no match plugin, so the sanctions a moderator sets in
+the panel reach them through this plugin instead. It syncs every 30 seconds and whenever the panel
+sends `player_management_refresh`, kicks banned players, mutes voice and blocks chat. It has no
+byte-pattern signatures, so a CS2 update does not break it.
+
+A server on a 5stack node loads it automatically. Anywhere else, take the asset from the same
+release as the match plugin and extract it into `game/csgo`:
+
+- CounterStrikeSharp: `PlayerManagement-css-v0.0.N.zip`, configured in
+ `addons/counterstrikesharp/configs/plugins/PlayerManagement/PlayerManagement.json`
+- SwiftlyS2: `PlayerManagement-sw-v0.0.N.zip`, configured in
+ `addons/swiftlys2/configs/plugins/PlayerManagement/config.jsonc` (under a `PlayerManagement` key)
+
+Either config takes `API_DOMAIN`, `SERVER_ID` and `SERVER_API_PASSWORD`; the panel's player
+management card shows the values for each server. Environment variables of the same names win over
+the file. Over RCON, `player_management_status` reports what the plugin sees.
diff --git a/apps/counterstrikesharp/Dockerfile b/apps/counterstrikesharp/Dockerfile
index 27fda2ec..3d1fcf8a 100644
--- a/apps/counterstrikesharp/Dockerfile
+++ b/apps/counterstrikesharp/Dockerfile
@@ -68,17 +68,47 @@ RUN dotnet build -c Release apps/utility-css/src/UtilityPractice.csproj -o relea
RUN rm -f /mod/release/CounterStrikeSharp.API.dll
+# Community servers load this in place of the match plugin; its zip is also
+# published beside the match plugin's for servers run outside a 5stack node.
+FROM dotnet-sdk AS player-management-build
+
+WORKDIR /mod
+
+COPY Directory.Build.props ./
+COPY apps/player-management-css/src/PlayerManagement.csproj apps/player-management-css/src/
+
+RUN dotnet restore apps/player-management-css/src/PlayerManagement.csproj
+
+COPY shared shared
+COPY apps/player-management-css apps/player-management-css
+
+ARG RELEASE_VERSION
+ENV RELEASE_VERSION=${RELEASE_VERSION}
+
+RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/player-management-css/src/PlayerManagementPlugin.cs
+
+RUN dotnet build -c Release apps/player-management-css/src/PlayerManagement.csproj -o release
+
+RUN rm -f /mod/release/CounterStrikeSharp.API.dll
+
# New stage for creating the zip file
FROM debian:bookworm-slim AS zip-creator
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends zip && \
+ rm -rf /var/lib/apt/lists/*
+
WORKDIR /zip-content
COPY --from=build /mod/release ./addons/counterstrikesharp/plugins/FiveStack/./
-RUN apt-get update && \
- apt-get install -y --no-install-recommends zip && \
- zip -r /mod-release.zip . && \
- rm -rf /var/lib/apt/lists/*
+RUN zip -r /mod-release.zip .
+
+WORKDIR /player-management-zip-content
+
+COPY --from=player-management-build /mod/release ./addons/counterstrikesharp/plugins/PlayerManagement/./
+
+RUN zip -r /player-management-release.zip .
FROM sniper
@@ -94,6 +124,7 @@ ENV PLUGINS_DIR="/opt/custom-plugins"
ENV INSTALL_5STACK_PLUGIN=true
ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false
+ENV INSTALL_PLAYER_MANAGEMENT_PLUGIN=false
ENV GAME_ID="730"
ENV GAME_PARAMS=""
@@ -161,6 +192,7 @@ COPY shared/scripts /opt/scripts
COPY apps/counterstrikesharp/scripts /opt/scripts
COPY --from=build /mod/release /opt/mod
COPY --from=utility-build /mod/release /opt/utility-practice
+COPY --from=player-management-build /mod/release /opt/player-management
RUN mv /opt/metamod/addons /opt/addons && \
cp -R /opt/counterstrikesharp/addons/metamod /opt/addons && \
diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh
index da94ec3d..ebcafb62 100755
--- a/apps/counterstrikesharp/scripts/setup.sh
+++ b/apps/counterstrikesharp/scripts/setup.sh
@@ -145,6 +145,18 @@ if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then
fi
fi
+# A community server's sanctions: it runs no match plugin, so without this a
+# mute or gag set in the panel never reaches it.
+if $INSTALL_PLAYER_MANAGEMENT_PLUGIN = true ; then
+ echo "---Install Player Management---"
+ PLAYER_MANAGEMENT_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/PlayerManagement"
+ if [ ! -e "$PLAYER_MANAGEMENT_PLUGIN_DIR" ]; then
+ ln -s "/opt/player-management" "$PLAYER_MANAGEMENT_PLUGIN_DIR"
+ else
+ echo "---Player Management: plugin dir already present, skipping /opt/player-management symlink---"
+ fi
+fi
+
if [ ! -e "$INSTANCE_SERVER_DIR/game/csgo/addons/counterstrikesharp/configs/core.json" ]; then
cp "/opt/server-cfg/core.json" "$INSTANCE_SERVER_DIR/game/csgo/addons/counterstrikesharp/configs"
fi
diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj
new file mode 100644
index 00000000..b8b8cb40
--- /dev/null
+++ b/apps/player-management-css/src/PlayerManagement.csproj
@@ -0,0 +1,25 @@
+
+
+ true
+ PlayerManagement
+ PlayerManagement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/apps/player-management-css/src/PlayerManagementConfig.cs b/apps/player-management-css/src/PlayerManagementConfig.cs
new file mode 100644
index 00000000..612938a6
--- /dev/null
+++ b/apps/player-management-css/src/PlayerManagementConfig.cs
@@ -0,0 +1,25 @@
+using CounterStrikeSharp.API.Core;
+using FiveStack.Entities.PlayerManagement;
+
+namespace PlayerManagement;
+
+// CounterStrikeSharp writes this to
+// addons/counterstrikesharp/configs/plugins/PlayerManagement/PlayerManagement.json
+// on first load, which is where a server outside a 5stack node sets it.
+public class PlayerManagementConfig : IBasePluginConfig
+{
+ public int Version { get; set; } = 1;
+ public string API_DOMAIN { get; set; } = "https://api.5stack.gg";
+ public string SERVER_ID { get; set; } = "";
+ public string SERVER_API_PASSWORD { get; set; } = "";
+
+ public PlayerManagementSettings Settings()
+ {
+ return new PlayerManagementSettings
+ {
+ API_DOMAIN = API_DOMAIN,
+ SERVER_ID = SERVER_ID,
+ SERVER_API_PASSWORD = SERVER_API_PASSWORD,
+ }.Resolve(Environment.GetEnvironmentVariable);
+ }
+}
diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs
new file mode 100644
index 00000000..2fbe8ff2
--- /dev/null
+++ b/apps/player-management-css/src/PlayerManagementPlugin.cs
@@ -0,0 +1,327 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Core.Attributes;
+using CounterStrikeSharp.API.Core.Attributes.Registration;
+using CounterStrikeSharp.API.Core.Translations;
+using CounterStrikeSharp.API.Modules.Commands;
+using CounterStrikeSharp.API.ValveConstants.Protobuf;
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Enums;
+using FiveStack.Utilities;
+using Microsoft.Extensions.Logging;
+
+namespace PlayerManagement;
+
+// Community servers only: a matchmaking server gets its sanctions on the match
+// payload from the match plugin, and never loads this one.
+[MinimumApiVersion(80)]
+public class PlayerManagementPlugin : BasePlugin, IPluginConfig
+{
+ private const string Runtime = "counterstrikesharp";
+
+ private const long EnforceEveryMs = 1000;
+
+ public override string ModuleName => "PlayerManagement";
+ public override string ModuleVersion => "__RELEASE_VERSION__";
+ public override string ModuleAuthor => "5Stack.gg";
+ public override string ModuleDescription =>
+ "Enforces 5Stack bans, mutes and gags on community servers";
+
+ public PlayerManagementConfig Config { get; set; } = new();
+
+ private readonly SanctionBook _book = new();
+ private SanctionSyncLoop? _loop;
+
+ // What was last applied to each player present, so changes are announced
+ // once, and whose mute bit this plugin set, so it only ever lifts its own
+ // and never one an admin plugin on the same server set.
+ private readonly Dictionary _applied = new();
+ private readonly HashSet _mutedByUs = new();
+ private readonly HashSet _kicked = new();
+
+ private long _lastEnforceMs;
+
+ public void OnConfigParsed(PlayerManagementConfig config)
+ {
+ Config = config;
+ }
+
+ public override void Load(bool hotReload)
+ {
+ PlayerManagementSettings settings = Config.Settings();
+
+ Logger.LogInformation(
+ "player management {version} loaded; panel {api}, configured: {configured}",
+ ModuleVersion,
+ settings.API_DOMAIN,
+ settings.IsConnected()
+ );
+
+ if (!settings.IsConnected())
+ {
+ Logger.LogWarning(
+ "player management is not configured; bans, mutes and gags are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set"
+ );
+ }
+
+ RegisterListener(OnTick);
+
+ RegisterListener(OnJoined);
+
+ // Joining and Steam authorizing race each other, and the id a player is
+ // enforced by can change from the claimed one to the verified one.
+ RegisterListener((slot, _) => OnJoined(slot));
+
+ RegisterListener(slot =>
+ {
+ CCSPlayerController? player = Utilities.GetPlayerFromSlot(slot);
+
+ if (player == null)
+ {
+ return;
+ }
+
+ ulong steamId = SteamIdOf(player);
+
+ _book.Left(steamId.ToString());
+ _applied.Remove(steamId);
+ _mutedByUs.Remove(steamId);
+ _kicked.Remove(steamId);
+ });
+
+ AddCommandListener("say", OnChat, HookMode.Pre);
+ AddCommandListener("say_team", OnChat, HookMode.Pre);
+
+ _loop = new SanctionSyncLoop(
+ _book,
+ new SanctionsClient(),
+ Config.Settings,
+ ModuleVersion,
+ Runtime,
+ message => Logger.LogWarning("{message}", message),
+ message => Logger.LogInformation("{message}", message)
+ );
+ _loop.Start();
+ }
+
+ public override void Unload(bool hotReload)
+ {
+ _loop?.Dispose();
+ _loop = null;
+ }
+
+ [ConsoleCommand(
+ "player_management_refresh",
+ "Syncs 5Stack sanctions for everyone on the server"
+ )]
+ [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)]
+ public void OnRefresh(CCSPlayerController? caller, CommandInfo command)
+ {
+ if (!Config.Settings().IsConnected())
+ {
+ command.ReplyToCommand(PlayerManagementReport.NotConfigured());
+ return;
+ }
+
+ _loop?.Request();
+
+ command.ReplyToCommand(PlayerManagementReport.Syncing(Humans().Count));
+ }
+
+ [ConsoleCommand("player_management_status", "Reports 5Stack player management state")]
+ [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)]
+ public void OnStatus(CCSPlayerController? caller, CommandInfo command)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ (DateTimeOffset? lastSyncAt, string? lastError) = _loop?.Status() ?? (null, null);
+
+ command.ReplyToCommand(
+ PlayerManagementReport.Status(
+ ModuleVersion,
+ Runtime,
+ Config.Settings(),
+ lastSyncAt,
+ lastError,
+ Humans()
+ .Select(player => new PlayerManagementPlayer(
+ player.PlayerName,
+ SteamIdOf(player).ToString(),
+ _book.StateFor(SteamIdOf(player).ToString(), now)
+ ))
+ .ToList(),
+ now
+ )
+ );
+ }
+
+ // Only records the join: kicking a client from inside its own connect
+ // callbacks is left to the next enforcement pass on the tick.
+ private void OnJoined(int slot)
+ {
+ CCSPlayerController? player = Utilities.GetPlayerFromSlot(slot);
+
+ if (player == null || player.IsBot || player.IsHLTV || SteamIdOf(player) == 0)
+ {
+ return;
+ }
+
+ _book.Joined(SteamIdOf(player).ToString());
+ _loop?.Request();
+ }
+
+ private void OnTick()
+ {
+ long nowMs = Environment.TickCount64;
+
+ if (nowMs - _lastEnforceMs < EnforceEveryMs)
+ {
+ return;
+ }
+
+ _lastEnforceMs = nowMs;
+
+ try
+ {
+ List humans = Humans();
+
+ _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString()));
+
+ Enforce(humans);
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to enforce sanctions");
+ }
+ }
+
+ private void Enforce(List humans)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ HashSet present = humans.Select(SteamIdOf).ToHashSet();
+
+ foreach (ulong gone in _applied.Keys.Where(steamId => !present.Contains(steamId)).ToList())
+ {
+ _applied.Remove(gone);
+ }
+
+ _mutedByUs.RemoveWhere(steamId => !present.Contains(steamId));
+ _kicked.RemoveWhere(steamId => !present.Contains(steamId));
+
+ foreach (CCSPlayerController player in humans)
+ {
+ ulong steamId = SteamIdOf(player);
+
+ if (_book.IsAwaiting(steamId.ToString()))
+ {
+ continue;
+ }
+
+ SanctionState state = _book.StateFor(steamId.ToString(), now);
+ SanctionState previous = _applied.GetValueOrDefault(steamId, SanctionState.None);
+ eSanctionChange changes = SanctionState.Changes(previous, state);
+
+ _applied[steamId] = state;
+
+ if (changes.HasFlag(eSanctionChange.Banned))
+ {
+ if (_kicked.Add(steamId))
+ {
+ Logger.LogInformation(
+ "kicking banned player {name} ({steamId})",
+ player.PlayerName,
+ steamId
+ );
+
+ player.Disconnect(NetworkDisconnectionReason.NETWORK_DISCONNECT_BANADDED);
+ }
+
+ continue;
+ }
+
+ // Re-asserted rather than set once: the engine resets voice flags
+ // across a reconnect and a map change.
+ if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlags.Muted))
+ {
+ player.VoiceFlags |= VoiceFlags.Muted;
+ _mutedByUs.Add(steamId);
+ }
+ else if (!state.IsMuted && _mutedByUs.Remove(steamId))
+ {
+ player.VoiceFlags &= ~VoiceFlags.Muted;
+ }
+
+ if (changes.HasFlag(eSanctionChange.Muted))
+ {
+ Tell(player, "sanction.muted", state.Mute!);
+ }
+ else if (changes.HasFlag(eSanctionChange.Unmuted))
+ {
+ player.PrintToChat(Localizer.ForPlayer(player, "sanction.unmuted"));
+ }
+
+ if (changes.HasFlag(eSanctionChange.Gagged))
+ {
+ Tell(player, "sanction.gagged", state.Gag!);
+ }
+ else if (changes.HasFlag(eSanctionChange.Ungagged))
+ {
+ player.PrintToChat(Localizer.ForPlayer(player, "sanction.ungagged"));
+ }
+ }
+ }
+
+ private HookResult OnChat(CCSPlayerController? player, CommandInfo info)
+ {
+ if (player == null || !IsHuman(player))
+ {
+ return HookResult.Continue;
+ }
+
+ SanctionState state = _book.StateFor(SteamIdOf(player).ToString(), DateTimeOffset.UtcNow);
+
+ if (!state.IsGagged)
+ {
+ return HookResult.Continue;
+ }
+
+ Tell(player, "sanction.gagged", state.Gag!);
+
+ return HookResult.Stop;
+ }
+
+ private void Tell(CCSPlayerController player, string key, PlayerSanction sanction)
+ {
+ player.PrintToChat(Localizer.ForPlayer(player, key));
+
+ string reason = SanctionBook.Reason(sanction);
+ if (reason.Length > 0)
+ {
+ player.PrintToChat(Localizer.ForPlayer(player, "sanction.reason", reason));
+ }
+
+ string until = SanctionBook.Until(sanction);
+ player.PrintToChat(
+ until.Length == 0
+ ? Localizer.ForPlayer(player, "sanction.permanent")
+ : Localizer.ForPlayer(player, "sanction.until", until)
+ );
+ }
+
+ private static List Humans()
+ {
+ return Utilities.GetPlayers().Where(IsHuman).ToList();
+ }
+
+ private static bool IsHuman(CCSPlayerController player)
+ {
+ return player.IsValid && !player.IsBot && !player.IsHLTV && SteamIdOf(player) != 0;
+ }
+
+ // Before Steam verifies a player only the id they claim is known. Enforcing
+ // on it is safe because a sanction only ever takes something away: a player
+ // who claims somebody else's id gains nothing, and can only inherit a ban.
+ private static ulong SteamIdOf(CCSPlayerController player)
+ {
+ return player.AuthorizedSteamID?.SteamId64 ?? player.SteamID;
+ }
+}
diff --git a/apps/player-management-css/src/lang/ar-SA.json b/apps/player-management-css/src/lang/ar-SA.json
new file mode 100644
index 00000000..b82dc0f3
--- /dev/null
+++ b/apps/player-management-css/src/lang/ar-SA.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}تم كتم صوتك: الدردشة الصوتية معطلة",
+ "sanction.unmuted": " {green}تم إلغاء كتم صوتك",
+ "sanction.gagged": " {red}تم منعك من الكتابة: الدردشة النصية معطلة",
+ "sanction.ungagged": " {green}تم إلغاء منعك من الكتابة",
+ "sanction.reason": " {grey}السبب: {0}",
+ "sanction.until": " {grey}حتى {0}",
+ "sanction.permanent": " {grey}دائم"
+}
diff --git a/apps/player-management-css/src/lang/da-DK.json b/apps/player-management-css/src/lang/da-DK.json
new file mode 100644
index 00000000..c21e81eb
--- /dev/null
+++ b/apps/player-management-css/src/lang/da-DK.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Du er muted: din stemmechat er slået fra",
+ "sanction.unmuted": " {green}Du er ikke længere muted",
+ "sanction.gagged": " {red}Du er gagged: din tekstchat er slået fra",
+ "sanction.ungagged": " {green}Du er ikke længere gagged",
+ "sanction.reason": " {grey}Årsag: {0}",
+ "sanction.until": " {grey}Indtil {0}",
+ "sanction.permanent": " {grey}Permanent"
+}
diff --git a/apps/player-management-css/src/lang/de-DE.json b/apps/player-management-css/src/lang/de-DE.json
new file mode 100644
index 00000000..c4a65816
--- /dev/null
+++ b/apps/player-management-css/src/lang/de-DE.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Du bist stummgeschaltet: dein Sprachchat ist deaktiviert",
+ "sanction.unmuted": " {green}Du bist nicht mehr stummgeschaltet",
+ "sanction.gagged": " {red}Du bist geknebelt: dein Textchat ist deaktiviert",
+ "sanction.ungagged": " {green}Du bist nicht mehr geknebelt",
+ "sanction.reason": " {grey}Grund: {0}",
+ "sanction.until": " {grey}Bis {0}",
+ "sanction.permanent": " {grey}Dauerhaft"
+}
diff --git a/apps/player-management-css/src/lang/en.json b/apps/player-management-css/src/lang/en.json
new file mode 100644
index 00000000..c4bf828f
--- /dev/null
+++ b/apps/player-management-css/src/lang/en.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}You are muted: your voice chat is disabled",
+ "sanction.unmuted": " {green}You are no longer muted",
+ "sanction.gagged": " {red}You are gagged: your text chat is disabled",
+ "sanction.ungagged": " {green}You are no longer gagged",
+ "sanction.reason": " {grey}Reason: {0}",
+ "sanction.until": " {grey}Until {0}",
+ "sanction.permanent": " {grey}Permanent"
+}
diff --git a/apps/player-management-css/src/lang/es-ES.json b/apps/player-management-css/src/lang/es-ES.json
new file mode 100644
index 00000000..7d9ac2d1
--- /dev/null
+++ b/apps/player-management-css/src/lang/es-ES.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Estás muteado: tu chat de voz está desactivado",
+ "sanction.unmuted": " {green}Ya no estás muteado",
+ "sanction.gagged": " {red}Estás silenciado: tu chat de texto está desactivado",
+ "sanction.ungagged": " {green}Ya no estás silenciado",
+ "sanction.reason": " {grey}Motivo: {0}",
+ "sanction.until": " {grey}Hasta {0}",
+ "sanction.permanent": " {grey}Permanente"
+}
diff --git a/apps/player-management-css/src/lang/fr-FR.json b/apps/player-management-css/src/lang/fr-FR.json
new file mode 100644
index 00000000..8e78c163
--- /dev/null
+++ b/apps/player-management-css/src/lang/fr-FR.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Vous êtes rendu muet : votre chat vocal est désactivé",
+ "sanction.unmuted": " {green}Vous n'êtes plus muet",
+ "sanction.gagged": " {red}Vous êtes bâillonné : votre chat textuel est désactivé",
+ "sanction.ungagged": " {green}Vous n'êtes plus bâillonné",
+ "sanction.reason": " {grey}Raison : {0}",
+ "sanction.until": " {grey}Jusqu'au {0}",
+ "sanction.permanent": " {grey}Permanent"
+}
diff --git a/apps/player-management-css/src/lang/it-IT.json b/apps/player-management-css/src/lang/it-IT.json
new file mode 100644
index 00000000..a0f3dfd3
--- /dev/null
+++ b/apps/player-management-css/src/lang/it-IT.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Sei mutato: la tua chat vocale è disattivata",
+ "sanction.unmuted": " {green}Non sei più mutato",
+ "sanction.gagged": " {red}Sei silenziato: la tua chat testuale è disattivata",
+ "sanction.ungagged": " {green}Non sei più silenziato",
+ "sanction.reason": " {grey}Motivo: {0}",
+ "sanction.until": " {grey}Fino al {0}",
+ "sanction.permanent": " {grey}Permanente"
+}
diff --git a/apps/player-management-css/src/lang/ja-JP.json b/apps/player-management-css/src/lang/ja-JP.json
new file mode 100644
index 00000000..855bf054
--- /dev/null
+++ b/apps/player-management-css/src/lang/ja-JP.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}ミュートされています:ボイスチャットは無効です",
+ "sanction.unmuted": " {green}ミュートが解除されました",
+ "sanction.gagged": " {red}チャット禁止中です:テキストチャットは無効です",
+ "sanction.ungagged": " {green}チャット禁止が解除されました",
+ "sanction.reason": " {grey}理由: {0}",
+ "sanction.until": " {grey}期限: {0}",
+ "sanction.permanent": " {grey}無期限"
+}
diff --git a/apps/player-management-css/src/lang/ko-KR.json b/apps/player-management-css/src/lang/ko-KR.json
new file mode 100644
index 00000000..550be4ff
--- /dev/null
+++ b/apps/player-management-css/src/lang/ko-KR.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}음성 채팅이 차단되었습니다",
+ "sanction.unmuted": " {green}음성 채팅 차단이 해제되었습니다",
+ "sanction.gagged": " {red}텍스트 채팅이 차단되었습니다",
+ "sanction.ungagged": " {green}텍스트 채팅 차단이 해제되었습니다",
+ "sanction.reason": " {grey}사유: {0}",
+ "sanction.until": " {grey}만료: {0}",
+ "sanction.permanent": " {grey}영구"
+}
diff --git a/apps/player-management-css/src/lang/pl-PL.json b/apps/player-management-css/src/lang/pl-PL.json
new file mode 100644
index 00000000..9cbef87d
--- /dev/null
+++ b/apps/player-management-css/src/lang/pl-PL.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Zostałeś wyciszony: twój czat głosowy jest wyłączony",
+ "sanction.unmuted": " {green}Nie jesteś już wyciszony",
+ "sanction.gagged": " {red}Zostałeś zakneblowany: twój czat tekstowy jest wyłączony",
+ "sanction.ungagged": " {green}Nie jesteś już zakneblowany",
+ "sanction.reason": " {grey}Powód: {0}",
+ "sanction.until": " {grey}Do {0}",
+ "sanction.permanent": " {grey}Na stałe"
+}
diff --git a/apps/player-management-css/src/lang/pt-BR.json b/apps/player-management-css/src/lang/pt-BR.json
new file mode 100644
index 00000000..e66a580c
--- /dev/null
+++ b/apps/player-management-css/src/lang/pt-BR.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Você está mutado: seu chat de voz está desativado",
+ "sanction.unmuted": " {green}Você não está mais mutado",
+ "sanction.gagged": " {red}Você está silenciado: seu chat de texto está desativado",
+ "sanction.ungagged": " {green}Você não está mais silenciado",
+ "sanction.reason": " {grey}Motivo: {0}",
+ "sanction.until": " {grey}Até {0}",
+ "sanction.permanent": " {grey}Permanente"
+}
diff --git a/apps/player-management-css/src/lang/ru-RU.json b/apps/player-management-css/src/lang/ru-RU.json
new file mode 100644
index 00000000..2304849f
--- /dev/null
+++ b/apps/player-management-css/src/lang/ru-RU.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Вы заглушены: голосовой чат отключён",
+ "sanction.unmuted": " {green}Вы больше не заглушены",
+ "sanction.gagged": " {red}Вам запрещён чат: текстовый чат отключён",
+ "sanction.ungagged": " {green}Запрет чата снят",
+ "sanction.reason": " {grey}Причина: {0}",
+ "sanction.until": " {grey}До {0}",
+ "sanction.permanent": " {grey}Навсегда"
+}
diff --git a/apps/player-management-css/src/lang/sv-SE.json b/apps/player-management-css/src/lang/sv-SE.json
new file mode 100644
index 00000000..2a547b9c
--- /dev/null
+++ b/apps/player-management-css/src/lang/sv-SE.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Du är tystad: din röstchatt är avstängd",
+ "sanction.unmuted": " {green}Du är inte längre tystad",
+ "sanction.gagged": " {red}Du har chattförbud: din textchatt är avstängd",
+ "sanction.ungagged": " {green}Ditt chattförbud har hävts",
+ "sanction.reason": " {grey}Anledning: {0}",
+ "sanction.until": " {grey}Till {0}",
+ "sanction.permanent": " {grey}Permanent"
+}
diff --git a/apps/player-management-css/src/lang/tr-TR.json b/apps/player-management-css/src/lang/tr-TR.json
new file mode 100644
index 00000000..c08dad1c
--- /dev/null
+++ b/apps/player-management-css/src/lang/tr-TR.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Susturuldunuz: sesli sohbetiniz devre dışı",
+ "sanction.unmuted": " {green}Artık susturulmuş değilsiniz",
+ "sanction.gagged": " {red}Sohbet yasağınız var: yazılı sohbetiniz devre dışı",
+ "sanction.ungagged": " {green}Sohbet yasağınız kaldırıldı",
+ "sanction.reason": " {grey}Sebep: {0}",
+ "sanction.until": " {grey}Bitiş: {0}",
+ "sanction.permanent": " {grey}Kalıcı"
+}
diff --git a/apps/player-management-css/src/lang/uk-UA.json b/apps/player-management-css/src/lang/uk-UA.json
new file mode 100644
index 00000000..5545834f
--- /dev/null
+++ b/apps/player-management-css/src/lang/uk-UA.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}Вас заглушено: голосовий чат вимкнено",
+ "sanction.unmuted": " {green}Вас більше не заглушено",
+ "sanction.gagged": " {red}Вам заборонено чат: текстовий чат вимкнено",
+ "sanction.ungagged": " {green}Заборону чату знято",
+ "sanction.reason": " {grey}Причина: {0}",
+ "sanction.until": " {grey}До {0}",
+ "sanction.permanent": " {grey}Назавжди"
+}
diff --git a/apps/player-management-css/src/lang/zh-Hans.json b/apps/player-management-css/src/lang/zh-Hans.json
new file mode 100644
index 00000000..84560141
--- /dev/null
+++ b/apps/player-management-css/src/lang/zh-Hans.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}你已被禁麦:语音聊天已禁用",
+ "sanction.unmuted": " {green}你的禁麦已解除",
+ "sanction.gagged": " {red}你已被禁言:文字聊天已禁用",
+ "sanction.ungagged": " {green}你的禁言已解除",
+ "sanction.reason": " {grey}原因:{0}",
+ "sanction.until": " {grey}截止:{0}",
+ "sanction.permanent": " {grey}永久"
+}
diff --git a/apps/player-management-css/src/lang/zh-Hant.json b/apps/player-management-css/src/lang/zh-Hant.json
new file mode 100644
index 00000000..051e0cf8
--- /dev/null
+++ b/apps/player-management-css/src/lang/zh-Hant.json
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " {red}你已被禁麥:語音聊天已停用",
+ "sanction.unmuted": " {green}你的禁麥已解除",
+ "sanction.gagged": " {red}你已被禁言:文字聊天已停用",
+ "sanction.ungagged": " {green}你的禁言已解除",
+ "sanction.reason": " {grey}原因:{0}",
+ "sanction.until": " {grey}截止:{0}",
+ "sanction.permanent": " {grey}永久"
+}
diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj
new file mode 100644
index 00000000..0bb8f74b
--- /dev/null
+++ b/apps/player-management-sw/src/PlayerManagement.csproj
@@ -0,0 +1,35 @@
+
+
+ true
+ true
+ PlayerManagement
+ PlayerManagement
+ $(MSBuildThisFileDirectory)build/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs
new file mode 100644
index 00000000..e3d6b473
--- /dev/null
+++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs
@@ -0,0 +1,417 @@
+using System.Reflection;
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Enums;
+using FiveStack.Utilities;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SwiftlyS2.Shared;
+using SwiftlyS2.Shared.Commands;
+using SwiftlyS2.Shared.Events;
+using SwiftlyS2.Shared.Misc;
+using SwiftlyS2.Shared.Players;
+using SwiftlyS2.Shared.Plugins;
+using SwiftlyS2.Shared.ProtobufDefinitions;
+using SwiftlyS2.Shared.Translation;
+
+namespace PlayerManagement;
+
+// Community servers only: a matchmaking server gets its sanctions on the match
+// payload from the match plugin, and never loads this one.
+[PluginMetadata(
+ Id = "PlayerManagement",
+ Version = "__RELEASE_VERSION__",
+ Name = "5stack-player-management",
+ Author = "5Stack.gg",
+ Description = "Enforces 5Stack bans, mutes and gags on community servers"
+)]
+public class PlayerManagementPlugin : BasePlugin
+{
+ private const string Runtime = "swiftlys2";
+
+ private const long EnforceEveryMs = 1000;
+
+ private ILogger _logger = null!;
+ private IConfiguration _configuration = null!;
+ private ServiceProvider? _serviceProvider;
+ private SanctionSyncLoop? _loop;
+
+ private readonly SanctionBook _book = new();
+
+ // What was last applied to each player present, so changes are announced
+ // once, and whose mute bit this plugin set, so it only ever lifts its own
+ // and never one an admin plugin on the same server set.
+ private readonly Dictionary _applied = new();
+ private readonly HashSet _mutedByUs = new();
+ private readonly HashSet _kicked = new();
+
+ private EventDelegates.OnTick? _tickHandler;
+ private EventDelegates.OnClientPutInServer? _putInServerHandler;
+ private EventDelegates.OnClientSteamAuthorize? _authorizeHandler;
+ private EventDelegates.OnClientDisconnected? _disconnectHandler;
+ private Guid _chatHookId;
+ private long _lastEnforceMs;
+
+ public PlayerManagementPlugin(ISwiftlyCore core)
+ : base(core) { }
+
+ public string ModuleVersion =>
+ typeof(PlayerManagementPlugin).GetCustomAttribute()?.Version ?? "unknown";
+
+ public override void Load(bool hotReload)
+ {
+ Core.Configuration.InitializeJsonWithModel(
+ "config.jsonc",
+ "PlayerManagement"
+ )
+ .Configure(builder =>
+ {
+ builder.AddJsonFile("config.jsonc", optional: true, reloadOnChange: true);
+ });
+
+ _configuration = Core.Configuration.Manager;
+
+ ServiceCollection services = new();
+ services.AddSwiftly(Core);
+ _serviceProvider = services.BuildServiceProvider();
+ _logger = _serviceProvider.GetRequiredService>();
+
+ PlayerManagementSettings settings = Settings();
+
+ _logger.LogInformation(
+ "player management {version} loaded; panel {api}, configured: {configured}",
+ ModuleVersion,
+ settings.API_DOMAIN,
+ settings.IsConnected()
+ );
+
+ if (!settings.IsConnected())
+ {
+ _logger.LogWarning(
+ "player management is not configured; bans, mutes and gags are not enforced until API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD are set"
+ );
+ }
+
+ _tickHandler = OnTick;
+ Core.Event.OnTick += _tickHandler;
+
+ _putInServerHandler = @event => OnJoined(@event.PlayerId);
+ Core.Event.OnClientPutInServer += _putInServerHandler;
+
+ // Joining and Steam authorizing race each other, and the id a player is
+ // enforced by can change from the claimed one to the verified one.
+ _authorizeHandler = @event => OnJoined(@event.PlayerId);
+ Core.Event.OnClientSteamAuthorize += _authorizeHandler;
+
+ _disconnectHandler = @event =>
+ {
+ IPlayer? player = Core.PlayerManager.GetPlayer(@event.PlayerId);
+
+ if (player == null)
+ {
+ return;
+ }
+
+ ulong steamId = SteamIdOf(player);
+
+ _book.Left(steamId.ToString());
+ _applied.Remove(steamId);
+ _mutedByUs.Remove(steamId);
+ _kicked.Remove(steamId);
+ };
+ Core.Event.OnClientDisconnected += _disconnectHandler;
+
+ _chatHookId = Core.Command.HookClientChat((playerId, text, teamonly) => OnChat(playerId));
+
+ _loop = new SanctionSyncLoop(
+ _book,
+ new SanctionsClient(),
+ Settings,
+ ModuleVersion,
+ Runtime,
+ message => _logger.LogWarning("{message}", message),
+ message => _logger.LogInformation("{message}", message)
+ );
+ _loop.Start();
+ }
+
+ public override void Unload()
+ {
+ _loop?.Dispose();
+ _loop = null;
+
+ if (_tickHandler != null)
+ {
+ Core.Event.OnTick -= _tickHandler;
+ }
+
+ if (_putInServerHandler != null)
+ {
+ Core.Event.OnClientPutInServer -= _putInServerHandler;
+ }
+
+ if (_authorizeHandler != null)
+ {
+ Core.Event.OnClientSteamAuthorize -= _authorizeHandler;
+ }
+
+ if (_disconnectHandler != null)
+ {
+ Core.Event.OnClientDisconnected -= _disconnectHandler;
+ }
+
+ if (_chatHookId != Guid.Empty)
+ {
+ Core.Command.UnhookClientChat(_chatHookId);
+ }
+
+ _serviceProvider?.Dispose();
+ }
+
+ [Command("player_management_refresh", registerRaw: true, permission: "")]
+ public void OnRefresh(ICommandContext context)
+ {
+ if (context.IsSentByPlayer)
+ {
+ return;
+ }
+
+ if (!Settings().IsConnected())
+ {
+ context.Reply(PlayerManagementReport.NotConfigured());
+ return;
+ }
+
+ _loop?.Request();
+
+ context.Reply(PlayerManagementReport.Syncing(Humans().Count));
+ }
+
+ [Command("player_management_status", registerRaw: true, permission: "")]
+ public void OnStatus(ICommandContext context)
+ {
+ if (context.IsSentByPlayer)
+ {
+ return;
+ }
+
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ (DateTimeOffset? lastSyncAt, string? lastError) = _loop?.Status() ?? (null, null);
+
+ context.Reply(
+ PlayerManagementReport.Status(
+ ModuleVersion,
+ Runtime,
+ Settings(),
+ lastSyncAt,
+ lastError,
+ Humans()
+ .Select(player => new PlayerManagementPlayer(
+ player.Name,
+ SteamIdOf(player).ToString(),
+ _book.StateFor(SteamIdOf(player).ToString(), now)
+ ))
+ .ToList(),
+ now
+ )
+ );
+ }
+
+ // A player may not be valid yet at either join event, so only the id is
+ // required here; the sync asks about them either way.
+ private void OnJoined(int playerId)
+ {
+ IPlayer? player = Core.PlayerManager.GetPlayer(playerId);
+
+ if (player == null || player.IsFakeClient || SteamIdOf(player) == 0)
+ {
+ return;
+ }
+
+ _book.Joined(SteamIdOf(player).ToString());
+ _loop?.Request();
+ }
+
+ // Throttled off the tick rather than a repeating timer: SwiftlyS2 replays
+ // every missed run of a timer after hibernation, and a timer whose
+ // callback throws is never scheduled again.
+ private void OnTick()
+ {
+ long nowMs = Environment.TickCount64;
+
+ if (nowMs - _lastEnforceMs < EnforceEveryMs)
+ {
+ return;
+ }
+
+ _lastEnforceMs = nowMs;
+
+ try
+ {
+ List humans = Humans();
+
+ _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString()));
+
+ Enforce(humans);
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to enforce sanctions");
+ }
+ }
+
+ private void Enforce(List humans)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ HashSet present = humans.Select(SteamIdOf).ToHashSet();
+
+ foreach (ulong gone in _applied.Keys.Where(steamId => !present.Contains(steamId)).ToList())
+ {
+ _applied.Remove(gone);
+ }
+
+ _mutedByUs.RemoveWhere(steamId => !present.Contains(steamId));
+ _kicked.RemoveWhere(steamId => !present.Contains(steamId));
+
+ foreach (IPlayer player in humans)
+ {
+ ulong steamId = SteamIdOf(player);
+
+ if (_book.IsAwaiting(steamId.ToString()))
+ {
+ continue;
+ }
+
+ SanctionState state = _book.StateFor(steamId.ToString(), now);
+ SanctionState previous = _applied.GetValueOrDefault(steamId, SanctionState.None);
+ eSanctionChange changes = SanctionState.Changes(previous, state);
+
+ _applied[steamId] = state;
+
+ if (changes.HasFlag(eSanctionChange.Banned))
+ {
+ if (_kicked.Add(steamId))
+ {
+ _logger.LogInformation(
+ "kicking banned player {name} ({steamId})",
+ player.Name,
+ steamId
+ );
+
+ player.Kick(
+ SanctionBook.KickReason(state.Ban!),
+ ENetworkDisconnectionReason.NETWORK_DISCONNECT_BANADDED
+ );
+ }
+
+ continue;
+ }
+
+ // Re-asserted rather than set once: the engine resets voice flags
+ // across a reconnect and a map change.
+ if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlagValue.Muted))
+ {
+ player.VoiceFlags |= VoiceFlagValue.Muted;
+ _mutedByUs.Add(steamId);
+ }
+ else if (!state.IsMuted && _mutedByUs.Remove(steamId))
+ {
+ player.VoiceFlags &= ~VoiceFlagValue.Muted;
+ }
+
+ if (changes == eSanctionChange.None)
+ {
+ continue;
+ }
+
+ ILocalizer localizer = Core.Translation.GetPlayerLocalizer(player);
+
+ if (changes.HasFlag(eSanctionChange.Muted))
+ {
+ Tell(player, localizer, "sanction.muted", state.Mute!);
+ }
+ else if (changes.HasFlag(eSanctionChange.Unmuted))
+ {
+ player.SendChat(localizer["sanction.unmuted"]);
+ }
+
+ if (changes.HasFlag(eSanctionChange.Gagged))
+ {
+ Tell(player, localizer, "sanction.gagged", state.Gag!);
+ }
+ else if (changes.HasFlag(eSanctionChange.Ungagged))
+ {
+ player.SendChat(localizer["sanction.ungagged"]);
+ }
+ }
+ }
+
+ private HookResult OnChat(int playerId)
+ {
+ IPlayer? player = Core.PlayerManager.GetPlayer(playerId);
+
+ if (player == null || !IsHuman(player))
+ {
+ return HookResult.Continue;
+ }
+
+ SanctionState state = _book.StateFor(SteamIdOf(player).ToString(), DateTimeOffset.UtcNow);
+
+ if (!state.IsGagged)
+ {
+ return HookResult.Continue;
+ }
+
+ Tell(player, Core.Translation.GetPlayerLocalizer(player), "sanction.gagged", state.Gag!);
+
+ return HookResult.Stop;
+ }
+
+ private static void Tell(
+ IPlayer player,
+ ILocalizer localizer,
+ string key,
+ PlayerSanction sanction
+ )
+ {
+ player.SendChat(localizer[key]);
+
+ string reason = SanctionBook.Reason(sanction);
+ if (reason.Length > 0)
+ {
+ player.SendChat(localizer["sanction.reason", reason]);
+ }
+
+ string until = SanctionBook.Until(sanction);
+ player.SendChat(
+ until.Length == 0 ? localizer["sanction.permanent"] : localizer["sanction.until", until]
+ );
+ }
+
+ private List Humans()
+ {
+ return Core.PlayerManager.GetAllPlayers().Where(IsHuman).ToList();
+ }
+
+ private static bool IsHuman(IPlayer player)
+ {
+ return player.IsValid && !player.IsFakeClient && SteamIdOf(player) != 0;
+ }
+
+ // Before Steam verifies a player only the id they claim is known. Enforcing
+ // on it is safe because a sanction only ever takes something away: a player
+ // who claims somebody else's id gains nothing, and can only inherit a ban.
+ private static ulong SteamIdOf(IPlayer player)
+ {
+ return player.IsAuthorized ? player.SteamID : player.UnauthorizedSteamID;
+ }
+
+ private PlayerManagementSettings Settings()
+ {
+ PlayerManagementSettings file =
+ _configuration.GetSection("PlayerManagement").Get()
+ ?? new PlayerManagementSettings();
+
+ return file.Resolve(Environment.GetEnvironmentVariable);
+ }
+}
diff --git a/apps/player-management-sw/src/resources/translations/ar.jsonc b/apps/player-management-sw/src/resources/translations/ar.jsonc
new file mode 100644
index 00000000..a9ec5fa9
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/ar.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]تم كتم صوتك: الدردشة الصوتية معطلة",
+ "sanction.unmuted": " [green]تم إلغاء كتم صوتك",
+ "sanction.gagged": " [red]تم منعك من الكتابة: الدردشة النصية معطلة",
+ "sanction.ungagged": " [green]تم إلغاء منعك من الكتابة",
+ "sanction.reason": " [grey]السبب: {0}",
+ "sanction.until": " [grey]حتى {0}",
+ "sanction.permanent": " [grey]دائم"
+}
diff --git a/apps/player-management-sw/src/resources/translations/da.jsonc b/apps/player-management-sw/src/resources/translations/da.jsonc
new file mode 100644
index 00000000..9bd2609c
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/da.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Du er muted: din stemmechat er slået fra",
+ "sanction.unmuted": " [green]Du er ikke længere muted",
+ "sanction.gagged": " [red]Du er gagged: din tekstchat er slået fra",
+ "sanction.ungagged": " [green]Du er ikke længere gagged",
+ "sanction.reason": " [grey]Årsag: {0}",
+ "sanction.until": " [grey]Indtil {0}",
+ "sanction.permanent": " [grey]Permanent"
+}
diff --git a/apps/player-management-sw/src/resources/translations/de.jsonc b/apps/player-management-sw/src/resources/translations/de.jsonc
new file mode 100644
index 00000000..f1aa0166
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/de.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Du bist stummgeschaltet: dein Sprachchat ist deaktiviert",
+ "sanction.unmuted": " [green]Du bist nicht mehr stummgeschaltet",
+ "sanction.gagged": " [red]Du bist geknebelt: dein Textchat ist deaktiviert",
+ "sanction.ungagged": " [green]Du bist nicht mehr geknebelt",
+ "sanction.reason": " [grey]Grund: {0}",
+ "sanction.until": " [grey]Bis {0}",
+ "sanction.permanent": " [grey]Dauerhaft"
+}
diff --git a/apps/player-management-sw/src/resources/translations/en.jsonc b/apps/player-management-sw/src/resources/translations/en.jsonc
new file mode 100644
index 00000000..0b6a3614
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/en.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]You are muted: your voice chat is disabled",
+ "sanction.unmuted": " [green]You are no longer muted",
+ "sanction.gagged": " [red]You are gagged: your text chat is disabled",
+ "sanction.ungagged": " [green]You are no longer gagged",
+ "sanction.reason": " [grey]Reason: {0}",
+ "sanction.until": " [grey]Until {0}",
+ "sanction.permanent": " [grey]Permanent"
+}
diff --git a/apps/player-management-sw/src/resources/translations/es.jsonc b/apps/player-management-sw/src/resources/translations/es.jsonc
new file mode 100644
index 00000000..283ce52b
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/es.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Estás muteado: tu chat de voz está desactivado",
+ "sanction.unmuted": " [green]Ya no estás muteado",
+ "sanction.gagged": " [red]Estás silenciado: tu chat de texto está desactivado",
+ "sanction.ungagged": " [green]Ya no estás silenciado",
+ "sanction.reason": " [grey]Motivo: {0}",
+ "sanction.until": " [grey]Hasta {0}",
+ "sanction.permanent": " [grey]Permanente"
+}
diff --git a/apps/player-management-sw/src/resources/translations/fr.jsonc b/apps/player-management-sw/src/resources/translations/fr.jsonc
new file mode 100644
index 00000000..5bb26c03
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/fr.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Vous êtes rendu muet : votre chat vocal est désactivé",
+ "sanction.unmuted": " [green]Vous n'êtes plus muet",
+ "sanction.gagged": " [red]Vous êtes bâillonné : votre chat textuel est désactivé",
+ "sanction.ungagged": " [green]Vous n'êtes plus bâillonné",
+ "sanction.reason": " [grey]Raison : {0}",
+ "sanction.until": " [grey]Jusqu'au {0}",
+ "sanction.permanent": " [grey]Permanent"
+}
diff --git a/apps/player-management-sw/src/resources/translations/it.jsonc b/apps/player-management-sw/src/resources/translations/it.jsonc
new file mode 100644
index 00000000..08440115
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/it.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Sei mutato: la tua chat vocale è disattivata",
+ "sanction.unmuted": " [green]Non sei più mutato",
+ "sanction.gagged": " [red]Sei silenziato: la tua chat testuale è disattivata",
+ "sanction.ungagged": " [green]Non sei più silenziato",
+ "sanction.reason": " [grey]Motivo: {0}",
+ "sanction.until": " [grey]Fino al {0}",
+ "sanction.permanent": " [grey]Permanente"
+}
diff --git a/apps/player-management-sw/src/resources/translations/ja.jsonc b/apps/player-management-sw/src/resources/translations/ja.jsonc
new file mode 100644
index 00000000..a1d9492e
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/ja.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]ミュートされています:ボイスチャットは無効です",
+ "sanction.unmuted": " [green]ミュートが解除されました",
+ "sanction.gagged": " [red]チャット禁止中です:テキストチャットは無効です",
+ "sanction.ungagged": " [green]チャット禁止が解除されました",
+ "sanction.reason": " [grey]理由: {0}",
+ "sanction.until": " [grey]期限: {0}",
+ "sanction.permanent": " [grey]無期限"
+}
diff --git a/apps/player-management-sw/src/resources/translations/ko.jsonc b/apps/player-management-sw/src/resources/translations/ko.jsonc
new file mode 100644
index 00000000..c8b2ae91
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/ko.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]음성 채팅이 차단되었습니다",
+ "sanction.unmuted": " [green]음성 채팅 차단이 해제되었습니다",
+ "sanction.gagged": " [red]텍스트 채팅이 차단되었습니다",
+ "sanction.ungagged": " [green]텍스트 채팅 차단이 해제되었습니다",
+ "sanction.reason": " [grey]사유: {0}",
+ "sanction.until": " [grey]만료: {0}",
+ "sanction.permanent": " [grey]영구"
+}
diff --git a/apps/player-management-sw/src/resources/translations/pl.jsonc b/apps/player-management-sw/src/resources/translations/pl.jsonc
new file mode 100644
index 00000000..c2e7c1a2
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/pl.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Zostałeś wyciszony: twój czat głosowy jest wyłączony",
+ "sanction.unmuted": " [green]Nie jesteś już wyciszony",
+ "sanction.gagged": " [red]Zostałeś zakneblowany: twój czat tekstowy jest wyłączony",
+ "sanction.ungagged": " [green]Nie jesteś już zakneblowany",
+ "sanction.reason": " [grey]Powód: {0}",
+ "sanction.until": " [grey]Do {0}",
+ "sanction.permanent": " [grey]Na stałe"
+}
diff --git a/apps/player-management-sw/src/resources/translations/pt-BR.jsonc b/apps/player-management-sw/src/resources/translations/pt-BR.jsonc
new file mode 100644
index 00000000..4f73d182
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/pt-BR.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Você está mutado: seu chat de voz está desativado",
+ "sanction.unmuted": " [green]Você não está mais mutado",
+ "sanction.gagged": " [red]Você está silenciado: seu chat de texto está desativado",
+ "sanction.ungagged": " [green]Você não está mais silenciado",
+ "sanction.reason": " [grey]Motivo: {0}",
+ "sanction.until": " [grey]Até {0}",
+ "sanction.permanent": " [grey]Permanente"
+}
diff --git a/apps/player-management-sw/src/resources/translations/ru.jsonc b/apps/player-management-sw/src/resources/translations/ru.jsonc
new file mode 100644
index 00000000..542dd576
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/ru.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Вы заглушены: голосовой чат отключён",
+ "sanction.unmuted": " [green]Вы больше не заглушены",
+ "sanction.gagged": " [red]Вам запрещён чат: текстовый чат отключён",
+ "sanction.ungagged": " [green]Запрет чата снят",
+ "sanction.reason": " [grey]Причина: {0}",
+ "sanction.until": " [grey]До {0}",
+ "sanction.permanent": " [grey]Навсегда"
+}
diff --git a/apps/player-management-sw/src/resources/translations/sv.jsonc b/apps/player-management-sw/src/resources/translations/sv.jsonc
new file mode 100644
index 00000000..b68794e7
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/sv.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Du är tystad: din röstchatt är avstängd",
+ "sanction.unmuted": " [green]Du är inte längre tystad",
+ "sanction.gagged": " [red]Du har chattförbud: din textchatt är avstängd",
+ "sanction.ungagged": " [green]Ditt chattförbud har hävts",
+ "sanction.reason": " [grey]Anledning: {0}",
+ "sanction.until": " [grey]Till {0}",
+ "sanction.permanent": " [grey]Permanent"
+}
diff --git a/apps/player-management-sw/src/resources/translations/tr.jsonc b/apps/player-management-sw/src/resources/translations/tr.jsonc
new file mode 100644
index 00000000..b458467b
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/tr.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Susturuldunuz: sesli sohbetiniz devre dışı",
+ "sanction.unmuted": " [green]Artık susturulmuş değilsiniz",
+ "sanction.gagged": " [red]Sohbet yasağınız var: yazılı sohbetiniz devre dışı",
+ "sanction.ungagged": " [green]Sohbet yasağınız kaldırıldı",
+ "sanction.reason": " [grey]Sebep: {0}",
+ "sanction.until": " [grey]Bitiş: {0}",
+ "sanction.permanent": " [grey]Kalıcı"
+}
diff --git a/apps/player-management-sw/src/resources/translations/uk.jsonc b/apps/player-management-sw/src/resources/translations/uk.jsonc
new file mode 100644
index 00000000..e6a01531
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/uk.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]Вас заглушено: голосовий чат вимкнено",
+ "sanction.unmuted": " [green]Вас більше не заглушено",
+ "sanction.gagged": " [red]Вам заборонено чат: текстовий чат вимкнено",
+ "sanction.ungagged": " [green]Заборону чату знято",
+ "sanction.reason": " [grey]Причина: {0}",
+ "sanction.until": " [grey]До {0}",
+ "sanction.permanent": " [grey]Назавжди"
+}
diff --git a/apps/player-management-sw/src/resources/translations/zh-CN.jsonc b/apps/player-management-sw/src/resources/translations/zh-CN.jsonc
new file mode 100644
index 00000000..9b9117c9
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/zh-CN.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]你已被禁麦:语音聊天已禁用",
+ "sanction.unmuted": " [green]你的禁麦已解除",
+ "sanction.gagged": " [red]你已被禁言:文字聊天已禁用",
+ "sanction.ungagged": " [green]你的禁言已解除",
+ "sanction.reason": " [grey]原因:{0}",
+ "sanction.until": " [grey]截止:{0}",
+ "sanction.permanent": " [grey]永久"
+}
diff --git a/apps/player-management-sw/src/resources/translations/zh-TW.jsonc b/apps/player-management-sw/src/resources/translations/zh-TW.jsonc
new file mode 100644
index 00000000..e02bb70b
--- /dev/null
+++ b/apps/player-management-sw/src/resources/translations/zh-TW.jsonc
@@ -0,0 +1,9 @@
+{
+ "sanction.muted": " [red]你已被禁麥:語音聊天已停用",
+ "sanction.unmuted": " [green]你的禁麥已解除",
+ "sanction.gagged": " [red]你已被禁言:文字聊天已停用",
+ "sanction.ungagged": " [green]你的禁言已解除",
+ "sanction.reason": " [grey]原因:{0}",
+ "sanction.until": " [grey]截止:{0}",
+ "sanction.permanent": " [grey]永久"
+}
diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj
new file mode 100644
index 00000000..788f3657
--- /dev/null
+++ b/apps/player-management-sw/test/FiveStack.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ false
+ bin/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/player-management-sw/test/PlayerManagementReportTests.cs b/apps/player-management-sw/test/PlayerManagementReportTests.cs
new file mode 100644
index 00000000..24d0b667
--- /dev/null
+++ b/apps/player-management-sw/test/PlayerManagementReportTests.cs
@@ -0,0 +1,75 @@
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Utilities;
+using Xunit;
+
+public class PlayerManagementReportTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
+
+ // The api's sanctionServerPlayer reads these two lines back over RCON to
+ // decide what to tell the moderator; changing them breaks that check.
+ [Fact]
+ public void TheRefreshRepliesKeepTheirContractWithTheApi()
+ {
+ Assert.Equal("PlayerManagement: syncing 3 player(s)", PlayerManagementReport.Syncing(3));
+ Assert.StartsWith(
+ "PlayerManagement: not configured",
+ PlayerManagementReport.NotConfigured()
+ );
+ }
+
+ [Fact]
+ public void TheStatusListsEachPlayersSanctions()
+ {
+ string report = PlayerManagementReport.Status(
+ "0.0.9",
+ "swiftlys2",
+ new PlayerManagementSettings
+ {
+ API_DOMAIN = "https://api.example.com",
+ SERVER_ID = "11111111-1111-1111-1111-111111111111",
+ SERVER_API_PASSWORD = "secret",
+ },
+ Now.AddSeconds(-12),
+ null,
+ [
+ new PlayerManagementPlayer("clean", "1", SanctionState.None),
+ new PlayerManagementPlayer(
+ "noisy",
+ "2",
+ new SanctionState(
+ null,
+ new PlayerSanction { type = "silence" },
+ new PlayerSanction { type = "silence" }
+ )
+ ),
+ ],
+ Now
+ );
+
+ Assert.Contains("Plugin Version: 0.0.9", report);
+ Assert.Contains("Configured: yes", report);
+ Assert.Contains("Last Sync: 12s ago", report);
+ Assert.Contains("Players: 2", report);
+ Assert.Contains(" clean (1): clean", report);
+ Assert.Contains(" noisy (2): muted, gagged", report);
+ Assert.DoesNotContain("secret", report);
+ }
+
+ [Fact]
+ public void AFailingSyncSaysWhyAndWhenItLastWorked()
+ {
+ string report = PlayerManagementReport.Status(
+ "0.0.9",
+ "counterstrikesharp",
+ new PlayerManagementSettings(),
+ null,
+ "401 unauthorized",
+ [],
+ Now
+ );
+
+ Assert.Contains("Configured: no", report);
+ Assert.Contains("Last Sync: failed (401 unauthorized); last success never", report);
+ }
+}
diff --git a/apps/player-management-sw/test/PlayerManagementSettingsTests.cs b/apps/player-management-sw/test/PlayerManagementSettingsTests.cs
new file mode 100644
index 00000000..9835806a
--- /dev/null
+++ b/apps/player-management-sw/test/PlayerManagementSettingsTests.cs
@@ -0,0 +1,89 @@
+using FiveStack.Entities.PlayerManagement;
+using Xunit;
+
+public class PlayerManagementSettingsTests
+{
+ private const string ServerId = "11111111-1111-1111-1111-111111111111";
+
+ private static Func Env(Dictionary values)
+ {
+ return name => values.TryGetValue(name, out string? value) ? value : null;
+ }
+
+ [Fact]
+ public void TheNodesEnvironmentWinsOverTheFile()
+ {
+ PlayerManagementSettings file = new()
+ {
+ API_DOMAIN = "https://api.example.com",
+ SERVER_ID = "file-id",
+ SERVER_API_PASSWORD = "file-password",
+ };
+
+ PlayerManagementSettings resolved = file.Resolve(
+ Env(
+ new()
+ {
+ ["API_DOMAIN"] = "https://api.5stack.test",
+ ["SERVER_ID"] = ServerId,
+ ["SERVER_API_PASSWORD"] = "env-password",
+ }
+ )
+ );
+
+ Assert.Equal("https://api.5stack.test", resolved.API_DOMAIN);
+ Assert.Equal(ServerId, resolved.SERVER_ID);
+ Assert.Equal("env-password", resolved.SERVER_API_PASSWORD);
+ }
+
+ [Fact]
+ public void ABlankVariableDoesNotEraseTheFile()
+ {
+ PlayerManagementSettings file = new() { SERVER_ID = ServerId };
+
+ PlayerManagementSettings resolved = file.Resolve(Env(new() { ["SERVER_ID"] = " " }));
+
+ Assert.Equal(ServerId, resolved.SERVER_ID);
+ }
+
+ [Fact]
+ public void TheApiDomainLosesATrailingSlashAndADoubledScheme()
+ {
+ PlayerManagementSettings file = new() { API_DOMAIN = "https://https://api.example.com/" };
+
+ Assert.Equal("https://api.example.com", file.Resolve(Env(new())).API_DOMAIN);
+ }
+
+ [Fact]
+ public void ItIsConnectedOnlyWithAServerUuidAndAPassword()
+ {
+ Assert.False(new PlayerManagementSettings().IsConnected());
+ Assert.False(
+ new PlayerManagementSettings
+ {
+ SERVER_ID = "not-a-uuid",
+ SERVER_API_PASSWORD = "password",
+ }.IsConnected()
+ );
+ Assert.False(new PlayerManagementSettings { SERVER_ID = ServerId }.IsConnected());
+ Assert.True(
+ new PlayerManagementSettings
+ {
+ SERVER_ID = ServerId,
+ SERVER_API_PASSWORD = "password",
+ }.IsConnected()
+ );
+ }
+
+ [Fact]
+ public void TheSyncUrlIsTheServersSanctionsRoute()
+ {
+ PlayerManagementSettings settings = new()
+ {
+ API_DOMAIN = "https://api.example.com",
+ SERVER_ID = ServerId,
+ };
+
+ Assert.Equal($"https://api.example.com/sanctions/server/{ServerId}", settings.SyncUrl());
+ }
+}
diff --git a/apps/player-management-sw/test/SanctionBookTests.cs b/apps/player-management-sw/test/SanctionBookTests.cs
new file mode 100644
index 00000000..49d955af
--- /dev/null
+++ b/apps/player-management-sw/test/SanctionBookTests.cs
@@ -0,0 +1,209 @@
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Enums;
+using FiveStack.Utilities;
+using Xunit;
+
+public class SanctionBookTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
+
+ private static PlayerSanction Sanction(
+ string steamId,
+ string type,
+ DateTimeOffset? expiresAt = null,
+ string? reason = null
+ )
+ {
+ return new PlayerSanction
+ {
+ steam_id = steamId,
+ type = type,
+ expires_at = expiresAt,
+ reason = reason,
+ };
+ }
+
+ [Fact]
+ public void APlayerThePanelWasAskedAboutAndSaysNothingOfIsCleared()
+ {
+ SanctionBook book = new();
+ book.Record(["1"], [Sanction("1", "mute")]);
+
+ book.Record(["1"], []);
+
+ Assert.Equal(SanctionState.None, book.StateFor("1", Now));
+ }
+
+ // A banned player who left is not in the next sync, and their ban must
+ // still be known if they rejoin while the panel is unreachable.
+ [Fact]
+ public void APlayerTheSyncDidNotAskAboutKeepsWhatWasKnown()
+ {
+ SanctionBook book = new();
+ book.Record(["1", "2"], [Sanction("1", "ban"), Sanction("2", "gag")]);
+
+ book.Record(["2"], []);
+
+ Assert.True(book.StateFor("1", Now).IsBanned);
+ Assert.False(book.StateFor("2", Now).IsGagged);
+ }
+
+ [Fact]
+ public void SilenceIsBothAMuteAndAGag()
+ {
+ SanctionBook book = new();
+ book.Record(["1"], [Sanction("1", "silence")]);
+
+ SanctionState state = book.StateFor("1", Now);
+
+ Assert.True(state.IsMuted);
+ Assert.True(state.IsGagged);
+ Assert.False(state.IsBanned);
+ }
+
+ [Fact]
+ public void AnExpiredSanctionNoLongerApplies()
+ {
+ SanctionBook book = new();
+ book.Record(["1"], [Sanction("1", "mute", Now.AddSeconds(30))]);
+
+ Assert.True(book.StateFor("1", Now).IsMuted);
+ Assert.False(book.StateFor("1", Now.AddSeconds(30)).IsMuted);
+ }
+
+ [Fact]
+ public void APermanentSanctionOutranksATimedOne()
+ {
+ SanctionBook book = new();
+ book.Record(
+ ["1"],
+ [Sanction("1", "gag", Now.AddDays(3), "timed"), Sanction("1", "gag", null, "permanent")]
+ );
+
+ Assert.Equal("permanent", book.StateFor("1", Now).Gag!.reason);
+ }
+
+ [Fact]
+ public void OfTwoTimedSanctionsTheLaterExpiryIsTheOneBeingServed()
+ {
+ SanctionBook book = new();
+ book.Record(
+ ["1"],
+ [
+ Sanction("1", "mute", Now.AddHours(1), "short"),
+ Sanction("1", "silence", Now.AddDays(1), "long"),
+ ]
+ );
+
+ Assert.Equal("long", book.StateFor("1", Now).Mute!.reason);
+ }
+
+ [Fact]
+ public void AnUnknownTypeIsIgnored()
+ {
+ SanctionBook book = new();
+ book.Record(["1"], [Sanction("1", "warning")]);
+
+ Assert.Equal(SanctionState.None, book.StateFor("1", Now));
+ }
+
+ [Fact]
+ public void AJoiningPlayerAwaitsUntilThePanelAnswersForThem()
+ {
+ SanctionBook book = new();
+ book.Joined("1");
+
+ Assert.True(book.IsAwaiting("1"));
+ Assert.Equal(["1"], book.Awaiting());
+
+ book.Record(["1"], []);
+
+ Assert.False(book.IsAwaiting("1"));
+ }
+
+ [Fact]
+ public void AnAnswerAboutOtherPlayersLeavesAJoiningPlayerAwaiting()
+ {
+ SanctionBook book = new();
+ book.Joined("1");
+
+ book.Record(["2"], []);
+
+ Assert.True(book.IsAwaiting("1"));
+ }
+
+ [Fact]
+ public void AnUnansweredSyncFallsBackToWhatWasKnown()
+ {
+ SanctionBook book = new();
+ book.Record(["1"], [Sanction("1", "ban")]);
+ book.Joined("1");
+
+ book.Unanswered(["1"]);
+
+ Assert.False(book.IsAwaiting("1"));
+ Assert.True(book.StateFor("1", Now).IsBanned);
+ }
+
+ [Fact]
+ public void LeavingStopsAwaiting()
+ {
+ SanctionBook book = new();
+ book.Joined("1");
+
+ book.Left("1");
+
+ Assert.Empty(book.Awaiting());
+ }
+
+ [Fact]
+ public void ChangesReportOnlyTransitionsForMuteAndGag()
+ {
+ SanctionState muted = new(null, Sanction("1", "mute"), null);
+ SanctionState gagged = new(null, null, Sanction("1", "gag"));
+
+ Assert.Equal(eSanctionChange.Muted, SanctionState.Changes(SanctionState.None, muted));
+ Assert.Equal(eSanctionChange.None, SanctionState.Changes(muted, muted));
+ Assert.Equal(
+ eSanctionChange.Unmuted | eSanctionChange.Gagged,
+ SanctionState.Changes(muted, gagged)
+ );
+ Assert.Equal(eSanctionChange.Ungagged, SanctionState.Changes(gagged, SanctionState.None));
+ }
+
+ // A banned player still on the server has to be kicked, whether or not the
+ // last pass already saw the ban.
+ [Fact]
+ public void ABanIsReportedEveryTimeItIsInForce()
+ {
+ SanctionState banned = new(Sanction("1", "ban"), null, null);
+
+ Assert.True(SanctionState.Changes(banned, banned).HasFlag(eSanctionChange.Banned));
+ }
+
+ [Fact]
+ public void AKickReasonCarriesTheReasonWithoutChatFormatting()
+ {
+ Assert.Equal("Banned", SanctionBook.KickReason(Sanction("1", "ban")));
+ Assert.Equal(
+ "Banned: cheating",
+ SanctionBook.KickReason(Sanction("1", "ban", reason: "[red]cheating"))
+ );
+ }
+
+ [Fact]
+ public void UntilIsUtcAndEmptyForAPermanentSanction()
+ {
+ Assert.Equal("", SanctionBook.Until(Sanction("1", "mute")));
+ Assert.Equal(
+ "2026-09-29 14:30 UTC",
+ SanctionBook.Until(
+ Sanction(
+ "1",
+ "mute",
+ new DateTimeOffset(2026, 9, 29, 16, 30, 0, TimeSpan.FromHours(2))
+ )
+ )
+ );
+ }
+}
diff --git a/apps/player-management-sw/test/SanctionSyncLoopTests.cs b/apps/player-management-sw/test/SanctionSyncLoopTests.cs
new file mode 100644
index 00000000..d22c0acd
--- /dev/null
+++ b/apps/player-management-sw/test/SanctionSyncLoopTests.cs
@@ -0,0 +1,211 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Utilities;
+using Xunit;
+
+public class SanctionSyncLoopTests
+{
+ private static readonly DateTimeOffset Start = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
+
+ private sealed class Panel : HttpMessageHandler
+ {
+ public readonly List> Asked = new();
+ public Func, Task> Answer = _ =>
+ Task.FromResult(Sanctions());
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ )
+ {
+ using JsonDocument body = JsonDocument.Parse(
+ await request.Content!.ReadAsStringAsync(cancellationToken)
+ );
+ List steamIds = body
+ .RootElement.GetProperty("steam_ids")
+ .EnumerateArray()
+ .Select(steamId => steamId.GetString()!)
+ .ToList();
+
+ Asked.Add(steamIds);
+
+ return await Answer(steamIds);
+ }
+
+ public static HttpResponseMessage Sanctions(string json = "[]")
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ $"{{\"sanctions\":{json}}}",
+ Encoding.UTF8,
+ "application/json"
+ ),
+ };
+ }
+ }
+
+ private static PlayerManagementSettings Connected()
+ {
+ return new PlayerManagementSettings
+ {
+ API_DOMAIN = "https://api.example.com",
+ SERVER_ID = "11111111-1111-1111-1111-111111111111",
+ SERVER_API_PASSWORD = "secret",
+ };
+ }
+
+ private static (
+ SanctionSyncLoop Loop,
+ SanctionBook Book,
+ Panel Panel,
+ List Warnings
+ ) Loop(Func? settings = null)
+ {
+ Panel panel = new();
+ SanctionBook book = new();
+ List warnings = new();
+
+ SanctionSyncLoop loop = new(
+ book,
+ new SanctionsClient(new HttpClient(panel)),
+ settings ?? Connected,
+ "0.0.9",
+ "swiftlys2",
+ warnings.Add,
+ _ => { }
+ );
+
+ return (loop, book, panel, warnings);
+ }
+
+ // An empty server is exactly when nothing else would call the panel, and
+ // the call is what shows the plugin as active.
+ [Fact]
+ public async Task AnEmptyServerStillSyncs()
+ {
+ var (loop, _, panel, _) = Loop();
+
+ await loop.Tick(Start);
+
+ List asked = Assert.Single(panel.Asked);
+ Assert.Empty(asked);
+ }
+
+ [Fact]
+ public async Task ItWaitsTheIntervalBetweenSyncs()
+ {
+ var (loop, _, panel, _) = Loop();
+
+ await loop.Tick(Start);
+ await loop.Tick(Start.AddSeconds(10));
+ Assert.Single(panel.Asked);
+
+ await loop.Tick(Start + SanctionSyncLoop.Interval);
+ Assert.Equal(2, panel.Asked.Count);
+ }
+
+ [Fact]
+ public async Task ARequestSyncsWithoutWaitingTheInterval()
+ {
+ var (loop, _, panel, _) = Loop();
+
+ await loop.Tick(Start);
+ loop.Request();
+ await loop.Tick(Start.AddSeconds(1));
+
+ Assert.Equal(2, panel.Asked.Count);
+ }
+
+ [Fact]
+ public async Task ItAsksAboutThePlayersPresentAndThoseJoining()
+ {
+ var (loop, book, panel, _) = Loop();
+ loop.Observe(["1", "2", "1"]);
+ book.Joined("3");
+
+ await loop.Tick(Start);
+
+ Assert.Equal(["1", "2", "3"], panel.Asked[0]);
+ }
+
+ // The bug this guards: a player banned and kicked here keeps that ban in
+ // the cache, is unbanned while away, and must not be kicked again on the
+ // way back in by the stale copy.
+ [Fact]
+ public async Task ALiftedBanIsNotEnforcedOnARejoiningPlayer()
+ {
+ var (loop, book, _, _) = Loop();
+ book.Record(["1"], [new PlayerSanction { steam_id = "1", type = "ban" }]);
+
+ book.Joined("1");
+ Assert.True(book.IsAwaiting("1"));
+
+ await loop.Tick(Start);
+
+ Assert.False(book.IsAwaiting("1"));
+ Assert.False(book.StateFor("1", Start).IsBanned);
+ }
+
+ [Fact]
+ public async Task AnUnreachablePanelFallsBackToTheCacheAndSaysSoOnce()
+ {
+ var (loop, book, panel, warnings) = Loop();
+ panel.Answer = _ => throw new HttpRequestException("connection refused");
+ book.Record(["1"], [new PlayerSanction { steam_id = "1", type = "ban" }]);
+ book.Joined("1");
+
+ await loop.Tick(Start);
+ await loop.Tick(Start + SanctionSyncLoop.Interval);
+
+ Assert.False(book.IsAwaiting("1"));
+ Assert.True(book.StateFor("1", Start).IsBanned);
+ Assert.Equal("connection refused", loop.Status().LastError);
+ Assert.Single(warnings);
+ }
+
+ [Fact]
+ public async Task ASyncInFlightIsNotDoubledAndTheAskIsKeptForTheNext()
+ {
+ var (loop, _, panel, _) = Loop();
+ TaskCompletionSource answer = new();
+ panel.Answer = _ => answer.Task;
+
+ Task first = loop.Tick(Start);
+ loop.Request();
+ await loop.Tick(Start.AddSeconds(1));
+ Assert.Single(panel.Asked);
+
+ answer.SetResult(Panel.Sanctions());
+ await first;
+
+ panel.Answer = _ => Task.FromResult(Panel.Sanctions());
+ await loop.Tick(Start.AddSeconds(2));
+ Assert.Equal(2, panel.Asked.Count);
+ }
+
+ [Fact]
+ public async Task AnUnconfiguredServerNeverCallsThePanelNorHoldsPlayersBack()
+ {
+ var (loop, book, panel, _) = Loop(() => new PlayerManagementSettings());
+ book.Joined("1");
+
+ await loop.Tick(Start);
+
+ Assert.Empty(panel.Asked);
+ Assert.False(book.IsAwaiting("1"));
+ }
+
+ [Fact]
+ public async Task ASuccessfulSyncIsReported()
+ {
+ var (loop, _, _, _) = Loop();
+
+ await loop.Tick(Start);
+
+ Assert.Equal((Start, (string?)null), loop.Status());
+ }
+}
diff --git a/apps/player-management-sw/test/SanctionsClientTests.cs b/apps/player-management-sw/test/SanctionsClientTests.cs
new file mode 100644
index 00000000..83ce9286
--- /dev/null
+++ b/apps/player-management-sw/test/SanctionsClientTests.cs
@@ -0,0 +1,149 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Utilities;
+using Xunit;
+
+public class SanctionsClientTests
+{
+ private const string ServerId = "11111111-1111-1111-1111-111111111111";
+
+ private static readonly PlayerManagementSettings Settings = new()
+ {
+ API_DOMAIN = "https://api.example.com",
+ SERVER_ID = ServerId,
+ SERVER_API_PASSWORD = "secret",
+ };
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly Func _respond;
+
+ public HttpRequestMessage? Request;
+ public string? Body;
+
+ public StubHandler(Func respond)
+ {
+ _respond = respond;
+ }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken
+ )
+ {
+ Request = request;
+ Body = request.Content == null ? null : await request.Content.ReadAsStringAsync();
+
+ return _respond(request);
+ }
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, string body)
+ {
+ return new HttpResponseMessage(status)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ }
+
+ private static PlayerSanctionsRequest Request()
+ {
+ return new PlayerSanctionsRequest
+ {
+ steam_ids = ["76561198000000001"],
+ plugin_version = "0.0.9",
+ plugin_runtime = "swiftlys2",
+ };
+ }
+
+ [Fact]
+ public async Task ItPostsThePlayersToTheServersRouteWithTheApiPassword()
+ {
+ StubHandler handler = new(_ => Json(HttpStatusCode.OK, "{\"sanctions\":[]}"));
+
+ await new SanctionsClient(new HttpClient(handler)).Sync(Settings, Request());
+
+ Assert.Equal(HttpMethod.Post, handler.Request!.Method);
+ Assert.Equal(
+ $"https://api.example.com/sanctions/server/{ServerId}",
+ handler.Request.RequestUri!.ToString()
+ );
+ Assert.Equal("Bearer secret", handler.Request.Headers.Authorization!.ToString());
+
+ using JsonDocument body = JsonDocument.Parse(handler.Body!);
+ Assert.Equal("76561198000000001", body.RootElement.GetProperty("steam_ids")[0].GetString());
+ Assert.Equal("0.0.9", body.RootElement.GetProperty("plugin_version").GetString());
+ Assert.Equal("swiftlys2", body.RootElement.GetProperty("plugin_runtime").GetString());
+ }
+
+ [Fact]
+ public async Task ItReadsThePanelsSanctions()
+ {
+ StubHandler handler = new(_ =>
+ Json(
+ HttpStatusCode.OK,
+ "{\"sanctions\":[{\"steam_id\":\"76561198000000001\",\"type\":\"gag\",\"reason\":\"spam\",\"expires_at\":\"2026-10-01T00:00:00.000Z\"},{\"steam_id\":\"76561198000000001\",\"type\":\"ban\",\"reason\":null,\"expires_at\":null}]}"
+ )
+ );
+
+ SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync(
+ Settings,
+ Request()
+ );
+
+ Assert.Null(result.Error);
+ Assert.Equal(2, result.Sanctions!.Count);
+ Assert.Equal("gag", result.Sanctions[0].type);
+ Assert.Equal("spam", result.Sanctions[0].reason);
+ Assert.Equal(
+ new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero),
+ result.Sanctions[0].expires_at
+ );
+ Assert.Null(result.Sanctions[1].expires_at);
+ }
+
+ [Fact]
+ public async Task AnUnauthorizedAnswerNamesTheSettingsToCheck()
+ {
+ StubHandler handler = new(_ => Json(HttpStatusCode.Unauthorized, ""));
+
+ SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync(
+ Settings,
+ Request()
+ );
+
+ Assert.Null(result.Sanctions);
+ Assert.Contains("SERVER_API_PASSWORD", result.Error);
+ }
+
+ [Fact]
+ public async Task AnUnreachablePanelIsAnErrorNotAThrow()
+ {
+ StubHandler handler = new(_ => throw new HttpRequestException("connection refused"));
+
+ SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync(
+ Settings,
+ Request()
+ );
+
+ Assert.Null(result.Sanctions);
+ Assert.Equal("connection refused", result.Error);
+ }
+
+ [Fact]
+ public async Task AnUnconfiguredServerNeverCallsThePanel()
+ {
+ StubHandler handler = new(_ => Json(HttpStatusCode.OK, "{\"sanctions\":[]}"));
+
+ SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync(
+ new PlayerManagementSettings(),
+ Request()
+ );
+
+ Assert.Null(handler.Request);
+ Assert.Null(result.Sanctions);
+ }
+}
diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile
index ffe0eff9..563ccb91 100644
--- a/apps/swiftly/Dockerfile
+++ b/apps/swiftly/Dockerfile
@@ -61,17 +61,45 @@ RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/utility-sw/src/Utili
RUN dotnet publish -c Release apps/utility-sw/src/UtilityPractice.csproj -o /mod/release
-FROM debian:bookworm-slim AS zip-creator
+# Community servers load this in place of the match plugin; its zip is also
+# published beside the match plugin's for servers run outside a 5stack node.
+FROM dotnet-sdk AS player-management-build
-WORKDIR /zip-content
+WORKDIR /mod
-COPY --from=build /mod/release ./addons/swiftlys2/plugins/FiveStack/./
+COPY Directory.Build.props ./
+COPY apps/player-management-sw/src/PlayerManagement.csproj apps/player-management-sw/src/
+
+RUN dotnet restore apps/player-management-sw/src/PlayerManagement.csproj
+
+COPY shared shared
+COPY apps/player-management-sw apps/player-management-sw
+
+ARG RELEASE_VERSION
+ENV RELEASE_VERSION=${RELEASE_VERSION}
+
+RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/player-management-sw/src/PlayerManagementPlugin.cs
+
+RUN dotnet publish -c Release apps/player-management-sw/src/PlayerManagement.csproj -o /mod/release
+
+FROM debian:bookworm-slim AS zip-creator
RUN apt-get update && \
apt-get install -y --no-install-recommends zip && \
- zip -r /mod-release.zip . && \
rm -rf /var/lib/apt/lists/*
+WORKDIR /zip-content
+
+COPY --from=build /mod/release ./addons/swiftlys2/plugins/FiveStack/./
+
+RUN zip -r /mod-release.zip .
+
+WORKDIR /player-management-zip-content
+
+COPY --from=player-management-build /mod/release ./addons/swiftlys2/plugins/PlayerManagement/./
+
+RUN zip -r /player-management-release.zip .
+
FROM sniper
ENV DATA_DIR="/serverdata"
@@ -86,6 +114,7 @@ ENV PLUGINS_DIR="/opt/custom-plugins"
ENV INSTALL_5STACK_PLUGIN=true
ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false
+ENV INSTALL_PLAYER_MANAGEMENT_PLUGIN=false
ENV GAME_ID="730"
ENV GAME_PARAMS=""
@@ -181,6 +210,7 @@ COPY shared/scripts /opt/scripts
COPY apps/swiftly/scripts /opt/scripts
COPY --from=build /mod/release /opt/mod
COPY --from=utility-build /mod/release /opt/utility-practice
+COPY --from=player-management-build /mod/release /opt/player-management
RUN cp -R /opt/swiftlys2/swiftlys2-linux-${SWIFTLYS2_VERSION}-with-runtimes/addons /opt/addons && \
rm -rf /opt/swiftlys2
diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh
index d40e6f68..20f7c8b7 100755
--- a/apps/swiftly/scripts/setup.sh
+++ b/apps/swiftly/scripts/setup.sh
@@ -257,6 +257,18 @@ EOF
esac
fi
+# A community server's sanctions: it runs no match plugin, so without this a
+# mute or gag set in the panel never reaches it.
+if $INSTALL_PLAYER_MANAGEMENT_PLUGIN = true ; then
+ echo "---Install Player Management---"
+ PLAYER_MANAGEMENT_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/PlayerManagement"
+ if [ ! -e "$PLAYER_MANAGEMENT_PLUGIN_DIR" ]; then
+ ln -s "/opt/player-management" "$PLAYER_MANAGEMENT_PLUGIN_DIR"
+ else
+ echo "---Player Management: plugin dir already present, skipping /opt/player-management symlink---"
+ fi
+fi
+
if [ ! -e "$INSTANCE_SERVER_DIR/game/csgo/addons/swiftlys2/configs/core.jsonc" ]; then
cp "/opt/server-cfg/core.jsonc" "$INSTANCE_SERVER_DIR/game/csgo/addons/swiftlys2/configs"
fi
diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs
new file mode 100644
index 00000000..1b216685
--- /dev/null
+++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs
@@ -0,0 +1,46 @@
+using System.Text.RegularExpressions;
+
+namespace FiveStack.Entities.PlayerManagement;
+
+public class PlayerManagementSettings
+{
+ public string API_DOMAIN { get; set; } = "https://api.5stack.gg";
+ public string SERVER_ID { get; set; } = "";
+ public string SERVER_API_PASSWORD { get; set; } = "";
+
+ // A 5stack node hands every pod these three as env, so a node server needs
+ // no config file at all; the file is for servers run outside a node.
+ public PlayerManagementSettings Resolve(Func environment)
+ {
+ string apiDomain = Pick(environment("API_DOMAIN"), API_DOMAIN).TrimEnd('/');
+
+ // A doubled scheme dials a host literally named "https" and dies quietly
+ // on DNS, which is invisible from outside the server.
+ apiDomain = Regex.Replace(apiDomain, "^(https?://)+", "$1");
+
+ return new PlayerManagementSettings
+ {
+ API_DOMAIN = apiDomain,
+ SERVER_ID = Pick(environment("SERVER_ID"), SERVER_ID).Trim(),
+ SERVER_API_PASSWORD = Pick(environment("SERVER_API_PASSWORD"), SERVER_API_PASSWORD)
+ .Trim(),
+ };
+ }
+
+ public bool IsConnected()
+ {
+ return !string.IsNullOrEmpty(API_DOMAIN)
+ && Guid.TryParse(SERVER_ID, out _)
+ && !string.IsNullOrEmpty(SERVER_API_PASSWORD);
+ }
+
+ public string SyncUrl()
+ {
+ return $"{API_DOMAIN}/sanctions/server/{SERVER_ID}";
+ }
+
+ private static string Pick(string? preferred, string fallback)
+ {
+ return string.IsNullOrWhiteSpace(preferred) ? fallback ?? "" : preferred;
+ }
+}
diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs
new file mode 100644
index 00000000..7b4989b3
--- /dev/null
+++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs
@@ -0,0 +1,21 @@
+namespace FiveStack.Entities.PlayerManagement;
+
+public class PlayerSanction
+{
+ public string steam_id { get; set; } = "";
+ public string type { get; set; } = "";
+ public string? reason { get; set; }
+ public DateTimeOffset? expires_at { get; set; }
+}
+
+public class PlayerSanctionsRequest
+{
+ public List steam_ids { get; set; } = new();
+ public string plugin_version { get; set; } = "";
+ public string plugin_runtime { get; set; } = "";
+}
+
+public class PlayerSanctionsResponse
+{
+ public List sanctions { get; set; } = new();
+}
diff --git a/shared/dotnet/FiveStack.Enums/eSanctionChange.cs b/shared/dotnet/FiveStack.Enums/eSanctionChange.cs
new file mode 100644
index 00000000..9825ff92
--- /dev/null
+++ b/shared/dotnet/FiveStack.Enums/eSanctionChange.cs
@@ -0,0 +1,12 @@
+namespace FiveStack.Enums;
+
+[Flags]
+public enum eSanctionChange
+{
+ None = 0,
+ Banned = 1,
+ Muted = 2,
+ Unmuted = 4,
+ Gagged = 8,
+ Ungagged = 16,
+}
diff --git a/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs b/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs
new file mode 100644
index 00000000..5070ad1a
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs
@@ -0,0 +1,84 @@
+using FiveStack.Entities.PlayerManagement;
+
+namespace FiveStack.Utilities;
+
+public sealed record PlayerManagementPlayer(string Name, string SteamId, SanctionState State);
+
+// What the plugin answers over RCON. The panel reads the refresh reply to tell
+// "installed and syncing" from "installed but not configured" from "not
+// installed" (an unknown command), so Marker and the syncing line are a
+// contract with the api, not just text.
+public static class PlayerManagementReport
+{
+ public const string Marker = "PlayerManagement:";
+
+ public static string Syncing(int players)
+ {
+ return $"{Marker} syncing {players} player(s)";
+ }
+
+ public static string NotConfigured()
+ {
+ return $"{Marker} not configured; set API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD";
+ }
+
+ public static string Status(
+ string version,
+ string runtime,
+ PlayerManagementSettings settings,
+ DateTimeOffset? lastSyncAt,
+ string? lastError,
+ IReadOnlyCollection players,
+ DateTimeOffset now
+ )
+ {
+ List lines =
+ [
+ "----- 5Stack Player Management -----",
+ $"Plugin Version: {version}",
+ $"Plugin Runtime: {runtime}",
+ $"Server ID: {(string.IsNullOrEmpty(settings.SERVER_ID) ? "unassigned" : settings.SERVER_ID)}",
+ $"API: {settings.API_DOMAIN}",
+ $"Configured: {(settings.IsConnected() ? "yes" : "no")}",
+ $"Last Sync: {LastSync(lastSyncAt, lastError, now)}",
+ $"Players: {players.Count}",
+ ];
+
+ foreach (PlayerManagementPlayer player in players)
+ {
+ lines.Add($" {player.Name} ({player.SteamId}): {Describe(player.State)}");
+ }
+
+ return string.Join("\n", lines);
+ }
+
+ public static string Describe(SanctionState state)
+ {
+ List parts = new();
+
+ if (state.IsBanned)
+ {
+ parts.Add("banned");
+ }
+
+ if (state.IsMuted)
+ {
+ parts.Add("muted");
+ }
+
+ if (state.IsGagged)
+ {
+ parts.Add("gagged");
+ }
+
+ return parts.Count == 0 ? "clean" : string.Join(", ", parts);
+ }
+
+ private static string LastSync(DateTimeOffset? at, string? error, DateTimeOffset now)
+ {
+ string when =
+ at == null ? "never" : $"{Math.Max(0, (int)(now - at.Value).TotalSeconds)}s ago";
+
+ return error == null ? when : $"failed ({error}); last success {when}";
+ }
+}
diff --git a/shared/dotnet/FiveStack.Utilities/SanctionBook.cs b/shared/dotnet/FiveStack.Utilities/SanctionBook.cs
new file mode 100644
index 00000000..a0f21106
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/SanctionBook.cs
@@ -0,0 +1,185 @@
+using System.Globalization;
+using FiveStack.Entities.PlayerManagement;
+using FiveStack.Enums;
+
+namespace FiveStack.Utilities;
+
+public sealed record SanctionState(PlayerSanction? Ban, PlayerSanction? Mute, PlayerSanction? Gag)
+{
+ public static readonly SanctionState None = new(null, null, null);
+
+ public bool IsBanned => Ban != null;
+ public bool IsMuted => Mute != null;
+ public bool IsGagged => Gag != null;
+
+ public static eSanctionChange Changes(SanctionState previous, SanctionState next)
+ {
+ eSanctionChange changes = eSanctionChange.None;
+
+ if (next.IsBanned)
+ {
+ changes |= eSanctionChange.Banned;
+ }
+
+ if (next.IsMuted && !previous.IsMuted)
+ {
+ changes |= eSanctionChange.Muted;
+ }
+ else if (!next.IsMuted && previous.IsMuted)
+ {
+ changes |= eSanctionChange.Unmuted;
+ }
+
+ if (next.IsGagged && !previous.IsGagged)
+ {
+ changes |= eSanctionChange.Gagged;
+ }
+ else if (!next.IsGagged && previous.IsGagged)
+ {
+ changes |= eSanctionChange.Ungagged;
+ }
+
+ return changes;
+ }
+}
+
+// What the panel last said about each player, kept after they leave so a
+// panel that is down cannot let a banned player straight back in. The price is
+// that the cache goes stale while a player is away -- their ban can be lifted
+// -- so a player who has just joined is awaiting until the panel answers for
+// them, and nothing is enforced on them from the cache before then.
+public class SanctionBook
+{
+ private readonly object _lock = new();
+ private readonly Dictionary> _bySteamId = new();
+ private readonly HashSet _awaiting = new();
+
+ // The panel answers for exactly the players it was asked about, so a
+ // player it was asked about and says nothing of has been cleared.
+ public void Record(IEnumerable queried, IEnumerable sanctions)
+ {
+ lock (_lock)
+ {
+ foreach (string steamId in queried)
+ {
+ _bySteamId.Remove(steamId);
+ _awaiting.Remove(steamId);
+ }
+
+ foreach (PlayerSanction sanction in sanctions)
+ {
+ if (string.IsNullOrEmpty(sanction.steam_id))
+ {
+ continue;
+ }
+
+ if (!_bySteamId.TryGetValue(sanction.steam_id, out List? list))
+ {
+ list = new List();
+ _bySteamId[sanction.steam_id] = list;
+ }
+
+ list.Add(sanction);
+ }
+ }
+ }
+
+ public void Joined(string steamId)
+ {
+ lock (_lock)
+ {
+ _awaiting.Add(steamId);
+ }
+ }
+
+ public void Left(string steamId)
+ {
+ lock (_lock)
+ {
+ _awaiting.Remove(steamId);
+ }
+ }
+
+ // The panel could not be asked, so the cache is the best answer there is.
+ public void Unanswered(IEnumerable queried)
+ {
+ lock (_lock)
+ {
+ foreach (string steamId in queried)
+ {
+ _awaiting.Remove(steamId);
+ }
+ }
+ }
+
+ public bool IsAwaiting(string steamId)
+ {
+ lock (_lock)
+ {
+ return _awaiting.Contains(steamId);
+ }
+ }
+
+ public List Awaiting()
+ {
+ lock (_lock)
+ {
+ return _awaiting.ToList();
+ }
+ }
+
+ public SanctionState StateFor(string steamId, DateTimeOffset now)
+ {
+ List active;
+
+ lock (_lock)
+ {
+ if (!_bySteamId.TryGetValue(steamId, out List? sanctions))
+ {
+ return SanctionState.None;
+ }
+
+ active = sanctions
+ .Where(sanction => sanction.expires_at == null || sanction.expires_at > now)
+ .ToList();
+ }
+
+ return new SanctionState(
+ Strongest(active.Where(sanction => sanction.type == "ban")),
+ Strongest(active.Where(sanction => sanction.type is "mute" or "silence")),
+ Strongest(active.Where(sanction => sanction.type is "gag" or "silence"))
+ );
+ }
+
+ public static string Until(PlayerSanction sanction)
+ {
+ return sanction.expires_at == null
+ ? ""
+ : sanction.expires_at.Value.UtcDateTime.ToString(
+ "yyyy-MM-dd HH:mm",
+ CultureInfo.InvariantCulture
+ ) + " UTC";
+ }
+
+ public static string Reason(PlayerSanction sanction)
+ {
+ return ChatUtility.StripFormatting(sanction.reason ?? "").Trim();
+ }
+
+ public static string KickReason(PlayerSanction ban)
+ {
+ string reason = Reason(ban);
+
+ return reason.Length == 0 ? "Banned" : $"Banned: {reason}";
+ }
+
+ // A permanent sanction outlasts any timed one, and of two timed ones the
+ // later expiry is the one the player is actually serving.
+ private static PlayerSanction? Strongest(IEnumerable sanctions)
+ {
+ return sanctions
+ .OrderByDescending(sanction => sanction.expires_at == null)
+ .ThenByDescending(sanction => sanction.expires_at)
+ .FirstOrDefault();
+ }
+}
diff --git a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs
new file mode 100644
index 00000000..3cfbf2b8
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs
@@ -0,0 +1,192 @@
+using FiveStack.Entities.PlayerManagement;
+
+namespace FiveStack.Utilities;
+
+// On its own timer, not a game one: a community server hibernates when empty,
+// which stops every frame-driven timer, and this sync is also the heartbeat the
+// panel reads to show the plugin as active. A frame timer also replays every
+// second it missed on waking, which would hammer the panel.
+public sealed class SanctionSyncLoop : IDisposable
+{
+ public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
+
+ private static readonly TimeSpan Poll = TimeSpan.FromSeconds(1);
+
+ private readonly SanctionBook _book;
+ private readonly SanctionsClient _client;
+ private readonly Func _settings;
+ private readonly string _version;
+ private readonly string _runtime;
+ private readonly Action _warn;
+ private readonly Action _info;
+
+ private readonly object _lock = new();
+ private List _present = new();
+ private bool _syncing;
+ private bool _requested;
+ private DateTimeOffset? _startedAt;
+ private DateTimeOffset? _lastSyncAt;
+ private string? _lastError;
+ private Timer? _timer;
+
+ public SanctionSyncLoop(
+ SanctionBook book,
+ SanctionsClient client,
+ Func settings,
+ string version,
+ string runtime,
+ Action warn,
+ Action info
+ )
+ {
+ _book = book;
+ _client = client;
+ _settings = settings;
+ _version = version;
+ _runtime = runtime;
+ _warn = warn;
+ _info = info;
+ }
+
+ public void Start()
+ {
+ _timer = new Timer(_ => _ = Tick(DateTimeOffset.UtcNow), null, TimeSpan.Zero, Poll);
+ }
+
+ public void Dispose()
+ {
+ Timer? timer = _timer;
+ _timer = null;
+ timer?.Dispose();
+ }
+
+ // Only the game thread can read the player list, so it hands it over here.
+ public void Observe(IEnumerable present)
+ {
+ List snapshot = present.Distinct().ToList();
+
+ lock (_lock)
+ {
+ _present = snapshot;
+ }
+ }
+
+ // A sync already in flight predates whatever asked for this one, so the
+ // ask is kept and served by the next sync rather than dropped.
+ public void Request()
+ {
+ lock (_lock)
+ {
+ _requested = true;
+ }
+
+ try
+ {
+ _timer?.Change(TimeSpan.Zero, Poll);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Unloading.
+ }
+ }
+
+ public (DateTimeOffset? LastSyncAt, string? LastError) Status()
+ {
+ lock (_lock)
+ {
+ return (_lastSyncAt, _lastError);
+ }
+ }
+
+ public async Task Tick(DateTimeOffset now)
+ {
+ List queried;
+
+ lock (_lock)
+ {
+ bool due = _requested || _startedAt == null || now - _startedAt >= Interval;
+
+ if (_syncing || !due)
+ {
+ return;
+ }
+
+ _syncing = true;
+ _requested = false;
+ _startedAt = now;
+ queried = _present.Concat(_book.Awaiting()).Distinct().ToList();
+ }
+
+ try
+ {
+ PlayerManagementSettings settings = _settings();
+
+ if (!settings.IsConnected())
+ {
+ _book.Unanswered(queried);
+ return;
+ }
+
+ SanctionSync result = await _client.Sync(
+ settings,
+ new PlayerSanctionsRequest
+ {
+ steam_ids = queried,
+ plugin_version = _version,
+ plugin_runtime = _runtime,
+ }
+ );
+
+ if (result.Sanctions == null)
+ {
+ Failed(queried, result.Error ?? "unknown error");
+ return;
+ }
+
+ _book.Record(queried, result.Sanctions);
+
+ bool recovered;
+
+ lock (_lock)
+ {
+ recovered = _lastError != null;
+ _lastError = null;
+ _lastSyncAt = now;
+ }
+
+ if (recovered)
+ {
+ _info("sanction sync recovered");
+ }
+ }
+ catch (Exception error)
+ {
+ Failed(queried, error.Message);
+ }
+ finally
+ {
+ lock (_lock)
+ {
+ _syncing = false;
+ }
+ }
+ }
+
+ private void Failed(List queried, string error)
+ {
+ _book.Unanswered(queried);
+
+ bool changed;
+
+ lock (_lock)
+ {
+ changed = _lastError != error;
+ _lastError = error;
+ }
+
+ if (changed)
+ {
+ _warn($"unable to sync sanctions: {error}");
+ }
+ }
+}
diff --git a/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs
new file mode 100644
index 00000000..da462586
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs
@@ -0,0 +1,85 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using FiveStack.Entities.PlayerManagement;
+
+namespace FiveStack.Utilities;
+
+public sealed record SanctionSync(List? Sanctions, string? Error);
+
+// Never throws at its caller: a panel that is down must not take a public
+// server's chat or voice down with it, so every failure comes back as Error.
+public class SanctionsClient
+{
+ private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10);
+
+ public static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly HttpClient _http;
+
+ public SanctionsClient(HttpClient? http = null)
+ {
+ _http = http ?? HttpClientProvider.Client;
+ }
+
+ public async Task Sync(
+ PlayerManagementSettings settings,
+ PlayerSanctionsRequest body
+ )
+ {
+ if (!settings.IsConnected())
+ {
+ return new SanctionSync(null, "not configured");
+ }
+
+ try
+ {
+ using HttpRequestMessage request = new(HttpMethod.Post, settings.SyncUrl());
+ request.Headers.Authorization = new AuthenticationHeaderValue(
+ "Bearer",
+ settings.SERVER_API_PASSWORD
+ );
+ request.Content = new StringContent(
+ JsonSerializer.Serialize(body, Json),
+ Encoding.UTF8,
+ "application/json"
+ );
+
+ using CancellationTokenSource timeout = new(RequestTimeout);
+ using HttpResponseMessage response = await _http.SendAsync(request, timeout.Token);
+ string text = await response.Content.ReadAsStringAsync(timeout.Token);
+
+ if (response.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ return new SanctionSync(
+ null,
+ "401 unauthorized; check SERVER_ID and SERVER_API_PASSWORD"
+ );
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return new SanctionSync(
+ null,
+ $"{(int)response.StatusCode} {(text.Length > 200 ? text[..200] : text)}".Trim()
+ );
+ }
+
+ PlayerSanctionsResponse? parsed = JsonSerializer.Deserialize(
+ text,
+ Json
+ );
+
+ return new SanctionSync(parsed?.sanctions ?? new List(), null);
+ }
+ catch (Exception error)
+ {
+ return new SanctionSync(null, error.Message);
+ }
+ }
+}