Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/swiftly/src/FiveStack.Events/PlayerConnected.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions apps/swiftly/src/FiveStack.Services/MatchManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion apps/swiftly/src/FiveStack.Services/ReadySystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
109 changes: 109 additions & 0 deletions apps/swiftly/test/FlexibleLineupSizeTests.cs
Original file line number Diff line number Diff line change
@@ -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<MatchData>(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)));
}
}
10 changes: 10 additions & 0 deletions shared/dotnet/FiveStack.Entities/MatchOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down