From 4cd49ef578b93f1e1fc4fa9aaa48f89a2e1fdf7f Mon Sep 17 00:00:00 2001 From: Kristians Laukis <7798036+LCrew@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:09:13 +0300 Subject: [PATCH 1/3] feat(match): honour a match sized by its game mode GetExpectedPlayerCount() read a hard-coded 10/4/2 off options.type, which is one of the four copies of that fact across the stack. The panel can now size a match from its custom game mode, so it sends the resolved per-lineup minimum in options.min_players_per_lineup. When that field is set it wins, and the count is starters only -- the same thing the type table below it counts, since a Competitive match expects 10 whether or not substitutes are rostered. Reading the lineups directly would count those substitutes and hang warmup waiting for players who were never required. That single branch carries the ready check, the short-handed auto-pause at round start, pause/resume on disconnect and reconnect, bot fill, the round restore gate and the surrender vote. Two places need more than the total: * the per-team join cap is now taken from the player's own lineup, so the third player of a 3v2 is not kicked on connect. * the ready check needs >= rather than ==, but only for a flexible match: an uneven roster reports the smaller side as its minimum, so more players can ready up than expected and an equality would never fire. Every other match keeps the exact == it has always used. Null on every existing match, so nothing changes without a custom mode. Mirrored across both the CounterStrikeSharp and Swiftly runtimes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz --- .../src/FiveStack.Events/PlayerConnected.cs | 2 +- .../src/FiveStack.Services/MatchManager.cs | 37 +++++++++++++++++++ .../src/FiveStack.Services/ReadySystem.cs | 10 ++++- .../src/FiveStack.Events/PlayerConnected.cs | 2 +- .../src/FiveStack.Services/MatchManager.cs | 37 +++++++++++++++++++ .../src/FiveStack.Services/ReadySystem.cs | 10 ++++- .../dotnet/FiveStack.Entities/MatchOptions.cs | 5 +++ 7 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs index e9bc75c6..3b168018 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs @@ -87,7 +87,7 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i } CsTeam expectedTeam = match.GetExpectedTeam(player); - int expectedTeamCount = match.GetExpectedPlayerCount() / 2; + int expectedTeamCount = match.GetExpectedTeamCount(lineup_id); int teamCount = TeamUtility.GetTeamCount(expectedTeam); if (player.Team == expectedTeam) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index 116979cb..e7aa556b 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -849,6 +849,15 @@ public int GetExpectedPlayerCount() return 10; } + // Starters only, matching what the type table below counts: a + // Competitive match expects 10 whether or not substitutes are rostered. + // Reading the lineups instead would count those substitutes and hang + // warmup waiting for players who were never required. + if (_matchData.options.min_players_per_lineup != null) + { + return _matchData.options.min_players_per_lineup.Value * 2; + } + if (_matchData.options.type == "Wingman") { return 4; @@ -862,6 +871,34 @@ public int GetExpectedPlayerCount() return 10; } + // How many players may sit on one side. A short-handed match is capped at + // whatever that lineup was actually given, so the third player of a 3v2 is + // not kicked on connect. + public int GetExpectedTeamCount(Guid? lineupId) + { + if (_matchData == null) + { + return 5; + } + + if (_matchData.options.min_players_per_lineup == null || lineupId == null) + { + return GetExpectedPlayerCount() / 2; + } + + if (lineupId == _matchData.lineup_1_id) + { + return _matchData.lineup_1?.lineup_players?.Count ?? 0; + } + + if (lineupId == _matchData.lineup_2_id) + { + return _matchData.lineup_2?.lineup_players?.Count ?? 0; + } + + return GetExpectedPlayerCount() / 2; + } + private void StartWarmup() { ConVar.Find("sv_disable_teamselect_menu")?.SetValue(false); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs index 02e07050..73c54977 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs @@ -159,7 +159,15 @@ public void ToggleReady(CCSPlayerController player) break; } - if (TotalReady() == expectedCount) + // A flexible-size match needs >=, not ==: an uneven roster (a 3v2 that + // started short-handed) reports the smaller side as its per-lineup + // minimum, so more players can ready up than expectedCount and an + // equality would never fire -- warmup would hang forever. Every other + // match keeps the exact == it has always used. + bool flexible = + currentMatch?.GetMatchData()?.options.min_players_per_lineup != null; + + if (flexible ? TotalReady() >= expectedCount : TotalReady() == expectedCount) { Reset(); currentMatch?.UpdateMapStatus(eMapStatus.Knife); diff --git a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs index 583e7f14..f37bb7a3 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs @@ -88,7 +88,7 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event) } Team expectedTeam = match.GetExpectedTeam(player); - int expectedTeamCount = match.GetExpectedPlayerCount() / 2; + int expectedTeamCount = match.GetExpectedTeamCount(lineup_id); int teamCount = TeamUtility.GetTeamCount(expectedTeam); if (player.Controller.Team == expectedTeam) diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index c5363d6c..d88317bc 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -842,6 +842,15 @@ public int GetExpectedPlayerCount() return 10; } + // Starters only, matching what the type table below counts: a + // Competitive match expects 10 whether or not substitutes are rostered. + // Reading the lineups instead would count those substitutes and hang + // warmup waiting for players who were never required. + if (_matchData.options.min_players_per_lineup != null) + { + return _matchData.options.min_players_per_lineup.Value * 2; + } + if (_matchData.options.type == "Wingman") { return 4; @@ -855,6 +864,34 @@ public int GetExpectedPlayerCount() return 10; } + // How many players may sit on one side. A short-handed match is capped at + // whatever that lineup was actually given, so the third player of a 3v2 is + // not kicked on connect. + public int GetExpectedTeamCount(Guid? lineupId) + { + if (_matchData == null) + { + return 5; + } + + if (_matchData.options.min_players_per_lineup == null || lineupId == null) + { + return GetExpectedPlayerCount() / 2; + } + + if (lineupId == _matchData.lineup_1_id) + { + return _matchData.lineup_1?.lineup_players?.Count ?? 0; + } + + if (lineupId == _matchData.lineup_2_id) + { + return _matchData.lineup_2?.lineup_players?.Count ?? 0; + } + + return GetExpectedPlayerCount() / 2; + } + private void StartWarmup() { SetConVar("sv_disable_teamselect_menu", false); diff --git a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs index 333142ec..17c25445 100644 --- a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs +++ b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs @@ -150,7 +150,15 @@ public void ToggleReady(IPlayer player) break; } - if (TotalReady() == expectedCount) + // A flexible-size match needs >=, not ==: an uneven roster (a 3v2 that + // started short-handed) reports the smaller side as its per-lineup + // minimum, so more players can ready up than expectedCount and an + // equality would never fire -- warmup would hang forever. Every other + // match keeps the exact == it has always used. + bool flexible = + currentMatch?.GetMatchData()?.options.min_players_per_lineup != null; + + if (flexible ? TotalReady() >= expectedCount : TotalReady() == expectedCount) { Reset(); currentMatch?.UpdateMapStatus(eMapStatus.Knife); diff --git a/shared/dotnet/FiveStack.Entities/MatchOptions.cs b/shared/dotnet/FiveStack.Entities/MatchOptions.cs index 9b15ab84..0638c592 100644 --- a/shared/dotnet/FiveStack.Entities/MatchOptions.cs +++ b/shared/dotnet/FiveStack.Entities/MatchOptions.cs @@ -14,6 +14,11 @@ public class MatchOptions public bool camera_required { get; set; } = false; public bool coaches { get; set; } = true; public int number_of_substitutes { get; set; } = 0; + + // Set only when this match is not the size its type implies -- a custom mode + // with its own team size, or a draft lobby that started short-handed. Null + // means fall back to the type's fixed count. + public int? min_players_per_lineup { get; set; } = null; public bool knife_round { get; set; } = true; public bool? default_models { get; set; } = false; public string ready_setting { get; set; } = "Players"; From 43996578ed9102f8455fd0ac741f71e4143a9e07 Mon Sep 17 00:00:00 2001 From: Kristians Laukis <7798036+LCrew@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:06:12 +0300 Subject: [PATCH 2/3] test(match): pin the flexible lineup size contract min_players_per_lineup travels from the panel as JSON, so a rename or a serializer change breaks it silently: the server falls back to the type's 10/4/2 and sits in warmup waiting for players who were never rostered. From the outside that looks like a panel bug, not a deserialization one. Covers both directions -- a sized mode expecting its own count, and a plain Wingman still expecting four, which is the half that proves an ordinary match is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz --- apps/swiftly/test/FlexibleLineupSizeTests.cs | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/swiftly/test/FlexibleLineupSizeTests.cs diff --git a/apps/swiftly/test/FlexibleLineupSizeTests.cs b/apps/swiftly/test/FlexibleLineupSizeTests.cs new file mode 100644 index 00000000..554594cc --- /dev/null +++ b/apps/swiftly/test/FlexibleLineupSizeTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using FiveStack.Entities; +using Xunit; + +// The panel sizes a match from its custom game mode by sending +// options.min_players_per_lineup. If that field does not survive +// deserialization the server silently falls back to the type's 10/4/2 and sits +// in warmup waiting for players who were never rostered -- which looks exactly +// like a panel bug from the outside. Pinned here. +public class FlexibleLineupSizeTests +{ + private static MatchData Match(string payload) => + JsonSerializer.Deserialize(payload)!; + + // A 1v1 custom mode on a Wingman-typed match: the mode says one a side, so + // the server must expect 2, not the 4 the type would imply. + private const string OneVOneCustomMode = + """ + { + "options": { + "type": "Wingman", + "min_players_per_lineup": 1, + "number_of_substitutes": 0 + }, + "lineup_1": { "lineup_players": [ { "steam_id": "1" } ] }, + "lineup_2": { "lineup_players": [ { "steam_id": "2" } ] } + } + """; + + private const string PlainWingman = + """ + { + "options": { "type": "Wingman", "number_of_substitutes": 0 }, + "lineup_1": { "lineup_players": [ { "steam_id": "1" }, { "steam_id": "2" } ] }, + "lineup_2": { "lineup_players": [ { "steam_id": "3" }, { "steam_id": "4" } ] } + } + """; + + [Fact] + public void CustomModeSizeIsDeserialized() + { + Assert.Equal(1, Match(OneVOneCustomMode).options.min_players_per_lineup); + } + + // Absent on every match the panel has ever sent before this feature, and on + // every match without a sized mode. Null is what makes the fallback fire. + [Fact] + public void AbsentFieldIsNull() + { + Assert.Null(Match(PlainWingman).options.min_players_per_lineup); + } + + // Mirrors MatchManager.GetExpectedPlayerCount(): the snapshot wins, and it + // counts starters a side x2 rather than the roster, so substitutes never + // inflate what warmup waits for. + private static int Expected(MatchData m) => + m.options.min_players_per_lineup != null + ? m.options.min_players_per_lineup.Value * 2 + : m.options.type switch { "Wingman" => 4, "Duel" => 2, _ => 10 }; + + [Fact] + public void SizedModeExpectsTwoPlayers() + { + Assert.Equal(2, Expected(Match(OneVOneCustomMode))); + } + + [Fact] + public void PlainWingmanStillExpectsFour() + { + Assert.Equal(4, Expected(Match(PlainWingman))); + } +} From d8baf7fc7cb52f9a1b352db5cec5aff85c8f66f9 Mon Sep 17 00:00:00 2001 From: Kristians Laukis <7798036+LCrew@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:51:49 +0300 Subject: [PATCH 3/3] fix(match): wait for everyone when the sides are uneven The panel records the smaller side in min_players_per_lineup, because its own gates apply that one number to both lineups. Doubling it therefore under-counts an uneven start: a 1v2 asked for 2 and could take the match live with the third player still connecting. It now sends the total outright when the sides are uneven, so prefer that and fall back to doubling only when it is absent. Even starts are unchanged, and a match with no short-handed start still reads its type. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz --- .../src/FiveStack.Services/MatchManager.cs | 8 +++ .../src/FiveStack.Services/ReadySystem.cs | 4 +- .../src/FiveStack.Services/MatchManager.cs | 8 +++ .../src/FiveStack.Services/ReadySystem.cs | 4 +- apps/swiftly/test/FlexibleLineupSizeTests.cs | 49 ++++++++++++++++--- .../dotnet/FiveStack.Entities/MatchOptions.cs | 5 ++ 6 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index e7aa556b..e7193146 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -849,6 +849,14 @@ public int GetExpectedPlayerCount() return 10; } + // An uneven start sends the total outright, because doubling the + // per-lineup minimum under-counts it: a 1v2 records 1 there (the panel's + // gates apply that to both sides) but three people still have to show up. + if (_matchData.options.expected_players != null) + { + return _matchData.options.expected_players.Value; + } + // Starters only, matching what the type table below counts: a // Competitive match expects 10 whether or not substitutes are rostered. // Reading the lineups instead would count those substitutes and hang diff --git a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs index 73c54977..dbb6e50c 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs @@ -164,8 +164,10 @@ public void ToggleReady(CCSPlayerController player) // minimum, so more players can ready up than expectedCount and an // equality would never fire -- warmup would hang forever. Every other // match keeps the exact == it has always used. + var flexOptions = currentMatch?.GetMatchData()?.options; bool flexible = - currentMatch?.GetMatchData()?.options.min_players_per_lineup != null; + flexOptions?.min_players_per_lineup != null + || flexOptions?.expected_players != null; if (flexible ? TotalReady() >= expectedCount : TotalReady() == expectedCount) { diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index d88317bc..db736d54 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -842,6 +842,14 @@ public int GetExpectedPlayerCount() return 10; } + // An uneven start sends the total outright, because doubling the + // per-lineup minimum under-counts it: a 1v2 records 1 there (the panel's + // gates apply that to both sides) but three people still have to show up. + if (_matchData.options.expected_players != null) + { + return _matchData.options.expected_players.Value; + } + // Starters only, matching what the type table below counts: a // Competitive match expects 10 whether or not substitutes are rostered. // Reading the lineups instead would count those substitutes and hang diff --git a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs index 17c25445..3f102fc8 100644 --- a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs +++ b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs @@ -155,8 +155,10 @@ public void ToggleReady(IPlayer player) // minimum, so more players can ready up than expectedCount and an // equality would never fire -- warmup would hang forever. Every other // match keeps the exact == it has always used. + var flexOptions = currentMatch?.GetMatchData()?.options; bool flexible = - currentMatch?.GetMatchData()?.options.min_players_per_lineup != null; + flexOptions?.min_players_per_lineup != null + || flexOptions?.expected_players != null; if (flexible ? TotalReady() >= expectedCount : TotalReady() == expectedCount) { diff --git a/apps/swiftly/test/FlexibleLineupSizeTests.cs b/apps/swiftly/test/FlexibleLineupSizeTests.cs index 554594cc..fe8ea4f2 100644 --- a/apps/swiftly/test/FlexibleLineupSizeTests.cs +++ b/apps/swiftly/test/FlexibleLineupSizeTests.cs @@ -50,13 +50,50 @@ public void AbsentFieldIsNull() Assert.Null(Match(PlainWingman).options.min_players_per_lineup); } - // Mirrors MatchManager.GetExpectedPlayerCount(): the snapshot wins, and it - // counts starters a side x2 rather than the roster, so substitutes never - // inflate what warmup waits for. + // An uneven short-handed start: the panel records the SMALLER side in + // min_players_per_lineup (its gates apply that to both lineups, so a 1v2 has + // to record 1 or the short side never clears) and the real total separately. + private const string UnevenOneVTwo = + """ + { + "options": { + "type": "Competitive", + "min_players_per_lineup": 1, + "expected_players": 3, + "number_of_substitutes": 0 + }, + "lineup_1": { "lineup_players": [ { "steam_id": "1" } ] }, + "lineup_2": { "lineup_players": [ { "steam_id": "2" }, { "steam_id": "3" } ] } + } + """; + + // Mirrors MatchManager.GetExpectedPlayerCount(), in precedence order: the + // explicit total, then the per-lineup snapshot doubled, then the type. private static int Expected(MatchData m) => - m.options.min_players_per_lineup != null - ? m.options.min_players_per_lineup.Value * 2 - : m.options.type switch { "Wingman" => 4, "Duel" => 2, _ => 10 }; + m.options.expected_players + ?? (m.options.min_players_per_lineup != null + ? m.options.min_players_per_lineup.Value * 2 + : m.options.type switch { "Wingman" => 4, "Duel" => 2, _ => 10 }); + + [Fact] + public void UnevenStartWaitsForEveryone() + { + // The bug this pins: min x 2 gives 2 here, so the match would go live + // with the third player still connecting. + Assert.Equal(3, Expected(Match(UnevenOneVTwo))); + } + + [Fact] + public void UnevenStartStillRecordsTheSmallerSideForTheGates() + { + Assert.Equal(1, Match(UnevenOneVTwo).options.min_players_per_lineup); + } + + [Fact] + public void EvenStartNeedsNoTotal() + { + Assert.Null(Match(OneVOneCustomMode).options.expected_players); + } [Fact] public void SizedModeExpectsTwoPlayers() diff --git a/shared/dotnet/FiveStack.Entities/MatchOptions.cs b/shared/dotnet/FiveStack.Entities/MatchOptions.cs index 0638c592..bb271f21 100644 --- a/shared/dotnet/FiveStack.Entities/MatchOptions.cs +++ b/shared/dotnet/FiveStack.Entities/MatchOptions.cs @@ -19,6 +19,11 @@ public class MatchOptions // with its own team size, or a draft lobby that started short-handed. Null // means fall back to the type's fixed count. public int? min_players_per_lineup { get; set; } = null; + + // The total across both lineups, sent only when a lobby started + // short-handed. Set whenever the sides are uneven, where doubling + // min_players_per_lineup would under-count -- a 1v2 needs 3, not 2. + public int? expected_players { get; set; } = null; public bool knife_round { get; set; } = true; public bool? default_models { get; set; } = false; public string ready_setting { get; set; } = "Players";