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..e7193146 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -849,6 +849,23 @@ 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 + // 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 +879,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..dbb6e50c 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs @@ -159,7 +159,17 @@ 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. + var flexOptions = currentMatch?.GetMatchData()?.options; + bool flexible = + flexOptions?.min_players_per_lineup != null + || flexOptions?.expected_players != 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..db736d54 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -842,6 +842,23 @@ 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 + // 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 +872,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..3f102fc8 100644 --- a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs +++ b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs @@ -150,7 +150,17 @@ 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. + var flexOptions = currentMatch?.GetMatchData()?.options; + bool flexible = + flexOptions?.min_players_per_lineup != null + || flexOptions?.expected_players != null; + + if (flexible ? TotalReady() >= expectedCount : TotalReady() == expectedCount) { Reset(); currentMatch?.UpdateMapStatus(eMapStatus.Knife); diff --git a/apps/swiftly/test/FlexibleLineupSizeTests.cs b/apps/swiftly/test/FlexibleLineupSizeTests.cs new file mode 100644 index 00000000..fe8ea4f2 --- /dev/null +++ b/apps/swiftly/test/FlexibleLineupSizeTests.cs @@ -0,0 +1,109 @@ +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); + } + + // 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.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() + { + Assert.Equal(2, Expected(Match(OneVOneCustomMode))); + } + + [Fact] + public void PlainWingmanStillExpectsFour() + { + Assert.Equal(4, Expected(Match(PlainWingman))); + } +} diff --git a/shared/dotnet/FiveStack.Entities/MatchOptions.cs b/shared/dotnet/FiveStack.Entities/MatchOptions.cs index 9b15ab84..bb271f21 100644 --- a/shared/dotnet/FiveStack.Entities/MatchOptions.cs +++ b/shared/dotnet/FiveStack.Entities/MatchOptions.cs @@ -14,6 +14,16 @@ 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; + + // 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";