From 29f449ab3a0fa1e3f7827dc422a7e412b1961b87 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 29 Sep 2026 21:44:49 -0400 Subject: [PATCH 1/2] feature: player management plugin for community servers --- .github/workflows/ci.yaml | 12 +- .github/workflows/plugin-build.yaml | 18 +- .github/workflows/test.yaml | 2 +- README.md | 20 + apps/counterstrikesharp/Dockerfile | 40 +- apps/counterstrikesharp/scripts/setup.sh | 12 + .../src/PlayerManagement.csproj | 45 ++ .../src/PlayerManagementConfig.cs | 25 + .../src/PlayerManagementPlugin.cs | 364 +++++++++++++++ .../player-management-css/src/lang/ar-SA.json | 9 + .../player-management-css/src/lang/da-DK.json | 9 + .../player-management-css/src/lang/de-DE.json | 9 + apps/player-management-css/src/lang/en.json | 9 + .../player-management-css/src/lang/es-ES.json | 9 + .../player-management-css/src/lang/fr-FR.json | 9 + .../player-management-css/src/lang/it-IT.json | 9 + .../player-management-css/src/lang/ja-JP.json | 9 + .../player-management-css/src/lang/ko-KR.json | 9 + .../player-management-css/src/lang/pl-PL.json | 9 + .../player-management-css/src/lang/pt-BR.json | 9 + .../player-management-css/src/lang/ru-RU.json | 9 + .../player-management-css/src/lang/sv-SE.json | 9 + .../player-management-css/src/lang/tr-TR.json | 9 + .../player-management-css/src/lang/uk-UA.json | 9 + .../src/lang/zh-Hans.json | 9 + .../src/lang/zh-Hant.json | 9 + .../src/PlayerManagement.csproj | 34 ++ .../src/PlayerManagementPlugin.cs | 442 ++++++++++++++++++ .../src/resources/translations/ar.jsonc | 9 + .../src/resources/translations/da.jsonc | 9 + .../src/resources/translations/de.jsonc | 9 + .../src/resources/translations/en.jsonc | 9 + .../src/resources/translations/es.jsonc | 9 + .../src/resources/translations/fr.jsonc | 9 + .../src/resources/translations/it.jsonc | 9 + .../src/resources/translations/ja.jsonc | 9 + .../src/resources/translations/ko.jsonc | 9 + .../src/resources/translations/pl.jsonc | 9 + .../src/resources/translations/pt-BR.jsonc | 9 + .../src/resources/translations/ru.jsonc | 9 + .../src/resources/translations/sv.jsonc | 9 + .../src/resources/translations/tr.jsonc | 9 + .../src/resources/translations/uk.jsonc | 9 + .../src/resources/translations/zh-CN.jsonc | 9 + .../src/resources/translations/zh-TW.jsonc | 9 + .../test/FiveStack.Tests.csproj | 43 ++ .../test/PlayerManagementReportTests.cs | 75 +++ .../test/PlayerManagementSettingsTests.cs | 89 ++++ .../test/SanctionBookTests.cs | 171 +++++++ .../test/SanctionsClientTests.cs | 149 ++++++ apps/swiftly/Dockerfile | 38 +- apps/swiftly/scripts/setup.sh | 12 + .../PlayerManagementSettings.cs | 46 ++ .../PlayerManagement/PlayerSanction.cs | 21 + .../dotnet/FiveStack.Enums/eSanctionChange.cs | 12 + .../PlayerManagementReport.cs | 84 ++++ .../FiveStack.Utilities/SanctionBook.cs | 142 ++++++ .../FiveStack.Utilities/SanctionsClient.cs | 85 ++++ 58 files changed, 2268 insertions(+), 19 deletions(-) create mode 100644 apps/player-management-css/src/PlayerManagement.csproj create mode 100644 apps/player-management-css/src/PlayerManagementConfig.cs create mode 100644 apps/player-management-css/src/PlayerManagementPlugin.cs create mode 100644 apps/player-management-css/src/lang/ar-SA.json create mode 100644 apps/player-management-css/src/lang/da-DK.json create mode 100644 apps/player-management-css/src/lang/de-DE.json create mode 100644 apps/player-management-css/src/lang/en.json create mode 100644 apps/player-management-css/src/lang/es-ES.json create mode 100644 apps/player-management-css/src/lang/fr-FR.json create mode 100644 apps/player-management-css/src/lang/it-IT.json create mode 100644 apps/player-management-css/src/lang/ja-JP.json create mode 100644 apps/player-management-css/src/lang/ko-KR.json create mode 100644 apps/player-management-css/src/lang/pl-PL.json create mode 100644 apps/player-management-css/src/lang/pt-BR.json create mode 100644 apps/player-management-css/src/lang/ru-RU.json create mode 100644 apps/player-management-css/src/lang/sv-SE.json create mode 100644 apps/player-management-css/src/lang/tr-TR.json create mode 100644 apps/player-management-css/src/lang/uk-UA.json create mode 100644 apps/player-management-css/src/lang/zh-Hans.json create mode 100644 apps/player-management-css/src/lang/zh-Hant.json create mode 100644 apps/player-management-sw/src/PlayerManagement.csproj create mode 100644 apps/player-management-sw/src/PlayerManagementPlugin.cs create mode 100644 apps/player-management-sw/src/resources/translations/ar.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/da.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/de.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/en.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/es.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/fr.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/it.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/ja.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/ko.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/pl.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/pt-BR.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/ru.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/sv.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/tr.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/uk.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/zh-CN.jsonc create mode 100644 apps/player-management-sw/src/resources/translations/zh-TW.jsonc create mode 100644 apps/player-management-sw/test/FiveStack.Tests.csproj create mode 100644 apps/player-management-sw/test/PlayerManagementReportTests.cs create mode 100644 apps/player-management-sw/test/PlayerManagementSettingsTests.cs create mode 100644 apps/player-management-sw/test/SanctionBookTests.cs create mode 100644 apps/player-management-sw/test/SanctionsClientTests.cs create mode 100644 shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerManagementSettings.cs create mode 100644 shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs create mode 100644 shared/dotnet/FiveStack.Enums/eSanctionChange.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs create mode 100644 shared/dotnet/FiveStack.Utilities/SanctionBook.cs create mode 100644 shared/dotnet/FiveStack.Utilities/SanctionsClient.cs 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..2ce89abe 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 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..16633c89 --- /dev/null +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -0,0 +1,45 @@ + + + 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..08412eb0 --- /dev/null +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -0,0 +1,364 @@ +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.Modules.Timers; +using CounterStrikeSharp.API.ValveConstants.Protobuf; +using FiveStack.Entities.PlayerManagement; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; + +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"; + + // Doubles as the heartbeat the panel reads to show the plugin as active, + // which is why an empty server still syncs. + private const int SyncSeconds = 30; + + 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 SanctionsClient _client = new(); + private readonly SanctionBook _book = new(); + + // What this plugin last applied per player, so it only ever lifts a mute it + // set itself and never one an admin plugin on the same server set. + private readonly Dictionary _applied = new(); + private readonly HashSet _kicked = new(); + + private Timer? _secondTimer; + + private int _secondsSinceSync = SyncSeconds; + private bool _syncing; + private bool _syncRequested; + private DateTimeOffset? _lastSyncAt; + private string? _lastError; + + 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(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; + } + + _applied.Remove(SteamIdOf(player)); + _kicked.Remove(SteamIdOf(player)); + }); + + AddCommandListener("say", OnChat, HookMode.Pre); + AddCommandListener("say_team", OnChat, HookMode.Pre); + + _secondTimer = AddTimer(1f, OnSecond, TimerFlags.REPEAT); + } + + public override void Unload(bool hotReload) + { + _secondTimer?.Kill(); + _secondTimer = 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; + } + + RequestSync(); + + 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; + + 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 + ) + ); + } + + private void OnJoined(int slot) + { + CCSPlayerController? player = Utilities.GetPlayerFromSlot(slot); + + if (player == null || !IsHuman(player)) + { + return; + } + + // The cached answer covers a panel that is unreachable right now; the + // sync that follows replaces it with the current one. + Enforce(); + RequestSync(); + } + + private void OnSecond() + { + if (++_secondsSinceSync >= SyncSeconds) + { + RequestSync(); + } + + // Every second rather than every sync, so a timed mute lifts when it + // runs out instead of up to a sync later. + Enforce(); + } + + private void RequestSync() + { + if (_syncing) + { + // The sync in flight predates whatever asked for this one. + _syncRequested = true; + return; + } + + PlayerManagementSettings settings = Config.Settings(); + + _secondsSinceSync = 0; + + if (!settings.IsConnected()) + { + return; + } + + List steamIds = Humans() + .Select(player => SteamIdOf(player).ToString()) + .Distinct() + .ToList(); + + PlayerSanctionsRequest body = new() + { + steam_ids = steamIds, + plugin_version = ModuleVersion, + plugin_runtime = Runtime, + }; + + _syncing = true; + _syncRequested = false; + + _ = Task.Run(async () => + { + SanctionSync result = await _client.Sync(settings, body); + + Server.NextFrame(() => Synced(steamIds, result)); + }); + } + + private void Synced(List steamIds, SanctionSync result) + { + _syncing = false; + + if (result.Sanctions == null) + { + if (result.Error != _lastError) + { + Logger.LogWarning("unable to sync sanctions: {error}", result.Error); + } + + _lastError = result.Error; + } + else + { + if (_lastError != null) + { + Logger.LogInformation("sanction sync recovered"); + } + + _lastError = null; + _lastSyncAt = DateTimeOffset.UtcNow; + _book.Record(steamIds, result.Sanctions); + + Enforce(); + } + + if (_syncRequested) + { + RequestSync(); + } + } + + private void Enforce() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + + foreach (CCSPlayerController player in Humans()) + { + ulong steamId = SteamIdOf(player); + 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 != VoiceFlags.Muted) + { + player.VoiceFlags = VoiceFlags.Muted; + } + else if (changes.HasFlag(eSanctionChange.Unmuted)) + { + player.VoiceFlags = VoiceFlags.Normal; + } + + 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..ab1e1e84 --- /dev/null +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -0,0 +1,34 @@ + + + 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..742e3f2b --- /dev/null +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -0,0 +1,442 @@ +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"; + + // Doubles as the heartbeat the panel reads to show the plugin as active, + // which is why an empty server still syncs. + private const int SyncSeconds = 30; + + private ILogger _logger = null!; + private IConfiguration _configuration = null!; + private ServiceProvider? _serviceProvider; + + private readonly SanctionsClient _client = new(); + private readonly SanctionBook _book = new(); + + // What this plugin last applied per player, so it only ever lifts a mute it + // set itself and never one an admin plugin on the same server set. + private readonly Dictionary _applied = new(); + private readonly HashSet _kicked = new(); + + private CancellationTokenSource? _secondTimer; + private EventDelegates.OnClientPutInServer? _putInServerHandler; + private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + private EventDelegates.OnClientDisconnected? _disconnectHandler; + private Guid _chatHookId; + + private int _secondsSinceSync = SyncSeconds; + private bool _syncing; + private bool _syncRequested; + private DateTimeOffset? _lastSyncAt; + private string? _lastError; + + 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" + ); + } + + _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; + } + + _applied.Remove(SteamIdOf(player)); + _kicked.Remove(SteamIdOf(player)); + }; + Core.Event.OnClientDisconnected += _disconnectHandler; + + _chatHookId = Core.Command.HookClientChat((playerId, text, teamonly) => OnChat(playerId)); + + _secondTimer = Core.Scheduler.RepeatBySeconds(1, OnSecond); + } + + public override void Unload() + { + _secondTimer?.Cancel(); + _secondTimer = null; + + 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; + } + + RequestSync(); + + 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; + + 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 + ) + ); + } + + private void OnJoined(int playerId) + { + IPlayer? player = Core.PlayerManager.GetPlayer(playerId); + + if (player == null || !IsHuman(player)) + { + return; + } + + // The cached answer covers a panel that is unreachable right now; the + // sync that follows replaces it with the current one. + Enforce(); + RequestSync(); + } + + private void OnSecond() + { + if (++_secondsSinceSync >= SyncSeconds) + { + RequestSync(); + } + + // Every second rather than every sync, so a timed mute lifts when it + // runs out instead of up to a sync later. + Enforce(); + } + + private void RequestSync() + { + if (_syncing) + { + // The sync in flight predates whatever asked for this one. + _syncRequested = true; + return; + } + + PlayerManagementSettings settings = Settings(); + + _secondsSinceSync = 0; + + if (!settings.IsConnected()) + { + return; + } + + List steamIds = Humans() + .Select(player => SteamIdOf(player).ToString()) + .Distinct() + .ToList(); + + PlayerSanctionsRequest body = new() + { + steam_ids = steamIds, + plugin_version = ModuleVersion, + plugin_runtime = Runtime, + }; + + _syncing = true; + _syncRequested = false; + + _ = Task.Run(async () => + { + SanctionSync result = await _client.Sync(settings, body); + + Core.Scheduler.NextTick(() => Synced(steamIds, result)); + }); + } + + private void Synced(List steamIds, SanctionSync result) + { + _syncing = false; + + if (result.Sanctions == null) + { + if (result.Error != _lastError) + { + _logger.LogWarning("unable to sync sanctions: {error}", result.Error); + } + + _lastError = result.Error; + } + else + { + if (_lastError != null) + { + _logger.LogInformation("sanction sync recovered"); + } + + _lastError = null; + _lastSyncAt = DateTimeOffset.UtcNow; + _book.Record(steamIds, result.Sanctions); + + Enforce(); + } + + if (_syncRequested) + { + RequestSync(); + } + } + + private void Enforce() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + + foreach (IPlayer player in Humans()) + { + ulong steamId = SteamIdOf(player); + 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 != VoiceFlagValue.Muted) + { + player.VoiceFlags = VoiceFlagValue.Muted; + } + else if (changes.HasFlag(eSanctionChange.Unmuted)) + { + player.VoiceFlags = VoiceFlagValue.Normal; + } + + 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..d28ade9e --- /dev/null +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + 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..e6ad0e7d --- /dev/null +++ b/apps/player-management-sw/test/SanctionBookTests.cs @@ -0,0 +1,171 @@ +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 ForgetDropsAPlayer() + { + SanctionBook book = new(); + book.Record(["1"], [Sanction("1", "ban")]); + + book.Forget("1"); + + Assert.False(book.StateFor("1", Now).IsBanned); + } + + [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/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..5619ff96 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/SanctionBook.cs @@ -0,0 +1,142 @@ +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; + } +} + +public class SanctionBook +{ + private readonly object _lock = new(); + private readonly Dictionary> _bySteamId = 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); + } + + 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 Forget(string steamId) + { + lock (_lock) + { + _bySteamId.Remove(steamId); + } + } + + 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/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); + } + } +} From 53c022ef64a7fac8b7695fc9a5ca3519374ab2a3 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 30 Sep 2026 00:06:56 -0400 Subject: [PATCH 2/2] bug: player management review fixes: stale-ban rejoin, hibernation heartbeat, voice bits --- .github/workflows/test.yaml | 20 ++ .../src/PlayerManagement.csproj | 36 +-- .../src/PlayerManagementPlugin.cs | 169 ++++++-------- .../src/PlayerManagement.csproj | 1 + .../src/PlayerManagementPlugin.cs | 177 +++++++-------- .../test/FiveStack.Tests.csproj | 36 +-- .../test/SanctionBookTests.cs | 44 +++- .../test/SanctionSyncLoopTests.cs | 211 ++++++++++++++++++ .../FiveStack.Utilities/SanctionBook.cs | 47 +++- .../FiveStack.Utilities/SanctionSyncLoop.cs | 192 ++++++++++++++++ 10 files changed, 668 insertions(+), 265 deletions(-) create mode 100644 apps/player-management-sw/test/SanctionSyncLoopTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2ce89abe..61511893 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index 16633c89..b8b8cb40 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -8,34 +8,14 @@ - - - - - - - + + + + + + + + diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 08412eb0..2fbe8ff2 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -4,13 +4,11 @@ using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Translations; using CounterStrikeSharp.API.Modules.Commands; -using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.ValveConstants.Protobuf; using FiveStack.Entities.PlayerManagement; using FiveStack.Enums; using FiveStack.Utilities; using Microsoft.Extensions.Logging; -using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; namespace PlayerManagement; @@ -21,9 +19,7 @@ public class PlayerManagementPlugin : BasePlugin, IPluginConfig "PlayerManagement"; public override string ModuleVersion => "__RELEASE_VERSION__"; @@ -33,21 +29,17 @@ public class PlayerManagementPlugin : BasePlugin, IPluginConfig _applied = new(); + private readonly HashSet _mutedByUs = new(); private readonly HashSet _kicked = new(); - private Timer? _secondTimer; - - private int _secondsSinceSync = SyncSeconds; - private bool _syncing; - private bool _syncRequested; - private DateTimeOffset? _lastSyncAt; - private string? _lastError; + private long _lastEnforceMs; public void OnConfigParsed(PlayerManagementConfig config) { @@ -72,6 +64,8 @@ public override void Load(bool hotReload) ); } + RegisterListener(OnTick); + RegisterListener(OnJoined); // Joining and Steam authorizing race each other, and the id a player is @@ -87,20 +81,33 @@ public override void Load(bool hotReload) return; } - _applied.Remove(SteamIdOf(player)); - _kicked.Remove(SteamIdOf(player)); + 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); - _secondTimer = AddTimer(1f, OnSecond, TimerFlags.REPEAT); + _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) { - _secondTimer?.Kill(); - _secondTimer = null; + _loop?.Dispose(); + _loop = null; } [ConsoleCommand( @@ -116,7 +123,7 @@ public void OnRefresh(CCSPlayerController? caller, CommandInfo command) return; } - RequestSync(); + _loop?.Request(); command.ReplyToCommand(PlayerManagementReport.Syncing(Humans().Count)); } @@ -126,14 +133,15 @@ public void OnRefresh(CCSPlayerController? caller, CommandInfo command) 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, + lastSyncAt, + lastError, Humans() .Select(player => new PlayerManagementPlayer( player.PlayerName, @@ -146,114 +154,68 @@ public void OnStatus(CCSPlayerController? caller, CommandInfo command) ); } + // 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 || !IsHuman(player)) + if (player == null || player.IsBot || player.IsHLTV || SteamIdOf(player) == 0) { return; } - // The cached answer covers a panel that is unreachable right now; the - // sync that follows replaces it with the current one. - Enforce(); - RequestSync(); - } - - private void OnSecond() - { - if (++_secondsSinceSync >= SyncSeconds) - { - RequestSync(); - } - - // Every second rather than every sync, so a timed mute lifts when it - // runs out instead of up to a sync later. - Enforce(); + _book.Joined(SteamIdOf(player).ToString()); + _loop?.Request(); } - private void RequestSync() + private void OnTick() { - if (_syncing) - { - // The sync in flight predates whatever asked for this one. - _syncRequested = true; - return; - } + long nowMs = Environment.TickCount64; - PlayerManagementSettings settings = Config.Settings(); - - _secondsSinceSync = 0; - - if (!settings.IsConnected()) + if (nowMs - _lastEnforceMs < EnforceEveryMs) { return; } - List steamIds = Humans() - .Select(player => SteamIdOf(player).ToString()) - .Distinct() - .ToList(); + _lastEnforceMs = nowMs; - PlayerSanctionsRequest body = new() + try { - steam_ids = steamIds, - plugin_version = ModuleVersion, - plugin_runtime = Runtime, - }; + List humans = Humans(); - _syncing = true; - _syncRequested = false; + _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString())); - _ = Task.Run(async () => + Enforce(humans); + } + catch (Exception error) { - SanctionSync result = await _client.Sync(settings, body); - - Server.NextFrame(() => Synced(steamIds, result)); - }); + Logger.LogError(error, "unable to enforce sanctions"); + } } - private void Synced(List steamIds, SanctionSync result) + private void Enforce(List humans) { - _syncing = false; + DateTimeOffset now = DateTimeOffset.UtcNow; + HashSet present = humans.Select(SteamIdOf).ToHashSet(); - if (result.Sanctions == null) + foreach (ulong gone in _applied.Keys.Where(steamId => !present.Contains(steamId)).ToList()) { - if (result.Error != _lastError) - { - Logger.LogWarning("unable to sync sanctions: {error}", result.Error); - } - - _lastError = result.Error; + _applied.Remove(gone); } - else - { - if (_lastError != null) - { - Logger.LogInformation("sanction sync recovered"); - } - _lastError = null; - _lastSyncAt = DateTimeOffset.UtcNow; - _book.Record(steamIds, result.Sanctions); - - Enforce(); - } + _mutedByUs.RemoveWhere(steamId => !present.Contains(steamId)); + _kicked.RemoveWhere(steamId => !present.Contains(steamId)); - if (_syncRequested) + foreach (CCSPlayerController player in humans) { - RequestSync(); - } - } + ulong steamId = SteamIdOf(player); - private void Enforce() - { - DateTimeOffset now = DateTimeOffset.UtcNow; + if (_book.IsAwaiting(steamId.ToString())) + { + continue; + } - foreach (CCSPlayerController player in Humans()) - { - ulong steamId = SteamIdOf(player); SanctionState state = _book.StateFor(steamId.ToString(), now); SanctionState previous = _applied.GetValueOrDefault(steamId, SanctionState.None); eSanctionChange changes = SanctionState.Changes(previous, state); @@ -278,13 +240,14 @@ private void Enforce() // Re-asserted rather than set once: the engine resets voice flags // across a reconnect and a map change. - if (state.IsMuted && player.VoiceFlags != VoiceFlags.Muted) + if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlags.Muted)) { - player.VoiceFlags = VoiceFlags.Muted; + player.VoiceFlags |= VoiceFlags.Muted; + _mutedByUs.Add(steamId); } - else if (changes.HasFlag(eSanctionChange.Unmuted)) + else if (!state.IsMuted && _mutedByUs.Remove(steamId)) { - player.VoiceFlags = VoiceFlags.Normal; + player.VoiceFlags &= ~VoiceFlags.Muted; } if (changes.HasFlag(eSanctionChange.Muted)) diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj index ab1e1e84..0bb8f74b 100644 --- a/apps/player-management-sw/src/PlayerManagement.csproj +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -21,6 +21,7 @@ + diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 742e3f2b..e3d6b473 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -29,33 +29,28 @@ public class PlayerManagementPlugin : BasePlugin { private const string Runtime = "swiftlys2"; - // Doubles as the heartbeat the panel reads to show the plugin as active, - // which is why an empty server still syncs. - private const int SyncSeconds = 30; + private const long EnforceEveryMs = 1000; private ILogger _logger = null!; private IConfiguration _configuration = null!; private ServiceProvider? _serviceProvider; + private SanctionSyncLoop? _loop; - private readonly SanctionsClient _client = new(); private readonly SanctionBook _book = new(); - // What this plugin last applied per player, so it only ever lifts a mute it - // set itself and never one an admin plugin on the same server set. + // 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 CancellationTokenSource? _secondTimer; + private EventDelegates.OnTick? _tickHandler; private EventDelegates.OnClientPutInServer? _putInServerHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; private EventDelegates.OnClientDisconnected? _disconnectHandler; private Guid _chatHookId; - - private int _secondsSinceSync = SyncSeconds; - private bool _syncing; - private bool _syncRequested; - private DateTimeOffset? _lastSyncAt; - private string? _lastError; + private long _lastEnforceMs; public PlayerManagementPlugin(ISwiftlyCore core) : base(core) { } @@ -97,6 +92,9 @@ public override void Load(bool hotReload) ); } + _tickHandler = OnTick; + Core.Event.OnTick += _tickHandler; + _putInServerHandler = @event => OnJoined(@event.PlayerId); Core.Event.OnClientPutInServer += _putInServerHandler; @@ -114,20 +112,38 @@ public override void Load(bool hotReload) return; } - _applied.Remove(SteamIdOf(player)); - _kicked.Remove(SteamIdOf(player)); + 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)); - _secondTimer = Core.Scheduler.RepeatBySeconds(1, OnSecond); + _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() { - _secondTimer?.Cancel(); - _secondTimer = null; + _loop?.Dispose(); + _loop = null; + + if (_tickHandler != null) + { + Core.Event.OnTick -= _tickHandler; + } if (_putInServerHandler != null) { @@ -166,7 +182,7 @@ public void OnRefresh(ICommandContext context) return; } - RequestSync(); + _loop?.Request(); context.Reply(PlayerManagementReport.Syncing(Humans().Count)); } @@ -180,14 +196,15 @@ public void OnStatus(ICommandContext context) } DateTimeOffset now = DateTimeOffset.UtcNow; + (DateTimeOffset? lastSyncAt, string? lastError) = _loop?.Status() ?? (null, null); context.Reply( PlayerManagementReport.Status( ModuleVersion, Runtime, Settings(), - _lastSyncAt, - _lastError, + lastSyncAt, + lastError, Humans() .Select(player => new PlayerManagementPlayer( player.Name, @@ -200,114 +217,71 @@ public void OnStatus(ICommandContext context) ); } + // 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 || !IsHuman(player)) + if (player == null || player.IsFakeClient || SteamIdOf(player) == 0) { return; } - // The cached answer covers a panel that is unreachable right now; the - // sync that follows replaces it with the current one. - Enforce(); - RequestSync(); - } - - private void OnSecond() - { - if (++_secondsSinceSync >= SyncSeconds) - { - RequestSync(); - } - - // Every second rather than every sync, so a timed mute lifts when it - // runs out instead of up to a sync later. - Enforce(); + _book.Joined(SteamIdOf(player).ToString()); + _loop?.Request(); } - private void RequestSync() + // 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() { - if (_syncing) - { - // The sync in flight predates whatever asked for this one. - _syncRequested = true; - return; - } - - PlayerManagementSettings settings = Settings(); + long nowMs = Environment.TickCount64; - _secondsSinceSync = 0; - - if (!settings.IsConnected()) + if (nowMs - _lastEnforceMs < EnforceEveryMs) { return; } - List steamIds = Humans() - .Select(player => SteamIdOf(player).ToString()) - .Distinct() - .ToList(); + _lastEnforceMs = nowMs; - PlayerSanctionsRequest body = new() + try { - steam_ids = steamIds, - plugin_version = ModuleVersion, - plugin_runtime = Runtime, - }; + List humans = Humans(); - _syncing = true; - _syncRequested = false; + _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString())); - _ = Task.Run(async () => + Enforce(humans); + } + catch (Exception error) { - SanctionSync result = await _client.Sync(settings, body); - - Core.Scheduler.NextTick(() => Synced(steamIds, result)); - }); + _logger.LogError(error, "unable to enforce sanctions"); + } } - private void Synced(List steamIds, SanctionSync result) + private void Enforce(List humans) { - _syncing = false; + DateTimeOffset now = DateTimeOffset.UtcNow; + HashSet present = humans.Select(SteamIdOf).ToHashSet(); - if (result.Sanctions == null) + foreach (ulong gone in _applied.Keys.Where(steamId => !present.Contains(steamId)).ToList()) { - if (result.Error != _lastError) - { - _logger.LogWarning("unable to sync sanctions: {error}", result.Error); - } - - _lastError = result.Error; + _applied.Remove(gone); } - else - { - if (_lastError != null) - { - _logger.LogInformation("sanction sync recovered"); - } - - _lastError = null; - _lastSyncAt = DateTimeOffset.UtcNow; - _book.Record(steamIds, result.Sanctions); - Enforce(); - } + _mutedByUs.RemoveWhere(steamId => !present.Contains(steamId)); + _kicked.RemoveWhere(steamId => !present.Contains(steamId)); - if (_syncRequested) + foreach (IPlayer player in humans) { - RequestSync(); - } - } + ulong steamId = SteamIdOf(player); - private void Enforce() - { - DateTimeOffset now = DateTimeOffset.UtcNow; + if (_book.IsAwaiting(steamId.ToString())) + { + continue; + } - foreach (IPlayer player in Humans()) - { - ulong steamId = SteamIdOf(player); SanctionState state = _book.StateFor(steamId.ToString(), now); SanctionState previous = _applied.GetValueOrDefault(steamId, SanctionState.None); eSanctionChange changes = SanctionState.Changes(previous, state); @@ -335,13 +309,14 @@ private void Enforce() // Re-asserted rather than set once: the engine resets voice flags // across a reconnect and a map change. - if (state.IsMuted && player.VoiceFlags != VoiceFlagValue.Muted) + if (state.IsMuted && !player.VoiceFlags.HasFlag(VoiceFlagValue.Muted)) { - player.VoiceFlags = VoiceFlagValue.Muted; + player.VoiceFlags |= VoiceFlagValue.Muted; + _mutedByUs.Add(steamId); } - else if (changes.HasFlag(eSanctionChange.Unmuted)) + else if (!state.IsMuted && _mutedByUs.Remove(steamId)) { - player.VoiceFlags = VoiceFlagValue.Normal; + player.VoiceFlags &= ~VoiceFlagValue.Muted; } if (changes == eSanctionChange.None) diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index d28ade9e..788f3657 100644 --- a/apps/player-management-sw/test/FiveStack.Tests.csproj +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -11,33 +11,13 @@ - - - - - - - + + + + + + + + diff --git a/apps/player-management-sw/test/SanctionBookTests.cs b/apps/player-management-sw/test/SanctionBookTests.cs index e6ad0e7d..49d955af 100644 --- a/apps/player-management-sw/test/SanctionBookTests.cs +++ b/apps/player-management-sw/test/SanctionBookTests.cs @@ -108,14 +108,52 @@ public void AnUnknownTypeIsIgnored() } [Fact] - public void ForgetDropsAPlayer() + 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.Forget("1"); + book.Left("1"); - Assert.False(book.StateFor("1", Now).IsBanned); + Assert.Empty(book.Awaiting()); } [Fact] 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/shared/dotnet/FiveStack.Utilities/SanctionBook.cs b/shared/dotnet/FiveStack.Utilities/SanctionBook.cs index 5619ff96..a0f21106 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionBook.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionBook.cs @@ -43,10 +43,16 @@ public static eSanctionChange Changes(SanctionState previous, SanctionState next } } +// 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. @@ -57,6 +63,7 @@ public void Record(IEnumerable queried, IEnumerable sanc foreach (string steamId in queried) { _bySteamId.Remove(steamId); + _awaiting.Remove(steamId); } foreach (PlayerSanction sanction in sanctions) @@ -77,11 +84,47 @@ public void Record(IEnumerable queried, IEnumerable sanc } } - public void Forget(string steamId) + public void Joined(string steamId) { lock (_lock) { - _bySteamId.Remove(steamId); + _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(); } } 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}"); + } + } +}