From b0dfb0c5419960b4a08dd034d153569a6a233946 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:05:34 -0400 Subject: [PATCH 1/5] feature: relay say_team into the team's web chat room Team chat is published with teamOnly + lineupId only when the api advertises relay_team_chat, so a newer plugin never leaks team chat into the shared match room of an older api. Coaches resolve to their lineup even while seated on spectator; gagged speakers are never relayed. --- .../src/FiveStack.Events/PlayerChat.cs | 88 +++++++++++++-- .../src/FiveStack.Utilities/MatchUtility.cs | 35 ++++++ .../counterstrikesharp/src/FiveStackPlugin.cs | 1 + .../test/MatchUtilityTests.cs | 103 ++++++++++++++++++ .../src/FiveStack.Events/PlayerChat.cs | 60 ++++++++-- .../src/FiveStack.Utilities/MatchUtility.cs | 35 ++++++ apps/swiftly/src/FiveStack.cs | 9 +- apps/swiftly/test/EntityContractTests.cs | 14 +++ apps/swiftly/test/MatchUtilityTests.cs | 103 ++++++++++++++++++ .../FiveStack.Entities/FiveStackMatch.cs | 4 + 10 files changed, 426 insertions(+), 26 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index b8008bd0..419eedd7 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -9,21 +9,44 @@ namespace FiveStack; public partial class FiveStackPlugin { public HookResult OnPlayerChat(CCSPlayerController? player, CommandInfo info) + { + return HandlePlayerChat(player, info, false); + } + + public HookResult OnPlayerTeamChat(CCSPlayerController? player, CommandInfo info) + { + return HandlePlayerChat(player, info, true); + } + + private HookResult HandlePlayerChat( + CCSPlayerController? player, + CommandInfo info, + bool teamOnly + ) { if (player == null || !player.IsValid) { return HookResult.Continue; } + string message = info.ArgString.Trim('"'); + + if (teamOnly) + { + RelayTeamChat(player, message); + + return HookResult.Continue; + } + if (player.Team == CsTeam.Spectator) { - PublishChatEvent(player, info.ArgString.Trim('"')); + PublishChatEvent(player, message); string clan = string.IsNullOrEmpty(player.Clan) ? "" : $"[{player.Clan}]"; _gameServer.Message( HudDestination.Chat, - $" {ChatColors.Red}{clan}{ChatColors.White} {player.PlayerName}: {info.ArgString.Trim('"')}" + $" {ChatColors.Red}{clan}{ChatColors.White} {player.PlayerName}: {message}" ); return HookResult.Stop; @@ -57,20 +80,63 @@ public HookResult OnPlayerChat(CCSPlayerController? player, CommandInfo info) } } - PublishChatEvent(player, info.ArgString.Trim('"')); + PublishChatEvent(player, message); return HookResult.Continue; } - private void PublishChatEvent(CCSPlayerController player, string message) + // CSS skips the remaining say_team listeners once one returns Stop, so the + // gag is left to GagPlayer, which also tells the speaker they are gagged. + private void RelayTeamChat(CCSPlayerController player, string message) { - _matchEvents.PublishGameEvent( - "chat", - new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - } + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null || !matchData.relay_team_chat) + { + return; + } + + string steamId = player.SteamID.ToString(); + + MatchMember? member = MatchUtility.GetMemberFromLineup( + matchData, + steamId, + player.PlayerName ); + + if (member != null && member.is_gagged) + { + return; + } + + string? lineupId = MatchUtility.GetTeamChatLineupId(matchData, steamId, player.PlayerName); + + if (lineupId == null) + { + return; + } + + PublishChatEvent(player, message, lineupId); + } + + private void PublishChatEvent( + CCSPlayerController player, + string message, + string? teamLineupId = null + ) + { + Dictionary data = new Dictionary + { + { "player", player.SteamID.ToString() }, + { "message", message }, + }; + + if (teamLineupId != null) + { + data["teamOnly"] = true; + data["lineupId"] = teamLineupId; + } + + _matchEvents.PublishGameEvent("chat", data); } } diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index b7b912ad..7f2f755d 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -39,6 +39,41 @@ string playerName }); } + public static string? GetTeamChatLineupId( + MatchData matchData, + string steamId, + string playerName + ) + { + Guid lineupId = + GetMemberFromLineup(matchData, steamId, playerName)?.match_lineup_id ?? Guid.Empty; + + if (lineupId == Guid.Empty) + { + if ( + !string.IsNullOrEmpty(matchData.lineup_1.coach_steam_id) + && matchData.lineup_1.coach_steam_id == steamId + ) + { + lineupId = matchData.lineup_1.id; + } + else if ( + !string.IsNullOrEmpty(matchData.lineup_2.coach_steam_id) + && matchData.lineup_2.coach_steam_id == steamId + ) + { + lineupId = matchData.lineup_2.id; + } + } + + if (lineupId == Guid.Empty) + { + return null; + } + + return lineupId.ToString(); + } + // A client presenting the raw match password is a streamer, unless the // lineup still has placeholder seats: then it may be the player // meant to fill one. diff --git a/apps/counterstrikesharp/src/FiveStackPlugin.cs b/apps/counterstrikesharp/src/FiveStackPlugin.cs index 038fb10c..2747c935 100644 --- a/apps/counterstrikesharp/src/FiveStackPlugin.cs +++ b/apps/counterstrikesharp/src/FiveStackPlugin.cs @@ -101,6 +101,7 @@ public override void Load(bool hotReload) _gameServer.Ping(ModuleVersion); AddCommandListener("say", OnPlayerChat, HookMode.Pre); + AddCommandListener("say_team", OnPlayerTeamChat, HookMode.Pre); AddCommandListener("say", GagPlayer, HookMode.Pre); AddCommandListener("say_team", GagPlayer, HookMode.Pre); diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index f640bb3e..49da6f3b 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -126,4 +126,107 @@ public void RosterSteamIds_ExcludesNonRosterSteamIds() Assert.DoesNotContain("76561198000009999", roster); } + + private static readonly Guid Lineup1Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid Lineup2Id = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + private static MatchData BuildTeamChatMatch() + { + return new MatchData + { + lineup_1 = new MatchLineUp + { + id = Lineup1Id, + coach_steam_id = "76561198000000011", + lineup_players = new List + { + new MatchMember + { + steam_id = "76561198000000001", + name = "Real", + match_lineup_id = Lineup1Id, + }, + }, + }, + lineup_2 = new MatchLineUp + { + id = Lineup2Id, + coach_steam_id = "76561198000000022", + lineup_players = new List + { + new MatchMember + { + steam_id = null, + placeholder_name = "AceBot", + match_lineup_id = Lineup2Id, + }, + }, + }, + }; + } + + [Fact] + public void GetTeamChatLineupId_ResolvesMemberLineup() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000001", "ignored") + ); + } + + [Fact] + public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "9999", "Ace") + ); + } + + [Theory] + [InlineData("76561198000000011", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000022", "44444444-4444-4444-4444-444444444444")] + public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expected) + { + Assert.Equal( + expected, + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), steamId, "Coach") + ); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullForUnknownPlayer() + { + Assert.Null( + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000009999", "Nobody") + ); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullWhenMemberLineupIsEmpty() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.lineup_players[0].match_lineup_id = Guid.Empty; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000001", "Real")); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullWhenCoachLineupIsEmpty() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_2.id = Guid.Empty; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000022", "Coach")); + } + + [Fact] + public void GetTeamChatLineupId_ToleratesLineupsWithoutCoach() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.coach_steam_id = null!; + match.lineup_2.coach_steam_id = ""; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000009999", "Nobody")); + } } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs index aacaaa30..f42985ea 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs @@ -15,6 +15,13 @@ public HookResult OnPlayerChat(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } + if (teamOnly) + { + RelayTeamChat(player, message); + + return HookResult.Continue; + } + if (player.Controller.Team == Team.Spectator) { PublishChatEvent(player, message); @@ -64,15 +71,50 @@ public HookResult OnPlayerChat(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } - private void PublishChatEvent(IPlayer player, string message) + // A gagged speaker is blocked by GagPlayer, which the chat hook runs + // after this and which also tells them why. + private void RelayTeamChat(IPlayer player, string message) { - _matchEvents.PublishGameEvent( - "chat", - new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - } - ); + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null || !matchData.relay_team_chat) + { + return; + } + + string steamId = player.SteamID.ToString(); + + MatchMember? member = MatchUtility.GetMemberFromLineup(matchData, steamId, player.Name); + + if (member != null && member.is_gagged) + { + return; + } + + string? lineupId = MatchUtility.GetTeamChatLineupId(matchData, steamId, player.Name); + + if (lineupId == null) + { + return; + } + + PublishChatEvent(player, message, lineupId); + } + + private void PublishChatEvent(IPlayer player, string message, string? teamLineupId = null) + { + Dictionary data = new Dictionary + { + { "player", player.SteamID.ToString() }, + { "message", message }, + }; + + if (teamLineupId != null) + { + data["teamOnly"] = true; + data["lineupId"] = teamLineupId; + } + + _matchEvents.PublishGameEvent("chat", data); } } diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index 7cd87f4c..196ad3a9 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -48,6 +48,41 @@ string playerName }); } + public static string? GetTeamChatLineupId( + MatchData matchData, + string steamId, + string playerName + ) + { + Guid lineupId = + GetMemberFromLineup(matchData, steamId, playerName)?.match_lineup_id ?? Guid.Empty; + + if (lineupId == Guid.Empty) + { + if ( + !string.IsNullOrEmpty(matchData.lineup_1.coach_steam_id) + && matchData.lineup_1.coach_steam_id == steamId + ) + { + lineupId = matchData.lineup_1.id; + } + else if ( + !string.IsNullOrEmpty(matchData.lineup_2.coach_steam_id) + && matchData.lineup_2.coach_steam_id == steamId + ) + { + lineupId = matchData.lineup_2.id; + } + } + + if (lineupId == Guid.Empty) + { + return null; + } + + return lineupId.ToString(); + } + // A client presenting the raw match password is a streamer, unless the // lineup still has placeholder seats: then it may be the player // meant to fill one. diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 1dc1043a..9dfc569a 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -144,13 +144,10 @@ public override void Load(bool hotReload) HookResult result = HookResult.Continue; - if (!teamonly) + HookResult chatResult = OnPlayerChat(player, message, teamonly); + if (chatResult != HookResult.Continue) { - HookResult chatResult = OnPlayerChat(player, message, teamonly); - if (chatResult != HookResult.Continue) - { - result = chatResult; - } + result = chatResult; } HookResult gagResult = GagPlayer(player, message, teamonly); diff --git a/apps/swiftly/test/EntityContractTests.cs b/apps/swiftly/test/EntityContractTests.cs index 219a9f4b..4e04ae2a 100644 --- a/apps/swiftly/test/EntityContractTests.cs +++ b/apps/swiftly/test/EntityContractTests.cs @@ -148,6 +148,20 @@ public void MatchMaps_Map() Assert.Equal("TERRORIST", map.lineup_2_side); } + [Fact] + public void RelayTeamChat_DefaultsOffWhenApiOmitsIt() + { + Assert.False(Deserialize().relay_team_chat); + } + + [Fact] + public void RelayTeamChat_MapsWhenApiAdvertisesIt() + { + MatchData? match = JsonSerializer.Deserialize("""{ "relay_team_chat": true }"""); + Assert.NotNull(match); + Assert.True(match!.relay_team_chat); + } + [Fact] public void EmptyBody_MustBeLengthGuarded() { diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 8dede300..2bfd8738 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -102,4 +102,107 @@ public void RosterSteamIds_ExcludesNonRosterSteamIds() Assert.DoesNotContain("76561198000009999", roster); } + + private static readonly Guid Lineup1Id = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid Lineup2Id = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + private static MatchData BuildTeamChatMatch() + { + return new MatchData + { + lineup_1 = new MatchLineUp + { + id = Lineup1Id, + coach_steam_id = "76561198000000011", + lineup_players = new List + { + new MatchMember + { + steam_id = "76561198000000001", + name = "Real", + match_lineup_id = Lineup1Id, + }, + }, + }, + lineup_2 = new MatchLineUp + { + id = Lineup2Id, + coach_steam_id = "76561198000000022", + lineup_players = new List + { + new MatchMember + { + steam_id = null, + placeholder_name = "AceBot", + match_lineup_id = Lineup2Id, + }, + }, + }, + }; + } + + [Fact] + public void GetTeamChatLineupId_ResolvesMemberLineup() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000001", "ignored") + ); + } + + [Fact] + public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "9999", "Ace") + ); + } + + [Theory] + [InlineData("76561198000000011", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000022", "44444444-4444-4444-4444-444444444444")] + public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expected) + { + Assert.Equal( + expected, + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), steamId, "Coach") + ); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullForUnknownPlayer() + { + Assert.Null( + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000009999", "Nobody") + ); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullWhenMemberLineupIsEmpty() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.lineup_players[0].match_lineup_id = Guid.Empty; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000001", "Real")); + } + + [Fact] + public void GetTeamChatLineupId_ReturnsNullWhenCoachLineupIsEmpty() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_2.id = Guid.Empty; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000022", "Coach")); + } + + [Fact] + public void GetTeamChatLineupId_ToleratesLineupsWithoutCoach() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.coach_steam_id = null!; + match.lineup_2.coach_steam_id = ""; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000009999", "Nobody")); + } } diff --git a/shared/dotnet/FiveStack.Entities/FiveStackMatch.cs b/shared/dotnet/FiveStack.Entities/FiveStackMatch.cs index f1daf412..48f36ce4 100644 --- a/shared/dotnet/FiveStack.Entities/FiveStackMatch.cs +++ b/shared/dotnet/FiveStack.Entities/FiveStackMatch.cs @@ -10,6 +10,10 @@ public class MatchData public bool is_tournament_match { get; set; } = false; public bool is_draft_match { get; set; } = false; + // Older APIs omit this and route every chat event into the shared match + // room, so team chat must only be relayed when the API advertises it. + public bool relay_team_chat { get; set; } = false; + // When the API will cancel this match if it never gets going. Null once the // match is underway or the deadline has been cleared. public DateTime? cancels_at { get; set; } = null; From 1ec1c3b05f269a846192010a412a75cfbf4210e7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:17:49 -0400 Subject: [PATCH 2/5] bug: harden team chat relay decision and cover it with tests Moves the relay_team_chat gate, gag check and lineup lookup into MatchUtility.GetTeamChatRelayLineupId so the leak rules are tested in both plugins. Exact steam ids resolve before placeholder name prefixes, coaches only resolve when the match allows coaches, and the chat payload shape is pinned by tests. --- .../src/FiveStack.Events/PlayerChat.cs | 37 ++--- .../src/FiveStack.Utilities/MatchUtility.cs | 99 ++++++++++--- .../test/MatchUtilityTests.cs | 139 +++++++++++++++++- .../src/FiveStack.Events/PlayerChat.cs | 36 ++--- .../src/FiveStack.Utilities/MatchUtility.cs | 99 ++++++++++--- apps/swiftly/test/MatchUtilityTests.cs | 139 +++++++++++++++++- 6 files changed, 458 insertions(+), 91 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index 419eedd7..5a8437c9 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -35,6 +35,9 @@ bool teamOnly { RelayTeamChat(player, message); + // CSS skips the remaining say_team listeners once one returns + // Handled or Stop, and GagPlayer after this one is what blocks a + // gagged speaker in game and tells them why. return HookResult.Continue; } @@ -85,32 +88,21 @@ bool teamOnly return HookResult.Continue; } - // CSS skips the remaining say_team listeners once one returns Stop, so the - // gag is left to GagPlayer, which also tells the speaker they are gagged. private void RelayTeamChat(CCSPlayerController player, string message) { MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); - if (matchData == null || !matchData.relay_team_chat) + if (matchData == null) { return; } - string steamId = player.SteamID.ToString(); - - MatchMember? member = MatchUtility.GetMemberFromLineup( + string? lineupId = MatchUtility.GetTeamChatRelayLineupId( matchData, - steamId, + player.SteamID.ToString(), player.PlayerName ); - if (member != null && member.is_gagged) - { - return; - } - - string? lineupId = MatchUtility.GetTeamChatLineupId(matchData, steamId, player.PlayerName); - if (lineupId == null) { return; @@ -125,18 +117,9 @@ private void PublishChatEvent( string? teamLineupId = null ) { - Dictionary data = new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - }; - - if (teamLineupId != null) - { - data["teamOnly"] = true; - data["lineupId"] = teamLineupId; - } - - _matchEvents.PublishGameEvent("chat", data); + _matchEvents.PublishGameEvent( + "chat", + MatchUtility.ChatEventData(player.SteamID.ToString(), message, teamLineupId) + ); } } diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index 7f2f755d..c1887088 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -39,39 +39,104 @@ string playerName }); } + public static string? GetTeamChatRelayLineupId( + MatchData matchData, + string steamId, + string playerName + ) + { + if (!matchData.relay_team_chat) + { + return null; + } + + bool gagged = + GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true + || matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Any(member => member.is_gagged && member.steam_id == steamId); + + if (gagged) + { + return null; + } + + return GetTeamChatLineupId(matchData, steamId, playerName); + } + + // A lineup_1 placeholder whose name prefixes a lineup_2 player would + // otherwise claim them, so exact steam ids are matched before names. public static string? GetTeamChatLineupId( MatchData matchData, string steamId, string playerName ) { + List players = matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .ToList(); + Guid lineupId = - GetMemberFromLineup(matchData, steamId, playerName)?.match_lineup_id ?? Guid.Empty; + players + .Find(member => + !string.IsNullOrEmpty(member.steam_id) && member.steam_id == steamId + ) + ?.match_lineup_id + ?? GetCoachLineupId(matchData, steamId) + ?? players + .Find(member => + member.steam_id == null && member.placeholder_name.StartsWith(playerName) + ) + ?.match_lineup_id + ?? Guid.Empty; if (lineupId == Guid.Empty) { - if ( - !string.IsNullOrEmpty(matchData.lineup_1.coach_steam_id) - && matchData.lineup_1.coach_steam_id == steamId - ) - { - lineupId = matchData.lineup_1.id; - } - else if ( - !string.IsNullOrEmpty(matchData.lineup_2.coach_steam_id) - && matchData.lineup_2.coach_steam_id == steamId - ) - { - lineupId = matchData.lineup_2.id; - } + return null; } - if (lineupId == Guid.Empty) + return lineupId.ToString(); + } + + private static Guid? GetCoachLineupId(MatchData matchData, string steamId) + { + if (!matchData.options.coaches || string.IsNullOrEmpty(steamId)) { return null; } - return lineupId.ToString(); + if (matchData.lineup_1.coach_steam_id == steamId) + { + return matchData.lineup_1.id; + } + + if (matchData.lineup_2.coach_steam_id == steamId) + { + return matchData.lineup_2.id; + } + + return null; + } + + public static Dictionary ChatEventData( + string steamId, + string message, + string? teamLineupId + ) + { + Dictionary data = new Dictionary + { + { "player", steamId }, + { "message", message }, + }; + + if (teamLineupId != null) + { + data["teamOnly"] = true; + data["lineupId"] = teamLineupId; + } + + return data; } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index 49da6f3b..0583fb9b 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -165,6 +165,28 @@ private static MatchData BuildTeamChatMatch() }; } + private static MatchData BuildShadowedMemberMatch() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.lineup_players.Add( + new MatchMember + { + steam_id = null, + placeholder_name = "Steve", + match_lineup_id = Lineup1Id, + } + ); + match.lineup_2.lineup_players.Add( + new MatchMember + { + steam_id = "76561198000000002", + name = "St", + match_lineup_id = Lineup2Id, + } + ); + return match; + } + [Fact] public void GetTeamChatLineupId_ResolvesMemberLineup() { @@ -183,6 +205,15 @@ public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() ); } + [Fact] + public void GetTeamChatLineupId_PrefersExactSteamIdOverPlaceholderPrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildShadowedMemberMatch(), "76561198000000002", "St") + ); + } + [Theory] [InlineData("76561198000000011", "33333333-3333-3333-3333-333333333333")] [InlineData("76561198000000022", "44444444-4444-4444-4444-444444444444")] @@ -194,6 +225,27 @@ public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expec ); } + [Fact] + public void GetTeamChatLineupId_PrefersMemberLineupOverCoachSeat() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_2.coach_steam_id = "76561198000000001"; + + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(match, "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() + { + MatchData match = BuildTeamChatMatch(); + match.options.coaches = false; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000011", "Coach")); + } + [Fact] public void GetTeamChatLineupId_ReturnsNullForUnknownPlayer() { @@ -221,12 +273,95 @@ public void GetTeamChatLineupId_ReturnsNullWhenCoachLineupIsEmpty() } [Fact] - public void GetTeamChatLineupId_ToleratesLineupsWithoutCoach() + public void GetTeamChatLineupId_DoesNotMatchAnEmptySteamIdToAnEmptyCoachSeat() { MatchData match = BuildTeamChatMatch(); match.lineup_1.coach_steam_id = null!; match.lineup_2.coach_steam_id = ""; - Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000009999", "Nobody")); + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "", "Nobody")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullWhenApiDoesNotAdvertiseIt() + { + Assert.Null( + MatchUtility.GetTeamChatRelayLineupId(BuildTeamChatMatch(), "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatRelayLineupId_ResolvesWhenApiAdvertisesIt() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullForGaggedMember() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + match.lineup_1.lineup_players[0].is_gagged = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000001", "Real")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullForGaggedMemberShadowedByPlaceholder() + { + MatchData match = BuildShadowedMemberMatch(); + match.relay_team_chat = true; + match.lineup_2.lineup_players[1].is_gagged = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000002", "St")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullWhenLineupDoesNotResolve() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000009999", "Nobody")); + } + + [Fact] + public void ChatEventData_KeepsTheAllChatShape() + { + Dictionary data = MatchUtility.ChatEventData( + "76561198000000001", + "gl hf", + null + ); + + Assert.Equal( + new[] { "message", "player" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal("76561198000000001", data["player"]); + Assert.Equal("gl hf", data["message"]); + } + + [Fact] + public void ChatEventData_TagsTeamChatWithItsLineup() + { + Dictionary data = MatchUtility.ChatEventData( + "76561198000000001", + "stack b", + Lineup1Id.ToString() + ); + + Assert.Equal( + new[] { "lineupId", "message", "player", "teamOnly" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal(true, data["teamOnly"]); + Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); } } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs index f42985ea..e5b46c87 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs @@ -71,27 +71,20 @@ public HookResult OnPlayerChat(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } - // A gagged speaker is blocked by GagPlayer, which the chat hook runs - // after this and which also tells them why. private void RelayTeamChat(IPlayer player, string message) { MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); - if (matchData == null || !matchData.relay_team_chat) - { - return; - } - - string steamId = player.SteamID.ToString(); - - MatchMember? member = MatchUtility.GetMemberFromLineup(matchData, steamId, player.Name); - - if (member != null && member.is_gagged) + if (matchData == null) { return; } - string? lineupId = MatchUtility.GetTeamChatLineupId(matchData, steamId, player.Name); + string? lineupId = MatchUtility.GetTeamChatRelayLineupId( + matchData, + player.SteamID.ToString(), + player.Name + ); if (lineupId == null) { @@ -103,18 +96,9 @@ private void RelayTeamChat(IPlayer player, string message) private void PublishChatEvent(IPlayer player, string message, string? teamLineupId = null) { - Dictionary data = new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - }; - - if (teamLineupId != null) - { - data["teamOnly"] = true; - data["lineupId"] = teamLineupId; - } - - _matchEvents.PublishGameEvent("chat", data); + _matchEvents.PublishGameEvent( + "chat", + MatchUtility.ChatEventData(player.SteamID.ToString(), message, teamLineupId) + ); } } diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index 196ad3a9..ce81d0e9 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -48,39 +48,104 @@ string playerName }); } + public static string? GetTeamChatRelayLineupId( + MatchData matchData, + string steamId, + string playerName + ) + { + if (!matchData.relay_team_chat) + { + return null; + } + + bool gagged = + GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true + || matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Any(member => member.is_gagged && member.steam_id == steamId); + + if (gagged) + { + return null; + } + + return GetTeamChatLineupId(matchData, steamId, playerName); + } + + // A lineup_1 placeholder whose name prefixes a lineup_2 player would + // otherwise claim them, so exact steam ids are matched before names. public static string? GetTeamChatLineupId( MatchData matchData, string steamId, string playerName ) { + List players = matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .ToList(); + Guid lineupId = - GetMemberFromLineup(matchData, steamId, playerName)?.match_lineup_id ?? Guid.Empty; + players + .Find(member => + !string.IsNullOrEmpty(member.steam_id) && member.steam_id == steamId + ) + ?.match_lineup_id + ?? GetCoachLineupId(matchData, steamId) + ?? players + .Find(member => + member.steam_id == null && member.placeholder_name.StartsWith(playerName) + ) + ?.match_lineup_id + ?? Guid.Empty; if (lineupId == Guid.Empty) { - if ( - !string.IsNullOrEmpty(matchData.lineup_1.coach_steam_id) - && matchData.lineup_1.coach_steam_id == steamId - ) - { - lineupId = matchData.lineup_1.id; - } - else if ( - !string.IsNullOrEmpty(matchData.lineup_2.coach_steam_id) - && matchData.lineup_2.coach_steam_id == steamId - ) - { - lineupId = matchData.lineup_2.id; - } + return null; } - if (lineupId == Guid.Empty) + return lineupId.ToString(); + } + + private static Guid? GetCoachLineupId(MatchData matchData, string steamId) + { + if (!matchData.options.coaches || string.IsNullOrEmpty(steamId)) { return null; } - return lineupId.ToString(); + if (matchData.lineup_1.coach_steam_id == steamId) + { + return matchData.lineup_1.id; + } + + if (matchData.lineup_2.coach_steam_id == steamId) + { + return matchData.lineup_2.id; + } + + return null; + } + + public static Dictionary ChatEventData( + string steamId, + string message, + string? teamLineupId + ) + { + Dictionary data = new Dictionary + { + { "player", steamId }, + { "message", message }, + }; + + if (teamLineupId != null) + { + data["teamOnly"] = true; + data["lineupId"] = teamLineupId; + } + + return data; } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 2bfd8738..01d62b82 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -141,6 +141,28 @@ private static MatchData BuildTeamChatMatch() }; } + private static MatchData BuildShadowedMemberMatch() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.lineup_players.Add( + new MatchMember + { + steam_id = null, + placeholder_name = "Steve", + match_lineup_id = Lineup1Id, + } + ); + match.lineup_2.lineup_players.Add( + new MatchMember + { + steam_id = "76561198000000002", + name = "St", + match_lineup_id = Lineup2Id, + } + ); + return match; + } + [Fact] public void GetTeamChatLineupId_ResolvesMemberLineup() { @@ -159,6 +181,15 @@ public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() ); } + [Fact] + public void GetTeamChatLineupId_PrefersExactSteamIdOverPlaceholderPrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildShadowedMemberMatch(), "76561198000000002", "St") + ); + } + [Theory] [InlineData("76561198000000011", "33333333-3333-3333-3333-333333333333")] [InlineData("76561198000000022", "44444444-4444-4444-4444-444444444444")] @@ -170,6 +201,27 @@ public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expec ); } + [Fact] + public void GetTeamChatLineupId_PrefersMemberLineupOverCoachSeat() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_2.coach_steam_id = "76561198000000001"; + + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(match, "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() + { + MatchData match = BuildTeamChatMatch(); + match.options.coaches = false; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000011", "Coach")); + } + [Fact] public void GetTeamChatLineupId_ReturnsNullForUnknownPlayer() { @@ -197,12 +249,95 @@ public void GetTeamChatLineupId_ReturnsNullWhenCoachLineupIsEmpty() } [Fact] - public void GetTeamChatLineupId_ToleratesLineupsWithoutCoach() + public void GetTeamChatLineupId_DoesNotMatchAnEmptySteamIdToAnEmptyCoachSeat() { MatchData match = BuildTeamChatMatch(); match.lineup_1.coach_steam_id = null!; match.lineup_2.coach_steam_id = ""; - Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000009999", "Nobody")); + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "", "Nobody")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullWhenApiDoesNotAdvertiseIt() + { + Assert.Null( + MatchUtility.GetTeamChatRelayLineupId(BuildTeamChatMatch(), "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatRelayLineupId_ResolvesWhenApiAdvertisesIt() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000001", "Real") + ); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullForGaggedMember() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + match.lineup_1.lineup_players[0].is_gagged = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000001", "Real")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullForGaggedMemberShadowedByPlaceholder() + { + MatchData match = BuildShadowedMemberMatch(); + match.relay_team_chat = true; + match.lineup_2.lineup_players[1].is_gagged = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000000002", "St")); + } + + [Fact] + public void GetTeamChatRelayLineupId_ReturnsNullWhenLineupDoesNotResolve() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Null(MatchUtility.GetTeamChatRelayLineupId(match, "76561198000009999", "Nobody")); + } + + [Fact] + public void ChatEventData_KeepsTheAllChatShape() + { + Dictionary data = MatchUtility.ChatEventData( + "76561198000000001", + "gl hf", + null + ); + + Assert.Equal( + new[] { "message", "player" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal("76561198000000001", data["player"]); + Assert.Equal("gl hf", data["message"]); + } + + [Fact] + public void ChatEventData_TagsTeamChatWithItsLineup() + { + Dictionary data = MatchUtility.ChatEventData( + "76561198000000001", + "stack b", + Lineup1Id.ToString() + ); + + Assert.Equal( + new[] { "lineupId", "message", "player", "teamOnly" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal(true, data["teamOnly"]); + Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); } } From 6cf31309cc6ea70723ab28ea7597ebcce99734b3 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:08:59 -0400 Subject: [PATCH 3/5] bug: send team chat under its own event so an older api drops it --- .../src/FiveStack.Events/PlayerChat.cs | 9 ++++++--- .../src/FiveStack.Utilities/MatchUtility.cs | 14 +++++++++----- .../test/MatchUtilityTests.cs | 17 +++++++++++------ apps/swiftly/src/FiveStack.Events/PlayerChat.cs | 9 ++++++--- .../src/FiveStack.Utilities/MatchUtility.cs | 14 +++++++++----- apps/swiftly/test/MatchUtilityTests.cs | 17 +++++++++++------ 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index 5a8437c9..9d8bca80 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -117,9 +117,12 @@ private void PublishChatEvent( string? teamLineupId = null ) { - _matchEvents.PublishGameEvent( - "chat", - MatchUtility.ChatEventData(player.SteamID.ToString(), message, teamLineupId) + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + player.SteamID.ToString(), + message, + teamLineupId ); + + _matchEvents.PublishGameEvent(eventName, data); } } diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index c1887088..5a19f284 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -118,7 +118,10 @@ string playerName return null; } - public static Dictionary ChatEventData( + // Team lines go out under their own event so an api that predates + // them drops them as unknown, instead of treating them as all chat and + // posting them where the other team reads. + public static (string Event, Dictionary Data) ChatEvent( string steamId, string message, string? teamLineupId @@ -130,13 +133,14 @@ public static Dictionary ChatEventData( { "message", message }, }; - if (teamLineupId != null) + if (teamLineupId == null) { - data["teamOnly"] = true; - data["lineupId"] = teamLineupId; + return ("chat", data); } - return data; + data["lineupId"] = teamLineupId; + + return ("teamChat", data); } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index 0583fb9b..43240e3a 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -332,14 +332,15 @@ public void GetTeamChatRelayLineupId_ReturnsNullWhenLineupDoesNotResolve() } [Fact] - public void ChatEventData_KeepsTheAllChatShape() + public void ChatEvent_KeepsTheAllChatShape() { - Dictionary data = MatchUtility.ChatEventData( + (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", "gl hf", null ); + Assert.Equal("chat", eventName); Assert.Equal( new[] { "message", "player" }, data.Keys.OrderBy(key => key, StringComparer.Ordinal) @@ -348,20 +349,24 @@ public void ChatEventData_KeepsTheAllChatShape() Assert.Equal("gl hf", data["message"]); } + // an api that predates team chat drops an event it has no handler for, + // but would post anything sent as chat to the room both teams read [Fact] - public void ChatEventData_TagsTeamChatWithItsLineup() + public void ChatEvent_SendsTeamChatUnderItsOwnEvent() { - Dictionary data = MatchUtility.ChatEventData( + (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", "stack b", Lineup1Id.ToString() ); + Assert.Equal("teamChat", eventName); Assert.Equal( - new[] { "lineupId", "message", "player", "teamOnly" }, + new[] { "lineupId", "message", "player" }, data.Keys.OrderBy(key => key, StringComparer.Ordinal) ); - Assert.Equal(true, data["teamOnly"]); + Assert.Equal("76561198000000001", data["player"]); + Assert.Equal("stack b", data["message"]); Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); } } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs index e5b46c87..b13aaefa 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs @@ -96,9 +96,12 @@ private void RelayTeamChat(IPlayer player, string message) private void PublishChatEvent(IPlayer player, string message, string? teamLineupId = null) { - _matchEvents.PublishGameEvent( - "chat", - MatchUtility.ChatEventData(player.SteamID.ToString(), message, teamLineupId) + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + player.SteamID.ToString(), + message, + teamLineupId ); + + _matchEvents.PublishGameEvent(eventName, data); } } diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index ce81d0e9..ded4d133 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -127,7 +127,10 @@ string playerName return null; } - public static Dictionary ChatEventData( + // Team lines go out under their own event so an api that predates + // them drops them as unknown, instead of treating them as all chat and + // posting them where the other team reads. + public static (string Event, Dictionary Data) ChatEvent( string steamId, string message, string? teamLineupId @@ -139,13 +142,14 @@ public static Dictionary ChatEventData( { "message", message }, }; - if (teamLineupId != null) + if (teamLineupId == null) { - data["teamOnly"] = true; - data["lineupId"] = teamLineupId; + return ("chat", data); } - return data; + data["lineupId"] = teamLineupId; + + return ("teamChat", data); } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 01d62b82..618c8729 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -308,14 +308,15 @@ public void GetTeamChatRelayLineupId_ReturnsNullWhenLineupDoesNotResolve() } [Fact] - public void ChatEventData_KeepsTheAllChatShape() + public void ChatEvent_KeepsTheAllChatShape() { - Dictionary data = MatchUtility.ChatEventData( + (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", "gl hf", null ); + Assert.Equal("chat", eventName); Assert.Equal( new[] { "message", "player" }, data.Keys.OrderBy(key => key, StringComparer.Ordinal) @@ -324,20 +325,24 @@ public void ChatEventData_KeepsTheAllChatShape() Assert.Equal("gl hf", data["message"]); } + // an api that predates team chat drops an event it has no handler for, + // but would post anything sent as chat to the room both teams read [Fact] - public void ChatEventData_TagsTeamChatWithItsLineup() + public void ChatEvent_SendsTeamChatUnderItsOwnEvent() { - Dictionary data = MatchUtility.ChatEventData( + (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", "stack b", Lineup1Id.ToString() ); + Assert.Equal("teamChat", eventName); Assert.Equal( - new[] { "lineupId", "message", "player", "teamOnly" }, + new[] { "lineupId", "message", "player" }, data.Keys.OrderBy(key => key, StringComparer.Ordinal) ); - Assert.Equal(true, data["teamOnly"]); + Assert.Equal("76561198000000001", data["player"]); + Assert.Equal("stack b", data["message"]); Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); } } From b08f927b2ecae6752641a580b90fe5ad5137d317 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:27:12 -0400 Subject: [PATCH 4/5] bug: build team chat events in one tested place, drop the fail-open lineup default --- .../src/FiveStack.Events/PlayerChat.cs | 18 ++---- .../src/FiveStack.Utilities/MatchUtility.cs | 40 ++++++++---- .../test/MatchUtilityTests.cs | 64 +++++++++++++++---- .../src/FiveStack.Events/PlayerChat.cs | 14 ++-- .../src/FiveStack.Utilities/MatchUtility.cs | 40 ++++++++---- apps/swiftly/test/MatchUtilityTests.cs | 64 +++++++++++++++---- 6 files changed, 170 insertions(+), 70 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index 9d8bca80..54832196 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -97,30 +97,26 @@ private void RelayTeamChat(CCSPlayerController player, string message) return; } - string? lineupId = MatchUtility.GetTeamChatRelayLineupId( + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( matchData, player.SteamID.ToString(), - player.PlayerName + player.PlayerName, + message ); - if (lineupId == null) + if (teamChat == null) { return; } - PublishChatEvent(player, message, lineupId); + _matchEvents.PublishGameEvent(teamChat.Value.Event, teamChat.Value.Data); } - private void PublishChatEvent( - CCSPlayerController player, - string message, - string? teamLineupId = null - ) + private void PublishChatEvent(CCSPlayerController player, string message) { (string eventName, Dictionary data) = MatchUtility.ChatEvent( player.SteamID.ToString(), - message, - teamLineupId + message ); _matchEvents.PublishGameEvent(eventName, data); diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index 5a19f284..9b0afb57 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -118,29 +118,43 @@ string playerName return null; } + public static (string Event, Dictionary Data) ChatEvent( + string steamId, + string message + ) + { + return ( + "chat", + new Dictionary { { "player", steamId }, { "message", message } } + ); + } + // Team lines go out under their own event so an api that predates // them drops them as unknown, instead of treating them as all chat and // posting them where the other team reads. - public static (string Event, Dictionary Data) ChatEvent( + public static (string Event, Dictionary Data)? TeamChatEvent( + MatchData matchData, string steamId, - string message, - string? teamLineupId + string playerName, + string message ) { - Dictionary data = new Dictionary - { - { "player", steamId }, - { "message", message }, - }; + string? lineupId = GetTeamChatRelayLineupId(matchData, steamId, playerName); - if (teamLineupId == null) + if (lineupId == null) { - return ("chat", data); + return null; } - data["lineupId"] = teamLineupId; - - return ("teamChat", data); + return ( + "teamChat", + new Dictionary + { + { "player", steamId }, + { "message", message }, + { "lineupId", lineupId }, + } + ); } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index 43240e3a..d7b31493 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -336,8 +336,7 @@ public void ChatEvent_KeepsTheAllChatShape() { (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", - "gl hf", - null + "gl hf" ); Assert.Equal("chat", eventName); @@ -351,22 +350,61 @@ public void ChatEvent_KeepsTheAllChatShape() // an api that predates team chat drops an event it has no handler for, // but would post anything sent as chat to the room both teams read - [Fact] - public void ChatEvent_SendsTeamChatUnderItsOwnEvent() + [Theory] + [InlineData("76561198000000001", "Real", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000011", "Coach", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000022", "Coach", "44444444-4444-4444-4444-444444444444")] + public void TeamChatEvent_SendsTeamChatUnderItsOwnEvent( + string steamId, + string playerName, + string lineupId + ) { - (string eventName, Dictionary data) = MatchUtility.ChatEvent( - "76561198000000001", - "stack b", - Lineup1Id.ToString() + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + match, + steamId, + playerName, + "stack b" ); - Assert.Equal("teamChat", eventName); + Assert.NotNull(teamChat); + Assert.Equal("teamChat", teamChat.Value.Event); Assert.Equal( new[] { "lineupId", "message", "player" }, - data.Keys.OrderBy(key => key, StringComparer.Ordinal) + teamChat.Value.Data.Keys.OrderBy(key => key, StringComparer.Ordinal) ); - Assert.Equal("76561198000000001", data["player"]); - Assert.Equal("stack b", data["message"]); - Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); + Assert.Equal(steamId, teamChat.Value.Data["player"]); + Assert.Equal("stack b", teamChat.Value.Data["message"]); + Assert.Equal(lineupId, teamChat.Value.Data["lineupId"]); + } + + [Fact] + public void TeamChatEvent_SendsNothingWhenApiDoesNotAdvertiseIt() + { + Assert.Null( + MatchUtility.TeamChatEvent(BuildTeamChatMatch(), "76561198000000001", "Real", "stack b") + ); + } + + [Fact] + public void TeamChatEvent_SendsNothingForGaggedMember() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + match.lineup_1.lineup_players[0].is_gagged = true; + + Assert.Null(MatchUtility.TeamChatEvent(match, "76561198000000001", "Real", "stack b")); + } + + [Fact] + public void TeamChatEvent_SendsNothingForPlayerOffTheRoster() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Null(MatchUtility.TeamChatEvent(match, "76561198000009999", "Caster", "stack b")); } } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs index b13aaefa..44a674ca 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs @@ -80,26 +80,26 @@ private void RelayTeamChat(IPlayer player, string message) return; } - string? lineupId = MatchUtility.GetTeamChatRelayLineupId( + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( matchData, player.SteamID.ToString(), - player.Name + player.Name, + message ); - if (lineupId == null) + if (teamChat == null) { return; } - PublishChatEvent(player, message, lineupId); + _matchEvents.PublishGameEvent(teamChat.Value.Event, teamChat.Value.Data); } - private void PublishChatEvent(IPlayer player, string message, string? teamLineupId = null) + private void PublishChatEvent(IPlayer player, string message) { (string eventName, Dictionary data) = MatchUtility.ChatEvent( player.SteamID.ToString(), - message, - teamLineupId + message ); _matchEvents.PublishGameEvent(eventName, data); diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index ded4d133..08777b4d 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -127,29 +127,43 @@ string playerName return null; } + public static (string Event, Dictionary Data) ChatEvent( + string steamId, + string message + ) + { + return ( + "chat", + new Dictionary { { "player", steamId }, { "message", message } } + ); + } + // Team lines go out under their own event so an api that predates // them drops them as unknown, instead of treating them as all chat and // posting them where the other team reads. - public static (string Event, Dictionary Data) ChatEvent( + public static (string Event, Dictionary Data)? TeamChatEvent( + MatchData matchData, string steamId, - string message, - string? teamLineupId + string playerName, + string message ) { - Dictionary data = new Dictionary - { - { "player", steamId }, - { "message", message }, - }; + string? lineupId = GetTeamChatRelayLineupId(matchData, steamId, playerName); - if (teamLineupId == null) + if (lineupId == null) { - return ("chat", data); + return null; } - data["lineupId"] = teamLineupId; - - return ("teamChat", data); + return ( + "teamChat", + new Dictionary + { + { "player", steamId }, + { "message", message }, + { "lineupId", lineupId }, + } + ); } // A client presenting the raw match password is a streamer, unless the diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 618c8729..24204a09 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -312,8 +312,7 @@ public void ChatEvent_KeepsTheAllChatShape() { (string eventName, Dictionary data) = MatchUtility.ChatEvent( "76561198000000001", - "gl hf", - null + "gl hf" ); Assert.Equal("chat", eventName); @@ -327,22 +326,61 @@ public void ChatEvent_KeepsTheAllChatShape() // an api that predates team chat drops an event it has no handler for, // but would post anything sent as chat to the room both teams read - [Fact] - public void ChatEvent_SendsTeamChatUnderItsOwnEvent() + [Theory] + [InlineData("76561198000000001", "Real", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000011", "Coach", "33333333-3333-3333-3333-333333333333")] + [InlineData("76561198000000022", "Coach", "44444444-4444-4444-4444-444444444444")] + public void TeamChatEvent_SendsTeamChatUnderItsOwnEvent( + string steamId, + string playerName, + string lineupId + ) { - (string eventName, Dictionary data) = MatchUtility.ChatEvent( - "76561198000000001", - "stack b", - Lineup1Id.ToString() + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + match, + steamId, + playerName, + "stack b" ); - Assert.Equal("teamChat", eventName); + Assert.NotNull(teamChat); + Assert.Equal("teamChat", teamChat.Value.Event); Assert.Equal( new[] { "lineupId", "message", "player" }, - data.Keys.OrderBy(key => key, StringComparer.Ordinal) + teamChat.Value.Data.Keys.OrderBy(key => key, StringComparer.Ordinal) ); - Assert.Equal("76561198000000001", data["player"]); - Assert.Equal("stack b", data["message"]); - Assert.Equal(Lineup1Id.ToString(), data["lineupId"]); + Assert.Equal(steamId, teamChat.Value.Data["player"]); + Assert.Equal("stack b", teamChat.Value.Data["message"]); + Assert.Equal(lineupId, teamChat.Value.Data["lineupId"]); + } + + [Fact] + public void TeamChatEvent_SendsNothingWhenApiDoesNotAdvertiseIt() + { + Assert.Null( + MatchUtility.TeamChatEvent(BuildTeamChatMatch(), "76561198000000001", "Real", "stack b") + ); + } + + [Fact] + public void TeamChatEvent_SendsNothingForGaggedMember() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + match.lineup_1.lineup_players[0].is_gagged = true; + + Assert.Null(MatchUtility.TeamChatEvent(match, "76561198000000001", "Real", "stack b")); + } + + [Fact] + public void TeamChatEvent_SendsNothingForPlayerOffTheRoster() + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + Assert.Null(MatchUtility.TeamChatEvent(match, "76561198000009999", "Caster", "stack b")); } } From 610a8708d6206e6f1e06bf138702ee0ef7d160db Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:22:29 -0400 Subject: [PATCH 5/5] test: prove a coach's team chat is not claimed by an opposing placeholder --- apps/counterstrikesharp/test/MatchUtilityTests.cs | 9 +++++++++ apps/swiftly/test/MatchUtilityTests.cs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index d7b31493..a8a58326 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -237,6 +237,15 @@ public void GetTeamChatLineupId_PrefersMemberLineupOverCoachSeat() ); } + [Fact] + public void GetTeamChatLineupId_PrefersCoachSeatOverPlaceholderPrefix() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000011", "Ace") + ); + } + [Fact] public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() { diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 24204a09..dd0aa54a 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -213,6 +213,15 @@ public void GetTeamChatLineupId_PrefersMemberLineupOverCoachSeat() ); } + [Fact] + public void GetTeamChatLineupId_PrefersCoachSeatOverPlaceholderPrefix() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000011", "Ace") + ); + } + [Fact] public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() {