diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index b8008bd0..54832196 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -9,21 +9,47 @@ 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); + + // 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; + } + 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 +83,42 @@ public HookResult OnPlayerChat(CCSPlayerController? player, CommandInfo info) } } - PublishChatEvent(player, info.ArgString.Trim('"')); + PublishChatEvent(player, message); return HookResult.Continue; } + private void RelayTeamChat(CCSPlayerController player, string message) + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null) + { + return; + } + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + matchData, + player.SteamID.ToString(), + player.PlayerName, + message + ); + + if (teamChat == null) + { + return; + } + + _matchEvents.PublishGameEvent(teamChat.Value.Event, teamChat.Value.Data); + } + private void PublishChatEvent(CCSPlayerController player, string message) { - _matchEvents.PublishGameEvent( - "chat", - new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - } + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + player.SteamID.ToString(), + message ); + + _matchEvents.PublishGameEvent(eventName, data); } } diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index b7b912ad..9b0afb57 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -39,6 +39,124 @@ 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 = + 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) + { + return null; + } + + return lineupId.ToString(); + } + + private static Guid? GetCoachLineupId(MatchData matchData, string steamId) + { + if (!matchData.options.coaches || string.IsNullOrEmpty(steamId)) + { + return null; + } + + 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 (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)? TeamChatEvent( + MatchData matchData, + string steamId, + string playerName, + string message + ) + { + string? lineupId = GetTeamChatRelayLineupId(matchData, steamId, playerName); + + if (lineupId == null) + { + return null; + } + + return ( + "teamChat", + new Dictionary + { + { "player", steamId }, + { "message", message }, + { "lineupId", lineupId }, + } + ); + } + // 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..a8a58326 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -126,4 +126,294 @@ 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, + }, + }, + }, + }; + } + + 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() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000001", "ignored") + ); + } + + [Fact] + public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "9999", "Ace") + ); + } + + [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")] + public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expected) + { + Assert.Equal( + expected, + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), steamId, "Coach") + ); + } + + [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_PrefersCoachSeatOverPlaceholderPrefix() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000011", "Ace") + ); + } + + [Fact] + public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() + { + MatchData match = BuildTeamChatMatch(); + match.options.coaches = false; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000011", "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_DoesNotMatchAnEmptySteamIdToAnEmptyCoachSeat() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.coach_steam_id = null!; + match.lineup_2.coach_steam_id = ""; + + 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 ChatEvent_KeepsTheAllChatShape() + { + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + "76561198000000001", + "gl hf" + ); + + Assert.Equal("chat", eventName); + Assert.Equal( + new[] { "message", "player" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal("76561198000000001", data["player"]); + 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 + [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 + ) + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + match, + steamId, + playerName, + "stack b" + ); + + Assert.NotNull(teamChat); + Assert.Equal("teamChat", teamChat.Value.Event); + Assert.Equal( + new[] { "lineupId", "message", "player" }, + teamChat.Value.Data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + 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 aacaaa30..44a674ca 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,37 @@ public HookResult OnPlayerChat(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } + private void RelayTeamChat(IPlayer player, string message) + { + MatchData? matchData = _matchService.GetCurrentMatch()?.GetMatchData(); + + if (matchData == null) + { + return; + } + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + matchData, + player.SteamID.ToString(), + player.Name, + message + ); + + if (teamChat == null) + { + return; + } + + _matchEvents.PublishGameEvent(teamChat.Value.Event, teamChat.Value.Data); + } + private void PublishChatEvent(IPlayer player, string message) { - _matchEvents.PublishGameEvent( - "chat", - new Dictionary - { - { "player", player.SteamID.ToString() }, - { "message", message }, - } + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + player.SteamID.ToString(), + message ); + + _matchEvents.PublishGameEvent(eventName, data); } } diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index 7cd87f4c..08777b4d 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -48,6 +48,124 @@ 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 = + 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) + { + return null; + } + + return lineupId.ToString(); + } + + private static Guid? GetCoachLineupId(MatchData matchData, string steamId) + { + if (!matchData.options.coaches || string.IsNullOrEmpty(steamId)) + { + return null; + } + + 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 (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)? TeamChatEvent( + MatchData matchData, + string steamId, + string playerName, + string message + ) + { + string? lineupId = GetTeamChatRelayLineupId(matchData, steamId, playerName); + + if (lineupId == null) + { + return null; + } + + return ( + "teamChat", + new Dictionary + { + { "player", steamId }, + { "message", message }, + { "lineupId", lineupId }, + } + ); + } + // 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..dd0aa54a 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -102,4 +102,294 @@ 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, + }, + }, + }, + }; + } + + 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() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000001", "ignored") + ); + } + + [Fact] + public void GetTeamChatLineupId_ResolvesPlaceholderByNamePrefix() + { + Assert.Equal( + Lineup2Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "9999", "Ace") + ); + } + + [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")] + public void GetTeamChatLineupId_ResolvesCoachLineup(string steamId, string expected) + { + Assert.Equal( + expected, + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), steamId, "Coach") + ); + } + + [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_PrefersCoachSeatOverPlaceholderPrefix() + { + Assert.Equal( + Lineup1Id.ToString(), + MatchUtility.GetTeamChatLineupId(BuildTeamChatMatch(), "76561198000000011", "Ace") + ); + } + + [Fact] + public void GetTeamChatLineupId_IgnoresCoachWhenCoachesAreDisabled() + { + MatchData match = BuildTeamChatMatch(); + match.options.coaches = false; + + Assert.Null(MatchUtility.GetTeamChatLineupId(match, "76561198000000011", "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_DoesNotMatchAnEmptySteamIdToAnEmptyCoachSeat() + { + MatchData match = BuildTeamChatMatch(); + match.lineup_1.coach_steam_id = null!; + match.lineup_2.coach_steam_id = ""; + + 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 ChatEvent_KeepsTheAllChatShape() + { + (string eventName, Dictionary data) = MatchUtility.ChatEvent( + "76561198000000001", + "gl hf" + ); + + Assert.Equal("chat", eventName); + Assert.Equal( + new[] { "message", "player" }, + data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + Assert.Equal("76561198000000001", data["player"]); + 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 + [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 + ) + { + MatchData match = BuildTeamChatMatch(); + match.relay_team_chat = true; + + (string Event, Dictionary Data)? teamChat = MatchUtility.TeamChatEvent( + match, + steamId, + playerName, + "stack b" + ); + + Assert.NotNull(teamChat); + Assert.Equal("teamChat", teamChat.Value.Event); + Assert.Equal( + new[] { "lineupId", "message", "player" }, + teamChat.Value.Data.Keys.OrderBy(key => key, StringComparer.Ordinal) + ); + 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/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;