Merge origin/master into feat/mcp-server

This commit is contained in:
Anon 2026-04-03 20:03:37 +02:00
commit f4c160979c
86 changed files with 16765 additions and 2758 deletions

View file

@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace MinecraftClient
{
/// <summary>
/// The type of an achievement or advancement.
/// </summary>
public enum AchievementType
{
Task,
Challenge,
Goal,
Legacy
}
/// <summary>
/// Represents a Minecraft achievement (pre-1.12) or advancement (1.12+).
/// </summary>
/// <param name="Id">Resource identifier, e.g. "minecraft:story/root" or "achievement.openInventory"</param>
/// <param name="Title">Display title (null for legacy achievements without display info)</param>
/// <param name="Description">Display description (null for legacy achievements without display info)</param>
/// <param name="Type">The frame type / achievement category</param>
/// <param name="IsHidden">Whether this advancement is hidden in the UI</param>
/// <param name="IsCompleted">Whether all requirements have been met</param>
/// <param name="Requirements">OR-groups of criterion names; all groups must be satisfied</param>
/// <param name="CriteriaProgress">Per-criterion completion status</param>
public record Achievement(
string Id,
string? Title,
string? Description,
AchievementType Type,
bool IsHidden,
bool IsCompleted,
IReadOnlyList<IReadOnlyList<string>> Requirements,
IReadOnlyDictionary<string, bool> CriteriaProgress);
}

View file

@ -5,7 +5,9 @@ using System.Threading;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.CommandHandler.Patch;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
using MinecraftClient.Scripting;
using Tomlet.Attributes;
@ -25,15 +27,12 @@ namespace MinecraftClient.ChatBots
public bool Enabled = false;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Auto_Tool_Switch$")]
public bool Auto_Tool_Switch = false;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Durability_Limit$")]
public int Durability_Limit = 2;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Drop_Low_Durability_Tools$")]
public bool Drop_Low_Durability_Tools = false;
@ -65,6 +64,8 @@ namespace MinecraftClient.ChatBots
public void OnSettingUpdate()
{
Durability_Limit = Math.Max(0, Durability_Limit);
if (Auto_Start_Delay >= 0)
Auto_Start_Delay = Math.Max(0.1, Auto_Start_Delay);
@ -225,6 +226,102 @@ namespace MinecraftClient.ChatBots
}
}
private static int GetLegacyMaxDamage(ItemType itemType)
{
return itemType switch
{
ItemType.WoodenPickaxe or ItemType.WoodenAxe or ItemType.WoodenShovel or ItemType.WoodenSword or ItemType.WoodenHoe => 59,
ItemType.StonePickaxe or ItemType.StoneAxe or ItemType.StoneShovel or ItemType.StoneSword or ItemType.StoneHoe => 131,
ItemType.IronPickaxe or ItemType.IronAxe or ItemType.IronShovel or ItemType.IronSword or ItemType.IronHoe => 250,
ItemType.GoldenPickaxe or ItemType.GoldenAxe or ItemType.GoldenShovel or ItemType.GoldenSword or ItemType.GoldenHoe => 32,
ItemType.DiamondPickaxe or ItemType.DiamondAxe or ItemType.DiamondShovel or ItemType.DiamondSword or ItemType.DiamondHoe => 1561,
ItemType.NetheritePickaxe or ItemType.NetheriteAxe or ItemType.NetheriteShovel or ItemType.NetheriteSword or ItemType.NetheriteHoe => 2031,
ItemType.Shears => 238,
_ => 0
};
}
private static int GetMaxDamage(Item item)
{
if (item.Components is not null)
{
var maxDamageComponent = item.Components.OfType<MaxDamageComponent>().FirstOrDefault();
if (maxDamageComponent is not null)
return maxDamageComponent.MaxDamage;
}
return GetLegacyMaxDamage(item.Type);
}
private static int GetRemainingDurability(Item item)
{
int maxDamage = GetMaxDamage(item);
return maxDamage > 0 ? maxDamage - item.Damage : int.MaxValue;
}
private bool HasEnoughDurability(Item item)
{
return Config.Durability_Limit <= 0 || GetRemainingDurability(item) >= Config.Durability_Limit;
}
private bool IsBelowDurabilityLimit(Item? item)
{
return item is not null && Config.Durability_Limit > 0 && GetRemainingDurability(item) < Config.Durability_Limit;
}
private static bool IsRecommendedTool(Item? item, ItemType[] recommendedTools)
{
return item is not null && recommendedTools.Contains(item.Type);
}
private bool SwapToolIntoHand(int sourceSlot, int handSlot)
{
return WindowAction(0, sourceSlot, WindowActionType.LeftClick)
&& WindowAction(0, handSlot, WindowActionType.LeftClick)
&& WindowAction(0, sourceSlot, WindowActionType.LeftClick);
}
private bool EnsureSuitableTool(Material blockType)
{
if (!inventoryEnabled || !Config.Auto_Tool_Switch)
return true;
ItemType[] recommendedTools = Material2Tool.GetCorrectToolForBlock(blockType);
if (recommendedTools.Length == 0)
return true;
Container container = GetPlayerInventory();
int handSlot = 36 + GetCurrentSlot();
container.Items.TryGetValue(handSlot, out Item? currentTool);
if (currentTool is not null && IsRecommendedTool(currentTool, recommendedTools) && HasEnoughDurability(currentTool))
return true;
foreach (ItemType recommendedTool in recommendedTools)
{
foreach ((int slot, Item item) in container.Items)
{
if (slot == handSlot || item.Type != recommendedTool || !HasEnoughDurability(item))
continue;
if (!SwapToolIntoHand(slot, handSlot))
return false;
LogToConsole(GetTimestamp() + ": " + string.Format(Translations.bot_autodig_switch, item.GetTypeString(), slot));
if (Config.Drop_Low_Durability_Tools && IsBelowDurabilityLimit(currentTool) &&
WindowAction(0, slot, WindowActionType.DropItemStack))
{
LogToConsole(GetTimestamp() + ": " + string.Format(Translations.bot_autodig_drop_low_durability, currentTool!.GetTypeString(), slot));
}
return true;
}
}
return !IsBelowDurabilityLimit(currentTool);
}
public override void Update()
{
lock (stateLock)
@ -293,6 +390,9 @@ namespace MinecraftClient.ChatBots
if (Config.Mode == Configs.ModeType.lookat ||
(Config.Mode == Configs.ModeType.both && Config._Locations.Contains(blockLoc)))
{
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: false))
{
currentDig = blockLoc;
@ -354,6 +454,9 @@ namespace MinecraftClient.ChatBots
if (minDistance <= 6.0)
{
if (!EnsureSuitableTool(targetBlock.Type))
return false;
if (DigBlock(target, Direction.Down, lookAtBlock: true))
{
currentDig = target;
@ -388,6 +491,9 @@ namespace MinecraftClient.ChatBots
((Config.List_Type == Configs.ListType.whitelist && Config.Blocks.Contains(block.Type)) ||
(Config.List_Type == Configs.ListType.blacklist && !Config.Blocks.Contains(block.Type))))
{
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: true))
{
currentDig = blockLoc;

View file

@ -62,6 +62,21 @@ namespace MinecraftClient.ChatBots
[TomlInlineComment("$ChatBot.AutoFishing.Hook_Threshold$")]
public double Hook_Threshold = 0.2;
[TomlInlineComment("$ChatBot.AutoFishing.Enable_Velocity_Detection$")]
public bool Enable_Velocity_Detection = true;
[TomlInlineComment("$ChatBot.AutoFishing.Velocity_Hook_Threshold$")]
public double Velocity_Hook_Threshold = -0.2;
[TomlInlineComment("$ChatBot.AutoFishing.Enable_Sound_Detection$")]
public bool Enable_Sound_Detection = true;
[TomlInlineComment("$ChatBot.AutoFishing.Sound_Distance$")]
public double Sound_Distance = 5.0;
[TomlInlineComment("$ChatBot.AutoFishing.Detection_Warmup$")]
public double Detection_Warmup = 1.0;
[TomlInlineComment("$ChatBot.AutoFishing.Log_Fish_Bobber$")]
public bool Log_Fish_Bobber = false;
@ -97,6 +112,15 @@ namespace MinecraftClient.ChatBots
if (Hook_Threshold < 0)
Hook_Threshold = -Hook_Threshold;
if (Velocity_Hook_Threshold > 0)
Velocity_Hook_Threshold = -Velocity_Hook_Threshold;
if (Sound_Distance < 0)
Sound_Distance = -Sound_Distance;
if (Detection_Warmup < 0)
Detection_Warmup = 0;
}
public struct LocationConfig
@ -171,6 +195,7 @@ namespace MinecraftClient.ChatBots
private Entity? fishingBobber;
private Location LastPos = Location.Zero;
private DateTime CaughtTime = DateTime.Now;
private DateTime BobberSpawnTime = DateTime.MinValue;
private int fishItemCounter = 15;
private Dictionary<ItemType, uint> fishItemCnt = new();
private Entity fishItem = new(-1, EntityType.Item, Location.Zero);
@ -464,6 +489,7 @@ namespace MinecraftClient.ChatBots
fishingBobber = entity;
LastPos = entity.Location;
isFishing = true;
BobberSpawnTime = DateTime.Now;
castTimeout = 24;
counter = 0;
@ -500,7 +526,7 @@ namespace MinecraftClient.ChatBots
public override void OnEntityMove(Entity entity)
{
if (isFishing && entity is not null && fishingBobber!.ID == entity.ID &&
(state == FishingState.WaitingFishToBite || state == FishingState.WaitingFishingBobber))
state == FishingState.WaitingFishToBite)
{
Location Pos = entity.Location;
double Dx = LastPos.X - Pos.X;
@ -515,13 +541,7 @@ namespace MinecraftClient.ChatBots
Math.Abs(Dz) < Math.Abs(Config.Stationary_Threshold) &&
Math.Abs(Dy) > Math.Abs(Config.Hook_Threshold))
{
// prevent triggering multiple time
if ((DateTime.Now - CaughtTime).TotalSeconds > 1)
{
isFishing = false;
CaughtTime = DateTime.Now;
OnCaughtFish();
}
TryCatchFish();
}
}
}
@ -540,6 +560,38 @@ namespace MinecraftClient.ChatBots
}
}
public override void OnEntityVelocity(Entity entity, double velocityX, double velocityY, double velocityZ)
{
if (!Config.Enable_Velocity_Detection || !CanUseAdvancedDetection())
return;
if (fishingBobber is null || entity.ID != fishingBobber.ID)
return;
if (velocityY <= Config.Velocity_Hook_Threshold)
TryCatchFish();
}
public override void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
Entity? sourceEntity)
{
if (!Config.Enable_Sound_Detection || !CanUseAdvancedDetection())
return;
if (!IsFishingBobberSplashSound(soundName))
return;
Location? soundLocation = location;
if (soundLocation is null && sourceEntity is not null)
soundLocation = sourceEntity.Location;
if (soundLocation is null || fishingBobber is null)
return;
if (soundLocation.Value.Distance(fishingBobber.Location) <= Config.Sound_Distance)
TryCatchFish();
}
public override void AfterGameJoined()
{
StartFishing();
@ -562,10 +614,42 @@ namespace MinecraftClient.ChatBots
fishingBobber = null;
LastPos = Location.Zero;
CaughtTime = DateTime.Now;
BobberSpawnTime = DateTime.MinValue;
return base.OnDisconnect(reason, message);
}
private bool CanUseAdvancedDetection()
{
if (!isFishing || fishingBobber is null || state != FishingState.WaitingFishToBite)
return false;
return (DateTime.Now - BobberSpawnTime).TotalSeconds >= Config.Detection_Warmup;
}
private void TryCatchFish()
{
if (!CanUseAdvancedDetection())
return;
// Prevent repeated catches from multiple packets of the same bite.
if ((DateTime.Now - CaughtTime).TotalSeconds <= 1)
return;
isFishing = false;
CaughtTime = DateTime.Now;
OnCaughtFish();
}
private static bool IsFishingBobberSplashSound(string? soundName)
{
return string.Equals(soundName, "minecraft:entity.fishing_bobber.splash",
StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "entity.fishing_bobber.splash", StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "minecraft:entity.bobber.splash", StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "entity.bobber.splash", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Called when detected a fish is caught
/// </summary>

View file

@ -1,4 +1,4 @@
using System;
using System;
using MinecraftClient.Scripting;
using Tomlet.Attributes;
@ -95,6 +95,11 @@ namespace MinecraftClient.ChatBots
_Initialize();
}
public override void AfterGameJoined()
{
Configs._BotRecoAttempts = 0;
}
private void _Initialize()
{
McClient.ReconnectionAttemptsLeft = Config.Retries;
@ -144,10 +149,17 @@ namespace MinecraftClient.ChatBots
{
double delay = random.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min;
LogDebugToConsole(string.Format(string.IsNullOrEmpty(msg) ? Translations.bot_autoRelog_reconnect_always : Translations.bot_autoRelog_reconnect, msg));
// TODO: Change this translation string to add the retries left text
LogToConsole(string.Format(Translations.bot_autoRelog_wait, delay) + $" ({Config.Retries - Configs._BotRecoAttempts} retries left)");
ReconnectToTheServer(Config.Retries - Configs._BotRecoAttempts, (int)Math.Floor(delay), true);
int retriesLeft = Config.Retries - Configs._BotRecoAttempts;
if (retriesLeft < 0)
retriesLeft = 0;
string retriesDisplay = Config.Retries == int.MaxValue
? Translations.bot_autoRelog_retries_unlimited
: retriesLeft.ToString();
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
ReconnectToTheServer(retriesLeft, (int)Math.Floor(delay), true);
}
public static bool OnDisconnectStatic(DisconnectReason reason, string message)

View file

@ -1,8 +1,11 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Brigadier.NET.Builder;
using DSharpPlus;
@ -34,6 +37,9 @@ namespace MinecraftClient.ChatBots
private DiscordChannel? discordChannel;
private BridgeDirection bridgeDirection = BridgeDirection.Both;
private readonly ConcurrentQueue<string> aggregationBuffer = new();
private Timer? aggregationTimer;
public static Configs Config = new();
[TomlDoNotInlineObject]
@ -62,6 +68,12 @@ namespace MinecraftClient.ChatBots
[TomlInlineComment("$ChatBot.DiscordBridge.AllowOtherBotMessages$")]
public bool Allow_Other_Bot_Messages = false;
[TomlInlineComment("$ChatBot.DiscordBridge.RelayAllMessages$")]
public bool Relay_All_Messages = false;
[TomlInlineComment("$ChatBot.DiscordBridge.MessageAggregationInterval$")]
public double Message_Aggregation_Interval = 3.0;
[TomlPrecedingComment("$ChatBot.DiscordBridge.Formats$")]
public string PrivateMessageFormat = "**[Private Message]** {username}: {message}";
public string PublicMessageFormat = "{username}: {message}";
@ -70,6 +82,8 @@ namespace MinecraftClient.ChatBots
public void OnSettingUpdate()
{
Message_Send_Timeout = Message_Send_Timeout <= 0 ? 3 : Message_Send_Timeout;
if (Message_Aggregation_Interval < 0)
Message_Aggregation_Interval = 0;
}
}
@ -100,6 +114,12 @@ namespace MinecraftClient.ChatBots
.Redirect(McClient.dispatcher.GetRoot().GetChild("help").GetChild(CommandName)))
);
if (Config.Message_Aggregation_Interval > 0)
{
var intervalMs = (int)(Config.Message_Aggregation_Interval * 1000);
aggregationTimer = new Timer(_ => FlushAggregationBuffer(), null, intervalMs, intervalMs);
}
Task.Run(async () => await MainAsync());
}
@ -107,6 +127,7 @@ namespace MinecraftClient.ChatBots
{
McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
StopAggregation();
Disconnect();
}
@ -147,6 +168,40 @@ namespace MinecraftClient.ChatBots
return r.SetAndReturn(CmdResult.Status.Done, string.Format(Translations.bot_DiscordBridge_direction, bridgeName));
}
private void FlushAggregationBuffer()
{
if (aggregationBuffer.IsEmpty || !CanSendMessages())
return;
var sb = new StringBuilder();
while (aggregationBuffer.TryDequeue(out var line))
{
if (sb.Length + line.Length + 1 > 1900)
{
SendMessage(sb.ToString());
sb.Clear();
}
if (sb.Length > 0)
sb.AppendLine();
sb.Append(line);
}
if (sb.Length > 0)
SendMessage(sb.ToString());
}
private void StopAggregation()
{
if (aggregationTimer is not null)
{
aggregationTimer.Dispose();
aggregationTimer = null;
}
FlushAggregationBuffer();
}
~DiscordBridge()
{
Disconnect();
@ -188,7 +243,6 @@ namespace MinecraftClient.ChatBots
text = GetVerbatim(text).Trim();
// Stop the crash when an empty text is recived somehow
if (string.IsNullOrEmpty(text))
return;
@ -205,7 +259,10 @@ namespace MinecraftClient.ChatBots
message = Config.TeleportRequestMessageFormat.Replace("{username}", username).Replace("{timestamp}", GetTimestamp()).Trim();
teleportRequest = true;
}
else message = text;
else if (Config.Relay_All_Messages)
message = text;
else
return;
if (teleportRequest)
{
@ -223,7 +280,13 @@ namespace MinecraftClient.ChatBots
SendMessage(messageBuilder);
return;
}
else SendMessage(GetDiscordText(message));
string discordText = GetDiscordText(message);
if (Config.Message_Aggregation_Interval > 0)
aggregationBuffer.Enqueue(discordText);
else
SendMessage(discordText);
}
/// <summary>

View file

@ -0,0 +1,106 @@
using System.Linq;
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
namespace MinecraftClient.Commands
{
public class AchievementCommand : Command
{
public override string CmdName => "achievement";
public override string CmdUsage => "achievement <list|locked|unlocked>";
public override string CmdDesc => Translations.cmd_achievement_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))
.Then(l => l.Literal("list")
.Executes(r => GetUsage(r.Source, "list")))
.Then(l => l.Literal("locked")
.Executes(r => GetUsage(r.Source, "locked")))
.Then(l => l.Literal("unlocked")
.Executes(r => GetUsage(r.Source, "unlocked")))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => ListAchievements(r.Source, null))
.Then(l => l.Literal("list")
.Executes(r => ListAchievements(r.Source, null)))
.Then(l => l.Literal("locked")
.Executes(r => ListAchievements(r.Source, false)))
.Then(l => l.Literal("unlocked")
.Executes(r => ListAchievements(r.Source, true)))
.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
"list" => GetCmdDescTranslated(),
"locked" => GetCmdDescTranslated(),
"unlocked" => GetCmdDescTranslated(),
_ => GetCmdDescTranslated(),
#pragma warning restore format
});
}
/// <param name="completed">null = all, true = unlocked only, false = locked only</param>
private static int ListAchievements(CmdResult r, bool? completed)
{
McClient handler = CmdResult.currentHandler!;
Achievement[] items = completed switch
{
true => handler.GetUnlockedAchievements(),
false => handler.GetLockedAchievements(),
null => handler.GetAchievements()
};
if (items.Length == 0)
{
string msg = completed switch
{
true => Translations.cmd_achievement_none_unlocked,
false => Translations.cmd_achievement_none_locked,
_ => Translations.cmd_achievement_none
};
return r.SetAndReturn(CmdResult.Status.Done, msg);
}
string header = completed switch
{
true => Translations.cmd_achievement_header_unlocked,
false => Translations.cmd_achievement_header_locked,
_ => Translations.cmd_achievement_header
};
StringBuilder sb = new();
sb.AppendLine(header);
foreach (Achievement a in items.OrderBy(static a => a.Id))
{
string status = a.IsCompleted
? Translations.cmd_achievement_done
: Translations.cmd_achievement_todo;
string display = a.Title is not null
? string.Format(Translations.cmd_achievement_entry_titled, status, a.Title, a.Id, a.Type)
: string.Format(Translations.cmd_achievement_entry, status, a.Id, a.Type);
sb.AppendLine(display);
}
handler.Log.Info(sb.ToString().TrimEnd());
return r.SetAndReturn(CmdResult.Status.Done);
}
}
}

View file

@ -0,0 +1,284 @@
using System;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Tui;
using Avalonia.Threading;
using static MinecraftClient.CommandHandler.CmdResult;
namespace MinecraftClient.Commands
{
class Minimap : Command
{
public override string CmdName => "minimap";
public override string CmdUsage => "minimap [on|off] | minimap zoom [in|out|<1-16>] | minimap names [players|hostile|neutral|passive] [on|off] | minimap names [all_on|all_off] | minimap position [top_left|top_right|center|bottom_left|bottom_right] | minimap cave [auto|on|off]";
public override string CmdDesc => Translations.cmd_minimap_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 => DoToggle(r.Source))
.Then(l => l.Literal("on")
.Executes(r => DoOn(r.Source)))
.Then(l => l.Literal("off")
.Executes(r => DoOff(r.Source)))
.Then(l => l.Literal("zoom")
.Executes(r => DoZoomInfo(r.Source))
.Then(l => l.Literal("in")
.Executes(r => DoZoomIn(r.Source)))
.Then(l => l.Literal("out")
.Executes(r => DoZoomOut(r.Source)))
.Then(l => l.Argument("level", Arguments.Integer(MinimapControl.MinZoom, MinimapControl.MaxZoom))
.Executes(r => DoZoomSet(r.Source, Arguments.GetInteger(r, "level")))))
.Then(l => l.Literal("names")
.Executes(r => DoNamesInfo(r.Source))
.Then(l => l.Literal("all_on")
.Executes(r => DoNamesAll(r.Source, true)))
.Then(l => l.Literal("all_off")
.Executes(r => DoNamesAll(r.Source, false)))
.Then(l => l.Literal("players")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Player))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, false))))
.Then(l => l.Literal("hostile")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Hostile))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, false))))
.Then(l => l.Literal("neutral")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Neutral))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, false))))
.Then(l => l.Literal("passive")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Passive))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, false)))))
.Then(l => l.Literal("position")
.Executes(r => DoPositionInfo(r.Source))
.Then(l => l.Literal("top_left")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.top_left)))
.Then(l => l.Literal("top_right")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.top_right)))
.Then(l => l.Literal("center")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.center)))
.Then(l => l.Literal("bottom_left")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_left)))
.Then(l => l.Literal("bottom_right")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_right))))
.Then(l => l.Literal("cave")
.Executes(r => DoCaveInfo(r.Source))
.Then(l => l.Literal("auto")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.auto)))
.Then(l => l.Literal("on")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.on)))
.Then(l => l.Literal("off")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.off))))
.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 _) =>
r.SetAndReturn(GetCmdDescTranslated());
private static MainTuiView? GetTuiView(CmdResult r)
{
if (ConsoleIO.Backend is not TuiConsoleBackend)
{
r.SetAndReturn(Status.Fail, Translations.cmd_minimap_tui_only);
return null;
}
return TuiConsoleBackend.Instance?.GetView();
}
private static int DoToggle(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
bool wasVisible = view.IsMinimapVisible;
Dispatcher.UIThread.Post(() => view.ToggleMinimap());
string msg = wasVisible
? Translations.cmd_minimap_disabled
: Translations.cmd_minimap_enabled;
return r.SetAndReturn(Status.Done, msg);
}
private static int DoOn(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.ShowMinimap());
return r.SetAndReturn(Status.Done, Translations.cmd_minimap_enabled);
}
private static int DoOff(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.HideMinimap());
return r.SetAndReturn(Status.Done, Translations.cmd_minimap_disabled);
}
private static int DoZoomInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int current = view.GetMinimapZoom();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_zoom_current, current, MinimapControl.MaxZoom));
}
private static int DoZoomIn(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int newLevel = Math.Max(view.GetMinimapZoom() - 1, MinimapControl.MinZoom);
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel));
}
private static int DoZoomOut(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int newLevel = Math.Min(view.GetMinimapZoom() + 1, MinimapControl.MaxZoom);
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel));
}
private static int DoZoomSet(CmdResult r, int level)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(level));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, level));
}
private static int DoNamesInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var nc = view.GetMinimapNameConfig();
string status = string.Format(Translations.cmd_minimap_names_status,
BoolStr(nc.Players), BoolStr(nc.Hostile), BoolStr(nc.Neutral), BoolStr(nc.Passive));
return r.SetAndReturn(Status.Done, status);
}
private static int DoNamesAll(CmdResult r, bool on)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() =>
{
view.GetMinimapNameConfig().SetAll(on);
view.SyncMinimapNameConfig();
});
string msg = on ? Translations.cmd_minimap_names_all_on : Translations.cmd_minimap_names_all_off;
return r.SetAndReturn(Status.Done, msg);
}
private static int DoNamesCatInfo(CmdResult r, MobCategory cat)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var nc = view.GetMinimapNameConfig();
bool val = cat switch
{
MobCategory.Player => nc.Players,
MobCategory.Hostile => nc.Hostile,
MobCategory.Neutral => nc.Neutral,
MobCategory.Passive => nc.Passive,
_ => false,
};
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_names_cat, cat, BoolStr(val)));
}
private static int DoNamesCatSet(CmdResult r, MobCategory cat, bool on)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() =>
{
var nc = view.GetMinimapNameConfig();
switch (cat)
{
case MobCategory.Player: nc.Players = on; break;
case MobCategory.Hostile: nc.Hostile = on; break;
case MobCategory.Neutral: nc.Neutral = on; break;
case MobCategory.Passive: nc.Passive = on; break;
}
view.SyncMinimapNameConfig();
});
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_names_cat_set, cat, BoolStr(on)));
}
private static int DoPositionInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var pos = view.GetMinimapPosition();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_position_current, pos));
}
private static int DoPositionSet(CmdResult r, MinimapPosition pos)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapPosition(pos));
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_position_set, pos));
}
private static int DoCaveInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var mode = view.GetMinimapCaveMode();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_cave_current, mode));
}
private static int DoCaveSet(CmdResult r, CaveModeOption mode)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapCaveMode(mode));
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_cave_set, mode));
}
private static string BoolStr(bool v) => v ? "ON" : "OFF";
}
}

View file

@ -0,0 +1,98 @@
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
namespace MinecraftClient.Commands
{
public class RecipeBook : Command
{
public override string CmdName => "recipebook";
public override string CmdUsage => "recipebook <list|craft|craftall> [recipe id]";
public override string CmdDesc => Translations.cmd_recipebook_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))
.Then(l => l.Literal("list")
.Executes(r => GetUsage(r.Source, "list")))
.Then(l => l.Literal("craft")
.Executes(r => GetUsage(r.Source, "craft")))
.Then(l => l.Literal("craftall")
.Executes(r => GetUsage(r.Source, "craftall")))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Literal("list")
.Executes(r => ListRecipes(r.Source)))
.Then(l => l.Literal("craft")
.Then(l => l.Argument("RecipeId", Arguments.String())
.Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: false))))
.Then(l => l.Literal("craftall")
.Then(l => l.Argument("RecipeId", Arguments.String())
.Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: true))))
.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
"list" => GetCmdDescTranslated(),
"craft" => GetCmdDescTranslated(),
"craftall" => GetCmdDescTranslated(),
_ => GetCmdDescTranslated(),
#pragma warning restore format // @formatter:on
});
}
private int ListRecipes(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetInventoryEnabled())
return r.SetAndReturn(CmdResult.Status.FailNeedInventory);
RecipeBookRecipeEntry[] recipes = handler.GetUnlockedRecipes();
if (recipes.Length == 0)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_recipes);
StringBuilder response = new();
response.AppendLine(Translations.cmd_recipebook_list);
foreach (RecipeBookRecipeEntry recipe in recipes)
response.AppendLine("- " + recipe.DisplayText);
handler.Log.Info(response.ToString().TrimEnd());
return r.SetAndReturn(CmdResult.Status.Done);
}
private int CraftRecipe(CmdResult r, string recipeId, bool makeAll)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetInventoryEnabled())
return r.SetAndReturn(CmdResult.Status.FailNeedInventory);
if (string.IsNullOrWhiteSpace(recipeId))
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_recipe_id_empty);
if (handler.GetProtocolVersion() < Protocol.Handlers.Protocol18Handler.MC_1_13_Version)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_unsupported);
if (handler.GetActiveRecipeBookInventory() is null)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_active_inventory);
string normalizedRecipeId = McClient.NormalizeRecipeArgument(recipeId, handler.GetProtocolVersion());
string successMessage = string.Format(makeAll ? Translations.cmd_recipebook_craftall_sent : Translations.cmd_recipebook_craft_sent, normalizedRecipeId);
return handler.SendPlaceRecipe(recipeId, makeAll)
? r.SetAndReturn(CmdResult.Status.Done, successMessage)
: r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_recipebook_craft_failed, normalizedRecipeId));
}
}
}

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,63 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using static MinecraftClient.Settings.ConsoleConfigHealper.ConsoleConfig;
namespace MinecraftClient.Commands
{
public class Tryout : Command
{
public override string CmdName => "tryout";
public override string CmdUsage => "tryout [list|tui]";
public override string CmdDesc => Translations.cmd_tryout_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => ListTryouts(r.Source))
.Then(l => l.Literal("list")
.Executes(r => ListTryouts(r.Source)))
.Then(l => l.Literal("tui")
.Executes(r => EnableTuiMode(r.Source)))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
);
}
private int GetUsage(CmdResult r)
{
return r.SetAndReturn(GetCmdDescTranslated());
}
private int ListTryouts(CmdResult r)
{
return r.SetAndReturn(string.Join('\n',
GetCmdDescTranslated(),
string.Empty,
Translations.cmd_tryout_list_header,
$" - {Translations.cmd_tryout_list_tui}"));
}
private int EnableTuiMode(CmdResult r)
{
var previousMode = Settings.Config.Console.General.ConsoleMode;
if (previousMode == ConsoleModeType.tui)
{
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_tryout_tui_already_enabled);
}
Settings.Config.Console.General.ConsoleMode = ConsoleModeType.tui;
Program.WriteBackSettings();
return r.SetAndReturn(CmdResult.Status.Done,
string.Format(Translations.cmd_tryout_tui_enabled, previousMode, ConsoleModeType.tui));
}
}
}

View file

@ -1,6 +1,7 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using static MinecraftClient.CommandHandler.CmdResult;
@ -9,7 +10,7 @@ namespace MinecraftClient.Commands
class Useblock : Command
{
public override string CmdName { get { return "useblock"; } }
public override string CmdUsage { get { return "useblock <x> <y> <z>"; } }
public override string CmdUsage { get { return "useblock <x> <y> <z> [mainhand|offhand]"; } }
public override string CmdDesc { get { return Translations.cmd_useblock_desc; } }
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
@ -22,7 +23,11 @@ namespace MinecraftClient.Commands
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Argument("Location", MccArguments.Location())
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"))))
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.MainHand))
.Then(l => l.Literal("mainhand")
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.MainHand)))
.Then(l => l.Literal("offhand")
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.OffHand))))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source, string.Empty))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
@ -39,7 +44,7 @@ namespace MinecraftClient.Commands
});
}
private int UseBlockAtLocation(CmdResult r, Location block)
private int UseBlockAtLocation(CmdResult r, Location block, Hand hand)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetTerrainEnabled())
@ -48,7 +53,7 @@ namespace MinecraftClient.Commands
Location current = handler.GetCurrentLocation();
block = block.ToAbsolute(current).ToFloor();
Location blockCenter = block.ToCenter();
bool res = handler.PlaceBlock(block, Direction.Down, lookAtBlock: true);
bool res = handler.PlaceBlock(block, Direction.Down, hand, lookAtBlock: true);
return r.SetAndReturn(string.Format(Translations.cmd_useblock_use, blockCenter.X, blockCenter.Y, blockCenter.Z, res ? "succeeded" : "failed"), res);
}
}

View file

@ -104,7 +104,7 @@ namespace MinecraftClient
/// </summary>
public static void WriteLine(string line)
{
if (BasicIO)
if (BasicIO || Backend is null)
Console.WriteLine(line);
else
Backend.WriteLine(line);
@ -137,7 +137,7 @@ namespace MinecraftClient
{
str = str.Replace('\n', ' ');
}
if (BasicIO)
if (BasicIO || Backend is null)
{
if (BasicIO_NoColor)
{

View file

@ -1,3 +1,4 @@
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -23,10 +24,46 @@ public static class Json
public static JsonNode? ParseJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
ReadOnlySpan<char> text = json.AsSpan().TrimStart();
if (!LooksLikeJson(text))
return JsonValue.Create(json);
try { return JsonNode.Parse(json); }
catch (JsonException) { return JsonValue.Create(json); }
}
private static bool LooksLikeJson(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
return text[0] switch
{
'{' or '"' => true,
'[' => LooksLikeJsonArray(text[1..]),
'-' => text.Length > 1 && char.IsAsciiDigit(text[1]),
>= '0' and <= '9' => true,
't' or 'f' or 'n' => true,
_ => false
};
}
private static bool LooksLikeJsonArray(ReadOnlySpan<char> text)
{
text = text.TrimStart();
if (text.IsEmpty)
return false;
return text[0] switch
{
']' or '{' or '[' or '"' => true,
'-' => text.Length > 1 && char.IsAsciiDigit(text[1]),
>= '0' and <= '9' => true,
't' or 'f' or 'n' => true,
_ => false
};
}
/// <summary>
/// Escape a string for embedding inside a JSON string literal.
/// Uses System.Text.Json serialization and strips the surrounding quotes.
@ -52,4 +89,4 @@ public static class JsonNodeExtensions
JsonValue val when val.TryGetValue<string>(out var s) => s,
_ => node.ToJsonString()
};
}
}

View file

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace MinecraftClient
{
internal static class LegacyAchievementCatalog
{
public static IReadOnlyList<string> Ids { get; } =
[
"achievement.openInventory",
"achievement.mineWood",
"achievement.buildWorkBench",
"achievement.buildPickaxe",
"achievement.buildFurnace",
"achievement.acquireIron",
"achievement.buildHoe",
"achievement.makeBread",
"achievement.bakeCake",
"achievement.buildBetterPickaxe",
"achievement.cookFish",
"achievement.onARail",
"achievement.buildSword",
"achievement.killEnemy",
"achievement.killCow",
"achievement.flyPig",
"achievement.snipeSkeleton",
"achievement.diamonds",
"achievement.diamondsToYou",
"achievement.portal",
"achievement.ghast",
"achievement.blazeRod",
"achievement.potion",
"achievement.theEnd",
"achievement.theEnd2",
"achievement.enchantments",
"achievement.overkill",
"achievement.bookcase",
"achievement.breedCow",
"achievement.spawnWither",
"achievement.killWither",
"achievement.fullBeacon",
"achievement.exploreAllBiomes",
"achievement.overpowered"
];
private static readonly HashSet<string> s_idSet = new(Ids, StringComparer.Ordinal);
public static bool Contains(string id)
{
return s_idSet.Contains(id);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,572 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MinecraftClient.Inventory;
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_21_5;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components.Subcomponents._1_20_6;
namespace MinecraftClient.Mapping
{
/// <summary>
/// Computes dig duration in ticks for survival-style block breaking.
/// Version-aware across 1.8-1.21.11+, using tool speed, enchantments, effects, and attributes.
/// </summary>
public static class MiningCalculator
{
/// <summary>
/// Compute the number of ticks required to break a block in survival mode.
/// Returns 0 for instant-break blocks, -1 for unbreakable blocks.
/// </summary>
/// <param name="blockMaterial">The block material to break</param>
/// <param name="heldItem">The item in the player's main hand (null for empty hand)</param>
/// <param name="helmetItem">The item in the player's helmet slot (null if empty, used for Aqua Affinity)</param>
/// <param name="effects">Currently active player effects</param>
/// <param name="playerAttributes">Cached player attribute values (from OnEntityProperties)</param>
/// <param name="isUnderwater">Whether the player's eyes are submerged in water</param>
/// <param name="isOnGround">Whether the player is on the ground</param>
/// <param name="protocolVersion">The Minecraft protocol version</param>
/// <returns>Ticks to break the block, 0 for instant, -1 for unbreakable</returns>
public static int ComputeDigTicks(
Material blockMaterial,
Item? heldItem,
Item? helmetItem,
Dictionary<Effects, EffectData> effects,
Dictionary<string, double> playerAttributes,
bool isUnderwater,
bool isOnGround,
int protocolVersion)
{
float hardness = BlockHardness.GetHardness(blockMaterial);
if (hardness < 0)
return -1; // Unbreakable
if (hardness == 0)
return 0; // Instant break
float destroySpeed = GetDestroySpeed(
blockMaterial, heldItem, helmetItem, effects, playerAttributes,
isUnderwater, isOnGround, protocolVersion);
bool correctTool = HasCorrectToolForDrops(blockMaterial, heldItem, protocolVersion);
int divisor = correctTool ? 30 : 100;
float destroyProgress = destroySpeed / hardness / divisor;
if (destroyProgress >= 1.0f)
return 0; // Instant break
return (int)MathF.Ceiling(1.0f / destroyProgress);
}
/// <summary>
/// Compute the player's destroy speed for a given block, following vanilla formulas.
/// </summary>
private static float GetDestroySpeed(
Material blockMaterial,
Item? heldItem,
Item? helmetItem,
Dictionary<Effects, EffectData> effects,
Dictionary<string, double> playerAttributes,
bool isUnderwater,
bool isOnGround,
int protocolVersion)
{
float speed = GetToolSpeed(blockMaterial, heldItem, protocolVersion);
if (speed > 1.0f)
{
speed += GetEfficiencyBonus(heldItem, playerAttributes, protocolVersion);
}
int digSpeedAmplifier = GetDigSpeedAmplifier(effects);
if (digSpeedAmplifier >= 0)
speed *= 1.0f + (digSpeedAmplifier + 1) * 0.2f;
// Mining Fatigue
if (effects.TryGetValue(Effects.MiningFatigue, out var fatigueData))
{
float multiplier = fatigueData.Amplifier switch
{
0 => 0.3f,
1 => 0.09f,
2 => 0.0027f,
_ => 8.1E-4f
};
speed *= multiplier;
}
// Attribute multipliers for modern versions
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version)
{
// BLOCK_BREAK_SPEED attribute (default 1.0)
if (playerAttributes.TryGetValue("player.block_break_speed", out double bbs))
speed *= (float)bbs;
}
// Underwater penalty
if (isUnderwater)
{
if (protocolVersion >= Protocol18Handler.MC_1_21_11_Version)
{
// 1.21.11+: Uses SUBMERGED_MINING_SPEED attribute (default 0.2)
double submergedSpeed = 0.2;
if (playerAttributes.TryGetValue("player.submerged_mining_speed", out double sms))
submergedSpeed = sms;
speed *= (float)submergedSpeed;
}
else
{
// Pre-1.21.11: /5 unless Aqua Affinity
bool hasAquaAffinity = GetEnchantmentLevel(helmetItem, Enchantments.AquaAffinity, protocolVersion) > 0;
if (!hasAquaAffinity)
speed /= 5.0f;
}
}
// Airborne penalty
if (!isOnGround)
speed /= 5.0f;
return speed;
}
/// <summary>
/// Get the base tool mining speed for a block.
/// For 1.20.6+ with ToolComponent, uses structured component data.
/// For older versions, uses hardcoded tool speed tables.
/// </summary>
private static float GetToolSpeed(Material blockMaterial, Item? heldItem, int protocolVersion)
{
if (heldItem is null)
return 1.0f;
// Modern path: use ToolComponent from structured components
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version
&& TryGetToolRules(heldItem, out List<RuleSubComponent>? rules, out float defaultMiningSpeed))
{
foreach (var rule in rules)
{
if (rule.HasSpeed && MatchesBlockSet(rule.Blocks, blockMaterial))
return rule.Speed;
}
// Structured tool data covers modern mining rules, but keep the legacy fallback for
// explicit block holder-sets that MCC cannot resolve yet (for example cobweb).
if (defaultMiningSpeed > 1.0f)
return defaultMiningSpeed;
}
// Legacy path: hardcoded tool speed tables
return GetLegacyToolSpeed(heldItem.Type, blockMaterial);
}
/// <summary>
/// Check whether the tool provides correct drops for a block.
/// </summary>
private static bool HasCorrectToolForDrops(Material blockMaterial, Item? heldItem, int protocolVersion)
{
if (!BlockHardness.RequiresCorrectTool(blockMaterial))
return true;
if (heldItem is null)
return false;
// Modern path: check ToolComponent rules
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version
&& TryGetToolRules(heldItem, out List<RuleSubComponent>? rules, out _))
{
foreach (var rule in rules)
{
if (rule.HasCorrectDropForBlocks && MatchesBlockSet(rule.Blocks, blockMaterial))
return rule.CorrectDropForBlocks;
}
}
// Legacy path, plus a modern fallback for direct block holder-sets MCC cannot resolve yet.
return IsCorrectToolLegacy(heldItem.Type, blockMaterial);
}
private static bool TryGetToolRules(
Item heldItem,
[NotNullWhen(true)] out List<RuleSubComponent>? rules,
out float defaultMiningSpeed)
{
rules = null;
defaultMiningSpeed = 1.0f;
if (heldItem.Components is null)
return false;
if (heldItem.Components.OfType<ToolComponent>().FirstOrDefault() is ToolComponent toolComponent)
{
rules = toolComponent.Rules;
defaultMiningSpeed = toolComponent.DefaultMiningSpeed;
return true;
}
if (heldItem.Components.OfType<ToolComponent1215>().FirstOrDefault() is ToolComponent1215 toolComponent1215)
{
rules = toolComponent1215.Rules;
defaultMiningSpeed = toolComponent1215.DefaultMiningSpeed;
return true;
}
return false;
}
/// <summary>
/// Match a block material against a ToolComponent BlockSetSubcomponent.
/// </summary>
private static bool MatchesBlockSet(
Protocol.Handlers.StructuredComponents.Components.Subcomponents._1_20_6.BlockSetSubcomponent blockSet,
Material blockMaterial)
{
if (blockSet.BlockIds is not null)
{
// Check against explicit block state IDs
foreach (int blockId in blockSet.BlockIds)
{
if (Block.Palette.FromId(blockId) == blockMaterial)
return true;
}
}
if (blockSet.TagName is not null)
{
// Match against tag name (e.g., "minecraft:mineable/pickaxe")
return MatchesBlockTag(blockSet.TagName, blockMaterial);
}
return false;
}
/// <summary>
/// Approximate block tag matching using Material2Tool categories.
/// Tags like "minecraft:mineable/pickaxe" map to the appropriate tool categories.
/// </summary>
private static bool MatchesBlockTag(string tagName, Material blockMaterial)
{
// Normalize tag name
string tag = tagName.Replace("minecraft:", "");
ItemType[] tools = Material2Tool.GetCorrectToolForBlock(blockMaterial);
return tag switch
{
"mineable/pickaxe" => tools.Length > 0 && IsPickaxe(tools[0]),
"mineable/axe" => tools.Length > 0 && IsAxe(tools[0]),
"mineable/shovel" => tools.Length > 0 && IsShovel(tools[0]),
"mineable/hoe" => tools.Length > 0 && IsHoe(tools[0]),
"leaves" => IsLeaf(blockMaterial),
"wool" => IsWool(blockMaterial),
"incorrect_for_wooden_tool" => RequiresHigherTier(blockMaterial, 0),
"incorrect_for_gold_tool" => RequiresHigherTier(blockMaterial, 0),
"incorrect_for_stone_tool" => RequiresHigherTier(blockMaterial, 1),
"incorrect_for_copper_tool" => RequiresHigherTier(blockMaterial, 1),
"incorrect_for_iron_tool" => RequiresHigherTier(blockMaterial, 2),
"incorrect_for_diamond_tool" => RequiresHigherTier(blockMaterial, 3),
"incorrect_for_netherite_tool" => RequiresHigherTier(blockMaterial, 4),
_ => false
};
}
private static bool RequiresHigherTier(Material blockMaterial, int tier)
{
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return false;
return GetRequiredTier(blockMaterial, recommended) > tier;
}
/// <summary>
/// Get the enchantment level from an item, supporting both legacy NBT and modern structured components.
/// </summary>
public static int GetEnchantmentLevel(Item? item, Enchantments enchantment, int protocolVersion)
{
if (item is null)
return 0;
// Modern path: structured components (1.20.6+)
var enchList = item.EnchantmentList;
if (enchList is not null)
{
var ench = enchList.FirstOrDefault(e => e.Type == enchantment);
if (ench is not null)
return ench.Level;
}
// Legacy path: NBT data
if (item.NBT is not null &&
item.NBT.TryGetValue("Enchantments", out object? enchantments))
{
try
{
string enchNameLower = GetEnchantmentResourceName(enchantment);
foreach (Dictionary<string, object> enchEntry in (object[])enchantments)
{
string id = ((string)enchEntry["id"]).ToLowerInvariant();
if (id == enchNameLower || id == "minecraft:" + enchNameLower)
return (short)enchEntry["lvl"];
}
}
catch
{
// NBT parsing failure - return 0
}
}
return 0;
}
/// <summary>
/// Map Enchantments enum to Minecraft resource name (e.g., "efficiency").
/// </summary>
private static string GetEnchantmentResourceName(Enchantments enchantment)
{
return enchantment switch
{
Enchantments.AquaAffinity => "aqua_affinity",
Enchantments.BaneOfArthropods => "bane_of_arthropods",
Enchantments.BlastProtection => "blast_protection",
Enchantments.Efficiency => "efficiency",
Enchantments.FeatherFalling => "feather_falling",
Enchantments.FireAspect => "fire_aspect",
Enchantments.FireProtection => "fire_protection",
Enchantments.FrostWalker => "frost_walker",
Enchantments.LuckOfTheSea => "luck_of_the_sea",
Enchantments.ProjectileProtection => "projectile_protection",
Enchantments.QuickCharge => "quick_charge",
Enchantments.SilkTouch => "silk_touch",
Enchantments.SoulSpeed => "soul_speed",
Enchantments.SwiftSneak => "swift_sneak",
Enchantments.VanishingCurse => "vanishing_curse",
Enchantments.BindingCurse => "binding_curse",
Enchantments.WindBurst => "wind_burst",
_ => enchantment.ToString().ToUnderscoreCase()
};
}
#region Legacy Tool Speed Tables
/// <summary>
/// Legacy tool speed for pre-1.20.6 versions using hardcoded values.
/// </summary>
private static float GetLegacyToolSpeed(ItemType toolType, Material blockMaterial)
{
float specialToolSpeed = toolType switch
{
ItemType.Shears => GetShearsSpeed(blockMaterial),
_ when IsSword(toolType) && blockMaterial == Material.Cobweb => 15.0f,
_ => 1.0f
};
if (specialToolSpeed > 1.0f)
return specialToolSpeed;
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return 1.0f;
// Check if the held tool matches the recommended tool category
ToolCategory heldCategory = GetToolCategory(toolType);
ToolCategory neededCategory = GetToolCategory(recommended[0]);
if (heldCategory == ToolCategory.None || heldCategory != neededCategory)
return 1.0f;
return GetBaseToolSpeed(toolType);
}
private static float GetBaseToolSpeed(ItemType toolType)
{
return toolType switch
{
// Wooden tools
ItemType.WoodenPickaxe or ItemType.WoodenAxe or ItemType.WoodenShovel or
ItemType.WoodenSword or ItemType.WoodenHoe => 2.0f,
// Stone tools
ItemType.StonePickaxe or ItemType.StoneAxe or ItemType.StoneShovel or
ItemType.StoneSword or ItemType.StoneHoe => 4.0f,
// Iron tools
ItemType.IronPickaxe or ItemType.IronAxe or ItemType.IronShovel or
ItemType.IronSword or ItemType.IronHoe => 6.0f,
// Diamond tools
ItemType.DiamondPickaxe or ItemType.DiamondAxe or ItemType.DiamondShovel or
ItemType.DiamondSword or ItemType.DiamondHoe => 8.0f,
// Netherite tools
ItemType.NetheritePickaxe or ItemType.NetheriteAxe or ItemType.NetheriteShovel or
ItemType.NetheriteSword or ItemType.NetheriteHoe => 9.0f,
// Golden tools
ItemType.GoldenPickaxe or ItemType.GoldenAxe or ItemType.GoldenShovel or
ItemType.GoldenSword or ItemType.GoldenHoe => 12.0f,
// Shears
ItemType.Shears => 2.0f,
_ => 1.0f
};
}
/// <summary>
/// Check if the held tool is the correct tool for drops in legacy versions.
/// Uses Material2Tool's recommendations to determine correctness.
/// </summary>
private static bool IsCorrectToolLegacy(ItemType toolType, Material blockMaterial)
{
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return false;
ToolCategory heldCategory = GetToolCategory(toolType);
ToolCategory neededCategory = GetToolCategory(recommended[0]);
if (heldCategory == ToolCategory.None || heldCategory != neededCategory)
{
if (toolType == ItemType.Shears && blockMaterial == Material.Cobweb)
return true;
if (IsSword(toolType) && blockMaterial == Material.Cobweb)
return true;
return false;
}
// Check tool tier requirement
int heldTier = GetToolTier(toolType);
int requiredTier = GetRequiredTier(blockMaterial, recommended);
return heldTier >= requiredTier;
}
/// <summary>
/// Get the minimum tool tier required for a block based on Material2Tool's recommendation ordering.
/// </summary>
private static int GetRequiredTier(Material blockMaterial, ItemType[] recommended)
{
if (recommended.Length == 0)
return 0;
// Material2Tool lists tools from highest to lowest tier.
// The last tool in the array is the minimum required tier.
return GetToolTier(recommended[^1]);
}
private enum ToolCategory
{
None,
Pickaxe,
Axe,
Shovel,
Hoe,
Sword,
Shears
}
private static ToolCategory GetToolCategory(ItemType item)
{
if (IsPickaxe(item)) return ToolCategory.Pickaxe;
if (IsAxe(item)) return ToolCategory.Axe;
if (IsShovel(item)) return ToolCategory.Shovel;
if (IsHoe(item)) return ToolCategory.Hoe;
if (IsSword(item)) return ToolCategory.Sword;
if (item == ItemType.Shears) return ToolCategory.Shears;
return ToolCategory.None;
}
private static int GetToolTier(ItemType item)
{
string name = item.ToString();
if (name.StartsWith("Wooden")) return 0;
if (name.StartsWith("Golden")) return 0;
if (name.StartsWith("Stone")) return 1;
if (name.StartsWith("Iron")) return 2;
if (name.StartsWith("Diamond")) return 3;
if (name.StartsWith("Netherite")) return 4;
return 0;
}
private static bool IsPickaxe(ItemType item) =>
item is ItemType.WoodenPickaxe or ItemType.StonePickaxe or ItemType.IronPickaxe
or ItemType.GoldenPickaxe or ItemType.DiamondPickaxe or ItemType.NetheritePickaxe;
private static bool IsAxe(ItemType item) =>
item is ItemType.WoodenAxe or ItemType.StoneAxe or ItemType.IronAxe
or ItemType.GoldenAxe or ItemType.DiamondAxe or ItemType.NetheriteAxe;
private static bool IsShovel(ItemType item) =>
item is ItemType.WoodenShovel or ItemType.StoneShovel or ItemType.IronShovel
or ItemType.GoldenShovel or ItemType.DiamondShovel or ItemType.NetheriteShovel;
private static bool IsHoe(ItemType item) =>
item is ItemType.WoodenHoe or ItemType.StoneHoe or ItemType.IronHoe
or ItemType.GoldenHoe or ItemType.DiamondHoe or ItemType.NetheriteHoe;
private static bool IsSword(ItemType item) =>
item is ItemType.WoodenSword or ItemType.StoneSword or ItemType.IronSword
or ItemType.GoldenSword or ItemType.DiamondSword or ItemType.NetheriteSword;
private static float GetShearsSpeed(Material block)
{
return block switch
{
Material.Cobweb => 15.0f,
Material.Vine or Material.GlowLichen => 2.0f,
_ when IsLeaf(block) => 15.0f,
_ when IsWool(block) => 5.0f,
_ => 1.0f
};
}
private static bool IsShearable(Material block) =>
block == Material.Cobweb || IsLeaf(block) || IsWool(block) || block is Material.Vine or Material.GlowLichen;
private static bool IsLeaf(Material block) =>
block is Material.OakLeaves or Material.SpruceLeaves or Material.BirchLeaves
or Material.JungleLeaves or Material.AcaciaLeaves or Material.DarkOakLeaves
or Material.CherryLeaves or Material.MangroveLeaves or Material.AzaleaLeaves
or Material.FloweringAzaleaLeaves or Material.PaleOakLeaves;
private static bool IsWool(Material block) =>
block is Material.WhiteWool or Material.OrangeWool or Material.MagentaWool
or Material.LightBlueWool or Material.YellowWool or Material.LimeWool
or Material.PinkWool or Material.GrayWool or Material.LightGrayWool
or Material.CyanWool or Material.PurpleWool or Material.BlueWool
or Material.BrownWool or Material.GreenWool or Material.RedWool
or Material.BlackWool;
private static float GetEfficiencyBonus(Item? heldItem, Dictionary<string, double> playerAttributes, int protocolVersion)
{
if (protocolVersion >= Protocol18Handler.MC_1_21_11_Version
&& playerAttributes.TryGetValue("player.mining_efficiency", out double miningEfficiency)
&& miningEfficiency > 0.0)
{
return (float)miningEfficiency;
}
int efficiencyLevel = GetEnchantmentLevel(heldItem, Enchantments.Efficiency, protocolVersion);
return efficiencyLevel > 0 ? efficiencyLevel * efficiencyLevel + 1 : 0.0f;
}
private static int GetDigSpeedAmplifier(Dictionary<Effects, EffectData> effects)
{
int amplifier = -1;
if (effects.TryGetValue(Effects.Haste, out var hasteData))
amplifier = Math.Max(amplifier, hasteData.Amplifier);
if (effects.TryGetValue(Effects.ConduitPower, out var conduitData))
amplifier = Math.Max(amplifier, conduitData.Amplifier);
return amplifier;
}
#endregion
}
}

View file

@ -232,8 +232,8 @@ namespace MinecraftClient.Mapping
int tentativeGScore = current.GScore + (int)current.Location.DistanceSquared(neighbor);
// If the neighbor is not in the GScoreDict OR its current tentativeGScore is lower than the previously saved one:
if (!gScoreDict.ContainsKey(neighbor) ||
(gScoreDict.ContainsKey(neighbor) && tentativeGScore < gScoreDict[neighbor]))
if (!gScoreDict.TryGetValue(neighbor, out int existingGScore) ||
tentativeGScore < existingGScore)
{
// Save the new relation between the neighbored block and the current one
cameFrom[neighbor] = current.Location;

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

@ -12,9 +12,9 @@ namespace MinecraftClient.Mapping
{
/// <summary>
/// The chunks contained into the Minecraft world
/// Tuple<int, int>: Tuple<chunkX, chunkZ>
/// (int ChunkX, int ChunkZ): chunkX, chunkZ
/// </summary>
private ConcurrentDictionary<Tuple<int, int>, ChunkColumn> chunks = new();
private ConcurrentDictionary<(int ChunkX, int ChunkZ), ChunkColumn> chunks = new();
/// <summary>
/// The dimension info of the world
@ -49,12 +49,12 @@ namespace MinecraftClient.Mapping
{
get
{
chunks.TryGetValue(new(chunkX, chunkZ), out ChunkColumn? chunkColumn);
chunks.TryGetValue((chunkX, chunkZ), out ChunkColumn? chunkColumn);
return chunkColumn;
}
set
{
Tuple<int, int> chunkCoord = new(chunkX, chunkZ);
var chunkCoord = (chunkX, chunkZ);
if (value is null)
chunks.TryRemove(chunkCoord, out _);
else
@ -361,7 +361,7 @@ namespace MinecraftClient.Mapping
/// <param name="loadCompleted">Whether the ChunkColumn has been fully loaded</param>
public void StoreChunk(int chunkX, int chunkY, int chunkZ, int chunkColumnSize, Chunk? chunk, bool loadCompleted)
{
ChunkColumn chunkColumn = chunks.GetOrAdd(new(chunkX, chunkZ), (_) => new(chunkColumnSize));
ChunkColumn chunkColumn = chunks.GetOrAdd((chunkX, chunkZ), (_) => new(chunkColumnSize));
chunkColumn[chunkY] = chunk;
if (loadCompleted)
chunkColumn.FullyLoaded = true;

View file

@ -44,10 +44,15 @@ namespace MinecraftClient
private readonly Queue<Action> threadTasks = new();
private readonly Lock threadTasksLock = new();
private readonly Lock recipeBookLock = new();
private readonly Lock achievementsLock = new();
private readonly List<ChatBot> bots = new();
private static readonly List<ChatBot> botsOnHold = new();
private static readonly Dictionary<int, Container> inventories = new();
private readonly Dictionary<string, RecipeBookRecipeEntry> unlockedRecipes = new(StringComparer.Ordinal);
private readonly Dictionary<string, Achievement> achievements = new(StringComparer.Ordinal);
private string? activeAdvancementTab;
private readonly Dictionary<string, List<ChatBot>> registeredBotPluginChannels = new();
private readonly List<string> registeredServerPluginChannels = new();
@ -105,6 +110,12 @@ namespace MinecraftClient
// player effects
private readonly Dictionary<Effects, EffectData> playerEffects = new();
// 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;
@ -156,6 +167,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; }
@ -1257,6 +1292,7 @@ namespace MinecraftClient
inventoryHandlingEnabled = false;
inventoryHandlingRequested = false;
inventories.Clear();
ClearUnlockedRecipes();
}
return true;
}
@ -1358,6 +1394,54 @@ namespace MinecraftClient
return lastEnchantment;
}
/// <summary>
/// Get all unlocked recipe book recipe identifiers.
/// </summary>
/// <returns>Unlocked recipe identifiers sorted alphabetically</returns>
public RecipeBookRecipeEntry[] GetUnlockedRecipes()
{
lock (recipeBookLock)
{
return unlockedRecipes.Values.OrderBy(static recipe => recipe.CommandId, StringComparer.Ordinal).ToArray();
}
}
/// <summary>
/// Get all achievements/advancements known to the client.
/// </summary>
/// <returns>Snapshot of all achievements</returns>
public Achievement[] GetAchievements()
{
lock (achievementsLock)
{
return [.. achievements.Values];
}
}
/// <summary>
/// Get only completed achievements/advancements.
/// </summary>
/// <returns>Snapshot of completed achievements</returns>
public Achievement[] GetUnlockedAchievements()
{
lock (achievementsLock)
{
return achievements.Values.Where(static a => a.IsCompleted).ToArray();
}
}
/// <summary>
/// Get only incomplete achievements/advancements.
/// </summary>
/// <returns>Snapshot of locked achievements</returns>
public Achievement[] GetLockedAchievements()
{
lock (achievementsLock)
{
return achievements.Values.Where(static a => !a.IsCompleted).ToArray();
}
}
/// <summary>
/// Get all Entities
/// </summary>
@ -1404,6 +1488,22 @@ namespace MinecraftClient
return GetInventory(0)!;
}
/// <summary>
/// Get the currently active inventory if it supports recipe book crafting.
/// </summary>
/// <returns>Active recipe book inventory, or null if the active inventory does not support recipe book crafting</returns>
public Container? GetActiveRecipeBookInventory()
{
if (InvokeRequired)
return InvokeOnMainThread(() => GetActiveRecipeBookInventory());
if (inventories.Count == 0)
return null;
Container activeInventory = inventories.MaxBy(static pair => pair.Key).Value;
return SupportsRecipeBook(activeInventory.Type) ? activeInventory : null;
}
/// <summary>
/// Get a set of online player names
/// </summary>
@ -1499,6 +1599,12 @@ namespace MinecraftClient
if (String.IsNullOrEmpty(text))
return;
if (!CanSendMessage)
{
Log.Warn(Translations.mcc_send_text_not_connected);
return;
}
int maxLength = handler.GetMaxChatMessageLength();
lock (chatQueue)
@ -2516,6 +2622,7 @@ namespace MinecraftClient
inventories.Clear();
inventories[0] = new Container(0, ContainerType.PlayerInventory, "Player Inventory");
ClearUnlockedRecipes();
return true;
}
@ -2599,6 +2706,13 @@ namespace MinecraftClient
if (lookAtBlock)
UpdateLocation(GetCurrentLocation(), location);
// Auto-compute dig duration for survival/adventure mode when not explicitly supplied
if (duration <= 0 && protocolversion >= Protocol18Handler.MC_1_8_Version
&& gamemode is 0 or 2) // Survival or Adventure
{
duration = ComputeAutoDigDuration(location);
}
// Send dig start and dig end, will need to wait for server response to know dig result
// See https://wiki.vg/How_to_Write_a_Client#Digging for more details
bool result = handler.SendPlayerDigging(0, location, blockFace, sequenceId++)
@ -2616,6 +2730,52 @@ namespace MinecraftClient
}
}
/// <summary>
/// Compute the automatic dig duration in seconds for a block, based on held tool,
/// enchantments, effects, attributes, and player state.
/// Returns 0 for instant-break blocks.
/// </summary>
private double ComputeAutoDigDuration(Location location)
{
try
{
Block block = world.GetBlock(location);
Material blockMaterial = block.Type;
if (blockMaterial == Material.Air)
return 0;
// Get held item from player inventory
Item? heldItem = null;
Item? helmetItem = null;
if (inventories.TryGetValue(0, out var playerInv))
{
int hotbarSlot = 36 + CurrentSlot; // Hotbar slots are 36-44
playerInv.Items.TryGetValue(hotbarSlot, out heldItem);
playerInv.Items.TryGetValue(5, out helmetItem); // Slot 5 = helmet
}
int ticks = MiningCalculator.ComputeDigTicks(
blockMaterial,
heldItem,
helmetItem,
playerEffects,
playerAttributes,
playerPhysics.InWater,
playerPhysics.OnGround,
protocolversion);
if (ticks <= 0)
return 0;
return (double)ticks / Settings.ClientTicksPerSecond;
}
catch
{
return 0;
}
}
/// <summary>
/// Change active slot in the player inventory
/// </summary>
@ -2752,6 +2912,31 @@ namespace MinecraftClient
return handler.SendRenameItem(itemName);
}
/// <summary>
/// Send a recipe book craft request for the currently active crafting inventory.
/// </summary>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if the packet was sent</returns>
public bool SendPlaceRecipe(string recipeId, bool makeAll)
{
if (InvokeRequired)
return InvokeOnMainThread(() => SendPlaceRecipe(recipeId, makeAll));
if (protocolversion < Protocol18Handler.MC_1_13_Version)
return false;
Container? activeInventory = GetActiveRecipeBookInventory();
if (activeInventory is null)
return false;
string normalizedRecipeId = NormalizeRecipeArgument(recipeId, protocolversion);
if (normalizedRecipeId.Length == 0)
return false;
return handler.SendPlaceRecipe(activeInventory.ID, normalizedRecipeId, makeAll);
}
#endregion
#region Event handlers: An event occurs on the Server
@ -3737,6 +3922,44 @@ namespace MinecraftClient
}
}
/// <summary>
/// Called when an entity velocity update is received.
/// </summary>
/// <param name="entityID">Entity ID</param>
/// <param name="velocityX">Velocity on X axis (blocks/tick)</param>
/// <param name="velocityY">Velocity on Y axis (blocks/tick)</param>
/// <param name="velocityZ">Velocity on Z axis (blocks/tick)</param>
public void OnEntityVelocity(int entityID, double velocityX, double velocityY, double velocityZ)
{
if (entities.TryGetValue(entityID, out Entity? entity))
DispatchBotEvent(bot => bot.OnEntityVelocity(entity, velocityX, velocityY, velocityZ));
}
/// <summary>
/// Called when a sound packet is received.
/// </summary>
/// <param name="soundName">Sound key when available, otherwise null</param>
/// <param name="location">Sound location when available</param>
/// <param name="category">Sound category id from packet</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="entityID">Source entity id for entity sound packets, if any</param>
public void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
int? entityID)
{
Entity? sourceEntity = null;
Location? resolvedLocation = location;
if (entityID is int id && entities.TryGetValue(id, out Entity? entity))
{
sourceEntity = entity;
resolvedLocation ??= entity.Location;
}
DispatchBotEvent(bot => bot.OnSoundEffect(soundName, resolvedLocation, category, volume, pitch,
sourceEntity));
}
/// <summary>
/// Called when received entity properties from server.
/// </summary>
@ -3746,6 +3969,9 @@ namespace MinecraftClient
{
if (EntityID == playerEntityID)
{
foreach (var kvp in prop)
playerAttributes[kvp.Key] = kvp.Value;
DispatchBotEvent(bot => bot.OnPlayerProperty(prop));
}
}
@ -3777,11 +4003,15 @@ namespace MinecraftClient
{
DateTime currentTime = DateTime.Now;
long tickDiff = WorldAge - lastAge;
Double tps = tickDiff / (currentTime - lastTime).TotalSeconds;
double tps = tickDiff / (currentTime - lastTime).TotalSeconds;
lastAge = WorldAge;
lastTime = currentTime;
if (tps <= 20 && tps > 0)
if (tps > 0)
{
// A Minecraft server cannot genuinely exceed 20 TPS; values above 20 are
// caused by packet-timing jitter. Clamp instead of discarding so that a
// healthy server averages to 20 rather than being biased downward.
tps = Math.Min(tps, 20.0);
// calculate average tps
if (tpsSamples.Count >= maxSamples)
{
@ -3951,7 +4181,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>
@ -4152,6 +4453,95 @@ namespace MinecraftClient
Log.Debug("CanSendMessage = " + canSendMessage);
}
public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace)
{
lock (recipeBookLock)
{
if (replace)
unlockedRecipes.Clear();
foreach (RecipeBookRecipeEntry recipe in recipes)
{
// Guard against malformed server packets that send empty display IDs.
if (!string.IsNullOrWhiteSpace(recipe.CommandId))
unlockedRecipes[recipe.CommandId] = recipe;
}
}
}
public void OnRecipeBookRemove(string[] recipeIds)
{
lock (recipeBookLock)
{
foreach (string recipeId in recipeIds)
{
if (!string.IsNullOrWhiteSpace(recipeId))
unlockedRecipes.Remove(recipeId);
}
}
}
public void OnAchievementsUpdate(IReadOnlyList<Achievement> added, IReadOnlyList<string> removedIds, bool reset)
{
lock (achievementsLock)
{
if (reset)
achievements.Clear();
// Remove entries
foreach (string id in removedIds)
achievements.Remove(id);
// Add/update entries. For progress-only updates (no definition),
// merge with existing definition if available.
foreach (Achievement entry in added)
{
if (entry.Title is null && achievements.TryGetValue(entry.Id, out Achievement? existing))
{
// Progress-only update - merge with existing definition
bool isCompleted = ComputeAchievementCompleted(existing.Requirements, entry.CriteriaProgress);
achievements[entry.Id] = existing with { IsCompleted = isCompleted, CriteriaProgress = entry.CriteriaProgress };
}
else
{
achievements[entry.Id] = entry;
}
}
}
DispatchBotEvent(bot => bot.OnAchievementUpdate(added, removedIds, reset));
}
public void OnSelectAdvancementTab(string? tabId)
{
activeAdvancementTab = tabId;
}
/// <summary>
/// Compute whether an achievement is completed based on AND-of-ORs requirements.
/// </summary>
private static bool ComputeAchievementCompleted(IReadOnlyList<IReadOnlyList<string>> requirements, IReadOnlyDictionary<string, bool> criteria)
{
if (requirements.Count == 0)
return true;
foreach (IReadOnlyList<string> group in requirements)
{
bool groupSatisfied = false;
foreach (string criterion in group)
{
if (criteria.TryGetValue(criterion, out bool done) && done)
{
groupSatisfied = true;
break;
}
}
if (!groupSatisfied)
return false;
}
return true;
}
/// <summary>
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom
@ -4296,6 +4686,51 @@ namespace MinecraftClient
return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z);
}
private static bool SupportsRecipeBook(ContainerType containerType)
{
return containerType switch
{
ContainerType.PlayerInventory or
ContainerType.Crafting or
ContainerType.Furnace or
ContainerType.BlastFurnace or
ContainerType.Smoker or
ContainerType.Stonecutter => true,
_ => false,
};
}
private void ClearUnlockedRecipes()
{
lock (recipeBookLock)
{
unlockedRecipes.Clear();
}
}
/// <summary>
/// Normalize a recipe argument for the target protocol version.
/// Legacy recipe-book packets use identifiers and default to the minecraft namespace.
/// 1.21.2+ recipe-book packets use numeric recipe display ids and should be left trimmed-only.
/// </summary>
internal static string NormalizeRecipeArgument(string recipeId, int protocolVersion)
{
return protocolVersion >= Protocol18Handler.MC_1_21_2_Version
? recipeId.Trim()
: NormalizeRecipeId(recipeId);
}
private static string NormalizeRecipeId(string recipeId)
{
string trimmedRecipeId = recipeId.Trim();
if (trimmedRecipeId.Length == 0)
return string.Empty;
return trimmedRecipeId.Contains(':', StringComparison.Ordinal)
? trimmedRecipeId
: "minecraft:" + trimmedRecipeId;
}
#endregion
}
}

View file

@ -21,6 +21,8 @@
<ItemGroup>
<EmbeddedResource Include="Physics\BlockShapeData.json" LogicalName="BlockShapeData.json" />
<EmbeddedResource Include="Mcp\Prompts\MccMcpOperatorPrompt.md" LogicalName="MccMcpOperatorPrompt.md" />
<EmbeddedResource Include="Tui\MinimapBlockColors.json" LogicalName="MinimapBlockColors.json" />
<EmbeddedResource Include="Tui\MinimapEntityCategories.json" LogicalName="MinimapEntityCategories.json" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Protocol\Handlers\Compression\**" />

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -19,7 +20,7 @@ namespace MinecraftClient.Physics
private static readonly Aabb[] FullBlockArray = { FullBlock };
private static readonly Aabb[] EmptyArray = Array.Empty<Aabb>();
private static Dictionary<int, Aabb[]>? stateToShape;
private static FrozenDictionary<int, Aabb[]>? stateToShape;
private static Dictionary<string, object>? prismarineBlocks;
private static Dictionary<int, Aabb[]>? prismarineShapes;
@ -136,14 +137,21 @@ namespace MinecraftClient.Physics
private static void BuildStateMap()
{
stateToShape = new Dictionary<int, Aabb[]>();
var builder = new Dictionary<int, Aabb[]>();
if (prismarineBlocks is null || prismarineShapes is null)
{
stateToShape = builder.ToFrozenDictionary();
return;
}
var palette = Block.Palette;
var dict = GetPaletteDict(palette);
if (dict is null) return;
if (dict is null)
{
stateToShape = builder.ToFrozenDictionary();
return;
}
// Group consecutive state IDs by Material to find state ranges per block
var materialRanges = new Dictionary<Material, List<(int start, int end)>>();
@ -182,20 +190,20 @@ namespace MinecraftClient.Physics
{
var shapes = prismarineShapes.GetValueOrDefault(singleShapeId, EmptyArray);
for (int sid = start; sid <= end; sid++)
stateToShape[sid] = shapes;
builder[sid] = shapes;
}
else if (blockShapeData is List<int> shapeIdList)
{
for (int i = 0; i < stateCount && (globalStateOffset + i) < shapeIdList.Count; i++)
{
int shapeId = shapeIdList[globalStateOffset + i];
stateToShape[start + i] = prismarineShapes.GetValueOrDefault(shapeId, EmptyArray);
builder[start + i] = prismarineShapes.GetValueOrDefault(shapeId, EmptyArray);
}
}
globalStateOffset += stateCount;
}
}
stateToShape = builder.ToFrozenDictionary();
}
/// <summary>

View file

@ -4,6 +4,8 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -161,14 +163,7 @@ namespace MinecraftClient
}
if (configResult.NeedWriteDefault)
{
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
WriteBackSettings(false);
}
else if (configResult.Success)
{
WriteBackSettings(true);
}
if (!Config.Main.Advanced.EnableSentry)
_sentrySdk?.Dispose();
@ -181,12 +176,22 @@ namespace MinecraftClient
};
// --- Determine console mode and initialize backend ---
if (!OperatingSystem.IsWindows())
InstallCursesNativeResolver();
if (!ConsoleIO.BasicIO && Config.Console.General.ConsoleMode == ConsoleModeType.tui)
{
ConsoleIO.Backend?.Shutdown();
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args, startupState);
try
{
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args, startupState);
}
catch (Exception ex)
{
HandleTuiStartupFailure(ex);
}
return;
}
@ -200,9 +205,46 @@ namespace MinecraftClient
if (!ProcessStartupState(startupState))
return;
// Wait for this issue to be fixed before enabling it: https://github.com/Consolonia/Consolonia/issues/602
// MaybePrintClassicModeTuiRecommendation();
RunStartupSequence(args);
}
/// <summary>
/// Consolonia's Unix.Terminal uses <c>[DllImport("libcoreclr.so")]</c> to reach
/// <c>dlopen</c>/<c>dlsym</c> on .NET Core. The library ships a
/// <c>SetDllImportResolver</c> that maps <c>libcoreclr.so</c> to the current
/// process, but it is compiled under <c>#if NET6_0</c> (exact TFM match) instead
/// of <c>NET6_0_OR_GREATER</c>, so it is dead code when the consuming project
/// targets net8.0+. On a self-contained single-file publish the physical
/// <c>libcoreclr.so</c> does not exist on the search path, causing a
/// <c>DllNotFoundException</c> that crashes the TUI.
///
/// We work around this by registering our own resolver before any Consolonia
/// code runs: if any assembly asks for <c>libcoreclr.so</c> we return
/// <c>(IntPtr)(-1)</c> which the runtime interprets as "the current process".
/// </summary>
private static void InstallCursesNativeResolver()
{
AssemblyLoadContext.Default.ResolvingUnmanagedDll += (assembly, libraryName) =>
libraryName == "libcoreclr.so" ? (IntPtr)(-1) : IntPtr.Zero;
}
private static void HandleTuiStartupFailure(Exception exception)
{
Config.Console.General.ConsoleMode = ConsoleModeType.classic;
WriteBackSettings(enableBackup: false);
ConsoleIO.Backend = new ClassicConsoleBackend();
ConsoleIO.Backend.Init();
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_tui_startup_failed);
ConsoleIO.WriteLine(exception.ToString());
ConsoleIO.WriteLineFormatted("§e" + Translations.mcc_report_issue);
ConsoleIO.WriteLineFormatted("§e" + Translations.mcc_tui_startup_fallback_classic);
}
/// <summary>
/// Prints the application banner and processes the startup state collected before
/// the console backend was ready. Called once from classic mode or from TUI after
@ -211,14 +253,33 @@ namespace MinecraftClient
/// <returns>True if startup can continue; false if config load failed and user chose to exit.</returns>
internal static bool ProcessStartupState(StartupState state)
{
ConsoleIO.WriteLine($"Minecraft Console Client v{Version} - for MC {MCLowestVersion} to {MCHighestVersion} - Github.com/MCCTeam");
if (BuildInfo is not null)
ConsoleIO.WriteLineFormatted("§8" + BuildInfo);
if (Config.Console.General.Display_Icon_Banner && ConsoleIO.Backend is Tui.TuiConsoleBackend tuiBanner)
{
var view = tuiBanner.GetView();
if (view is not null)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var panel = Tui.MccBannerPanelBuilder.Build(BuildInfo);
view.AppendControlToLog(panel);
});
}
else
{
ShowClassicBanner();
}
}
else
{
ShowClassicBanner();
}
var cfg = state.ConfigResult;
if (cfg.NeedWriteDefault)
{
WriteBackSettings(false);
if (cfg.IsLegacyUpgrade)
{
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_use_new_config);
@ -243,6 +304,8 @@ namespace MinecraftClient
}
else
{
WriteBackSettings(true);
if (!Config.Main.Advanced.Language.StartsWith("en"))
ConsoleIO.WriteLine(string.Format(Translations.mcc_help_us_translate, Settings.TranslationProjectUrl));
}
@ -250,6 +313,26 @@ namespace MinecraftClient
return true;
}
private static void ShowClassicBanner()
{
ConsoleIO.WriteLine(string.Format(Translations.mcc_banner_classic, Version, MCLowestVersion, MCHighestVersion, "Github.com/MCCTeam"));
if (BuildInfo is not null)
ConsoleIO.WriteLineFormatted("§8" + BuildInfo);
}
private static void MaybePrintClassicModeTuiRecommendation()
{
if (ConsoleIO.BasicIO
|| Config.Console.General.ConsoleMode != ConsoleModeType.classic
|| Console.IsInputRedirected)
{
return;
}
char cmdChar = Config.Main.Advanced.InternalCmdChar.ToChar();
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.mcc_console_mode_tui_recommendation, cmdChar));
}
/// <summary>
/// Handles a failed config load by prompting the user to fix or regenerate the config file.
/// </summary>

View file

@ -411,6 +411,37 @@ namespace MinecraftClient.Protocol.Handlers
return ReadNextNbt(cache, true);
}
/// <summary>
/// Read an ItemStackTemplate (26.1+) from a cache of bytes.
/// Unlike ItemStack, this uses item-first encoding: item_id, count, DataComponentPatch.
/// ItemStackTemplate is always non-empty (no count=0 sentinel).
/// </summary>
public Item ReadNextItemStackTemplate(Queue<byte> cache, ItemPalette itemPalette)
{
var itemId = ReadNextVarInt(cache);
var itemCount = ReadNextVarInt(cache);
var item = new Item(itemPalette.FromId(itemId), itemCount, null);
var numberOfComponentsToAdd = ReadNextVarInt(cache);
var numberofComponentsToRemove = ReadNextVarInt(cache);
var structuredComponentHandler = new StructuredComponentsHandler(protocolversion, this, itemPalette);
var strcturedComponentsToAdd = new List<StructuredComponent>(numberOfComponentsToAdd);
for (var i = 0; i < numberOfComponentsToAdd; i++)
{
var componentTypeId = ReadNextVarInt(cache);
strcturedComponentsToAdd.Add(structuredComponentHandler.Parse(componentTypeId, cache));
}
for (var i = 0; i < numberofComponentsToRemove; i++)
ReadNextVarInt(cache);
if (strcturedComponentsToAdd.Count > 0)
item.Components = strcturedComponentsToAdd;
return item;
}
/// <summary>
/// Read a single item slot from a cache of bytes and remove it from the cache
/// </summary>
@ -664,8 +695,10 @@ namespace MinecraftClient.Protocol.Handlers
}
}
return new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch,
var entity = new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch,
data);
entity.UUID = entityUUID;
return entity;
}
/// <summary>
@ -1021,20 +1054,44 @@ namespace MinecraftClient.Protocol.Handlers
}
}
private static bool HasLpVec3Continuation(int firstByte) => (firstByte & 4) == 4;
private static double UnpackLpVec3(long packedAxis)
{
return Math.Min((double)(packedAxis & 32767L), 32766.0) * 2.0 / 32766.0 - 1.0;
}
/// <summary>
/// Read an LpVec3 (low-precision vec3) from the cache (1.21.9+).
/// Variable-length encoding: first byte 0 = zero vector; otherwise
/// 2 bytes + 4 bytes (6 total), plus an optional VarInt continuation.
/// Read and decode an LpVec3 (low-precision vec3) from the cache (1.21.9+).
/// Returned vector is expressed in blocks per tick.
/// </summary>
public void ReadNextLpVec3(Queue<byte> cache)
public (double X, double Y, double Z) ReadNextLpVec3Values(Queue<byte> cache)
{
int first = ReadNextByte(cache);
if (first == 0)
return;
ReadNextByte(cache); // second byte
ReadData(4, cache); // uint32
if ((first & 4) == 4) // continuation bit set
ReadNextVarInt(cache);
return (0.0, 0.0, 0.0);
int second = ReadNextByte(cache);
uint high = (uint)ReadNextInt(cache);
long packed = ((long)high << 16) | (long)(second << 8) | (uint)first;
long scale = first & 3;
if (HasLpVec3Continuation(first))
scale |= ((long)ReadNextVarInt(cache) & 0xFFFFFFFFL) << 2;
return (
UnpackLpVec3(packed >> 3) * scale,
UnpackLpVec3(packed >> 18) * scale,
UnpackLpVec3(packed >> 33) * scale
);
}
/// <summary>
/// Read an LpVec3 (low-precision vec3) from the cache (1.21.9+) and discard it.
/// </summary>
public void ReadNextLpVec3(Queue<byte> cache)
{
ReadNextLpVec3Values(cache);
}
/// <summary>

View file

@ -811,6 +811,11 @@ namespace MinecraftClient.Protocol.Handlers
return false; //Currently not implemented
}
public bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll)
{
return false; //MC 1.8-1.12.1 recipe book not supported
}
public bool SendCloseWindow(int windowId)
{
return false; //Currently not implemented

File diff suppressed because it is too large Load diff

View file

@ -190,6 +190,15 @@ namespace MinecraftClient.Protocol
bool ClickContainerButton(int windowId, int buttonId);
/// <summary>
/// Send a place recipe packet to the server for the active recipe book container.
/// </summary>
/// <param name="windowId">Id of the window being clicked</param>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if packet was successfully sent</returns>
bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll);
/// <summary>
/// Plays animation
/// </summary>

View file

@ -295,6 +295,16 @@ namespace MinecraftClient.Protocol
/// <param name="onGround">TRUE if on ground</param>
void OnEntityTeleport(int entityID, Double x, Double y, Double z, bool onGround);
/// <summary>
/// Called when an entity velocity update packet is received.
/// Velocity values are in blocks per tick.
/// </summary>
/// <param name="entityID">Entity ID</param>
/// <param name="velocityX">Velocity X</param>
/// <param name="velocityY">Velocity Y</param>
/// <param name="velocityZ">Velocity Z</param>
void OnEntityVelocity(int entityID, double velocityX, double velocityY, double velocityZ);
/// <summary>
/// Called when additional properties have been received for an entity
/// </summary>
@ -371,6 +381,17 @@ namespace MinecraftClient.Protocol
/// <param name="affectedBlocks">Amount of affected blocks</param>
void OnExplosion(Location location, float strength, int affectedBlocks);
/// <summary>
/// Called when a sound packet is received.
/// </summary>
/// <param name="soundName">Sound key if available, otherwise null</param>
/// <param name="location">Sound location for world sounds, or null if unavailable</param>
/// <param name="category">Sound category id</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="entityID">Source entity id for entity-sound packets, if any</param>
void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch, int? entityID);
/// <summary>
/// Called when a player's game mode has changed
/// </summary>
@ -468,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>
@ -524,6 +562,33 @@ namespace MinecraftClient.Protocol
public void SetCanSendMessage(bool canSendMessage);
/// <summary>
/// Called when recipe book recipes are added or replaced.
/// </summary>
/// <param name="recipes">Recipe entries to add</param>
/// <param name="replace">True to replace the currently tracked recipe book entries</param>
public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace);
/// <summary>
/// Called when recipe book recipes are removed.
/// </summary>
/// <param name="recipeIds">Recipe identifiers to remove</param>
public void OnRecipeBookRemove(string[] recipeIds);
/// <summary>
/// Called when achievement/advancement data is received from the server.
/// </summary>
/// <param name="added">Achievements that were added or updated</param>
/// <param name="removedIds">IDs of achievements that were removed</param>
/// <param name="reset">True if all existing state should be cleared before applying</param>
public void OnAchievementsUpdate(IReadOnlyList<Achievement> added, IReadOnlyList<string> removedIds, bool reset);
/// <summary>
/// Called when the server selects an advancement tab.
/// </summary>
/// <param name="tabId">The tab identifier, or null if no tab is selected</param>
public void OnSelectAdvancementTab(string? tabId);
/// <summary>
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom

View file

@ -6,6 +6,7 @@ using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using DnsClient;
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.Forge;
@ -388,6 +389,61 @@ namespace MinecraftClient.Protocol
}
}
private static readonly Regex VersionTokenRegex = new(@"\d+\.\d+(?:\.\d+)?", RegexOptions.Compiled);
private static readonly int[] SupportedProtocols18 =
[
4, 5, 47, 107, 108, 109, 110, 210, 315, 316, 335, 338, 340, 393, 401, 404,
477, 480, 485, 490, 498, 573, 575, 578, 735, 736, 751, 753, 754, 755, 756,
757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771,
772, 773, 774, 775
];
/// <summary>
/// For multi-version servers (e.g. "Requires MC 1.8 / 1.21"), try to find the
/// highest protocol version that both the server and MCC support.
/// Returns true if the protocol was upgraded, with the new value in
/// <paramref name="protocolVersion"/>.
/// </summary>
public static bool TryUpgradeProtocolVersion(string versionName, ref int protocolVersion)
{
if (string.IsNullOrEmpty(versionName))
return false;
var matches = VersionTokenRegex.Matches(versionName);
if (matches.Count < 2)
return false;
int bestProtocol = protocolVersion;
string bestVersion = "";
foreach (Match m in matches)
{
int proto = MCVer2ProtocolVersion(m.Value);
if (proto <= 0)
continue;
if (Array.IndexOf(SupportedProtocols18, proto) < 0)
continue;
if (proto > bestProtocol)
{
bestProtocol = proto;
bestVersion = m.Value;
}
}
if (bestProtocol > protocolVersion && bestVersion.Length > 0)
{
ConsoleIO.WriteLineFormatted("§8" + string.Format(
Translations.mcc_server_info_version_upgrade,
ProtocolVersion2MCVer(protocolVersion), protocolVersion,
"§a" + bestVersion + "§8", bestProtocol));
protocolVersion = bestProtocol;
return true;
}
return false;
}
/// <summary>
/// Convert a network protocol version number to human-readable Minecraft version number
/// </summary>

View file

@ -0,0 +1,119 @@
using System;
using System.Text;
using MinecraftClient.Protocol.Message;
using MinecraftClient.Scripting;
namespace MinecraftClient.Protocol
{
internal static class ServerStatusDisplay
{
private const int MaxSamplePlayers = 10;
internal static void Show(ServerStatusInfo info)
{
if (ConsoleIO.Backend is Tui.TuiConsoleBackend tuiBackend)
ShowTui(info, tuiBackend);
else
ShowClassic(info);
}
private static void ShowClassic(ServerStatusInfo info)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.Append("§8§m");
sb.Append(new string('-', 50));
sb.AppendLine("§r");
if (!string.IsNullOrEmpty(info.MotdRaw))
{
try
{
sb.AppendLine(ChatParser.ParseText(info.MotdRaw));
}
catch
{
sb.AppendLine(info.MotdRaw);
}
}
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_server);
sb.Append(" §b");
sb.Append(info.Host);
sb.Append("§7:§b");
sb.AppendLine(info.Port.ToString());
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_version);
sb.Append(" §b");
sb.Append(ChatBot.GetVerbatim(info.VersionName));
sb.Append(" §7(");
sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§e" + info.ProtocolVersion + "§7"));
sb.AppendLine(")");
if (info.ResolvedProtocol != 0)
{
string resolvedMcVer = ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol);
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_connecting_as);
sb.Append(" §a");
sb.Append(resolvedMcVer);
sb.Append(" §7(");
sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§a" + info.ResolvedProtocol + "§7"));
sb.AppendLine(")");
}
if (info.PingMs >= 0)
{
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_ping);
sb.Append(" §a");
sb.AppendLine(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs));
}
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_players);
sb.Append(" §a");
sb.Append(info.OnlinePlayers);
sb.Append("§7/§c");
sb.AppendLine(info.MaxPlayers.ToString());
if (info.SamplePlayers.Count > 0)
{
sb.Append("§f");
sb.AppendLine(Translations.mcc_server_info_label_online);
int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers);
for (int i = 0; i < shown; i++)
sb.AppendLine($" §a{info.SamplePlayers[i].Name}");
if (info.SamplePlayers.Count > shown)
sb.AppendLine($" §7{string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}");
}
sb.Append("§8§m");
sb.Append(new string('-', 50));
sb.Append("§r");
ConsoleIO.WriteLineFormatted(sb.ToString(), acceptnewlines: true);
}
private static void ShowTui(ServerStatusInfo info, Tui.TuiConsoleBackend backend)
{
var view = backend.GetView();
if (view is null)
{
ShowClassic(info);
return;
}
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var panel = Tui.ServerStatusPanelBuilder.Build(info);
view.AppendControlToLog(panel);
});
}
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace MinecraftClient.Protocol
{
/// <summary>
/// Holds the structured result of a Minecraft server status (SLP) ping,
/// including MOTD, player counts, sample player list, version, and favicon.
/// </summary>
public sealed class ServerStatusInfo
{
public string Host { get; init; } = string.Empty;
public int Port { get; init; }
public string VersionName { get; init; } = string.Empty;
public int ProtocolVersion { get; init; }
public int ResolvedProtocol { get; set; }
public int OnlinePlayers { get; init; }
public int MaxPlayers { get; init; }
public List<SamplePlayer> SamplePlayers { get; init; } = [];
public string MotdRaw { get; init; } = string.Empty;
public string? FaviconBase64 { get; init; }
public long PingMs { get; init; }
public sealed class SamplePlayer
{
public string Name { get; init; } = string.Empty;
public string Id { get; init; } = string.Empty;
}
}
}

View file

@ -0,0 +1,4 @@
namespace MinecraftClient
{
public readonly record struct RecipeBookRecipeEntry(string CommandId, string DisplayText);
}

File diff suppressed because it is too large Load diff

View file

@ -311,6 +311,21 @@ You can use "/fish" to control the bot manually.
<data name="ChatBot.AutoFishing.Hook_Threshold" xml:space="preserve">
<value>A "stationary" hook that moves above this threshold in the Y-axis will be considered to have caught a fish.</value>
</data>
<data name="ChatBot.AutoFishing.Enable_Velocity_Detection" xml:space="preserve">
<value>Enable fish bite detection using fishing bobber velocity packets.</value>
</data>
<data name="ChatBot.AutoFishing.Velocity_Hook_Threshold" xml:space="preserve">
<value>Velocity Y threshold (blocks/tick). Values below this are treated as a bite. Keep this value negative.</value>
</data>
<data name="ChatBot.AutoFishing.Enable_Sound_Detection" xml:space="preserve">
<value>Enable fish bite detection using splash sounds near the fishing bobber.</value>
</data>
<data name="ChatBot.AutoFishing.Sound_Distance" xml:space="preserve">
<value>Maximum distance (blocks) between splash sound and bobber to treat it as a bite.</value>
</data>
<data name="ChatBot.AutoFishing.Detection_Warmup" xml:space="preserve">
<value>Delay (seconds) after bobber spawn before bite detection starts. Helps ignore cast-entry splash/motion.</value>
</data>
<data name="ChatBot.AutoFishing.Log_Fish_Bobber" xml:space="preserve">
<value>Used to adjust the above two thresholds, which when enabled will print the change in the position of the fishhook entity upon receipt of its movement packet.</value>
</data>
@ -393,6 +408,12 @@ For Discord message formatting, check the following: https://mccteam.github.io/r
<data name="ChatBot.DiscordBridge.AllowOtherBotMessages" xml:space="preserve">
<value>When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops.</value>
</data>
<data name="ChatBot.DiscordBridge.RelayAllMessages" xml:space="preserve">
<value>When enabled, all text received from the Minecraft server (including system messages, join/leave notifications, etc.) will be relayed to Discord, not just player chat and private messages.</value>
</data>
<data name="ChatBot.DiscordBridge.MessageAggregationInterval" xml:space="preserve">
<value>Interval in seconds to aggregate messages before sending them to Discord. When set to 0, messages are sent immediately one by one. When set to a value like 1.0, messages received within that interval are batched into a single Discord message. Useful for reducing Discord API rate limits.</value>
</data>
<data name="ChatBot.Farmer" xml:space="preserve">
<value>Automatically farms crops for you (plants, breaks and bonemeals them).
Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat.
@ -560,9 +581,18 @@ Custom colors are only available when using "vt100_24bit" color mode.</value>
<data name="Console.General.ConsoleColorMode" xml:space="preserve">
<value>Use "disable", "legacy_4bit", "vt100_4bit", "vt100_8bit" or "vt100_24bit". If a garbled code like "←[0m" appears on the terminal, you can try switching to "legacy_4bit" mode, or just disable it.</value>
</data>
<data name="Console.General.Display_Icon_Banner" xml:space="preserve">
<value>Whether to display the MCC startup icon banner.</value>
</data>
<data name="Console.General.Display_Input" xml:space="preserve">
<value>You can use "Ctrl+P" to print out the current input and cursor position.</value>
</data>
<data name="Console.General.History_Input_Records" xml:space="preserve">
<value>Maximum number of input history records to keep.</value>
</data>
<data name="Console.General.TUI_Log_Scrollback" xml:space="preserve">
<value>Maximum log lines kept in TUI mode scrollback. Set to 0 for automatic.</value>
</data>
<data name="Head" xml:space="preserve">
<value>Startup Config File
Please do not record extraneous data in this file as it will be overwritten by MCC.
@ -933,6 +963,42 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
<data name="Main.Advanced.enable_sentry" xml:space="preserve">
<value>Set to false to opt-out of Sentry error logging.</value>
</data>
<data name="Console.Minimap" xml:space="preserve">
<value>Settings for the TUI minimap overlay that shows terrain and entities.</value>
</data>
<data name="Console.Minimap.Enabled" xml:space="preserve">
<value>Whether the minimap is visible on startup in TUI mode.</value>
</data>
<data name="Console.Minimap.Zoom" xml:space="preserve">
<value>Blocks per pixel, 1-16. 1 = closest (1:1), 16 = farthest (16 blocks per pixel).</value>
</data>
<data name="Console.Minimap.Width" xml:space="preserve">
<value>Map width in pixels (characters). Range 10-120, default 40.</value>
</data>
<data name="Console.Minimap.Height" xml:space="preserve">
<value>Map height in pixels (must be even, uses half-block chars). Range 4-80, default 40.</value>
</data>
<data name="Console.Minimap.Position" xml:space="preserve">
<value>Minimap position: "top_left", "top_right", "center", "bottom_left", or "bottom_right".</value>
</data>
<data name="Console.Minimap.ShowPlayerNames" xml:space="preserve">
<value>Show player names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowHostileNames" xml:space="preserve">
<value>Show hostile mob names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowNeutralNames" xml:space="preserve">
<value>Show neutral mob names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowPassiveNames" xml:space="preserve">
<value>Show passive mob names on the minimap.</value>
</data>
<data name="Console.Minimap.RefreshInterval" xml:space="preserve">
<value>Minimap refresh interval in milliseconds (100-5000).</value>
</data>
<data name="Console.Minimap.CaveMode" xml:space="preserve">
<value>Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view).</value>
</data>
<data name="Main.General.AuthlibUser" xml:space="preserve">
<value>Yggdrasil authlib multi-user selection.</value>
</data>

View file

@ -437,6 +437,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Dropped low durability {0} from slot {1}..
/// </summary>
internal static string bot_autodig_drop_low_durability {
get {
return ResourceManager.GetString("bot.autodig.drop_low_durability", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The block currently pointed to is not in the allowed list..
/// </summary>
@ -473,6 +482,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Switch to {0} from slot {1}..
/// </summary>
internal static string bot_autodig_switch {
get {
return ResourceManager.GetString("bot.autodig.switch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Added item {0}.
/// </summary>
@ -879,6 +897,24 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Waiting {0:0.000} seconds before reconnecting... ({1} retries left).
/// </summary>
internal static string bot_autoRelog_wait_with_retries {
get {
return ResourceManager.GetString("bot.autoRelog.wait_with_retries", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to unlimited.
/// </summary>
internal static string bot_autoRelog_retries_unlimited {
get {
return ResourceManager.GetString("bot.autoRelog.retries_unlimited", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to File not found: &apos;{0}&apos;.
/// </summary>
@ -2269,6 +2305,78 @@ namespace MinecraftClient {
}
}
internal static string mcc_banner_classic {
get {
return ResourceManager.GetString("mcc.banner.classic", resourceCulture);
}
}
internal static string mcc_banner_label_mc_versions {
get {
return ResourceManager.GetString("mcc.banner.label_mc_versions", resourceCulture);
}
}
internal static string mcc_server_info_label_server {
get {
return ResourceManager.GetString("mcc.server_info.label_server", resourceCulture);
}
}
internal static string mcc_server_info_label_version {
get {
return ResourceManager.GetString("mcc.server_info.label_version", resourceCulture);
}
}
internal static string mcc_server_info_label_protocol {
get {
return ResourceManager.GetString("mcc.server_info.label_protocol", resourceCulture);
}
}
internal static string mcc_server_info_label_players {
get {
return ResourceManager.GetString("mcc.server_info.label_players", resourceCulture);
}
}
internal static string mcc_server_info_label_ping {
get {
return ResourceManager.GetString("mcc.server_info.label_ping", resourceCulture);
}
}
internal static string mcc_server_info_label_ping_ms {
get {
return ResourceManager.GetString("mcc.server_info.label_ping_ms", resourceCulture);
}
}
internal static string mcc_server_info_label_connecting_as {
get {
return ResourceManager.GetString("mcc.server_info.label_connecting_as", resourceCulture);
}
}
internal static string mcc_server_info_label_online {
get {
return ResourceManager.GetString("mcc.server_info.label_online", resourceCulture);
}
}
internal static string mcc_server_info_sample_more {
get {
return ResourceManager.GetString("mcc.server_info.sample_more", resourceCulture);
}
}
internal static string mcc_server_info_version_upgrade {
get {
return ResourceManager.GetString("mcc.server_info.version_upgrade", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Converting session cache from disk: {0}.
/// </summary>
@ -3558,6 +3666,51 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to quickly enable recommended features..
/// </summary>
internal static string cmd_tryout_desc {
get {
return ResourceManager.GetString("cmd.tryout.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Available quick actions:.
/// </summary>
internal static string cmd_tryout_list_header {
get {
return ResourceManager.GetString("cmd.tryout.list.header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to tui: set [Console.General] ConsoleMode = &quot;tui&quot; for the next restart..
/// </summary>
internal static string cmd_tryout_list_tui {
get {
return ResourceManager.GetString("cmd.tryout.list.tui", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [Console.General] ConsoleMode is already &quot;tui&quot; in the config. To switch back, set [Console.General] ConsoleMode = &quot;classic&quot;. Restart MCC after changing it for the new mode to take effect..
/// </summary>
internal static string cmd_tryout_tui_already_enabled {
get {
return ResourceManager.GetString("cmd.tryout.tui.already_enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Updated [Console.General] ConsoleMode from &quot;{0}&quot; to &quot;{1}&quot; in the config. To switch back, set [Console.General] ConsoleMode = &quot;classic&quot;. Restart MCC to apply the change..
/// </summary>
internal static string cmd_tryout_tui_enabled {
get {
return ResourceManager.GetString("cmd.tryout.tui.enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Display Health and Food saturation..
/// </summary>
@ -4254,6 +4407,87 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.nameitem.successful", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to send recipe book craft request for {0}..
/// </summary>
internal static string cmd_recipebook_craft_failed {
get {
return ResourceManager.GetString("cmd.recipebook.craft.failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Requested recipe {0}..
/// </summary>
internal static string cmd_recipebook_craft_sent {
get {
return ResourceManager.GetString("cmd.recipebook.craft.sent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Requested recipe {0} with craft-all..
/// </summary>
internal static string cmd_recipebook_craftall_sent {
get {
return ResourceManager.GetString("cmd.recipebook.craftall.sent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to List unlocked recipe book recipes and craft them through the active recipe book inventory..
/// </summary>
internal static string cmd_recipebook_desc {
get {
return ResourceManager.GetString("cmd.recipebook.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unlocked recipe book recipes.
/// </summary>
internal static string cmd_recipebook_list {
get {
return ResourceManager.GetString("cmd.recipebook.list", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory..
/// </summary>
internal static string cmd_recipebook_no_active_inventory {
get {
return ResourceManager.GetString("cmd.recipebook.no.active.inventory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No unlocked recipe book recipes are currently tracked..
/// </summary>
internal static string cmd_recipebook_no_recipes {
get {
return ResourceManager.GetString("cmd.recipebook.no.recipes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The recipe identifier cannot be empty..
/// </summary>
internal static string cmd_recipebook_recipe_id_empty {
get {
return ResourceManager.GetString("cmd.recipebook.recipe.id.empty", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Recipe book crafting is only supported on Minecraft 1.13 and newer..
/// </summary>
internal static string cmd_recipebook_unsupported {
get {
return ResourceManager.GetString("cmd.recipebook.unsupported", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to restart and reconnect to the server..
@ -4462,6 +4696,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>
@ -5534,6 +5813,42 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Tip: try TUI mode for a cleaner interface, mouse-friendly container actions, and a nicer layout. Run {0}feature tui§8 to switch [Console.General] ConsoleMode to &quot;tui&quot; for the next restart..
/// </summary>
internal static string mcc_console_mode_tui_recommendation {
get {
return ResourceManager.GetString("mcc.console_mode_tui_recommendation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to MCC encountered a problem while starting TUI mode..
/// </summary>
internal static string mcc_tui_startup_failed {
get {
return ResourceManager.GetString("mcc.tui_startup_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to As a fallback, MCC has automatically switched [Console.General] ConsoleMode to &quot;classic&quot;. This will take effect after you restart MCC..
/// </summary>
internal static string mcc_tui_startup_fallback_classic {
get {
return ResourceManager.GetString("mcc.tui_startup_fallback_classic", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please report this issue to the MCC Team..
/// </summary>
internal static string mcc_report_issue {
get {
return ResourceManager.GetString("mcc.report_issue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to To sign in, open {0} in your browser and enter the code: {1}.
/// </summary>
@ -5861,6 +6176,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Cannot send text: not connected to a server..
/// </summary>
internal static string mcc_send_text_not_connected {
get {
return ResourceManager.GetString("mcc.send_text_not_connected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Waiting {0} seconds before restarting....
/// </summary>
@ -6799,7 +7123,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("tui.crafting.grid", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Starting embedded MCP server....
/// </summary>
@ -6808,7 +7132,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.starting", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP server started on {0}.
/// </summary>
@ -6817,7 +7141,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.started", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to start embedded MCP server: {0}.
/// </summary>
@ -6826,7 +7150,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.start_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP auth token is required but environment variable {0} is empty..
/// </summary>
@ -6835,7 +7159,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.missing_auth_token", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP server stopped..
/// </summary>
@ -6844,7 +7168,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.stopped", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to stop embedded MCP server cleanly: {0}.
/// </summary>
@ -6853,5 +7177,275 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.stop_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggle the TUI minimap overlay, or adjust its zoom level..
/// </summary>
internal static string cmd_minimap_desc {
get {
return ResourceManager.GetString("cmd.minimap.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap enabled..
/// </summary>
internal static string cmd_minimap_enabled {
get {
return ResourceManager.GetString("cmd.minimap.enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap disabled..
/// </summary>
internal static string cmd_minimap_disabled {
get {
return ResourceManager.GetString("cmd.minimap.disabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap zoom set to {0}:1 (blocks per pixel)..
/// </summary>
internal static string cmd_minimap_zoom_set {
get {
return ResourceManager.GetString("cmd.minimap.zoom_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current minimap zoom: {0}:1 blocks/px (range 1-{1})..
/// </summary>
internal static string cmd_minimap_zoom_current {
get {
return ResourceManager.GetString("cmd.minimap.zoom_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The minimap command is only available in TUI mode..
/// </summary>
internal static string cmd_minimap_tui_only {
get {
return ResourceManager.GetString("cmd.minimap.tui_only", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hostile.
/// </summary>
internal static string tui_minimap_legend_hostile {
get {
return ResourceManager.GetString("tui.minimap.legend.hostile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Passive.
/// </summary>
internal static string tui_minimap_legend_passive {
get {
return ResourceManager.GetString("tui.minimap.legend.passive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Neutral.
/// </summary>
internal static string tui_minimap_legend_neutral {
get {
return ResourceManager.GetString("tui.minimap.legend.neutral", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Player.
/// </summary>
internal static string tui_minimap_legend_player {
get {
return ResourceManager.GetString("tui.minimap.legend.player", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3}.
/// </summary>
internal static string cmd_minimap_names_status {
get {
return ResourceManager.GetString("cmd.minimap.names_status", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All entity name labels enabled..
/// </summary>
internal static string cmd_minimap_names_all_on {
get {
return ResourceManager.GetString("cmd.minimap.names_all_on", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All entity name labels disabled..
/// </summary>
internal static string cmd_minimap_names_all_off {
get {
return ResourceManager.GetString("cmd.minimap.names_all_off", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} name display: {1}.
/// </summary>
internal static string cmd_minimap_names_cat {
get {
return ResourceManager.GetString("cmd.minimap.names_cat", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} name display set to {1}..
/// </summary>
internal static string cmd_minimap_names_cat_set {
get {
return ResourceManager.GetString("cmd.minimap.names_cat_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current minimap position: {0}.
/// </summary>
internal static string cmd_minimap_position_current {
get {
return ResourceManager.GetString("cmd.minimap.position_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap position set to: {0}.
/// </summary>
internal static string cmd_minimap_position_set {
get {
return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current cave mode: {0}.
/// </summary>
internal static string cmd_minimap_cave_current {
get {
return ResourceManager.GetString("cmd.minimap.cave_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Cave mode set to: {0}.
/// </summary>
internal static string cmd_minimap_cave_set {
get {
return ResourceManager.GetString("cmd.minimap.cave_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to list achievements/advancements from the server..
/// </summary>
internal static string cmd_achievement_desc {
get {
return ResourceManager.GetString("cmd.achievement.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No achievements/advancements received yet..
/// </summary>
internal static string cmd_achievement_none {
get {
return ResourceManager.GetString("cmd.achievement.none", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No completed achievements/advancements..
/// </summary>
internal static string cmd_achievement_none_unlocked {
get {
return ResourceManager.GetString("cmd.achievement.none_unlocked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No incomplete achievements/advancements..
/// </summary>
internal static string cmd_achievement_none_locked {
get {
return ResourceManager.GetString("cmd.achievement.none_locked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Achievements/Advancements:.
/// </summary>
internal static string cmd_achievement_header {
get {
return ResourceManager.GetString("cmd.achievement.header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Completed achievements/advancements:.
/// </summary>
internal static string cmd_achievement_header_unlocked {
get {
return ResourceManager.GetString("cmd.achievement.header_unlocked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Incomplete achievements/advancements:.
/// </summary>
internal static string cmd_achievement_header_locked {
get {
return ResourceManager.GetString("cmd.achievement.header_locked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [DONE].
/// </summary>
internal static string cmd_achievement_done {
get {
return ResourceManager.GetString("cmd.achievement.done", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [TODO].
/// </summary>
internal static string cmd_achievement_todo {
get {
return ResourceManager.GetString("cmd.achievement.todo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} ({2}) [{3}].
/// </summary>
internal static string cmd_achievement_entry_titled {
get {
return ResourceManager.GetString("cmd.achievement.entry_titled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} [{2}].
/// </summary>
internal static string cmd_achievement_entry {
get {
return ResourceManager.GetString("cmd.achievement.entry", resourceCulture);
}
}
}
}

View file

@ -243,6 +243,9 @@
<data name="bot.autodig.no_inv_handle" xml:space="preserve">
<value>Inventory handling is not enabled. Unable to switch tools automatically.</value>
</data>
<data name="bot.autodig.drop_low_durability" xml:space="preserve">
<value>Dropped low durability {0} from slot {1}.</value>
</data>
<data name="bot.autodig.start" xml:space="preserve">
<value>Automatic digging has started.</value>
</data>
@ -252,6 +255,9 @@
<data name="bot.autodig.stop" xml:space="preserve">
<value>Auto-digging has been stopped.</value>
</data>
<data name="bot.autodig.switch" xml:space="preserve">
<value>Switch to {0} from slot {1}.</value>
</data>
<data name="bot.autoDrop.added" xml:space="preserve">
<value>Added item {0}</value>
</data>
@ -388,6 +394,12 @@
<data name="bot.autoRelog.wait" xml:space="preserve">
<value>Waiting {0:0.000} seconds before reconnecting...</value>
</data>
<data name="bot.autoRelog.wait_with_retries" xml:space="preserve">
<value>Waiting {0:0.000} seconds before reconnecting... ({1} retries left)</value>
</data>
<data name="bot.autoRelog.retries_unlimited" xml:space="preserve">
<value>unlimited</value>
</data>
<data name="bot.autoRespond.file_not_found" xml:space="preserve">
<value>File not found: '{0}'</value>
</data>
@ -830,6 +842,42 @@ Add the ID of this chat to "Authorized_Chat_Ids" field in the configuration file
<data name="botname.TestBot" xml:space="preserve">
<value>TestBot</value>
</data>
<data name="mcc.banner.classic" xml:space="preserve">
<value>Minecraft Console Client v{0} - for MC {1} to {2} - {3}</value>
</data>
<data name="mcc.banner.label_mc_versions" xml:space="preserve">
<value>Supported MC Versions:</value>
</data>
<data name="mcc.server_info.label_server" xml:space="preserve">
<value>Server:</value>
</data>
<data name="mcc.server_info.label_version" xml:space="preserve">
<value>Version:</value>
</data>
<data name="mcc.server_info.label_protocol" xml:space="preserve">
<value>Protocol: {0}</value>
</data>
<data name="mcc.server_info.label_players" xml:space="preserve">
<value>Players:</value>
</data>
<data name="mcc.server_info.label_ping" xml:space="preserve">
<value>Ping:</value>
</data>
<data name="mcc.server_info.label_ping_ms" xml:space="preserve">
<value>{0} ms</value>
</data>
<data name="mcc.server_info.label_connecting_as" xml:space="preserve">
<value>Connecting as:</value>
</data>
<data name="mcc.server_info.label_online" xml:space="preserve">
<value>Online Players:</value>
</data>
<data name="mcc.server_info.sample_more" xml:space="preserve">
<value>... +{0}</value>
</data>
<data name="mcc.server_info.version_upgrade" xml:space="preserve">
<value>Server reported protocol {0} ({1}), upgraded to {2} ({3}) for best compatibility</value>
</data>
<data name="cache.converting" xml:space="preserve">
<value>Converting session cache from disk: {0}</value>
</data>
@ -1249,6 +1297,21 @@ Change EnableEmoji=false in the settings if the display is confusing.</value>
<data name="cmd.effects.none" xml:space="preserve">
<value>No active effects.</value>
</data>
<data name="cmd.tryout.desc" xml:space="preserve">
<value>try a recommended feature.</value>
</data>
<data name="cmd.tryout.list.header" xml:space="preserve">
<value>Available tryouts:</value>
</data>
<data name="cmd.tryout.list.tui" xml:space="preserve">
<value>tui: set [Console.General] ConsoleMode = "tui" for the next restart.</value>
</data>
<data name="cmd.tryout.tui.already_enabled" xml:space="preserve">
<value>[Console.General] ConsoleMode is already "tui" in the config. To switch back, set [Console.General] ConsoleMode = "classic". Restart MCC after changing it for the new mode to take effect.</value>
</data>
<data name="cmd.tryout.tui.enabled" xml:space="preserve">
<value>Updated [Console.General] ConsoleMode from "{0}" to "{1}" in the config. To switch back, set [Console.General] ConsoleMode = "classic". Restart MCC to apply the change.</value>
</data>
<data name="cmd.health.desc" xml:space="preserve">
<value>Display Health and Food saturation.</value>
</data>
@ -1511,6 +1574,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>
@ -1863,6 +1941,18 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
<data name="mcc.connecting" xml:space="preserve">
<value>Connecting to {0}...</value>
</data>
<data name="mcc.console_mode_tui_recommendation" xml:space="preserve">
<value>Tip: try TUI mode for a cleaner interface, mouse-friendly inventory actions, and a nicer layout. Run {0}tryout tui§8 to switch [Console.General] ConsoleMode to "tui" for the next restart.</value>
</data>
<data name="mcc.tui_startup_failed" xml:space="preserve">
<value>MCC encountered a problem while starting TUI mode.</value>
</data>
<data name="mcc.tui_startup_fallback_classic" xml:space="preserve">
<value>As a fallback, MCC has automatically switched [Console.General] ConsoleMode to "classic". This will take effect after you restart MCC.</value>
</data>
<data name="mcc.report_issue" xml:space="preserve">
<value>Please report this issue to the MCC Team.</value>
</data>
<data name="mcc.device_code_prompt" xml:space="preserve">
<value>To sign in, open {0} in your browser and enter the code: §e{1}</value>
</data>
@ -1974,6 +2064,9 @@ Type '{0}quit' to leave the server.</value>
<data name="mcc.restart" xml:space="preserve">
<value>Restarting Minecraft Console Client...</value>
</data>
<data name="mcc.send_text_not_connected" xml:space="preserve">
<value>Cannot send text: not connected to a server.</value>
</data>
<data name="mcc.restart_delay" xml:space="preserve">
<value>Waiting {0} seconds before restarting...</value>
</data>
@ -1988,10 +2081,10 @@ MCC is running with default settings.</value>
<value>Server is in offline mode.</value>
</data>
<data name="mcc.server_protocol" xml:space="preserve">
<value>Server version : {0} (protocol v{1})</value>
<value>Server version: {0} (protocol v{1})</value>
</data>
<data name="mcc.server_version" xml:space="preserve">
<value>Server version : </value>
<value>Server version: </value>
</data>
<data name="mcc.session" xml:space="preserve">
<value>Checking Session...</value>
@ -2151,6 +2244,33 @@ Logging in...</value>
<data name="cmd.nameitem.desc" xml:space="preserve">
<value>Set an item name when an Anvil inventory is active and the item is in the first slot.</value>
</data>
<data name="cmd.recipebook.craft.failed" xml:space="preserve">
<value>Failed to send recipe book craft request for {0}.</value>
</data>
<data name="cmd.recipebook.craft.sent" xml:space="preserve">
<value>Requested recipe {0}.</value>
</data>
<data name="cmd.recipebook.craftall.sent" xml:space="preserve">
<value>Requested recipe {0} with craft-all.</value>
</data>
<data name="cmd.recipebook.desc" xml:space="preserve">
<value>List unlocked recipe book recipes and craft them through the active recipe book inventory.</value>
</data>
<data name="cmd.recipebook.list" xml:space="preserve">
<value>Unlocked recipe book recipes</value>
</data>
<data name="cmd.recipebook.no.active.inventory" xml:space="preserve">
<value>You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory.</value>
</data>
<data name="cmd.recipebook.no.recipes" xml:space="preserve">
<value>No unlocked recipe book recipes are currently tracked.</value>
</data>
<data name="cmd.recipebook.recipe.id.empty" xml:space="preserve">
<value>The recipe identifier cannot be empty.</value>
</data>
<data name="cmd.recipebook.unsupported" xml:space="preserve">
<value>Recipe book crafting is only supported on Minecraft 1.13 and newer.</value>
</data>
<data name="bot.antiafk.may.not.move" xml:space="preserve">
<value>Bot movement lock is held by bot {0}, so the Anti AFK bot might not move!</value>
</data>
@ -2413,4 +2533,94 @@ see item details.</value>
<data name="bot.mcpserver.stop_failed" xml:space="preserve">
<value>Failed to stop embedded MCP server cleanly: {0}</value>
</data>
<data name="cmd.minimap.desc" xml:space="preserve">
<value>Toggle the TUI minimap overlay, or adjust its zoom level.</value>
</data>
<data name="cmd.minimap.enabled" xml:space="preserve">
<value>Minimap enabled.</value>
</data>
<data name="cmd.minimap.disabled" xml:space="preserve">
<value>Minimap disabled.</value>
</data>
<data name="cmd.minimap.zoom_set" xml:space="preserve">
<value>Minimap zoom set to {0}:1 (blocks per pixel).</value>
</data>
<data name="cmd.minimap.zoom_current" xml:space="preserve">
<value>Current minimap zoom: {0}:1 blocks/px (range 1-{1}).</value>
</data>
<data name="cmd.minimap.tui_only" xml:space="preserve">
<value>The minimap command is only available in TUI mode.</value>
</data>
<data name="tui.minimap.legend.hostile" xml:space="preserve">
<value>Hostile</value>
</data>
<data name="tui.minimap.legend.passive" xml:space="preserve">
<value>Passive</value>
</data>
<data name="tui.minimap.legend.neutral" xml:space="preserve">
<value>Neutral</value>
</data>
<data name="tui.minimap.legend.player" xml:space="preserve">
<value>Player</value>
</data>
<data name="cmd.minimap.names_status" xml:space="preserve">
<value>Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3}</value>
</data>
<data name="cmd.minimap.names_all_on" xml:space="preserve">
<value>All entity name labels enabled.</value>
</data>
<data name="cmd.minimap.names_all_off" xml:space="preserve">
<value>All entity name labels disabled.</value>
</data>
<data name="cmd.minimap.names_cat" xml:space="preserve">
<value>{0} name display: {1}</value>
</data>
<data name="cmd.minimap.names_cat_set" xml:space="preserve">
<value>{0} name display set to {1}.</value>
</data>
<data name="cmd.minimap.position_current" xml:space="preserve">
<value>Current minimap position: {0}</value>
</data>
<data name="cmd.minimap.position_set" xml:space="preserve">
<value>Minimap position set to: {0}</value>
</data>
<data name="cmd.minimap.cave_current" xml:space="preserve">
<value>Current cave mode: {0}</value>
</data>
<data name="cmd.minimap.cave_set" xml:space="preserve">
<value>Cave mode set to: {0}</value>
</data>
<data name="cmd.achievement.desc" xml:space="preserve">
<value>list achievements/advancements from the server.</value>
</data>
<data name="cmd.achievement.none" xml:space="preserve">
<value>No achievements/advancements received yet.</value>
</data>
<data name="cmd.achievement.none_unlocked" xml:space="preserve">
<value>No completed achievements/advancements.</value>
</data>
<data name="cmd.achievement.none_locked" xml:space="preserve">
<value>No incomplete achievements/advancements.</value>
</data>
<data name="cmd.achievement.header" xml:space="preserve">
<value>Achievements/Advancements:</value>
</data>
<data name="cmd.achievement.header_unlocked" xml:space="preserve">
<value>Completed achievements/advancements:</value>
</data>
<data name="cmd.achievement.header_locked" xml:space="preserve">
<value>Incomplete achievements/advancements:</value>
</data>
<data name="cmd.achievement.done" xml:space="preserve">
<value>[DONE]</value>
</data>
<data name="cmd.achievement.todo" xml:space="preserve">
<value>[TODO]</value>
</data>
<data name="cmd.achievement.entry_titled" xml:space="preserve">
<value>{0} {1} ({2}) [{3}]</value>
</data>
<data name="cmd.achievement.entry" xml:space="preserve">
<value>{0} {1} [{2}]</value>
</data>
</root>

View file

@ -205,6 +205,29 @@ namespace MinecraftClient.Scripting
/// <param name="entity">Entity with updated location</param>
public virtual void OnEntityMove(Entity entity) { }
/// <summary>
/// Called when a tracked entity receives a velocity update packet.
/// Velocity is expressed in blocks per tick.
/// </summary>
/// <param name="entity">Entity with updated velocity</param>
/// <param name="velocityX">Velocity on X axis (blocks/tick)</param>
/// <param name="velocityY">Velocity on Y axis (blocks/tick)</param>
/// <param name="velocityZ">Velocity on Z axis (blocks/tick)</param>
public virtual void OnEntityVelocity(Entity entity, double velocityX, double velocityY, double velocityZ) { }
/// <summary>
/// Called when a sound packet is received.
/// The sound name is null when the protocol provides only a registry id.
/// </summary>
/// <param name="soundName">Sound key when available, otherwise null</param>
/// <param name="location">Sound position when available</param>
/// <param name="category">Sound category id from packet</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="sourceEntity">Source entity for entity-sound packets when tracked</param>
public virtual void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
Entity? sourceEntity) { }
/// <summary>
/// Called when an entity rotates
/// </summary>
@ -367,6 +390,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>
@ -520,6 +560,14 @@ namespace MinecraftClient.Scripting
/// <param name="block">The block</param>
public virtual void OnBlockChange(Location location, Block block) { }
/// <summary>
/// Called when achievement/advancement data is updated.
/// </summary>
/// <param name="updated">Achievements that were added or updated</param>
/// <param name="removedIds">IDs of achievements that were removed</param>
/// <param name="reset">Whether the achievement state was fully reset before this update</param>
public virtual void OnAchievementUpdate(IReadOnlyList<Achievement> updated, IReadOnlyList<string> removedIds, bool reset) { }
/* =================================================================== */
/* ToolBox - Methods below might be useful while creating your bot. */
/* You should not need to interact with other classes of the program. */
@ -1095,9 +1143,10 @@ namespace MinecraftClient.Scripting
/// <param name="direction">Example: if your player is under a block that is being destroyed, use Down</param>
/// <param name="swingArms">Also perform the "arm swing" animation</param>
/// <param name="lookAtBlock">Also look at the block before digging</param>
protected bool DigBlock(Location location, Direction direction, bool swingArms = true, bool lookAtBlock = true)
/// <param name="duration">Dig duration in seconds. 0 = auto-compute for survival, or instant for creative</param>
protected bool DigBlock(Location location, Direction direction, bool swingArms = true, bool lookAtBlock = true, double duration = 0)
{
return Handler.DigBlock(location, direction, swingArms, lookAtBlock);
return Handler.DigBlock(location, direction, swingArms, lookAtBlock, duration);
}
/// <summary>
@ -1126,6 +1175,33 @@ namespace MinecraftClient.Scripting
return Handler.GetEntities();
}
/// <summary>
/// Get all achievements/advancements.
/// </summary>
/// <returns>Snapshot of all achievements</returns>
protected Achievement[] GetAchievements()
{
return Handler.GetAchievements();
}
/// <summary>
/// Get only completed achievements/advancements.
/// </summary>
/// <returns>Snapshot of unlocked achievements</returns>
protected Achievement[] GetUnlockedAchievements()
{
return Handler.GetUnlockedAchievements();
}
/// <summary>
/// Get only incomplete achievements/advancements.
/// </summary>
/// <returns>Snapshot of locked achievements</returns>
protected Achievement[] GetLockedAchievements()
{
return Handler.GetLockedAchievements();
}
/// <summary>
/// Get all players Latency
/// </summary>

View file

@ -1108,6 +1108,9 @@ namespace MinecraftClient
[TomlPrecedingComment("$Console.CommandSuggestion$")]
public CommandSuggestionConfig CommandSuggestion = new();
[TomlPrecedingComment("$Console.Minimap$")]
public MinimapConfig Minimap = new();
public void OnSettingUpdate()
{
var backend = ConsoleIO.Backend;
@ -1207,11 +1210,17 @@ namespace MinecraftClient
[TomlInlineComment("$Console.General.ConsoleColorMode$")]
public ConsoleColorModeType ConsoleColorMode = ConsoleColorModeType.vt100_24bit;
[TomlInlineComment("$Console.General.Display_Icon_Banner$")]
public bool Display_Icon_Banner = true;
[TomlInlineComment("$Console.General.Display_Input$")]
public bool Display_Input = true;
[TomlInlineComment("$Console.General.History_Input_Records$")]
public int History_Input_Records = 32;
[TomlInlineComment("$Console.General.TUI_Log_Scrollback$")]
public int TUI_Log_Scrollback = 0;
}
[TomlDoNotInlineObject]
@ -1246,6 +1255,53 @@ namespace MinecraftClient
public enum ConsoleModeType { classic, tui };
public enum ConsoleColorModeType { disable, legacy_4bit, vt100_4bit, vt100_8bit, vt100_24bit };
[TomlDoNotInlineObject]
public class MinimapConfig
{
[TomlInlineComment("$Console.Minimap.Enabled$")]
public bool Enabled = false;
[TomlInlineComment("$Console.Minimap.Zoom$")]
public int Zoom = Tui.MinimapControl.DefaultZoom;
[TomlInlineComment("$Console.Minimap.Width$")]
public int Width = Tui.MinimapControl.DefaultWidth;
[TomlInlineComment("$Console.Minimap.Height$")]
public int Height = Tui.MinimapControl.DefaultHeight;
[TomlInlineComment("$Console.Minimap.Position$")]
public Tui.MinimapPosition Position = Tui.MinimapPosition.top_right;
[TomlInlineComment("$Console.Minimap.ShowPlayerNames$")]
public bool ShowPlayerNames = false;
[TomlInlineComment("$Console.Minimap.ShowHostileNames$")]
public bool ShowHostileNames = false;
[TomlInlineComment("$Console.Minimap.ShowNeutralNames$")]
public bool ShowNeutralNames = false;
[TomlInlineComment("$Console.Minimap.ShowPassiveNames$")]
public bool ShowPassiveNames = false;
[TomlInlineComment("$Console.Minimap.RefreshInterval$")]
public int RefreshInterval = Tui.MinimapControl.DefaultRefreshMs;
[TomlInlineComment("$Console.Minimap.CaveMode$")]
public Tui.CaveModeOption CaveMode = Tui.CaveModeOption.auto;
public void OnSettingUpdate()
{
Zoom = Math.Clamp(Zoom, Tui.MinimapControl.MinZoom, Tui.MinimapControl.MaxZoom);
Width = Math.Clamp(Width, 10, 120);
Height = Math.Clamp(Height, 4, 80);
if (Height % 2 != 0) Height++;
RefreshInterval = Math.Clamp(RefreshInterval,
Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs);
}
}
}
}

View file

@ -0,0 +1,125 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class IconGridBuilder
{
internal static Grid BuildFromRgba(byte[] rgba, int srcWidth, int srcHeight, int displaySize)
{
int cellCols = displaySize;
int cellRows = displaySize / 2;
var grid = new Grid();
for (int c = 0; c < cellCols; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
for (int r = 0; r < cellRows; r++)
grid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
for (int row = 0; row < cellRows; row++)
{
for (int col = 0; col < cellCols; col++)
{
int topPixelY = row * 2;
int bottomPixelY = row * 2 + 1;
var topColor = SamplePixel(rgba, srcWidth, srcHeight, col, topPixelY, cellCols, displaySize);
var bottomColor = SamplePixel(rgba, srcWidth, srcHeight, col, bottomPixelY, cellCols, displaySize);
var cell = new TextBlock
{
Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
Padding = new Thickness(0),
Margin = new Thickness(0),
};
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
grid.Children.Add(cell);
}
}
return grid;
}
internal static Grid BuildFromBase64(string base64Data, int displaySize)
{
byte[] imageBytes;
try
{
imageBytes = Convert.FromBase64String(base64Data);
}
catch
{
return new Grid();
}
return BuildFromImageBytes(imageBytes, displaySize) ?? new Grid();
}
internal static Grid? BuildFromImageBytes(byte[] imageBytes, int displaySize)
{
int srcWidth, srcHeight;
byte[] rgba;
try
{
(srcWidth, srcHeight, rgba) = DecodeImageToRgba(imageBytes);
}
catch
{
return null;
}
return BuildFromRgba(rgba, srcWidth, srcHeight, displaySize);
}
internal static (int Width, int Height, byte[] Rgba) DecodeImageToRgba(byte[] imageData)
{
using var image = new ImageMagick.MagickImage(imageData);
int w = (int)image.Width;
int h = (int)image.Height;
using var pixels = image.GetPixelsUnsafe();
var rgba = new byte[w * h * 4];
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
var pixel = pixels.GetPixel(x, y)!;
int idx = (y * w + x) * 4;
var color = pixel.ToColor()!;
rgba[idx] = (byte)(color.R >> 8);
rgba[idx + 1] = (byte)(color.G >> 8);
rgba[idx + 2] = (byte)(color.B >> 8);
rgba[idx + 3] = (byte)(color.A >> 8);
}
}
return (w, h, rgba);
}
private static Color SamplePixel(byte[] rgba, int srcW, int srcH, int dstX, int dstY, int dstW, int dstH)
{
int srcX = dstX * srcW / dstW;
int srcY = dstY * srcH / dstH;
srcX = Math.Clamp(srcX, 0, srcW - 1);
srcY = Math.Clamp(srcY, 0, srcH - 1);
int idx = (srcY * srcW + srcX) * 4;
if (idx + 3 >= rgba.Length)
return Color.FromRgb(0, 0, 0);
byte r = rgba[idx];
byte g = rgba[idx + 1];
byte b = rgba[idx + 2];
byte a = rgba[idx + 3];
return a < 128 ? Color.FromRgb(0, 0, 0) : Color.FromRgb(r, g, b);
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
@ -16,9 +17,20 @@ namespace MinecraftClient.Tui
{
public class MainTuiView : UserControl
{
private const int MaxLogLines = 5000;
private static readonly int MaxLogLines = ResolveMaxLogLines();
private const int CtrlCDoublePressMsec = 1500;
private static int ResolveMaxLogLines()
{
int configured = Settings.Config.Console.General.TUI_Log_Scrollback;
if (configured > 0)
return configured;
bool isArm = RuntimeInformation.ProcessArchitecture
is Architecture.Arm or Architecture.Arm64;
return isArm ? 500 : 3000;
}
private readonly ObservableCollection<string> _logLines = new();
private readonly ObservableCollection<Control> _logControls = new();
private readonly ItemsControl _logItemsControl;
@ -41,6 +53,12 @@ namespace MinecraftClient.Tui
private long _lastLogClickTicks;
private const int DoubleClickMsec = 500;
private readonly Border _minimapBorder;
private readonly MinimapControl _minimapControl;
private volatile bool _minimapVisible;
private TuiTooltipService? _tooltipService;
private readonly Border _suggestionBorder;
private readonly StackPanel _suggestionPanel;
private CommandSuggestion[] _suggestions = Array.Empty<CommandSuggestion>();
@ -53,6 +71,8 @@ namespace MinecraftClient.Tui
private int MaxVisibleSuggestions =>
Math.Max(1, Settings.Config.Console.CommandSuggestion.Max_Displayed_Suggestions);
public TuiTooltipService? TooltipService => _tooltipService;
public MainTuiView()
{
Background = Brushes.Black;
@ -70,6 +90,7 @@ namespace MinecraftClient.Tui
{
ItemsSource = _logControls,
Focusable = false,
ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel()),
};
_logScrollViewer = new ScrollViewer
@ -150,6 +171,30 @@ namespace MinecraftClient.Tui
Margin = new Thickness(0, 0, 0, 1),
};
var mmCfg = Settings.Config.Console.Minimap;
mmCfg.OnSettingUpdate();
_minimapControl = new MinimapControl(mmCfg.Width, mmCfg.Height);
_minimapControl.BlocksPerPixel = mmCfg.Zoom;
_minimapControl.RefreshIntervalMs = mmCfg.RefreshInterval;
_minimapControl.NameConfig.Players = mmCfg.ShowPlayerNames;
_minimapControl.NameConfig.Hostile = mmCfg.ShowHostileNames;
_minimapControl.NameConfig.Neutral = mmCfg.ShowNeutralNames;
_minimapControl.NameConfig.Passive = mmCfg.ShowPassiveNames;
_minimapControl.CaveMode = mmCfg.CaveMode;
var (hAlign, vAlign, margin) = GetMinimapAlignment(mmCfg.Position);
_minimapBorder = new Border
{
Background = new SolidColorBrush(Color.FromArgb(220, 15, 15, 15)),
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Child = _minimapControl,
IsVisible = false,
HorizontalAlignment = hAlign,
VerticalAlignment = vAlign,
Margin = margin,
};
_mainContent = new DockPanel
{
Background = Brushes.Black,
@ -164,11 +209,22 @@ namespace MinecraftClient.Tui
_rootPanel = new Panel
{
Background = Brushes.Black,
Children = { _mainContent, _notificationBorder, _suggestionBorder }
Children = { _mainContent, _minimapBorder, _notificationBorder, _suggestionBorder }
};
_tooltipService = new TuiTooltipService(_rootPanel);
_minimapControl.TooltipService = _tooltipService;
_minimapControl.Position = mmCfg.Position;
Content = _rootPanel;
if (mmCfg.Enabled)
{
_minimapVisible = true;
_minimapBorder.IsVisible = true;
_minimapControl.Start();
}
StartStatusBarTimer();
}
@ -986,6 +1042,104 @@ namespace MinecraftClient.Tui
#endregion
#region Minimap
public void ShowMinimap()
{
if (_minimapVisible) return;
_minimapVisible = true;
_minimapBorder.IsVisible = true;
_minimapControl.Start();
Settings.Config.Console.Minimap.Enabled = true;
}
public void HideMinimap()
{
if (!_minimapVisible) return;
_minimapVisible = false;
_minimapControl.Stop();
_minimapBorder.IsVisible = false;
Settings.Config.Console.Minimap.Enabled = false;
}
public void ToggleMinimap()
{
if (_minimapVisible)
HideMinimap();
else
ShowMinimap();
}
public bool IsMinimapVisible => _minimapVisible;
public void SetMinimapZoom(int level)
{
_minimapControl.BlocksPerPixel = level;
Settings.Config.Console.Minimap.Zoom = level;
}
public int GetMinimapZoom() => _minimapControl.BlocksPerPixel;
public NameDisplayConfig GetMinimapNameConfig() => _minimapControl.NameConfig;
public void SyncMinimapNameConfig()
{
var nc = _minimapControl.NameConfig;
var cfg = Settings.Config.Console.Minimap;
cfg.ShowPlayerNames = nc.Players;
cfg.ShowHostileNames = nc.Hostile;
cfg.ShowNeutralNames = nc.Neutral;
cfg.ShowPassiveNames = nc.Passive;
}
public void ResizeMinimap(int width, int height)
{
_minimapControl.Resize(width, height);
Settings.Config.Console.Minimap.Width = width;
Settings.Config.Console.Minimap.Height = height;
}
public void SetMinimapPosition(MinimapPosition pos)
{
var (hAlign, vAlign, margin) = GetMinimapAlignment(pos);
_minimapBorder.HorizontalAlignment = hAlign;
_minimapBorder.VerticalAlignment = vAlign;
_minimapBorder.Margin = margin;
_minimapControl.Position = pos;
Settings.Config.Console.Minimap.Position = pos;
}
public MinimapPosition GetMinimapPosition() => Settings.Config.Console.Minimap.Position;
public void SetMinimapCaveMode(CaveModeOption mode)
{
_minimapControl.CaveMode = mode;
Settings.Config.Console.Minimap.CaveMode = mode;
}
public CaveModeOption GetMinimapCaveMode() => _minimapControl.CaveMode;
private static (HorizontalAlignment h, VerticalAlignment v, Thickness margin) GetMinimapAlignment(MinimapPosition pos) => pos switch
{
MinimapPosition.top_left => (HorizontalAlignment.Left, VerticalAlignment.Top, new Thickness(1, 1, 0, 0)),
MinimapPosition.top_right => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)),
MinimapPosition.center => (HorizontalAlignment.Center, VerticalAlignment.Center, new Thickness(0)),
MinimapPosition.bottom_left => (HorizontalAlignment.Left, VerticalAlignment.Bottom, new Thickness(1, 0, 0, 2)),
MinimapPosition.bottom_right => (HorizontalAlignment.Right, VerticalAlignment.Bottom, new Thickness(0, 0, 1, 2)),
_ => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)),
};
public void ApplyMinimapConfig()
{
var cfg = Settings.Config.Console.Minimap;
if (cfg.Enabled && !_minimapVisible)
ShowMinimap();
else if (!cfg.Enabled && _minimapVisible)
HideMinimap();
}
#endregion
#region Overlay
public void ShowOverlay(Control content, Action? onClose = null)
@ -1039,5 +1193,18 @@ namespace MinecraftClient.Tui
_commandInput.Focus();
}, DispatcherPriority.Loaded);
}
#region Custom Control Append
public void AppendControlToLog(Control control)
{
_logLines.Add(string.Empty);
_logControls.Add(control);
TrimLog();
if (_autoScroll)
ScheduleScrollToEnd();
}
#endregion
}
}

View file

@ -52,6 +52,8 @@ namespace MinecraftClient.Tui
IBrush currentColor = Brushes.White;
bool bold = false;
bool italic = false;
bool underline = false;
bool strikethrough = false;
int start = 0;
for (int i = 0; i < text.Length; i++)
@ -59,7 +61,7 @@ namespace MinecraftClient.Tui
if (text[i] == '§' && i + 1 < text.Length)
{
if (i > start)
AddRun(tb, text[start..i], currentColor, bold, italic);
AddRun(tb, text[start..i], currentColor, bold, italic, underline, strikethrough);
char code = char.ToLower(text[i + 1]);
@ -68,6 +70,8 @@ namespace MinecraftClient.Tui
currentColor = brush;
bold = false;
italic = false;
underline = false;
strikethrough = false;
}
else
{
@ -75,10 +79,14 @@ namespace MinecraftClient.Tui
{
case 'l': bold = true; break;
case 'o': italic = true; break;
case 'n': underline = true; break;
case 'm': strikethrough = true; break;
case 'r':
currentColor = Brushes.White;
bold = false;
italic = false;
underline = false;
strikethrough = false;
break;
}
}
@ -89,7 +97,7 @@ namespace MinecraftClient.Tui
}
if (start < text.Length)
AddRun(tb, text[start..], currentColor, bold, italic);
AddRun(tb, text[start..], currentColor, bold, italic, underline, strikethrough);
if (tb.Inlines?.Count == 0)
{
@ -100,16 +108,29 @@ namespace MinecraftClient.Tui
return tb;
}
private static void AddRun(TextBlock tb, string text, IBrush color, bool bold, bool italic)
private static void AddRun(TextBlock tb, string text, IBrush color,
bool bold, bool italic, bool underline, bool strikethrough)
{
if (text.Length == 0) return;
tb.Inlines ??= new InlineCollection();
TextDecorationCollection? decorations = null;
if (underline || strikethrough)
{
decorations = [];
if (underline)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Underline });
if (strikethrough)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Strikethrough });
}
tb.Inlines.Add(new Run(text)
{
Foreground = color,
FontWeight = bold ? FontWeight.Bold : FontWeight.Normal,
FontStyle = italic ? FontStyle.Italic : FontStyle.Normal,
TextDecorations = decorations,
});
}
}

View file

@ -0,0 +1,178 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class MccBannerPanelBuilder
{
internal static Border Build(string? buildInfo)
{
var contentPanel = new DockPanel { Background = Brushes.Black };
var icon = BuildIcon();
icon.VerticalAlignment = VerticalAlignment.Center;
DockPanel.SetDock(icon, Dock.Left);
contentPanel.Children.Add(icon);
var infoPanel = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(1, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
AddTitle(infoPanel);
AddVersionRange(infoPanel);
AddGithub(infoPanel);
if (buildInfo is not null)
AddBuildInfo(infoPanel, buildInfo);
contentPanel.Children.Add(infoPanel);
return new Border
{
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)),
Padding = new Thickness(1, 0),
Child = contentPanel,
Margin = new Thickness(0),
};
}
private static void AddTitle(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(new Run("Minecraft Console Client")
{ Foreground = Pal.Gold, FontWeight = FontWeight.Bold });
row.Inlines.Add(new Run($" v{Program.Version}") { Foreground = Pal.Aqua });
panel.Children.Add(row);
}
private static void AddVersionRange(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(Lbl(Translations.mcc_banner_label_mc_versions));
row.Inlines.Add(Val(Program.MCLowestVersion, Pal.Green));
row.Inlines.Add(new Run(" - ") { Foreground = Pal.Gray });
row.Inlines.Add(Val(Program.MCHighestVersion, Pal.Green));
panel.Children.Add(row);
}
private static void AddGithub(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(Val("Github.com/MCCTeam", Pal.Gray));
panel.Children.Add(row);
}
private static void AddBuildInfo(StackPanel panel, string buildInfo)
{
panel.Children.Add(new TextBlock
{
Text = buildInfo,
Foreground = Pal.DarkGray,
});
}
#region Icon
private static readonly Color B1 = Color.FromRgb(200, 200, 200); // bezel bright
private static readonly Color B2 = Color.FromRgb(160, 160, 160); // bezel mid
private static readonly Color B3 = Color.FromRgb(120, 120, 120); // bezel dark
private static readonly Color S = Color.FromRgb(20, 20, 20); // screen bg
private static readonly Color C = Color.FromRgb(55, 200, 55); // creeper green
// @formatter:off
private static readonly Color[,] Pixels =
{
{ B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B2 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, C, C, S, S, C, C, S, B3 },
{ B1, S, S, S, S, S, S, S, S, C, C, S, S, C, C, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, C, C, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, C, C, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, C, C, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, S, S, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B2, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3 },
};
// @formatter:on
private static Control BuildIcon()
{
int cols = Pixels.GetLength(1);
int textRows = Pixels.GetLength(0) / 2;
var pixelGrid = new Grid();
for (int c = 0; c < cols; c++)
pixelGrid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
for (int r = 0; r < textRows; r++)
pixelGrid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
for (int row = 0; row < textRows; row++)
{
for (int col = 0; col < cols; col++)
{
var topColor = Pixels[row * 2, col];
var bottomColor = Pixels[row * 2 + 1, col];
var cell = new TextBlock
{
Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
Padding = new Thickness(0),
Margin = new Thickness(0),
};
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
pixelGrid.Children.Add(cell);
}
}
var prompt = new TextBlock
{
Text = " _",
Foreground = new SolidColorBrush(Color.FromRgb(255, 255, 255)),
Background = new SolidColorBrush(S),
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
};
Grid.SetRow(prompt, 1);
Grid.SetColumn(prompt, 1);
Grid.SetColumnSpan(prompt, 4);
pixelGrid.Children.Add(prompt);
return pixelGrid;
}
#endregion
private static Run Lbl(string text) =>
new(text + " ") { Foreground = Pal.Gray };
private static Run Val(string text, IBrush color) =>
new(text) { Foreground = color };
private static class Pal
{
public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170));
public static readonly IBrush DarkGray = new SolidColorBrush(Color.FromRgb(85, 85, 85));
public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255));
public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85));
public static readonly IBrush Gold = new SolidColorBrush(Color.FromRgb(255, 170, 0));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,191 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using Avalonia.Media;
using MinecraftClient.Mapping;
namespace MinecraftClient.Tui
{
/// <summary>
/// Maps block Materials to minimap colors using data extracted from Minecraft's
/// official MapColor table. Colors are loaded from the embedded MinimapBlockColors.json
/// resource generated by tools/gen_block_color_map.py.
/// </summary>
public static class MinimapColorMap
{
public static readonly Color WaterColor = Color.FromRgb(64, 64, 255);
public static readonly Color IceColor = Color.FromRgb(160, 160, 255);
public static readonly Color LavaColor = Color.FromRgb(255, 100, 0);
public static readonly Color DefaultColor = Color.FromRgb(60, 60, 60);
public static readonly Color VoidColor = Color.FromRgb(0, 0, 0);
public static readonly Color CaveBorderColor = Color.FromRgb(16, 16, 16);
public static readonly Color CaveSolidColor = Color.FromRgb(24, 20, 18);
private static readonly FrozenDictionary<Material, Color> ColorTable;
private static readonly FrozenSet<Material> FullyTransparentMats;
private static readonly FrozenSet<Material> WaterMats;
private static readonly FrozenSet<Material> IceMats;
static MinimapColorMap()
{
var colors = new Dictionary<Material, Color>();
var transparent = new HashSet<Material>();
var water = new HashSet<Material>();
var ice = new HashSet<Material>();
try
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MinimapBlockColors.json");
if (stream is not null)
{
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
if (root.TryGetProperty("colors", out var colorsEl))
{
foreach (var prop in colorsEl.EnumerateObject())
{
if (!Enum.TryParse<Material>(prop.Name, out var mat))
continue;
var arr = prop.Value;
if (arr.GetArrayLength() < 3) continue;
byte r = (byte)arr[0].GetInt32();
byte g = (byte)arr[1].GetInt32();
byte b = (byte)arr[2].GetInt32();
colors[mat] = Color.FromRgb(r, g, b);
}
}
if (root.TryGetProperty("transparent", out var transEl))
{
foreach (var item in transEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
transparent.Add(mat);
}
}
if (root.TryGetProperty("water", out var waterEl))
{
foreach (var item in waterEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
water.Add(mat);
}
}
if (root.TryGetProperty("ice", out var iceEl))
{
foreach (var item in iceEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
ice.Add(mat);
}
}
}
}
catch (Exception ex)
{
ConsoleIO.WriteLineFormatted($"\u00a7e[Minimap] Failed to load color data: {ex.Message}");
}
if (transparent.Count == 0)
{
transparent.Add(Material.Air);
transparent.Add(Material.CaveAir);
transparent.Add(Material.VoidAir);
}
if (water.Count == 0)
water.Add(Material.Water);
if (ice.Count == 0)
{
ice.Add(Material.Ice);
ice.Add(Material.PackedIce);
ice.Add(Material.BlueIce);
ice.Add(Material.FrostedIce);
}
ColorTable = colors.ToFrozenDictionary();
FullyTransparentMats = transparent.ToFrozenSet();
WaterMats = water.ToFrozenSet();
IceMats = ice.ToFrozenSet();
}
public static bool IsFullyTransparent(Material m) => FullyTransparentMats.Contains(m);
/// <summary>
/// Returns true for materials that block light propagation (solid, liquids),
/// used by cave mode to find the surface from the player's Y level.
/// Mirrors VoxelMap's lightDampening > 0 check.
/// </summary>
public static bool IsLightBlocking(Material m)
=> (m == Material.Lava) || (!FullyTransparentMats.Contains(m) && m.IsSolid());
public static bool IsWater(Material m) => WaterMats.Contains(m);
public static bool IsIce(Material m) => IceMats.Contains(m);
public static Color GetBaseColor(Material m)
{
if (m == Material.Lava)
return LavaColor;
return ColorTable.GetValueOrDefault(m, DefaultColor);
}
/// <summary>
/// Apply Minecraft-style height shading. The shade multiplier depends on
/// the height difference between the current block and the block to its north.
/// Vanilla maps use four brightness levels: LOW (180/255), NORMAL (220/255),
/// HIGH (255/255), and LOWEST (135/255). We use NORMAL as baseline and shift
/// up/down based on delta.
/// </summary>
public static Color ApplyHeightShade(Color baseColor, int heightDelta)
{
int multiplier = heightDelta switch
{
> 0 => 255, // higher than neighbor: brightest
0 => 220, // same height: normal
_ => 180, // lower than neighbor: darker
};
byte r = (byte)(baseColor.R * multiplier / 255);
byte g = (byte)(baseColor.G * multiplier / 255);
byte b = (byte)(baseColor.B * multiplier / 255);
return Color.FromRgb(r, g, b);
}
public static Color BlendWaterColor(Color bottomColor, int waterDepth)
{
double alpha = Math.Min(0.85, 0.35 + waterDepth * 0.08);
return Blend(WaterColor, bottomColor, alpha);
}
public static Color BlendIceColor(Color bottomColor)
{
return Blend(IceColor, bottomColor, 0.35);
}
/// <summary>
/// Darken a color to simulate underground lighting. Cave floors receive
/// a minimum brightness of ~32/255 for non-solid blocks (matching VoxelMap),
/// while solid/unreachable columns render as near-black.
/// </summary>
public static Color ApplyCaveDarkening(Color baseColor, double factor = 0.55)
{
byte r = (byte)(baseColor.R * factor);
byte g = (byte)(baseColor.G * factor);
byte b = (byte)(baseColor.B * factor);
return Color.FromRgb(r, g, b);
}
private static Color Blend(Color top, Color bottom, double topAlpha)
{
byte r = (byte)(top.R * topAlpha + bottom.R * (1.0 - topAlpha));
byte g = (byte)(top.G * topAlpha + bottom.G * (1.0 - topAlpha));
byte b = (byte)(top.B * topAlpha + bottom.B * (1.0 - topAlpha));
return Color.FromRgb(r, g, b);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
{
"version": "26.1-rc-2",
"hostile": [
"Blaze",
"Bogged",
"Breeze",
"CamelHusk",
"Creaking",
"Creeper",
"Drowned",
"ElderGuardian",
"EnderDragon",
"Endermite",
"Evoker",
"Ghast",
"Giant",
"Guardian",
"Hoglin",
"Husk",
"Illusioner",
"MagmaCube",
"Parched",
"Phantom",
"Piglin",
"PiglinBrute",
"Pillager",
"Ravager",
"Shulker",
"Silverfish",
"Skeleton",
"Slime",
"Stray",
"Vex",
"Vindicator",
"Warden",
"Witch",
"Wither",
"WitherSkeleton",
"Zoglin",
"Zombie",
"ZombieNautilus",
"ZombieVillager"
],
"passive": [
"Allay",
"Armadillo",
"Axolotl",
"Bat",
"Camel",
"Cat",
"Chicken",
"Cod",
"Cow",
"Donkey",
"Fox",
"Frog",
"GlowSquid",
"HappyGhast",
"Horse",
"Mooshroom",
"Mule",
"Nautilus",
"Ocelot",
"Parrot",
"Pig",
"Pufferfish",
"Rabbit",
"Salmon",
"Sheep",
"SkeletonHorse",
"Sniffer",
"Squid",
"Strider",
"Tadpole",
"TropicalFish",
"Turtle",
"Villager",
"WanderingTrader",
"ZombieHorse"
],
"neutral": [
"Bee",
"CaveSpider",
"CopperGolem",
"Dolphin",
"Enderman",
"Goat",
"IronGolem",
"Llama",
"Panda",
"PolarBear",
"SnowGolem",
"Spider",
"TraderLlama",
"Wolf",
"ZombifiedPiglin"
],
"non_living": [
"AcaciaBoat",
"AcaciaChestBoat",
"AreaEffectCloud",
"ArmorStand",
"Arrow",
"BambooChestRaft",
"BambooRaft",
"BirchBoat",
"BirchChestBoat",
"BlockDisplay",
"BreezeWindCharge",
"CherryBoat",
"CherryChestBoat",
"ChestMinecart",
"CommandBlockMinecart",
"DarkOakBoat",
"DarkOakChestBoat",
"DragonFireball",
"Egg",
"EndCrystal",
"EnderPearl",
"EvokerFangs",
"ExperienceBottle",
"ExperienceOrb",
"EyeOfEnder",
"FallingBlock",
"Fireball",
"FireworkRocket",
"FishingBobber",
"FurnaceMinecart",
"GlowItemFrame",
"HopperMinecart",
"Interaction",
"Item",
"ItemDisplay",
"ItemFrame",
"JungleBoat",
"JungleChestBoat",
"LeashKnot",
"LightningBolt",
"LingeringPotion",
"LlamaSpit",
"MangroveBoat",
"MangroveChestBoat",
"Mannequin",
"Marker",
"Minecart",
"OakBoat",
"OakChestBoat",
"OminousItemSpawner",
"Painting",
"PaleOakBoat",
"PaleOakChestBoat",
"ShulkerBullet",
"SmallFireball",
"Snowball",
"SpawnerMinecart",
"SpectralArrow",
"SplashPotion",
"SpruceBoat",
"SpruceChestBoat",
"TextDisplay",
"Tnt",
"TntMinecart",
"Trident",
"WindCharge",
"WitherSkull"
]
}

View file

@ -0,0 +1,178 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using Avalonia.Media;
using MinecraftClient.Mapping;
namespace MinecraftClient.Tui
{
public enum MobCategory
{
Hostile,
Passive,
Neutral,
Player,
NonLiving,
}
public enum MinimapPosition
{
top_left,
top_right,
center,
bottom_left,
bottom_right,
}
public sealed class NameDisplayConfig
{
public volatile bool Players = false;
public volatile bool Hostile = false;
public volatile bool Neutral = false;
public volatile bool Passive = false;
public bool AnyEnabled => Players || Hostile || Neutral || Passive;
public void SetAll(bool value)
{
Players = value;
Hostile = value;
Neutral = value;
Passive = value;
}
public bool ShouldShowName(MobCategory category) => category switch
{
MobCategory.Player => Players,
MobCategory.Hostile => Hostile,
MobCategory.Neutral => Neutral,
MobCategory.Passive => Passive,
_ => false,
};
}
/// <summary>
/// Classifies entities into minimap categories using data extracted from
/// Minecraft's MobCategory assignments. Categories are loaded from the
/// embedded MinimapEntityCategories.json resource generated by
/// tools/gen_entity_category_map.py.
/// </summary>
public static class MinimapEntityClassifier
{
public static readonly Color HostileColor = Color.FromRgb(255, 68, 68);
public static readonly Color PassiveColor = Color.FromRgb(68, 255, 68);
public static readonly Color NeutralColor = Color.FromRgb(255, 170, 0);
public static readonly Color PlayerColor = Color.FromRgb(255, 255, 255);
public static readonly Color FadedGray = Color.FromRgb(100, 100, 100);
private static readonly FrozenDictionary<EntityType, MobCategory> CategoryTable;
static MinimapEntityClassifier()
{
var table = new Dictionary<EntityType, MobCategory>();
try
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MinimapEntityCategories.json");
if (stream is not null)
{
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
LoadCategory(root, "hostile", MobCategory.Hostile, table);
LoadCategory(root, "passive", MobCategory.Passive, table);
LoadCategory(root, "neutral", MobCategory.Neutral, table);
LoadCategory(root, "non_living", MobCategory.NonLiving, table);
}
}
catch (Exception ex)
{
ConsoleIO.WriteLogLine($"[Minimap] Failed to load entity categories: {ex.Message}");
}
CategoryTable = table.ToFrozenDictionary();
}
private static void LoadCategory(JsonElement root, string key,
MobCategory category, Dictionary<EntityType, MobCategory> table)
{
if (!root.TryGetProperty(key, out var arr))
return;
foreach (var el in arr.EnumerateArray())
{
var name = el.GetString();
if (name is not null && Enum.TryParse<EntityType>(name, out var et))
table.TryAdd(et, category);
}
}
public static MobCategory Classify(EntityType type)
{
if (type == EntityType.Player)
return MobCategory.Player;
return CategoryTable.GetValueOrDefault(type, MobCategory.NonLiving);
}
public static Color GetBaseColor(MobCategory category) => category switch
{
MobCategory.Hostile => HostileColor,
MobCategory.Passive => PassiveColor,
MobCategory.Neutral => NeutralColor,
MobCategory.Player => PlayerColor,
_ => FadedGray,
};
public static Color ApplyDepthFade(Color baseColor, double playerY, double entityY)
{
double depth = playerY - entityY;
if (depth <= 5.0)
return baseColor;
if (depth >= 15.0)
return FadedGray;
double t = (depth - 5.0) / 10.0;
return Lerp(baseColor, FadedGray, t);
}
public static bool ShouldDisplay(MobCategory category, double playerY, double entityY)
{
if (category == MobCategory.Player)
return true;
if (entityY >= playerY)
return true;
return playerY - entityY <= 15.0;
}
public static int GetPriority(MobCategory category) => category switch
{
MobCategory.Hostile => 4,
MobCategory.Player => 3,
MobCategory.Neutral => 2,
MobCategory.Passive => 1,
_ => 0,
};
public static string GetCategoryLabel(MobCategory category) => category switch
{
MobCategory.Hostile => Translations.tui_minimap_legend_hostile,
MobCategory.Passive => Translations.tui_minimap_legend_passive,
MobCategory.Neutral => Translations.tui_minimap_legend_neutral,
MobCategory.Player => Translations.tui_minimap_legend_player,
_ => "?",
};
private static Color Lerp(Color a, Color b, double t)
{
byte r = (byte)(a.R + (b.R - a.R) * t);
byte g = (byte)(a.G + (b.G - a.G) * t);
byte bl = (byte)(a.B + (b.B - a.B) * t);
return Color.FromRgb(r, g, bl);
}
}
}

View file

@ -0,0 +1,196 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class ServerStatusPanelBuilder
{
private const int MaxSamplePlayers = 10;
private const int FaviconDisplaySize = 16;
internal static Border Build(Protocol.ServerStatusInfo info)
{
var contentPanel = new DockPanel { Background = Brushes.Black };
if (info.FaviconBase64 is not null)
{
var iconGrid = BuildFaviconGrid(info.FaviconBase64, FaviconDisplaySize);
iconGrid.VerticalAlignment = VerticalAlignment.Center;
DockPanel.SetDock(iconGrid, Dock.Left);
contentPanel.Children.Add(iconGrid);
}
var infoPanel = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(1, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
AddMotd(infoPanel, info);
AddAddress(infoPanel, info);
AddVersion(infoPanel, info);
AddConnectingAs(infoPanel, info);
AddPing(infoPanel, info);
AddPlayers(infoPanel, info);
AddSamplePlayers(infoPanel, info);
contentPanel.Children.Add(infoPanel);
return new Border
{
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)),
Padding = new Thickness(1, 0),
Child = contentPanel,
Margin = new Thickness(0),
};
}
private static void AddMotd(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (string.IsNullOrEmpty(info.MotdRaw))
return;
try
{
string motdFormatted = Protocol.Message.ChatParser.ParseText(info.MotdRaw);
foreach (string line in motdFormatted.Split('\n'))
panel.Children.Add(McColorParser.CreateColoredTextBlock(line, TextWrapping.NoWrap));
}
catch
{
panel.Children.Add(new TextBlock
{
Text = info.MotdRaw,
Foreground = Brushes.White,
TextWrapping = TextWrapping.NoWrap,
});
}
}
private static void AddAddress(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_server));
row.Inlines.Add(Value(info.Host, McColors.Aqua));
row.Inlines.Add(new Run($":{info.Port}") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddVersion(StackPanel panel, Protocol.ServerStatusInfo info)
{
string versionClean = Scripting.ChatBot.GetVerbatim(info.VersionName);
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_version));
row.Inlines.Add(Value(versionClean, McColors.Aqua));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ProtocolVersion))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddConnectingAs(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.ResolvedProtocol == 0)
return;
string resolvedMcVer = Protocol.ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol);
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_connecting_as));
row.Inlines.Add(Value(resolvedMcVer, McColors.Green));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ResolvedProtocol))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddPing(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.PingMs < 0)
return;
var pingColor = info.PingMs < 100
? McColors.Green
: info.PingMs < 300
? McColors.Yellow
: McColors.Red;
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_ping));
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs))
{ Foreground = pingColor });
panel.Children.Add(row);
}
private static void AddPlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_players));
row.Inlines.Add(Value($"{info.OnlinePlayers}", McColors.Green));
row.Inlines.Add(new Run("/") { Foreground = McColors.Gray });
row.Inlines.Add(Value($"{info.MaxPlayers}", McColors.Red));
panel.Children.Add(row);
}
private static void AddSamplePlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.SamplePlayers.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = Translations.mcc_server_info_label_online,
Foreground = McColors.Gray,
});
int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers);
for (int i = 0; i < shown; i++)
{
string name = info.SamplePlayers[i].Name;
if (name.Contains('\u00a7'))
panel.Children.Add(McColorParser.CreateColoredTextBlock($" {name}", TextWrapping.NoWrap));
else
panel.Children.Add(new TextBlock
{
Text = $" {name}",
Foreground = McColors.Green,
});
}
if (info.SamplePlayers.Count > shown)
{
panel.Children.Add(new TextBlock
{
Text = $" {string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}",
Foreground = McColors.Gray,
});
}
}
private static Run Label(string text) =>
new(text + " ") { Foreground = McColors.Gray };
private static Run Value(string text, IBrush color) =>
new(text) { Foreground = color };
private static Grid BuildFaviconGrid(string base64Png, int displaySize) =>
IconGridBuilder.BuildFromBase64(base64Png, displaySize);
private static class McColors
{
public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170));
public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255));
public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85));
public static readonly IBrush Red = new SolidColorBrush(Color.FromRgb(255, 85, 85));
public static readonly IBrush Yellow = new SolidColorBrush(Color.FromRgb(255, 255, 85));
}
}
}

View file

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
public sealed class TuiTooltipLine
{
public string Text { get; init; } = "";
public IBrush Foreground { get; init; } = Brushes.White;
}
/// <summary>
/// Global tooltip that floats above all TUI content.
/// Owned by MainTuiView, used by minimap / chat / other components.
/// </summary>
public sealed class TuiTooltipService
{
private readonly Panel _rootPanel;
private readonly Canvas _canvas;
private readonly Border _border;
private readonly StackPanel _content;
internal TuiTooltipService(Panel rootPanel)
{
_content = new StackPanel { Orientation = Avalonia.Layout.Orientation.Vertical };
_border = new Border
{
Background = new SolidColorBrush(Color.FromArgb(230, 20, 20, 20)),
BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)),
BorderThickness = new Thickness(1),
Padding = new Thickness(1),
Child = _content,
IsVisible = false,
};
_canvas = new Canvas
{
IsHitTestVisible = false,
Children = { _border },
};
_rootPanel = rootPanel;
rootPanel.Children.Add(_canvas);
}
/// <param name="mouseX">Global X of the mouse cursor.</param>
/// <param name="mouseY">Global Y of the mouse cursor.</param>
/// <param name="preferRight">
/// If true, try placing tooltip to the right of mouseX;
/// if false, try placing to the left.
/// The service auto-flips when the tooltip would overflow the screen.
/// </param>
public void Show(double mouseX, double mouseY, IReadOnlyList<TuiTooltipLine> lines,
bool preferRight = true)
{
_content.Children.Clear();
if (lines.Count == 0)
{
_border.IsVisible = false;
return;
}
int maxChars = 0;
foreach (var line in lines)
{
_content.Children.Add(new TextBlock
{
Text = line.Text,
Foreground = line.Foreground,
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(0),
Margin = new Thickness(0),
FontSize = 1,
});
if (line.Text.Length > maxChars)
maxChars = line.Text.Length;
}
double tipW = maxChars + 4;
double screenW = _rootPanel.Bounds.Width;
const double gap = 1;
double gx;
if (preferRight)
{
gx = mouseX + gap;
if (gx + tipW > screenW)
gx = mouseX - tipW - gap;
}
else
{
gx = mouseX - tipW - gap;
if (gx < 0)
gx = mouseX + gap;
}
Canvas.SetLeft(_border, Math.Max(0, gx));
Canvas.SetTop(_border, Math.Max(0, mouseY));
_border.IsVisible = true;
}
public void Hide()
{
_border.IsVisible = false;
_content.Children.Clear();
}
public bool IsVisible => _border.IsVisible;
}
}

View file

@ -4,8 +4,6 @@ MCC.LoadBot(new PacketCadenceCaptureBot());
//MCCScript Extensions
using System.Threading;
public class PacketCadenceCaptureBot : ChatBot
{
private const int CaptureDurationSeconds = 5;