diff --git a/MinecraftClient/Commands/Teams.cs b/MinecraftClient/Commands/Teams.cs new file mode 100644 index 00000000..af7a06d1 --- /dev/null +++ b/MinecraftClient/Commands/Teams.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Commands +{ + public class Teams : Command + { + public override string CmdName => "teams"; + public override string CmdUsage => "teams"; + public override string CmdDesc => Translations.cmd_teams_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source, string.Empty)) + ) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => DoListTeams(r.Source)) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source, string.Empty)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r, string? cmd) + { + return r.SetAndReturn(cmd switch + { +#pragma warning disable format // @formatter:off + _ => GetCmdDescTranslated(), +#pragma warning restore format // @formatter:on + }); + } + + private static int DoListTeams(CmdResult r) + { + McClient handler = CmdResult.currentHandler!; + Dictionary snapshot = handler.GetTeams(); + + if (snapshot.Count == 0) + return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_teams_no_teams); + + var sb = new StringBuilder(); + foreach (var team in snapshot.Values.OrderBy(static t => t.Name, StringComparer.Ordinal)) + { + sb.AppendLine(string.Format(Translations.cmd_teams_team_header, + team.Name, + team.DisplayName, + team.Color, + team.Prefix, + team.Suffix, + team.NameTagVisibility, + team.CollisionRule, + team.AllowFriendlyFire, + team.SeeFriendlyInvisibles)); + + if (team.Members.Count == 0) + sb.AppendLine(Translations.cmd_teams_team_no_members); + else + sb.AppendLine(string.Format(Translations.cmd_teams_team_members, + team.Members.Count, + string.Join(", ", team.Members.OrderBy(static m => m, StringComparer.OrdinalIgnoreCase)))); + } + + return r.SetAndReturn(CmdResult.Status.Done, sb.ToString().TrimEnd()); + } + } +} diff --git a/MinecraftClient/Mapping/PlayerTeam.cs b/MinecraftClient/Mapping/PlayerTeam.cs new file mode 100644 index 00000000..4ad7ca64 --- /dev/null +++ b/MinecraftClient/Mapping/PlayerTeam.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Mapping +{ + /// + /// Represents a Minecraft scoreboard team and its current state. + /// + public class PlayerTeam + { + /// Team internal name (up to 16 chars) + public string Name { get; set; } = string.Empty; + + /// Display name component (formatted text) + public string DisplayName { get; set; } = string.Empty; + + /// Friendly fire is allowed between team members + public bool AllowFriendlyFire { get; set; } + + /// Team members can see invisible teammates + public bool SeeFriendlyInvisibles { get; set; } + + /// + /// Nametag visibility rule. + /// Values: "always", "hideForOtherTeams", "hideForOwnTeam", "never" + /// + public string NameTagVisibility { get; set; } = string.Empty; + + /// + /// Collision rule. + /// Values: "always", "pushOtherTeams", "pushOwnTeam", "never" + /// + public string CollisionRule { get; set; } = string.Empty; + + /// + /// Team color as ChatFormatting enum ordinal (-1 = RESET/none, + /// 0–15 = BLACK … WHITE). + /// + public int Color { get; set; } = -1; + + /// Prefix displayed before member names (formatted text) + public string Prefix { get; set; } = string.Empty; + + /// Suffix displayed after member names (formatted text) + public string Suffix { get; set; } = string.Empty; + + /// Current set of player / entity names on this team + public HashSet Members { get; } = new(System.StringComparer.OrdinalIgnoreCase); + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index e1079c3c..394d01cd 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -113,6 +113,9 @@ namespace MinecraftClient // player attributes (e.g., block_break_speed, mining_efficiency, submerged_mining_speed) private readonly Dictionary playerAttributes = new(); + + // scoreboard teams (key = team name) + private readonly Dictionary teams = new(StringComparer.Ordinal); // Sneaking public bool IsSneaking { get; set; } = false; @@ -162,6 +165,30 @@ namespace MinecraftClient return new Dictionary(playerEffects); } + /// + /// Get a snapshot of all known scoreboard teams. + /// + /// Dictionary mapping team name to + public Dictionary GetTeams() + { + lock (teams) + return new Dictionary(teams, StringComparer.Ordinal); + } + + /// + /// Get the team that contains the given player/entity name, or null if not found. + /// + public PlayerTeam? GetPlayerTeam(string playerName) + { + lock (teams) + { + foreach (var team in teams.Values) + if (team.Members.Contains(playerName)) + return team; + return null; + } + } + public int GetLevel() { return playerLevel; } public int GetTotalExperience() { return playerTotalExperience; } public byte GetCurrentSlot() { return CurrentSlot; } @@ -4053,7 +4080,78 @@ namespace MinecraftClient { DispatchBotEvent(bot => bot.OnUpdateScore(entityName, action, objectiveName, objectiveDisplayName, objectiveValue, numberFormat)); } - + + /// + /// Called when a Teams packet is received. Updates the internal team state and notifies bots. + /// + public void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags, + string nameTagVisibility, string collisionRule, int color, + string prefix, string suffix, List players) + { + lock (teams) + { + switch (method) + { + case 0: // create + var newTeam = new PlayerTeam + { + Name = teamName, + DisplayName = displayName, + AllowFriendlyFire = (friendlyFlags & 0x01) != 0, + SeeFriendlyInvisibles = (friendlyFlags & 0x02) != 0, + NameTagVisibility = nameTagVisibility, + CollisionRule = collisionRule, + Color = color, + Prefix = prefix, + Suffix = suffix + }; + foreach (var p in players) + newTeam.Members.Add(p); + teams[teamName] = newTeam; + break; + + case 1: // remove + teams.Remove(teamName); + break; + + case 2: // update parameters + if (!teams.TryGetValue(teamName, out var updateTeam)) + { + updateTeam = new PlayerTeam { Name = teamName }; + teams[teamName] = updateTeam; + } + updateTeam.DisplayName = displayName; + updateTeam.AllowFriendlyFire = (friendlyFlags & 0x01) != 0; + updateTeam.SeeFriendlyInvisibles = (friendlyFlags & 0x02) != 0; + updateTeam.NameTagVisibility = nameTagVisibility; + updateTeam.CollisionRule = collisionRule; + updateTeam.Color = color; + updateTeam.Prefix = prefix; + updateTeam.Suffix = suffix; + break; + + case 3: // add players + if (!teams.TryGetValue(teamName, out var addTeam)) + { + addTeam = new PlayerTeam { Name = teamName }; + teams[teamName] = addTeam; + } + foreach (var p in players) + addTeam.Members.Add(p); + break; + + case 4: // remove players + if (teams.TryGetValue(teamName, out var removeTeam)) + foreach (var p in players) + removeTeam.Members.Remove(p); + break; + } + } + DispatchBotEvent(bot => bot.OnTeam(teamName, method, displayName, friendlyFlags, + nameTagVisibility, collisionRule, color, prefix, suffix, players)); + } + + /// /// Called when the client received the Tab Header and Footer /// diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index a27c4b0a..0085725b 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -3035,6 +3035,84 @@ namespace MinecraftClient.Protocol.Handlers handler.OnUpdateScore(entityName, action3, objectiveName3, objectiveDisplayName3, objectiveValue2, numberFormat2); break; + case PacketTypesIn.Teams: + // Wire format per version: + // All versions: name (string), method (byte) + // method 0/2: displayName (component), options (byte), + // nameTagVisibility, collisionRule, color (VarInt), + // prefix (component), suffix (component) + // method 0/3/4: players list (VarInt count + strings) + // 1.21.9+ (protocol 773): nameTagVisibility and collisionRule are + // VarInt-encoded enum IDs instead of UTF strings. + var teamName = dataTypes.ReadNextString(packetData); + var teamMethod = dataTypes.ReadNextByte(packetData); + + var teamDisplayName = string.Empty; + byte teamFriendlyFlags = 0; + var teamNameTagVisibility = string.Empty; + var teamCollisionRule = string.Empty; + var teamColor = -1; + var teamPrefix = string.Empty; + var teamSuffix = string.Empty; + + if (teamMethod is 0 or 2) + { + teamDisplayName = dataTypes.ReadNextChat(packetData); + teamFriendlyFlags = dataTypes.ReadNextByte(packetData); + + // nameTagVisibility + if (protocolVersion >= MC_1_21_9_Version) + { + // STREAM_CODEC: 0=always, 1=never, 2=hideForOtherTeams, 3=hideForOwnTeam + teamNameTagVisibility = dataTypes.ReadNextVarInt(packetData) switch + { + 0 => "always", + 1 => "never", + 2 => "hideForOtherTeams", + 3 => "hideForOwnTeam", + _ => "always" + }; + } + else + { + teamNameTagVisibility = dataTypes.ReadNextString(packetData); + } + + // collisionRule + if (protocolVersion >= MC_1_21_9_Version) + { + // STREAM_CODEC: 0=always, 1=never, 2=pushOtherTeams, 3=pushOwnTeam + teamCollisionRule = dataTypes.ReadNextVarInt(packetData) switch + { + 0 => "always", + 1 => "never", + 2 => "pushOtherTeams", + 3 => "pushOwnTeam", + _ => "always" + }; + } + else + { + teamCollisionRule = dataTypes.ReadNextString(packetData); + } + + teamColor = dataTypes.ReadNextVarInt(packetData); + teamPrefix = dataTypes.ReadNextChat(packetData); + teamSuffix = dataTypes.ReadNextChat(packetData); + } + + var teamPlayers = new List(); + if (teamMethod is 0 or 3 or 4) + { + int playerCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < playerCount; i++) + teamPlayers.Add(dataTypes.ReadNextString(packetData)); + } + + handler.OnTeam(teamName, teamMethod, teamDisplayName, teamFriendlyFlags, + teamNameTagVisibility, teamCollisionRule, teamColor, + teamPrefix, teamSuffix, teamPlayers); + break; case PacketTypesIn.BlockChangedAck: handler.OnBlockChangeAck(dataTypes.ReadNextVarInt(packetData)); break; diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index 13f5a628..bfaebe1d 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -489,6 +489,23 @@ namespace MinecraftClient.Protocol /// Number format: 0 - blank, 1 - styled, 2 - fixed void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int objectiveValue, int numberFormat); + /// + /// Called when a Teams packet is received from the server. + /// + /// Internal team name (up to 16 chars) + /// 0=create, 1=remove, 2=update, 3=add players, 4=remove players + /// Display name (formatted). Present when method is 0 or 2. + /// Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2. + /// Nametag visibility rule string. Present when method is 0 or 2. + /// Collision rule string. Present when method is 0 or 2. + /// ChatFormatting color value (-1=none). Present when method is 0 or 2. + /// Member name prefix (formatted). Present when method is 0 or 2. + /// Member name suffix (formatted). Present when method is 0 or 2. + /// Player/entity names. Present when method is 0, 3, or 4. + void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags, + string nameTagVisibility, string collisionRule, int color, + string prefix, string suffix, List players); + /// /// Called when the client received the Tab Header and Footer /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index de6784ae..96e143d3 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -4660,6 +4660,51 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to List all scoreboard teams and their members. + /// + internal static string cmd_teams_desc { + get { + return ResourceManager.GetString("cmd.teams.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No teams are currently tracked. + /// + internal static string cmd_teams_no_teams { + get { + return ResourceManager.GetString("cmd.teams.no_teams", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Team '{0}' (display: {1}, ...). + /// + internal static string cmd_teams_team_header { + get { + return ResourceManager.GetString("cmd.teams.team_header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Members ({0}): {1}. + /// + internal static string cmd_teams_team_members { + get { + return ResourceManager.GetString("cmd.teams.team_members", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No members. + /// + internal static string cmd_teams_team_no_members { + get { + return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture); + } + } + /// /// Looks up a localized string similar to Place a block or open chest. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index fddf6400..f25a6165 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1562,6 +1562,21 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s Display server current tps (tick per second). May not be accurate + + List all scoreboard teams and their members. + + + No teams are currently tracked. + + + Team '{0}' (display: {1}, color: {2}, prefix: '{3}', suffix: '{4}', nameTagVisibility: {5}, collisionRule: {6}, friendlyFire: {7}, seeInvisibles: {8}) + + + Members ({0}): {1} + + + No members. + Place a block or open chest diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index c5950334..3dc27c40 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -384,6 +384,23 @@ namespace MinecraftClient.Scripting /// Number format: 0 - blank, 1 - styled, 2 - fixed public virtual void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int value, int numberFormat) { } + /// + /// Called when a Teams packet is received from the server. + /// + /// Internal team name (up to 16 chars) + /// 0=create, 1=remove, 2=update, 3=add players, 4=remove players + /// Display name (formatted). Present when method is 0 or 2. + /// Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2. + /// Nametag visibility rule. Present when method is 0 or 2. + /// Collision rule. Present when method is 0 or 2. + /// ChatFormatting color value (-1=none). Present when method is 0 or 2. + /// Member name prefix (formatted). Present when method is 0 or 2. + /// Member name suffix (formatted). Present when method is 0 or 2. + /// Player/entity names. Present when method is 0, 3, or 4. + public virtual void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags, + string nameTagVisibility, string collisionRule, int color, + string prefix, string suffix, List players) { } + /// /// Called when the client received the Tab Header and Footer ///