feat: Added in-game teams support

This commit is contained in:
Anon 2026-03-31 02:05:40 +02:00 committed by GitHub
commit 5f9549586a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 483 additions and 1 deletions

View file

@ -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<CmdResult> 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<string, PlayerTeam> 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());
}
}
}

View file

@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace MinecraftClient.Mapping
{
/// <summary>
/// Represents a Minecraft scoreboard team and its current state.
/// </summary>
public class PlayerTeam
{
/// <summary>Team internal name (up to 16 chars)</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Display name component (formatted text)</summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>Friendly fire is allowed between team members</summary>
public bool AllowFriendlyFire { get; set; }
/// <summary>Team members can see invisible teammates</summary>
public bool SeeFriendlyInvisibles { get; set; }
/// <summary>
/// Nametag visibility rule.
/// Values: "always", "hideForOtherTeams", "hideForOwnTeam", "never"
/// </summary>
public string NameTagVisibility { get; set; } = string.Empty;
/// <summary>
/// Collision rule.
/// Values: "always", "pushOtherTeams", "pushOwnTeam", "never"
/// </summary>
public string CollisionRule { get; set; } = string.Empty;
/// <summary>
/// Team color as ChatFormatting enum ordinal (-1 = RESET/none,
/// 015 = BLACK … WHITE).
/// </summary>
public int Color { get; set; } = -1;
/// <summary>Prefix displayed before member names (formatted text)</summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>Suffix displayed after member names (formatted text)</summary>
public string Suffix { get; set; } = string.Empty;
/// <summary>Current set of player / entity names on this team</summary>
public HashSet<string> Members { get; } = new(System.StringComparer.OrdinalIgnoreCase);
}
}

View file

@ -113,6 +113,9 @@ namespace MinecraftClient
// player attributes (e.g., block_break_speed, mining_efficiency, submerged_mining_speed)
private readonly Dictionary<string, double> playerAttributes = new();
// scoreboard teams (key = team name)
private readonly Dictionary<string, PlayerTeam> teams = new(StringComparer.Ordinal);
// Sneaking
public bool IsSneaking { get; set; } = false;
@ -162,6 +165,30 @@ namespace MinecraftClient
return new Dictionary<Effects, EffectData>(playerEffects);
}
/// <summary>
/// Get a snapshot of all known scoreboard teams.
/// </summary>
/// <returns>Dictionary mapping team name to <see cref="PlayerTeam"/></returns>
public Dictionary<string, PlayerTeam> GetTeams()
{
lock (teams)
return new Dictionary<string, PlayerTeam>(teams, StringComparer.Ordinal);
}
/// <summary>
/// Get the team that contains the given player/entity name, or <c>null</c> if not found.
/// </summary>
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));
}
/// <summary>
/// Called when a Teams packet is received. Updates the internal team state and notifies bots.
/// </summary>
public void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> 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));
}
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>

View file

@ -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<string>();
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;

View file

@ -489,6 +489,23 @@ namespace MinecraftClient.Protocol
/// <param name="numberFormat">Number format: 0 - blank, 1 - styled, 2 - fixed</param>
void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int objectiveValue, int numberFormat);
/// <summary>
/// Called when a Teams packet is received from the server.
/// </summary>
/// <param name="teamName">Internal team name (up to 16 chars)</param>
/// <param name="method">0=create, 1=remove, 2=update, 3=add players, 4=remove players</param>
/// <param name="displayName">Display name (formatted). Present when method is 0 or 2.</param>
/// <param name="friendlyFlags">Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2.</param>
/// <param name="nameTagVisibility">Nametag visibility rule string. Present when method is 0 or 2.</param>
/// <param name="collisionRule">Collision rule string. Present when method is 0 or 2.</param>
/// <param name="color">ChatFormatting color value (-1=none). Present when method is 0 or 2.</param>
/// <param name="prefix">Member name prefix (formatted). Present when method is 0 or 2.</param>
/// <param name="suffix">Member name suffix (formatted). Present when method is 0 or 2.</param>
/// <param name="players">Player/entity names. Present when method is 0, 3, or 4.</param>
void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> players);
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>

View file

@ -4660,6 +4660,51 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to List all scoreboard teams and their members.
/// </summary>
internal static string cmd_teams_desc {
get {
return ResourceManager.GetString("cmd.teams.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No teams are currently tracked.
/// </summary>
internal static string cmd_teams_no_teams {
get {
return ResourceManager.GetString("cmd.teams.no_teams", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Team '{0}' (display: {1}, ...).
/// </summary>
internal static string cmd_teams_team_header {
get {
return ResourceManager.GetString("cmd.teams.team_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Members ({0}): {1}.
/// </summary>
internal static string cmd_teams_team_members {
get {
return ResourceManager.GetString("cmd.teams.team_members", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No members.
/// </summary>
internal static string cmd_teams_team_no_members {
get {
return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Place a block or open chest.
/// </summary>

View file

@ -1562,6 +1562,21 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
<data name="cmd.tps.desc" xml:space="preserve">
<value>Display server current tps (tick per second). May not be accurate</value>
</data>
<data name="cmd.teams.desc" xml:space="preserve">
<value>List all scoreboard teams and their members.</value>
</data>
<data name="cmd.teams.no_teams" xml:space="preserve">
<value>No teams are currently tracked.</value>
</data>
<data name="cmd.teams.team_header" xml:space="preserve">
<value>Team '{0}' (display: {1}, color: {2}, prefix: '{3}', suffix: '{4}', nameTagVisibility: {5}, collisionRule: {6}, friendlyFire: {7}, seeInvisibles: {8})</value>
</data>
<data name="cmd.teams.team_members" xml:space="preserve">
<value> Members ({0}): {1}</value>
</data>
<data name="cmd.teams.team_no_members" xml:space="preserve">
<value> No members.</value>
</data>
<data name="cmd.useblock.desc" xml:space="preserve">
<value>Place a block or open chest</value>
</data>

View file

@ -384,6 +384,23 @@ namespace MinecraftClient.Scripting
/// <param name="numberFormat">Number format: 0 - blank, 1 - styled, 2 - fixed</param>
public virtual void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int value, int numberFormat) { }
/// <summary>
/// Called when a Teams packet is received from the server.
/// </summary>
/// <param name="teamName">Internal team name (up to 16 chars)</param>
/// <param name="method">0=create, 1=remove, 2=update, 3=add players, 4=remove players</param>
/// <param name="displayName">Display name (formatted). Present when method is 0 or 2.</param>
/// <param name="friendlyFlags">Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2.</param>
/// <param name="nameTagVisibility">Nametag visibility rule. Present when method is 0 or 2.</param>
/// <param name="collisionRule">Collision rule. Present when method is 0 or 2.</param>
/// <param name="color">ChatFormatting color value (-1=none). Present when method is 0 or 2.</param>
/// <param name="prefix">Member name prefix (formatted). Present when method is 0 or 2.</param>
/// <param name="suffix">Member name suffix (formatted). Present when method is 0 or 2.</param>
/// <param name="players">Player/entity names. Present when method is 0, 3, or 4.</param>
public virtual void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> players) { }
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>

View file

@ -281,6 +281,68 @@ public class AchievementWatcher : ChatBot
}
```
## Scoreboard teams
Chat bots and C# scripts can read the current team state and react to team changes.
Useful methods and events:
- `GetTeams()` - returns a snapshot of all teams the server has sent
- `GetPlayerTeam(playerName)` - returns the team a specific player is on, or `null`
- `OnTeam(teamName, method, displayName, friendlyFlags, nameTagVisibility, collisionRule, color, prefix, suffix, players)` - called whenever a team packet arrives
The `method` byte tells you what changed:
- `0` - team created (includes full parameters and initial member list)
- `1` - team removed
- `2` - team parameters updated (display name, colors, rules)
- `3` - players added to the team
- `4` - players removed from the team
The `color` field is a `ChatFormatting` enum ordinal. Common values: `0`=black, `9`=blue, `10`=green, `12`=red, `14`=yellow, `-1`=none/reset.
The `nameTagVisibility` and `collisionRule` strings take values from the Minecraft wiki: `"always"`, `"never"`, `"hideForOtherTeams"`, `"hideForOwnTeam"` (visibility) or `"pushOtherTeams"`, `"pushOwnTeam"` (collision).
Example:
```csharp
//MCCScript 1.0
MCC.LoadBot(new TeamWatcher());
//MCCScript Extensions
public class TeamWatcher : ChatBot
{
public override void AfterGameJoined()
{
foreach (var team in GetTeams().Values)
LogToConsole($"Team '{team.Name}' has {team.Members.Count} member(s)");
}
public override void OnTeam(string teamName, byte method, string displayName,
byte friendlyFlags, string nameTagVisibility, string collisionRule,
int color, string prefix, string suffix, List<string> players)
{
switch (method)
{
case 0:
LogToConsole($"Team '{teamName}' created with {players.Count} member(s)");
break;
case 1:
LogToConsole($"Team '{teamName}' removed");
break;
case 3:
LogToConsole($"{string.Join(", ", players)} joined team '{teamName}'");
break;
case 4:
LogToConsole($"{string.Join(", ", players)} left team '{teamName}'");
break;
}
}
}
```
## C# API
The authoritative reference for the C# API is [ChatBot.cs](https://github.com/MCCTeam/Minecraft-Console-Client/blob/master/MinecraftClient/Scripting/ChatBot.cs).

View file

@ -953,6 +953,30 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details>
<details>
<summary><code>teams</code></summary>
- **Description:**
List all scoreboard teams the server has sent, along with their members and settings.
- **Usage:**
```
/teams
```
- **Example output:**
```
Team 'RedTeam' (display: RedTeam, color: 12, prefix: '', suffix: '', nameTagVisibility: always, collisionRule: always, friendlyFire: True, seeInvisibles: True)
Members (2): Steve, Alex
Team 'BlueTeam' (display: BlueTeam, color: 9, prefix: '', suffix: '', nameTagVisibility: always, collisionRule: always, friendlyFire: True, seeInvisibles: True)
No members.
```
</details>
<details>
<summary><code>useitem</code></summary>