From c0fb13d20028cbea26c56b82091484b99d036c92 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 12 Aug 2026 19:17:49 -0400 Subject: [PATCH 1/2] wip --- .../src/FiveStack.Commands/Camera.cs | 48 ++++ .../src/FiveStack.Services/CameraSystem.cs | 251 ++++++++++++++++++ .../src/FiveStack.Services/ReadySystem.cs | 9 + .../src/FiveStack.Services/TimeoutSystem.cs | 21 ++ .../counterstrikesharp/src/FiveStackPlugin.cs | 3 + .../src/FiveStackServiceCollection.cs | 3 + apps/counterstrikesharp/src/lang/en.json | 11 +- apps/swiftly/src/FiveStack.Commands/Camera.cs | 46 ++++ .../src/FiveStack.Services/CameraSystem.cs | 239 +++++++++++++++++ .../src/FiveStack.Services/ReadySystem.cs | 9 + .../src/FiveStack.Services/TimeoutSystem.cs | 21 ++ apps/swiftly/src/FiveStack.cs | 5 + .../src/resources/translations/en.jsonc | 12 +- apps/swiftly/test/CameraSystemTests.cs | 64 +++++ apps/swiftly/test/EntityContractTests.cs | 8 +- .../dotnet/FiveStack.Entities/MatchMember.cs | 4 + .../dotnet/FiveStack.Entities/MatchOptions.cs | 1 + 17 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 apps/counterstrikesharp/src/FiveStack.Commands/Camera.cs create mode 100644 apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs create mode 100644 apps/swiftly/src/FiveStack.Commands/Camera.cs create mode 100644 apps/swiftly/src/FiveStack.Services/CameraSystem.cs create mode 100644 apps/swiftly/test/CameraSystemTests.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Commands/Camera.cs b/apps/counterstrikesharp/src/FiveStack.Commands/Camera.cs new file mode 100644 index 00000000..45d3f289 --- /dev/null +++ b/apps/counterstrikesharp/src/FiveStack.Commands/Camera.cs @@ -0,0 +1,48 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Utils; + +namespace FiveStack; + +public partial class FiveStackPlugin +{ + // Server-only, and deliberately unprefixed like get_match / force_ready: + // nothing but the API ever calls it, so it does not need the css_/sw_ + // treatment that player-facing commands do. + [ConsoleCommand("camera_state", "Reports which players have no working camera")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnCameraState(CCSPlayerController? player, CommandInfo? command) + { + if (command == null) + { + return; + } + + _cameraSystem.UpdateState(command.ArgByIndex(1) ?? ""); + } + + [ConsoleCommand("css_cam", "Shows your camera status")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnCameraStatus(CCSPlayerController? player, CommandInfo? command) + { + if (player == null || !player.IsValid) + { + return; + } + + if (!_cameraSystem.IsRequired()) + { + _gameServer.Message(HudDestination.Chat, _localizer["camera.not_required"], player); + return; + } + + _gameServer.Message( + HudDestination.Chat, + _cameraSystem.IsPlayerBlocked(player) + ? _localizer["camera.yours_down"] + : _localizer["camera.yours_ok"], + player + ); + } +} diff --git a/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs new file mode 100644 index 00000000..bad42d54 --- /dev/null +++ b/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs @@ -0,0 +1,251 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Timers; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities; +using FiveStack.Utilities; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; + +namespace FiveStack; + +// The server cannot see a webcam. Everything here is driven by the API, which +// watches the actual media and sends `camera_state ` whenever +// the set of players without a working feed changes (empty = everyone is fine). +public class CameraSystem +{ + private readonly GameServer _gameServer; + private readonly MatchService _matchService; + private readonly ILogger _logger; + private readonly IStringLocalizer _localizer; + + private Timer? _reminderTimer; + private HashSet _offline = new HashSet(); + private Guid _matchId = Guid.Empty; + + public CameraSystem( + ILogger logger, + GameServer gameServer, + MatchService matchService, + IStringLocalizer localizer + ) + { + _logger = logger; + _gameServer = gameServer; + _matchService = matchService; + _localizer = localizer; + } + + public bool IsRequired() + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + // The API only re-sends state when the offending set changes, so a new + // match on this server would otherwise inherit whoever was offline when + // the last one ended and start out paused for no reason. + if ((matchData?.id ?? Guid.Empty) != _matchId) + { + _matchId = matchData?.id ?? Guid.Empty; + Reset(); + } + + return matchData?.options.camera_required == true; + } + + public bool IsBlocking() + { + return IsRequired() && _offline.Count > 0; + } + + public bool IsPlayerBlocked(CCSPlayerController? player) + { + if (player == null || !player.IsValid || player.IsBot || !IsRequired()) + { + return false; + } + + return _offline.Contains(player.SteamID); + } + + public string OfflineNames() + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null) + { + return string.Join(", ", _offline); + } + + List names = new List(); + + foreach (ulong steamId in _offline) + { + MatchMember? member = MatchUtility.GetMemberFromLineup( + matchData, + steamId.ToString(), + string.Empty + ); + + names.Add(member?.name ?? steamId.ToString()); + } + + return string.Join(", ", names); + } + + // Called from the `camera_state` console command. The payload is the full + // set every time, never a delta, so a dropped message self-corrects on the + // next change rather than leaving the server permanently out of sync. + public void UpdateState(string payload) + { + IsRequired(); + + HashSet offline = ParseSteamIds(payload); + + if (offline.SetEquals(_offline)) + { + return; + } + + bool wasBlocking = _offline.Count > 0; + _offline = offline; + + _logger.LogInformation( + $"camera state updated: {(_offline.Count == 0 ? "all clear" : OfflineNames())}" + ); + + UpdateScoreboardTags(); + + if (_offline.Count > 0) + { + OnCamerasLost(!wasBlocking); + return; + } + + OnCamerasRestored(); + } + + // RCON hands us whatever was typed. Anything that is not a steam id is + // dropped rather than throwing: a malformed message must not take the + // camera system down mid-match. + public static HashSet ParseSteamIds(string payload) + { + HashSet steamIds = new HashSet(); + + if (string.IsNullOrWhiteSpace(payload)) + { + return steamIds; + } + + foreach ( + string entry in payload.Split( + new[] { ',', ' ' }, + StringSplitOptions.RemoveEmptyEntries + ) + ) + { + if (ulong.TryParse(entry.Trim(), out ulong steamId) && steamId > 0) + { + steamIds.Add(steamId); + } + } + + return steamIds; + } + + public void Reset() + { + _offline.Clear(); + _reminderTimer?.Kill(); + _reminderTimer = null; + } + + private void OnCamerasLost(bool firstBreach) + { + MatchManager? match = _matchService.GetCurrentMatch(); + + if (match == null) + { + return; + } + + string message = _localizer["camera.paused", OfflineNames()]; + + if (firstBreach) + { + match.PauseMatch(message); + } + else + { + _gameServer.Message(HudDestination.Alert, message); + } + + _reminderTimer?.Kill(); + _reminderTimer = TimerUtility.AddTimer( + 10, + () => + { + if (_offline.Count == 0) + { + return; + } + + _gameServer.Message( + HudDestination.Alert, + _localizer["camera.waiting", OfflineNames()] + ); + }, + TimerFlags.REPEAT + ); + } + + // Deliberately does not resume: whoever is at the keyboard decides when + // play restarts, the same as any other technical pause. + private void OnCamerasRestored() + { + _reminderTimer?.Kill(); + _reminderTimer = null; + + _gameServer.Message( + HudDestination.Alert, + _localizer["camera.restored", CommandUtility.PublicChatTrigger] + ); + } + + // Reuses the ready-system convention: the scoreboard is the one place every + // player already looks to see who is holding things up. + private void UpdateScoreboardTags() + { + MatchManager? match = _matchService.GetCurrentMatch(); + MatchData? matchData = match?.GetMatchData(); + + if (match == null || matchData == null) + { + return; + } + + foreach (CCSPlayerController player in MatchUtility.Players()) + { + if (!player.IsValid || player.IsBot) + { + continue; + } + + MatchMember? member = MatchUtility.GetMemberFromLineup( + matchData, + player.SteamID.ToString(), + player.PlayerName + ); + + if (member == null) + { + continue; + } + + string? tag = _offline.Contains(player.SteamID) + ? _localizer["camera.tag"].Value + : null; + + match.UpdatePlayerName(player, member.name, tag); + } + } +} diff --git a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs index b0fa9ea1..02e07050 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs @@ -19,6 +19,7 @@ public class ReadySystem private readonly ILogger _logger; private readonly CoachSystem _coachSystem; private readonly CaptainSystem _captainSystem; + private readonly CameraSystem _cameraSystem; private readonly IStringLocalizer _localizer; private Dictionary _readyPlayers = new Dictionary(); @@ -29,6 +30,7 @@ public ReadySystem( MatchService matchService, CoachSystem coachSystem, CaptainSystem captainSystem, + CameraSystem cameraSystem, IStringLocalizer localizer ) { @@ -37,6 +39,7 @@ IStringLocalizer localizer _matchService = matchService; _coachSystem = coachSystem; _captainSystem = captainSystem; + _cameraSystem = cameraSystem; _localizer = localizer; } @@ -116,6 +119,12 @@ public void ToggleReady(CCSPlayerController player) return; } + if (_cameraSystem.IsPlayerBlocked(player)) + { + _gameServer.Message(HudDestination.Chat, _localizer["camera.cannot_ready"], player); + return; + } + if (!CanVote(player)) { _gameServer.Message(HudDestination.Chat, _localizer["ready.not_allowed"], player); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/TimeoutSystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/TimeoutSystem.cs index 8c624ab1..0321e9b4 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/TimeoutSystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/TimeoutSystem.cs @@ -22,6 +22,7 @@ public class TimeoutSystem private readonly IServiceProvider _serviceProvider; private readonly CoachSystem _coachSystem; private readonly CaptainSystem _captainSystem; + private readonly CameraSystem _cameraSystem; private readonly IStringLocalizer _localizer; public VoteSystem? pauseVote; public VoteSystem? resumeVote; @@ -35,6 +36,7 @@ public TimeoutSystem( IServiceProvider serviceProvider, CoachSystem coachSystem, CaptainSystem captainSystem, + CameraSystem cameraSystem, IStringLocalizer localizer ) { @@ -46,6 +48,7 @@ IStringLocalizer localizer _backUpManagement = backUpManagement; _coachSystem = coachSystem; _captainSystem = captainSystem; + _cameraSystem = cameraSystem; _localizer = localizer; } @@ -240,6 +243,24 @@ public void RequestResume(CCSPlayerController? player) string resumeMessage = _localizer["timeout.admin_resumed"]; + // Refuse while a required camera is still down, on the same terms as the + // empty-team gate below: the pause exists because someone is unwatched, + // so resuming before that is fixed defeats the whole point. Admins keep + // their override for a camera that is never coming back. + if ( + player != null + && !IsAdminOrOrganizer(player, matchData) + && _cameraSystem.IsBlocking() + ) + { + _gameServer.Message( + HudDestination.Chat, + _localizer["camera.cannot_resume", _cameraSystem.OfflineNames()], + player + ); + return; + } + // Refuse while a side has nobody in the server. The match pauses itself // when it goes short-handed, and resuming into an empty team just plays // rounds out against nobody -- free rounds for whoever is still here. diff --git a/apps/counterstrikesharp/src/FiveStackPlugin.cs b/apps/counterstrikesharp/src/FiveStackPlugin.cs index c4c61050..038fb10c 100644 --- a/apps/counterstrikesharp/src/FiveStackPlugin.cs +++ b/apps/counterstrikesharp/src/FiveStackPlugin.cs @@ -18,6 +18,7 @@ public partial class FiveStackPlugin : BasePlugin private readonly MatchService _matchService; private readonly CaptainSystem _captainSystem; private readonly RankSystem _rankSystem; + private readonly CameraSystem _cameraSystem; private readonly SurrenderSystem _surrenderSystem; private readonly IStringLocalizer _localizer; private readonly ILogger _logger; @@ -38,6 +39,7 @@ public FiveStackPlugin( MatchService matchService, CaptainSystem captainSystem, RankSystem rankSystem, + CameraSystem cameraSystem, ReadySystem readySystem, TimeoutSystem timeoutSystem, ILogger logger, @@ -57,6 +59,7 @@ IStringLocalizer localizer _matchService = matchService; _captainSystem = captainSystem; _rankSystem = rankSystem; + _cameraSystem = cameraSystem; _timeoutSystem = timeoutSystem; _surrenderSystem = surrenderSystem; _gameBackupRounds = backUpManagement; diff --git a/apps/counterstrikesharp/src/FiveStackServiceCollection.cs b/apps/counterstrikesharp/src/FiveStackServiceCollection.cs index a915ce40..3ae0445b 100644 --- a/apps/counterstrikesharp/src/FiveStackServiceCollection.cs +++ b/apps/counterstrikesharp/src/FiveStackServiceCollection.cs @@ -21,6 +21,9 @@ public void ConfigureServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + // Singleton: the offline set has to outlive the transient MatchManager + // that gets rebuilt on every match refresh. + serviceCollection.AddSingleton(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/apps/counterstrikesharp/src/lang/en.json b/apps/counterstrikesharp/src/lang/en.json index 760e3c86..49c1fc2a 100644 --- a/apps/counterstrikesharp/src/lang/en.json +++ b/apps/counterstrikesharp/src/lang/en.json @@ -73,5 +73,14 @@ "vote.prompt_count_timer": " Vote to {0} ({1}s)", "vote.prompt_options_timer": " Vote to {0} ({1}y or {2}n) ({3}s)", "match.map_over_series_done": "Match complete — stats will appear on the match page once the demo finishes processing.", - "match.map_over_next_map": "Map complete — the next map is coming up, stay connected." + "match.map_over_next_map": "Map complete — the next map is coming up, stay connected.", + "camera.paused": "Match paused - no camera from: {0}", + "camera.waiting": "Waiting for camera: {0}", + "camera.restored": "All cameras are back - type {0}resume to continue", + "camera.cannot_resume": "Cannot resume, still waiting on a camera from: {0}", + "camera.cannot_ready": "You cannot ready up until your camera is connected", + "camera.tag": "no cam", + "camera.not_required": "This match does not require a camera", + "camera.yours_ok": "Your camera is connected", + "camera.yours_down": "Your camera is NOT connected - reconnect it from the match page" } diff --git a/apps/swiftly/src/FiveStack.Commands/Camera.cs b/apps/swiftly/src/FiveStack.Commands/Camera.cs new file mode 100644 index 00000000..d8462229 --- /dev/null +++ b/apps/swiftly/src/FiveStack.Commands/Camera.cs @@ -0,0 +1,46 @@ +using SwiftlyS2.Shared.Commands; +using SwiftlyS2.Shared.Players; + +namespace FiveStack; + +public partial class FiveStackPlugin +{ + // Server-only, and deliberately unprefixed like get_match / force_ready: + // nothing but the API ever calls it, so it does not need the css_/sw_ + // treatment that player-facing commands do. + [Command("camera_state", registerRaw: true, permission: "")] + public void OnCameraState(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + _cameraSystem.UpdateState(string.Join(",", context.Args)); + } + + [Command("cam", registerRaw: false, permission: "")] + public void OnCameraStatus(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + if (!_cameraSystem.IsRequired()) + { + _gameServer.Message(MessageType.Chat, _localizer["camera.not_required"], player); + return; + } + + _gameServer.Message( + MessageType.Chat, + _cameraSystem.IsPlayerBlocked(player) + ? _localizer["camera.yours_down"] + : _localizer["camera.yours_ok"], + player + ); + } +} diff --git a/apps/swiftly/src/FiveStack.Services/CameraSystem.cs b/apps/swiftly/src/FiveStack.Services/CameraSystem.cs new file mode 100644 index 00000000..2a675f30 --- /dev/null +++ b/apps/swiftly/src/FiveStack.Services/CameraSystem.cs @@ -0,0 +1,239 @@ +using FiveStack.Entities; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.Translation; + +namespace FiveStack; + +// The server cannot see a webcam. Everything here is driven by the API, which +// watches the actual media and sends `camera_state ` whenever +// the set of players without a working feed changes (empty = everyone is fine). +public class CameraSystem +{ + private readonly GameServer _gameServer; + private readonly MatchService _matchService; + private readonly ILogger _logger; + private readonly ILocalizer _localizer; + + private CancellationTokenSource? _reminderTimer; + private HashSet _offline = new HashSet(); + private Guid _matchId = Guid.Empty; + + public CameraSystem( + ILogger logger, + GameServer gameServer, + MatchService matchService, + ILocalizer localizer + ) + { + _logger = logger; + _gameServer = gameServer; + _matchService = matchService; + _localizer = localizer; + } + + public bool IsRequired() + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + // The API only re-sends state when the offending set changes, so a new + // match on this server would otherwise inherit whoever was offline when + // the last one ended and start out paused for no reason. + if ((matchData?.id ?? Guid.Empty) != _matchId) + { + _matchId = matchData?.id ?? Guid.Empty; + Reset(); + } + + return matchData?.options.camera_required == true; + } + + public bool IsBlocking() + { + return IsRequired() && _offline.Count > 0; + } + + public bool IsPlayerBlocked(IPlayer? player) + { + if (player == null || !player.IsValid || player.IsFakeClient || !IsRequired()) + { + return false; + } + + return _offline.Contains(player.SteamID); + } + + public string OfflineNames() + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null) + { + return string.Join(", ", _offline); + } + + List names = new List(); + + foreach (ulong steamId in _offline) + { + MatchMember? member = MatchUtility.GetMemberFromLineup( + matchData, + steamId.ToString(), + string.Empty + ); + + names.Add(member?.name ?? steamId.ToString()); + } + + return string.Join(", ", names); + } + + // Called from the `camera_state` console command. The payload is the full + // set every time, never a delta, so a dropped message self-corrects on the + // next change rather than leaving the server permanently out of sync. + public void UpdateState(string payload) + { + IsRequired(); + + HashSet offline = ParseSteamIds(payload); + + if (offline.SetEquals(_offline)) + { + return; + } + + bool wasBlocking = _offline.Count > 0; + _offline = offline; + + _logger.LogInformation( + $"camera state updated: {(_offline.Count == 0 ? "all clear" : OfflineNames())}" + ); + + UpdateScoreboardTags(); + + if (_offline.Count > 0) + { + OnCamerasLost(!wasBlocking); + return; + } + + OnCamerasRestored(); + } + + // RCON hands us whatever was typed. Anything that is not a steam id is + // dropped rather than throwing: a malformed message must not take the + // camera system down mid-match. + public static HashSet ParseSteamIds(string payload) + { + HashSet steamIds = new HashSet(); + + if (string.IsNullOrWhiteSpace(payload)) + { + return steamIds; + } + + foreach ( + string entry in payload.Split( + new[] { ',', ' ' }, + StringSplitOptions.RemoveEmptyEntries + ) + ) + { + if (ulong.TryParse(entry.Trim(), out ulong steamId) && steamId > 0) + { + steamIds.Add(steamId); + } + } + + return steamIds; + } + + public void Reset() + { + _offline.Clear(); + TimerUtility.Kill(_reminderTimer); + _reminderTimer = null; + } + + private void OnCamerasLost(bool firstBreach) + { + MatchManager? match = _matchService.GetCurrentMatch(); + + if (match == null) + { + return; + } + + string message = _localizer["camera.paused", OfflineNames()]; + + if (firstBreach) + { + match.PauseMatch(message); + } + else + { + _gameServer.Message(MessageType.Alert, message); + } + + TimerUtility.Kill(_reminderTimer); + _reminderTimer = TimerUtility.Repeat( + 10, + () => + { + if (_offline.Count == 0) + { + return; + } + + _gameServer.Message(MessageType.Alert, _localizer["camera.waiting", OfflineNames()]); + } + ); + } + + // Deliberately does not resume: whoever is at the keyboard decides when + // play restarts, the same as any other technical pause. + private void OnCamerasRestored() + { + TimerUtility.Kill(_reminderTimer); + _reminderTimer = null; + + _gameServer.Message( + MessageType.Alert, + _localizer["camera.restored", CommandUtility.PublicChatTrigger] + ); + } + + // Reuses the ready-system convention: the scoreboard is the one place every + // player already looks to see who is holding things up. + private void UpdateScoreboardTags() + { + MatchManager? match = _matchService.GetCurrentMatch(); + MatchData? matchData = match?.GetMatchData(); + + if (match == null || matchData == null) + { + return; + } + + foreach (IPlayer player in MatchUtility.Players()) + { + MatchMember? member = MatchUtility.GetMemberFromLineup( + matchData, + player.SteamID.ToString(), + player.Name + ); + + if (member == null) + { + continue; + } + + string? tag = _offline.Contains(player.SteamID) + ? _localizer["camera.tag"] + : null; + + match.UpdatePlayerName(player, member.name, tag); + } + } +} diff --git a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs index 505ffda8..333142ec 100644 --- a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs +++ b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs @@ -18,6 +18,7 @@ public class ReadySystem private readonly ILogger _logger; private readonly CoachSystem _coachSystem; private readonly CaptainSystem _captainSystem; + private readonly CameraSystem _cameraSystem; private readonly ILocalizer _localizer; private Dictionary _readyPlayers = new Dictionary(); @@ -28,6 +29,7 @@ public ReadySystem( MatchService matchService, CoachSystem coachSystem, CaptainSystem captainSystem, + CameraSystem cameraSystem, ILocalizer localizer ) { @@ -36,6 +38,7 @@ ILocalizer localizer _matchService = matchService; _coachSystem = coachSystem; _captainSystem = captainSystem; + _cameraSystem = cameraSystem; _localizer = localizer; } @@ -107,6 +110,12 @@ public bool IsWaitingForReady() public void ToggleReady(IPlayer player) { + if (_cameraSystem.IsPlayerBlocked(player)) + { + _gameServer.Message(MessageType.Chat, _localizer["camera.cannot_ready"], player); + return; + } + if (!CanVote(player)) { _gameServer.Message(MessageType.Chat, _localizer["ready.not_allowed"], player); diff --git a/apps/swiftly/src/FiveStack.Services/TimeoutSystem.cs b/apps/swiftly/src/FiveStack.Services/TimeoutSystem.cs index 433cb6b5..d7650061 100644 --- a/apps/swiftly/src/FiveStack.Services/TimeoutSystem.cs +++ b/apps/swiftly/src/FiveStack.Services/TimeoutSystem.cs @@ -24,6 +24,7 @@ public class TimeoutSystem private readonly IServiceProvider _serviceProvider; private readonly CoachSystem _coachSystem; private readonly CaptainSystem _captainSystem; + private readonly CameraSystem _cameraSystem; private readonly ILocalizer _localizer; public VoteSystem? pauseVote; public VoteSystem? resumeVote; @@ -38,6 +39,7 @@ public TimeoutSystem( IServiceProvider serviceProvider, CoachSystem coachSystem, CaptainSystem captainSystem, + CameraSystem cameraSystem, ILocalizer localizer ) { @@ -50,6 +52,7 @@ ILocalizer localizer _backUpManagement = backUpManagement; _coachSystem = coachSystem; _captainSystem = captainSystem; + _cameraSystem = cameraSystem; _localizer = localizer; } @@ -244,6 +247,24 @@ public void RequestResume(IPlayer? player) string resumeMessage = _localizer["timeout.admin_resumed"]; + // Refuse while a required camera is still down, on the same terms as the + // empty-team gate below: the pause exists because someone is unwatched, + // so resuming before that is fixed defeats the whole point. Admins keep + // their override for a camera that is never coming back. + if ( + player != null + && !IsAdminOrOrganizer(player, matchData) + && _cameraSystem.IsBlocking() + ) + { + _gameServer.Message( + MessageType.Chat, + _localizer["camera.cannot_resume", _cameraSystem.OfflineNames()], + player + ); + return; + } + // Refuse while a side has nobody in the server. The match pauses itself // when it goes short-handed, and resuming into an empty team just plays // rounds out against nobody -- free rounds for whoever is still here. diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 1345ad73..1dc1043a 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -35,6 +35,7 @@ public partial class FiveStackPlugin : BasePlugin private CaptainSystem _captainSystem = null!; private CoachSystem _coachSystem = null!; private RankSystem _rankSystem = null!; + private CameraSystem _cameraSystem = null!; private SurrenderSystem _surrenderSystem = null!; private GameBackUpRounds _gameBackupRounds = null!; private EnvironmentService _environmentService = null!; @@ -82,6 +83,9 @@ public override void Load(bool hotReload) .AddSingleton() .AddSingleton() .AddSingleton() + // Singleton: the offline set has to outlive the transient + // MatchManager that gets rebuilt on every match refresh. + .AddSingleton() .AddTransient() .AddTransient() .AddTransient() @@ -107,6 +111,7 @@ public override void Load(bool hotReload) _coachSystem = _serviceProvider.GetRequiredService(); _captainSystem = _serviceProvider.GetRequiredService(); _rankSystem = _serviceProvider.GetRequiredService(); + _cameraSystem = _serviceProvider.GetRequiredService(); _environmentService.Load(); diff --git a/apps/swiftly/src/resources/translations/en.jsonc b/apps/swiftly/src/resources/translations/en.jsonc index 982cdc2d..8b2246b4 100644 --- a/apps/swiftly/src/resources/translations/en.jsonc +++ b/apps/swiftly/src/resources/translations/en.jsonc @@ -80,5 +80,15 @@ "vote.prompt_count": " Vote to {0}", "vote.prompt_options": " Vote to {0} ({1}y or {2}n)", "vote.prompt_count_timer": " Vote to {0} ({1}s)", - "vote.prompt_options_timer": " Vote to {0} ({1}y or {2}n) ({3}s)" + "vote.prompt_options_timer": " Vote to {0} ({1}y or {2}n) ({3}s)", + + "camera.paused": "Match paused - no camera from: {0}", + "camera.waiting": "Waiting for camera: {0}", + "camera.restored": "All cameras are back - type {0}resume to continue", + "camera.cannot_resume": "Cannot resume, still waiting on a camera from: {0}", + "camera.cannot_ready": "You cannot ready up until your camera is connected", + "camera.tag": "no cam", + "camera.not_required": "This match does not require a camera", + "camera.yours_ok": "Your camera is connected", + "camera.yours_down": "Your camera is NOT connected - reconnect it from the match page" } diff --git a/apps/swiftly/test/CameraSystemTests.cs b/apps/swiftly/test/CameraSystemTests.cs new file mode 100644 index 00000000..afa594d6 --- /dev/null +++ b/apps/swiftly/test/CameraSystemTests.cs @@ -0,0 +1,64 @@ +using FiveStack; +using Xunit; + +public class CameraSystemTests +{ + [Fact] + public void ParsesACommaSeparatedList() + { + var parsed = CameraSystem.ParseSteamIds("76561198000000001,76561198000000002"); + + Assert.Equal( + new HashSet { 76561198000000001, 76561198000000002 }, + parsed + ); + } + + // An empty payload is the all-clear, not a parse failure. + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(",,")] + public void TreatsAnEmptyPayloadAsNobodyOffline(string payload) + { + Assert.Empty(CameraSystem.ParseSteamIds(payload)); + } + + [Fact] + public void IgnoresSurroundingWhitespace() + { + var parsed = CameraSystem.ParseSteamIds(" 76561198000000001 , 76561198000000002 "); + + Assert.Equal(2, parsed.Count); + } + + // RCON strips nothing for us, so garbage has to be survivable rather than + // fatal — a bad message must not wedge the camera system mid-match. + [Theory] + [InlineData("not-a-steamid")] + [InlineData("0")] + [InlineData("-1")] + [InlineData("99999999999999999999999999")] + public void DropsAnythingThatIsNotASteamId(string payload) + { + Assert.Empty(CameraSystem.ParseSteamIds(payload)); + } + + [Fact] + public void KeepsTheValidEntriesAlongsideGarbage() + { + var parsed = CameraSystem.ParseSteamIds("76561198000000001,nope,0"); + + Assert.Equal(new HashSet { 76561198000000001 }, parsed); + } + + [Fact] + public void DeduplicatesRepeatedIds() + { + var parsed = CameraSystem.ParseSteamIds( + "76561198000000001,76561198000000001" + ); + + Assert.Single(parsed); + } +} diff --git a/apps/swiftly/test/EntityContractTests.cs b/apps/swiftly/test/EntityContractTests.cs index 8f74383c..9ba9aa86 100644 --- a/apps/swiftly/test/EntityContractTests.cs +++ b/apps/swiftly/test/EntityContractTests.cs @@ -21,6 +21,7 @@ public class EntityContractTests "tv_delay": 105, "round_restart_delay": 7, "halftime_pausematch": true, + "camera_required": true, "coaches": true, "number_of_substitutes": 2, "knife_round": true, @@ -38,7 +39,7 @@ public class EntityContractTests "tag": "A", "coach_steam_id": "76561198000000009", "lineup_players": [ - { "name": "p1", "role": "verified_user", "steam_id": "76561198000000001", "captain": true, "elo": 1500, "is_gagged": true }, + { "name": "p1", "role": "verified_user", "steam_id": "76561198000000001", "captain": true, "elo": 1500, "is_gagged": true, "camera_ok": false }, { "name": "p2", "role": "", "placeholder_name": "Bot", "steam_id": null, "captain": false, "elo": null } ] }, @@ -98,6 +99,7 @@ public void Options_Map() Assert.False(options.default_models); Assert.Equal(7, options.round_restart_delay); Assert.True(options.halftime_pausematch); + Assert.True(options.camera_required); Assert.Equal("0", options.cfg_overrides["sv_cheats"]); } @@ -115,11 +117,15 @@ public void Lineup_And_Members_Map() Assert.True(captain.captain); Assert.Equal(1500, captain.elo); Assert.True(captain.is_gagged); + Assert.False(captain.camera_ok); MatchMember placeholder = lineup.lineup_players[1]; Assert.Null(placeholder.steam_id); Assert.Null(placeholder.elo); Assert.Equal("Bot", placeholder.placeholder_name); + // Absent from the payload: a plugin talking to an older API must not + // decide every player is uncompliant. + Assert.True(placeholder.camera_ok); } [Fact] diff --git a/shared/dotnet/FiveStack.Entities/MatchMember.cs b/shared/dotnet/FiveStack.Entities/MatchMember.cs index e9b69e75..1048d02b 100644 --- a/shared/dotnet/FiveStack.Entities/MatchMember.cs +++ b/shared/dotnet/FiveStack.Entities/MatchMember.cs @@ -11,5 +11,9 @@ public class MatchMember public bool is_banned { get; set; } = false; public bool is_gagged { get; set; } = false; public bool is_muted { get; set; } = false; + + // Only meaningful when the match requires cameras. The API decides this -- + // the server cannot see a webcam feed, it is only told about one. + public bool camera_ok { get; set; } = true; public int? elo { get; set; } } diff --git a/shared/dotnet/FiveStack.Entities/MatchOptions.cs b/shared/dotnet/FiveStack.Entities/MatchOptions.cs index dc3bed96..0c01c483 100644 --- a/shared/dotnet/FiveStack.Entities/MatchOptions.cs +++ b/shared/dotnet/FiveStack.Entities/MatchOptions.cs @@ -11,6 +11,7 @@ public class MatchOptions public int tv_delay { get; set; } = 115; public int? round_restart_delay { get; set; } = null; public bool halftime_pausematch { get; set; } = false; + public bool camera_required { get; set; } = false; public bool coaches { get; set; } = true; public int number_of_substitutes { get; set; } = 0; public bool knife_round { get; set; } = true; From 74eecf69c0d2a1e616cec3a394d13e6b08ec8441 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 14 Aug 2026 00:13:44 -0400 Subject: [PATCH 2/2] wip --- .../src/FiveStack.Services/CameraSystem.cs | 37 +++++++++++++++++-- .../src/FiveStack.Services/CameraSystem.cs | 37 +++++++++++++++++-- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs index bad42d54..ab8b65f6 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/CameraSystem.cs @@ -47,11 +47,38 @@ public bool IsRequired() { _matchId = matchData?.id ?? Guid.Empty; Reset(); + SeedFromMatchData(matchData); } return matchData?.options.camera_required == true; } + // Updates are edge-triggered, so the API stays quiet for as long as the + // offending set holds. A plugin that lost its state mid-match -- a restart, + // a fresh map load -- would never be told who is still offline, and would + // let a blocked player ready up or a paused match resume. The match payload + // carries the API's current answer, so pick it back up from there. + private void SeedFromMatchData(MatchData? matchData) + { + if (matchData?.options.camera_required != true) + { + return; + } + + foreach (MatchLineUp lineup in new[] { matchData.lineup_1, matchData.lineup_2 }) + { + foreach (MatchMember member in lineup.lineup_players) + { + if (member.camera_ok || !ulong.TryParse(member.steam_id, out ulong steamId)) + { + continue; + } + + _offline.Add(steamId); + } + } + } + public bool IsBlocking() { return IsRequired() && _offline.Count > 0; @@ -106,7 +133,6 @@ public void UpdateState(string payload) return; } - bool wasBlocking = _offline.Count > 0; _offline = offline; _logger.LogInformation( @@ -117,7 +143,7 @@ public void UpdateState(string payload) if (_offline.Count > 0) { - OnCamerasLost(!wasBlocking); + OnCamerasLost(); return; } @@ -159,7 +185,7 @@ public void Reset() _reminderTimer = null; } - private void OnCamerasLost(bool firstBreach) + private void OnCamerasLost() { MatchManager? match = _matchService.GetCurrentMatch(); @@ -170,7 +196,10 @@ private void OnCamerasLost(bool firstBreach) string message = _localizer["camera.paused", OfflineNames()]; - if (firstBreach) + // Gated on play actually being stopped rather than on whether anyone + // was already offline: an organizer can resume over a breach, and the + // next player to lose their camera has to stop play again. + if (!match.IsPaused()) { match.PauseMatch(message); } diff --git a/apps/swiftly/src/FiveStack.Services/CameraSystem.cs b/apps/swiftly/src/FiveStack.Services/CameraSystem.cs index 2a675f30..82e8e78a 100644 --- a/apps/swiftly/src/FiveStack.Services/CameraSystem.cs +++ b/apps/swiftly/src/FiveStack.Services/CameraSystem.cs @@ -44,11 +44,38 @@ public bool IsRequired() { _matchId = matchData?.id ?? Guid.Empty; Reset(); + SeedFromMatchData(matchData); } return matchData?.options.camera_required == true; } + // Updates are edge-triggered, so the API stays quiet for as long as the + // offending set holds. A plugin that lost its state mid-match -- a restart, + // a fresh map load -- would never be told who is still offline, and would + // let a blocked player ready up or a paused match resume. The match payload + // carries the API's current answer, so pick it back up from there. + private void SeedFromMatchData(MatchData? matchData) + { + if (matchData?.options.camera_required != true) + { + return; + } + + foreach (MatchLineUp lineup in new[] { matchData.lineup_1, matchData.lineup_2 }) + { + foreach (MatchMember member in lineup.lineup_players) + { + if (member.camera_ok || !ulong.TryParse(member.steam_id, out ulong steamId)) + { + continue; + } + + _offline.Add(steamId); + } + } + } + public bool IsBlocking() { return IsRequired() && _offline.Count > 0; @@ -103,7 +130,6 @@ public void UpdateState(string payload) return; } - bool wasBlocking = _offline.Count > 0; _offline = offline; _logger.LogInformation( @@ -114,7 +140,7 @@ public void UpdateState(string payload) if (_offline.Count > 0) { - OnCamerasLost(!wasBlocking); + OnCamerasLost(); return; } @@ -156,7 +182,7 @@ public void Reset() _reminderTimer = null; } - private void OnCamerasLost(bool firstBreach) + private void OnCamerasLost() { MatchManager? match = _matchService.GetCurrentMatch(); @@ -167,7 +193,10 @@ private void OnCamerasLost(bool firstBreach) string message = _localizer["camera.paused", OfflineNames()]; - if (firstBreach) + // Gated on play actually being stopped rather than on whether anyone + // was already offline: an organizer can resume over a breach, and the + // next player to lose their camera has to stop play again. + if (!match.IsPaused()) { match.PauseMatch(message); }