More tools, added debug tools, fixed issues with some tools.

This commit is contained in:
Anon 2026-03-28 04:12:37 +01:00
parent 79b091cab6
commit 7f9023e7bb
16 changed files with 4005 additions and 72 deletions

View file

@ -113,6 +113,8 @@ namespace MinecraftClient
// Entity handling
private readonly Dictionary<int, Entity> entities = new();
private readonly Lock signDataLock = new();
private readonly Dictionary<(int x, int y, int z), (string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)> knownSigns = new();
// server TPS
private long lastAge = 0;
@ -166,6 +168,21 @@ namespace MinecraftClient
public void GetCookie(string key, out byte[]? data) => Cookies.TryGetValue(key, out data);
public void SetCookie(string key, byte[] data) => Cookies[key] = data;
public void DeleteCookie(string key) => Cookies.Remove(key, out var data);
public (Location location, string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)[] GetKnownSigns()
{
lock (signDataLock)
{
return knownSigns
.Select(pair => (
location: new Location(pair.Key.x, pair.Key.y, pair.Key.z),
material: pair.Value.material,
typeLabel: pair.Value.typeLabel,
frontText: (string[])pair.Value.frontText.Clone(),
backText: (string[])pair.Value.backText.Clone(),
isWaxed: pair.Value.isWaxed))
.ToArray();
}
}
TcpClient client = null!;
IMinecraftCom handler = null!;
@ -478,6 +495,7 @@ namespace MinecraftClient
physicsInput.Reset();
world.Clear();
entities.Clear();
ClearKnownSigns();
ClearInventories();
}
@ -763,6 +781,7 @@ namespace MinecraftClient
handler.Dispose();
world.Clear();
ClearKnownSigns();
if (timeoutdetector is not null)
{
@ -2804,6 +2823,7 @@ namespace MinecraftClient
}
entities.Clear();
ClearKnownSigns();
ClearInventories();
DispatchBotEvent(bot => bot.OnRespawn());
}
@ -4036,9 +4056,16 @@ namespace MinecraftClient
public void OnBlockChange(Location location, Block block)
{
world.SetBlock(location, block);
if (!IsSignMaterial(block.Type))
RemoveKnownSign(location);
DispatchBotEvent(bot => bot.OnBlockChange(location, block));
}
public void OnBlockEntityData(Location location, Dictionary<string, object>? nbt)
{
UpdateKnownSign(location, nbt);
}
/// <summary>
/// Called when "AutoComplete" completes.
/// </summary>
@ -4068,6 +4095,137 @@ namespace MinecraftClient
return handler.ClickContainerButton(windowId, buttonId);
}
private void ClearKnownSigns()
{
lock (signDataLock)
{
knownSigns.Clear();
}
}
private void RemoveKnownSign(Location location)
{
var key = ToBlockKey(location);
lock (signDataLock)
{
knownSigns.Remove(key);
}
}
private void UpdateKnownSign(Location location, Dictionary<string, object>? nbt)
{
var key = ToBlockKey(location);
var block = world.GetBlock(new Location(key.x, key.y, key.z));
if (!IsSignMaterial(block.Type) || !TryExtractSignText(nbt, out string[] frontText, out string[] backText, out bool isWaxed))
{
lock (signDataLock)
{
knownSigns.Remove(key);
}
return;
}
lock (signDataLock)
{
knownSigns[key] = (block.Type.ToString(), block.GetTypeString(), frontText, backText, isWaxed);
}
}
private static bool TryExtractSignText(Dictionary<string, object>? nbt, out string[] frontText, out string[] backText, out bool isWaxed)
{
frontText = ExtractSignLines(nbt, "front_text");
backText = ExtractSignLines(nbt, "back_text");
if (frontText.Length == 0 && backText.Length == 0)
frontText = ExtractLegacySignLines(nbt);
isWaxed = nbt is not null
&& nbt.TryGetValue("is_waxed", out object? waxedValue)
&& waxedValue is bool waxed
&& waxed;
return frontText.Length > 0 || backText.Length > 0;
}
private static string[] ExtractSignLines(Dictionary<string, object>? nbt, string sideKey)
{
if (nbt is null
|| !nbt.TryGetValue(sideKey, out object? sideValue)
|| sideValue is not Dictionary<string, object> sideData
|| !sideData.TryGetValue("messages", out object? messagesValue)
|| messagesValue is not object[] messages)
{
return [];
}
return messages
.Take(4)
.Select(ConvertSignMessage)
.ToArray();
}
private static string[] ExtractLegacySignLines(Dictionary<string, object>? nbt)
{
if (nbt is null)
return [];
List<string> lines = new(4);
for (int i = 1; i <= 4; i++)
{
if (nbt.TryGetValue($"Text{i}", out object? value))
lines.Add(ConvertSignMessage(value));
}
return lines.ToArray();
}
private static string ConvertSignMessage(object? value)
{
try
{
return value switch
{
null => string.Empty,
string text => ParseMaybeJsonText(text),
Dictionary<string, object> nbt => ChatParser.ParseText(nbt),
object[] items => string.Concat(items.Select(ConvertSignMessage)),
_ => value.ToString() ?? string.Empty
};
}
catch
{
return value?.ToString() ?? string.Empty;
}
}
private static string ParseMaybeJsonText(string text)
{
string trimmed = text.Trim();
if ((trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal))
|| (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal)))
{
try
{
return ChatParser.ParseText(trimmed);
}
catch
{
}
}
return text;
}
private static bool IsSignMaterial(Material material)
{
return material.ToString().Contains("Sign", StringComparison.Ordinal);
}
private static (int x, int y, int z) ToBlockKey(Location location)
{
Location blockLocation = location.ToFloor();
return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z);
}
#endregion
}
}

View file

@ -8,6 +8,9 @@ public interface IMccMcpCapabilities
MccMcpResult GetPlayersList();
MccMcpResult GetChatHistory(int maxCount, bool includeJson);
MccMcpResult GetInternalCommands();
MccMcpResult GetMaterialsList(string? filter, int maxCount);
MccMcpResult GetBlockTypesList(string? filter, int maxCount);
MccMcpResult GetEntityTypesList(string? filter, int maxCount);
MccMcpResult SendChat(string text);
MccMcpResult QuitClient();
MccMcpResult RunInternalCommand(string command);
@ -21,6 +24,7 @@ public interface IMccMcpCapabilities
MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch);
MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf);
MccMcpResult LocatePlayer(string playerName, bool includeSelf);
MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs);
MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
MccMcpResult LookAt(double x, double y, double z);
@ -30,5 +34,8 @@ public interface IMccMcpCapabilities
MccMcpResult QueryEntities(int maxCount);
MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius);
MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects);
MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText);
MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount);
MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs);
MccMcpResult GetWorldBlockAt(int x, int y, int z);
}

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using MinecraftClient.Protocol.Message;
using MinecraftClient.Scripting;
namespace MinecraftClient.Mcp;
@ -14,13 +15,22 @@ namespace MinecraftClient.Mcp;
public sealed class MccMcpCapabilities : IMccMcpCapabilities
{
private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase;
private static readonly double[] s_defaultDigAttemptDurations = [1.5, 3.0, 5.0];
private const int CoordinateRoundingPrecision = 2;
private const double SelfEntityDistanceThreshold = 0.2;
private const int MaxBlockScanRadius = 12;
private const int MaxBlockFindRadius = 32;
private const double DigReachDistance = 5.0;
private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance;
private const int DefaultPathQueryTimeoutMs = 5000;
private const int MinPathQueryTimeoutMs = 250;
private const int MaxPathQueryTimeoutMs = 15000;
private const int DefaultArrivalWaitMs = 3500;
private const int MinArrivalWaitMs = 250;
private const int MaxArrivalWaitMs = 15000;
private const double DefaultArrivalTolerance = 1.5;
private const int ArrivalPollIntervalMs = 125;
private const int MaxBlockVerifyWaitMs = 12000;
private sealed class InternalCommandInfo
{
@ -42,6 +52,18 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
public required int Latency { get; init; }
}
private sealed class NearbyItemSnapshot
{
public required int EntityId { get; init; }
public required ItemType ItemType { get; init; }
public required string TypeLabel { get; init; }
public required int Count { get; init; }
public required double X { get; init; }
public required double Y { get; init; }
public required double Z { get; init; }
public required double Distance { get; init; }
}
private readonly Func<MccMcpCapabilityToggles> togglesProvider;
public MccMcpCapabilities(Func<MccMcpCapabilityToggles> togglesProvider)
@ -226,6 +248,96 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
});
}
public MccMcpResult GetMaterialsList(string? filter, int maxCount)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
int limit = Math.Clamp(maxCount, 1, 5000);
string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim();
Material[] allMaterials = Enum.GetValues<Material>();
var materials = allMaterials
.Select(material => new
{
name = material.ToString(),
typeLabel = GetMaterialTypeLabel(material)
})
.Where(material => normalizedFilter is null
|| TextMatchesFilter(material.name, normalizedFilter)
|| TextMatchesFilter(material.typeLabel, normalizedFilter))
.OrderBy(material => material.name, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToArray();
return MccMcpResult.Ok(new
{
total = allMaterials.Length,
count = materials.Length,
filter = normalizedFilter,
materials
});
}
public MccMcpResult GetBlockTypesList(string? filter, int maxCount)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
int limit = Math.Clamp(maxCount, 1, 5000);
string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim();
Material[] allMaterials = Enum.GetValues<Material>();
var blockTypes = allMaterials
.Select(material => new
{
name = material.ToString(),
typeLabel = GetMaterialTypeLabel(material)
})
.Where(blockType => normalizedFilter is null
|| TextMatchesFilter(blockType.name, normalizedFilter)
|| TextMatchesFilter(blockType.typeLabel, normalizedFilter))
.OrderBy(blockType => blockType.name, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToArray();
return MccMcpResult.Ok(new
{
total = allMaterials.Length,
count = blockTypes.Length,
filter = normalizedFilter,
blockTypes
});
}
public MccMcpResult GetEntityTypesList(string? filter, int maxCount)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
int limit = Math.Clamp(maxCount, 1, 5000);
string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim();
EntityType[] allEntityTypes = Enum.GetValues<EntityType>();
var entityTypes = allEntityTypes
.Select(entityType => new
{
name = entityType.ToString(),
typeLabel = Entity.GetTypeString(entityType)
})
.Where(entityType => normalizedFilter is null
|| TextMatchesFilter(entityType.name, normalizedFilter)
|| TextMatchesFilter(entityType.typeLabel, normalizedFilter))
.OrderBy(entityType => entityType.name, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToArray();
return MccMcpResult.Ok(new
{
total = allEntityTypes.Length,
count = entityTypes.Length,
filter = normalizedFilter,
entityTypes
});
}
public MccMcpResult SendChat(string text)
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
@ -343,7 +455,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
return MccMcpResult.Fail("capability_disabled");
if (durationSeconds < 0)
return MccMcpResult.Fail("invalid_args");
{
return MccMcpResult.Fail("invalid_args", data: new
{
parameter = "durationSeconds",
min = 0
});
}
McClient? client = GetClient();
if (client is null)
@ -352,13 +470,74 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
string sx = x.ToString(CultureInfo.InvariantCulture);
string sy = y.ToString(CultureInfo.InvariantCulture);
string sz = z.ToString(CultureInfo.InvariantCulture);
string command = durationSeconds > 0
? $"dig {sx} {sy} {sz} {durationSeconds.ToString(CultureInfo.InvariantCulture)}"
: $"dig {sx} {sy} {sz}";
return ExecuteInternalCommand(client, command);
Location target = ToBlockLocation(x, y, z);
Location currentLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
Location eyesLocation = currentLocation.EyesLocation();
Location centeredTarget = target.ToCenter();
Block beforeBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target));
if (beforeBlock.Type == Material.Air)
{
return MccMcpResult.Fail("invalid_state", data: new
{
target = ToCoordinate(target),
beforeBlock = ToBlockState(beforeBlock)
});
}
double distance = eyesLocation.Distance(centeredTarget);
if (distance > DigReachDistance)
{
return MccMcpResult.Fail("action_incomplete", data: new
{
reason = "too_far",
target = ToCoordinate(target),
playerLocation = ToCoordinate(currentLocation),
distance,
maxReach = DigReachDistance,
beforeBlock = ToBlockState(beforeBlock)
});
}
double[] attemptDurations = GetDigAttemptDurations(durationSeconds);
List<double> attemptedDurations = new();
Block afterBlock = beforeBlock;
bool changed = false;
bool commandAccepted = false;
foreach (double attemptDuration in attemptDurations)
{
attemptedDurations.Add(attemptDuration);
bool accepted = client.InvokeOnMainThread(() => client.DigBlock(target, Direction.Down, duration: attemptDuration));
commandAccepted |= accepted;
if (!accepted)
continue;
if (WaitForBlockChange(client, target, beforeBlock, GetDigVerifyWaitMs(attemptDuration), out afterBlock))
{
changed = true;
break;
}
}
afterBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target));
object resultData = new
{
success = changed,
target = ToCoordinate(target),
beforeBlock = ToBlockState(beforeBlock),
afterBlock = ToBlockState(afterBlock),
commandAccepted,
changed,
destroyed = changed && afterBlock.Type == Material.Air,
attempts = attemptedDurations.Count,
attemptedDurationsSeconds = attemptedDurations.ToArray(),
distance,
playerLocation = ToCoordinate(currentLocation)
};
return changed
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock)
@ -411,8 +590,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (radius is < 1 or > 8)
return MccMcpResult.Fail("invalid_args");
if (radius is < 1 or > MaxBlockScanRadius)
{
return MccMcpResult.Fail("invalid_args", data: new
{
parameter = "radius",
min = 1,
max = MaxBlockScanRadius
});
}
McClient? client = GetClient();
if (client is null)
@ -444,8 +630,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
continue;
string material = block.Type.ToString();
if (filter is not null && !material.Contains(filter, StringComparison.OrdinalIgnoreCase))
string typeLabel = block.GetTypeString();
if (filter is not null
&& !TextMatchesFilter(material, filter)
&& !TextMatchesFilter(typeLabel, filter))
{
continue;
}
double dx = x + 0.5 - playerLocation.X;
double dy = y + 0.5 - playerLocation.Y;
@ -456,6 +647,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
y,
z,
material,
typeLabel,
blockId = block.BlockId,
blockMeta = block.BlockMeta,
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
@ -479,8 +671,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (radius is < 1 or > 16)
return MccMcpResult.Fail("invalid_args");
if (radius is < 1 or > MaxBlockFindRadius)
{
return MccMcpResult.Fail("invalid_args", data: new
{
parameter = "radius",
min = 1,
max = MaxBlockFindRadius
});
}
McClient? client = GetClient();
if (client is null)
@ -558,6 +757,61 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
});
}
public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0)
{
return MccMcpResult.Fail("invalid_args", data: new
{
maxOffset,
minOffset,
timeoutMs
});
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
Location goal = new(x, y, z);
Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
World world = client.InvokeOnMainThread(client.GetWorld);
int effectiveTimeoutMs = GetPathQueryTimeoutMs(timeoutMs);
Queue<Location>? path = Movement.CalculatePath(
world,
startLocation,
goal,
allowUnsafe,
maxOffset,
minOffset,
TimeSpan.FromMilliseconds(effectiveTimeoutMs));
Location? finalWaypoint = path?.LastOrDefault();
double? finalDistance = finalWaypoint is Location waypoint
? GetDistance(waypoint, goal)
: null;
return MccMcpResult.Ok(new
{
reachable = path is not null,
exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(),
target = ToCoordinate(goal),
startLocation = ToCoordinate(startLocation),
finalWaypoint = finalWaypoint is Location finalLocation ? ToCoordinate(finalLocation) : null,
finalDistance,
waypointCount = path?.Count ?? 0,
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = effectiveTimeoutMs
});
}
public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
@ -672,6 +926,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0)
{
return MccMcpResult.Fail("invalid_args", data: new
{
maxOffset,
minOffset,
timeoutMs
});
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
@ -680,6 +944,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
return MccMcpResult.Fail("feature_disabled");
Location goal = new(x, y, z);
Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null;
bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout));
@ -687,16 +952,28 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
double tolerance = GetArrivalTolerance(maxOffset, minOffset);
Location? finalLocation = null;
bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation);
return MccMcpResult.Ok(new
finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation);
object resultData = new
{
pathFound,
arrived,
tolerance,
verifyWaitMs,
target = ToCoordinate(goal),
finalLocation = finalLocation is Location location ? ToCoordinate(location) : null
});
startLocation = ToCoordinate(startLocation),
finalLocation = ToCoordinate(finalLocation.Value),
finalDistance = GetDistance(finalLocation.Value, goal),
distanceMoved = GetDistance(startLocation, finalLocation.Value),
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
};
return pathFound && arrived
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs)
@ -707,6 +984,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
if (string.IsNullOrWhiteSpace(playerName))
return MccMcpResult.Fail("invalid_args");
if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0)
{
return MccMcpResult.Fail("invalid_args", data: new
{
maxOffset,
minOffset,
timeoutMs
});
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
@ -718,53 +1005,68 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
return MccMcpResult.Fail("feature_disabled");
string nameFilter = playerName.Trim();
return client.InvokeOnMainThread(() =>
NearbyPlayerSnapshot? target = client.InvokeOnMainThread(() =>
{
List<NearbyPlayerSnapshot> trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf: false);
NearbyPlayerSnapshot? target = trackedPlayers
return trackedPlayers
.Where(player => PlayerNameMatches(player, nameFilter))
.OrderBy(player => player.Distance)
.FirstOrDefault();
if (target is null)
{
return MccMcpResult.Fail("invalid_state", data: new
{
playerName = nameFilter,
trackedPlayers = trackedPlayers
.Select(player => player.Name)
.OfType<string>()
.Distinct(NameComparer)
.ToArray()
});
}
Location goal = new(target.X, target.Y, target.Z);
TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null;
bool pathFound = client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout);
int verifyWaitMs = GetArrivalWaitMs(timeoutMs);
double tolerance = GetArrivalTolerance(maxOffset, minOffset);
Location? finalLocation = null;
bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation);
return MccMcpResult.Ok(new
{
pathFound,
arrived,
tolerance,
verifyWaitMs,
target = new
{
playerName = target.Name,
entityId = target.EntityId,
x = RoundCoordinate(target.X),
y = RoundCoordinate(target.Y),
z = RoundCoordinate(target.Z)
},
finalLocation = finalLocation is Location location ? ToCoordinate(location) : null
});
});
if (target is null)
{
string[] trackedPlayers = client.InvokeOnMainThread(() => BuildTrackedPlayerSnapshots(client, includeSelf: false)
.Select(player => player.Name)
.OfType<string>()
.Distinct(NameComparer)
.ToArray());
return MccMcpResult.Fail("invalid_state", data: new
{
playerName = nameFilter,
trackedPlayers
});
}
Location goal = new(target.X, target.Y, target.Z);
Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null;
bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout));
int verifyWaitMs = GetArrivalWaitMs(timeoutMs);
double tolerance = GetArrivalTolerance(maxOffset, minOffset);
Location? finalLocation = null;
bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation);
finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation);
object resultData = new
{
pathFound,
arrived,
tolerance,
verifyWaitMs,
target = new
{
playerName = target.Name,
entityId = target.EntityId,
x = RoundCoordinate(target.X),
y = RoundCoordinate(target.Y),
z = RoundCoordinate(target.Z)
},
startLocation = ToCoordinate(startLocation),
finalLocation = ToCoordinate(finalLocation.Value),
finalDistance = GetDistance(finalLocation.Value, goal),
distanceMoved = GetDistance(startLocation, finalLocation.Value),
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
};
return pathFound && arrived
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
public MccMcpResult LookAt(double x, double y, double z)
@ -1168,6 +1470,237 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
});
}
public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(text) || radius is < 1 or > MaxBlockFindRadius)
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
string filter = text.Trim();
int limit = Math.Clamp(maxCount, 1, 500);
return client.InvokeOnMainThread(() =>
{
Location playerLocation = client.GetCurrentLocation();
World world = client.GetWorld();
var signs = client.GetKnownSigns()
.Select(sign =>
{
double dx = sign.location.X + 0.5 - playerLocation.X;
double dy = sign.location.Y + 0.5 - playerLocation.Y;
double dz = sign.location.Z + 0.5 - playerLocation.Z;
return new
{
sign,
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
};
})
.Where(entry => entry.distance <= radius)
.Where(entry => IsSignMaterial(world.GetBlock(entry.sign.location).Type))
.Select(entry =>
{
string[] frontText = entry.sign.frontText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray();
string[] backText = includeBackText
? entry.sign.backText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray()
: [];
string[] matchedLines = frontText
.Concat(backText)
.Where(line => exactMatch ? TextEqualsFilter(line, filter) : TextMatchesFilter(line, filter))
.Distinct(NameComparer)
.ToArray();
return new
{
entry.sign,
entry.distance,
frontText,
backText,
matchedLines
};
})
.Where(entry => entry.matchedLines.Length > 0)
.OrderBy(entry => entry.distance)
.Take(limit)
.Select(entry => new
{
x = (int)Math.Floor(entry.sign.location.X),
y = (int)Math.Floor(entry.sign.location.Y),
z = (int)Math.Floor(entry.sign.location.Z),
material = entry.sign.material,
typeLabel = entry.sign.typeLabel,
distance = entry.distance,
isWaxed = entry.sign.isWaxed,
frontText = entry.frontText,
backText = entry.backText,
matchedLines = entry.matchedLines
})
.ToArray();
return MccMcpResult.Ok(new
{
text = filter,
exactMatch,
radius,
includeBackText,
count = signs.Length,
signs
});
});
}
public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (radius <= 0 || radius > 1024)
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
ItemType? parsedItemType = null;
string? itemTypeFilter = null;
if (!string.IsNullOrWhiteSpace(itemType))
{
itemTypeFilter = itemType.Trim();
if (!TryParseItemType(itemTypeFilter, out ItemType resolvedType))
return MccMcpResult.Fail("invalid_args");
parsedItemType = resolvedType;
}
int limit = Math.Clamp(maxCount, 1, 500);
return client.InvokeOnMainThread(() =>
{
NearbyItemSnapshot[] items = BuildNearbyItemSnapshots(client, parsedItemType, radius, limit);
return MccMcpResult.Ok(new
{
itemType = parsedItemType?.ToString() ?? itemTypeFilter,
radius,
count = items.Length,
items = items.Select(item => new
{
entityId = item.EntityId,
itemType = item.ItemType.ToString(),
typeLabel = item.TypeLabel,
count = item.Count,
x = RoundCoordinate(item.X),
y = RoundCoordinate(item.Y),
z = RoundCoordinate(item.Z),
distance = item.Distance
}).ToArray()
});
});
}
public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs)
{
if (!IsCategoryEnabled(t => t.EntityWorld) || !IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(itemType) || radius <= 0 || radius > 1024 || maxItems < 1 || timeoutMs < 0)
return MccMcpResult.Fail("invalid_args");
if (!TryParseItemType(itemType.Trim(), out ItemType parsedItemType))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled() || !client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
int limit = Math.Clamp(maxItems, 1, 50);
NearbyItemSnapshot[] targets = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, limit));
if (targets.Length == 0)
{
return MccMcpResult.Fail("invalid_state", data: new
{
itemType = parsedItemType.ToString(),
radius,
maxItems = limit
});
}
bool inventoryEnabled = client.GetInventoryEnabled();
int beforeCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : 0;
int initialCount = beforeCount;
int verifyWaitMs = timeoutMs > 0 ? Math.Clamp(timeoutMs, MinArrivalWaitMs, MaxArrivalWaitMs) : 2500;
List<object> attempts = new(targets.Length);
int successfulPickups = 0;
foreach (NearbyItemSnapshot target in targets)
{
Location targetLocation = new(target.X, target.Y, target.Z);
Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
TimeSpan? moveTimeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null;
bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(targetLocation, allowUnsafe, false, 0, 0, moveTimeout));
Location? finalLocation = null;
bool arrived = pathFound && WaitForArrival(client, targetLocation, verifyWaitMs, 2.0, out finalLocation);
finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation);
bool entityGone = WaitForEntityRemoval(client, target.EntityId, verifyWaitMs);
int afterCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : beforeCount;
int inventoryDelta = inventoryEnabled ? Math.Max(0, afterCount - beforeCount) : 0;
bool pickedUp = entityGone || inventoryDelta > 0;
if (pickedUp)
successfulPickups++;
attempts.Add(new
{
entityId = target.EntityId,
itemType = target.ItemType.ToString(),
typeLabel = target.TypeLabel,
expectedCount = target.Count,
target = ToCoordinate(target.X, target.Y, target.Z),
pathFound,
arrived,
entityGone,
inventoryDelta,
startLocation = ToCoordinate(startLocation),
finalLocation = ToCoordinate(finalLocation.Value),
finalDistance = GetDistance(finalLocation.Value, targetLocation)
});
beforeCount = afterCount;
}
int remainingNearby = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, 1000).Length);
int collectedCount = inventoryEnabled ? Math.Max(0, beforeCount - initialCount) : successfulPickups;
object resultData = new
{
itemType = parsedItemType.ToString(),
radius,
maxItems = limit,
allowUnsafe,
timeoutMs = verifyWaitMs,
attempted = attempts.Count,
successfulPickups,
collectedCount,
initialInventoryCount = inventoryEnabled ? (int?)initialCount : null,
finalInventoryCount = inventoryEnabled ? (int?)beforeCount : null,
remainingNearby,
attempts = attempts.ToArray()
};
return successfulPickups > 0 || collectedCount > 0
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
public MccMcpResult GetWorldBlockAt(int x, int y, int z)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
@ -1436,6 +1969,112 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
return Math.Max(DefaultArrivalTolerance, toleranceFromOffset);
}
private static int GetPathQueryTimeoutMs(int timeoutMs)
{
if (timeoutMs <= 0)
return DefaultPathQueryTimeoutMs;
return Math.Clamp(timeoutMs, MinPathQueryTimeoutMs, MaxPathQueryTimeoutMs);
}
private static bool WaitForBlockChange(McClient client, Location target, Block beforeBlock, int waitMs, out Block afterBlock)
{
afterBlock = beforeBlock;
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
Block current = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target));
afterBlock = current;
if (!AreEquivalentBlocks(current, beforeBlock))
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static bool AreEquivalentBlocks(Block left, Block right)
{
return left.BlockId == right.BlockId
&& left.BlockMeta == right.BlockMeta
&& left.Type == right.Type;
}
private static double[] GetDigAttemptDurations(double durationSeconds)
{
if (durationSeconds > 0)
return [durationSeconds];
return s_defaultDigAttemptDurations;
}
private static int GetDigVerifyWaitMs(double durationSeconds)
{
int waitMs = (int)Math.Ceiling(durationSeconds * 1000) + 2000;
return Math.Clamp(waitMs, 1500, MaxBlockVerifyWaitMs);
}
private static bool AreValidPathOffsets(int maxOffset, int minOffset)
{
return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset;
}
private static NearbyItemSnapshot[] BuildNearbyItemSnapshots(McClient client, ItemType? itemType, double radius, int maxCount)
{
Location playerLocation = client.GetCurrentLocation();
return client.GetEntities().Values
.Where(entity => entity.Type == EntityType.Item && !entity.Item.IsEmpty)
.Where(entity => !itemType.HasValue || entity.Item.Type == itemType.Value)
.Select(entity =>
{
double dx = entity.Location.X - playerLocation.X;
double dy = entity.Location.Y - playerLocation.Y;
double dz = entity.Location.Z - playerLocation.Z;
return new NearbyItemSnapshot
{
EntityId = entity.ID,
ItemType = entity.Item.Type,
TypeLabel = entity.Item.GetTypeString(),
Count = entity.Item.Count,
X = entity.Location.X,
Y = entity.Location.Y,
Z = entity.Location.Z,
Distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
};
})
.Where(item => item.Distance <= radius)
.OrderBy(item => item.Distance)
.Take(maxCount)
.ToArray();
}
private static bool WaitForEntityRemoval(McClient client, int entityId, int waitMs)
{
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
bool exists = client.InvokeOnMainThread(() => client.GetEntities().ContainsKey(entityId));
if (!exists)
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static int GetInventoryItemCount(McClient client, ItemType itemType)
{
Container? inventory = client.GetInventory(0);
if (inventory is null)
return 0;
return inventory.Items.Values
.Where(item => item.Type == itemType)
.Sum(item => item.Count);
}
private static object ToCoordinate(Location location)
{
return ToCoordinate(location.X, location.Y, location.Z);
@ -1456,6 +2095,51 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero);
}
private static Location ToBlockLocation(double x, double y, double z)
{
return new Location(Math.Floor(x), Math.Floor(y), Math.Floor(z));
}
private static object ToBlockState(Block block)
{
return new
{
material = block.Type.ToString(),
typeLabel = block.GetTypeString(),
blockId = block.BlockId,
blockMeta = block.BlockMeta
};
}
private static string GetMaterialTypeLabel(Material material)
{
string key = "block.minecraft." + ToTranslationKey(material.ToString());
string? translation = ChatParser.TranslateString(key);
return string.IsNullOrEmpty(translation) ? material.ToString() : translation;
}
private static string ToTranslationKey(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
List<char> chars = new(value.Length * 2);
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
if (char.IsUpper(current) && i > 0 && (char.IsLower(value[i - 1]) || char.IsDigit(value[i - 1])))
chars.Add('_');
chars.Add(char.ToLowerInvariant(current));
}
return new string(chars.ToArray());
}
private static bool IsSignMaterial(Material material)
{
return material.ToString().Contains("Sign", StringComparison.Ordinal);
}
private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary<string, string> uuidToName)
{
if (!string.IsNullOrWhiteSpace(entity.Name))
@ -1492,12 +2176,30 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
string typeLabel = block.GetTypeString();
if (exactMatch)
{
return material.Equals(filter, StringComparison.OrdinalIgnoreCase)
|| typeLabel.Equals(filter, StringComparison.OrdinalIgnoreCase);
return TextEqualsFilter(material, filter)
|| TextEqualsFilter(typeLabel, filter);
}
return material.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| typeLabel.Contains(filter, StringComparison.OrdinalIgnoreCase);
return TextMatchesFilter(material, filter)
|| TextMatchesFilter(typeLabel, filter);
}
private static bool TextEqualsFilter(string text, string filter)
{
return text.Equals(filter, StringComparison.OrdinalIgnoreCase)
|| NormalizeToken(text) == NormalizeToken(filter);
}
private static bool TextMatchesFilter(string text, string filter)
{
if (text.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
string normalizedFilter = NormalizeToken(filter);
if (normalizedFilter.Length == 0)
return false;
return NormalizeToken(text).Contains(normalizedFilter, StringComparison.Ordinal);
}
private static void ParseBlockQuery(string? query, out int? blockId, out int? blockMeta)

View file

@ -49,6 +49,24 @@ public sealed class MccMcpToolSet
return capabilities.GetInternalCommands();
}
[McpServerTool(Name = "mcc_materials_list"), Description("List known MCC material names with optional filtering.")]
public object MaterialsList(string? filter = null, int maxCount = 500)
{
return capabilities.GetMaterialsList(filter, maxCount);
}
[McpServerTool(Name = "mcc_block_types_list"), Description("List known MCC block type names with optional filtering.")]
public object BlockTypesList(string? filter = null, int maxCount = 500)
{
return capabilities.GetBlockTypesList(filter, maxCount);
}
[McpServerTool(Name = "mcc_entity_types_list"), Description("List known MCC entity type names with optional filtering.")]
public object EntityTypesList(string? filter = null, int maxCount = 500)
{
return capabilities.GetEntityTypesList(filter, maxCount);
}
[McpServerTool(Name = "mcc_send_chat"), Description("Send chat text or slash-command to the connected Minecraft server.")]
public object SendChat([Description("Text to send to server chat.")] string text)
{
@ -127,6 +145,12 @@ public sealed class MccMcpToolSet
return capabilities.LocatePlayer(playerName, includeSelf);
}
[McpServerTool(Name = "mcc_can_reach_position"), Description("Check whether MCC can currently path to a world coordinate without moving there.")]
public object CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
{
return capabilities.CanReachPosition(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs);
}
[McpServerTool(Name = "mcc_move_to"), Description("Request movement/pathing to a world coordinate and verify arrival.")]
public object MoveTo(double x, double y, double z, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
{
@ -185,6 +209,24 @@ public sealed class MccMcpToolSet
return capabilities.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects);
}
[McpServerTool(Name = "mcc_signs_find"), Description("Find nearby signs whose text exactly matches or contains the requested text.")]
public object SignsFind(string text, bool exactMatch = false, int radius = 16, int maxCount = 50, bool includeBackText = true)
{
return capabilities.FindSigns(text, exactMatch, radius, maxCount, includeBackText);
}
[McpServerTool(Name = "mcc_items_list"), Description("List nearby dropped item entities with optional item type filtering.")]
public object ItemsList(string? itemType = null, double radius = 32, int maxCount = 100)
{
return capabilities.ListItemEntities(itemType, radius, maxCount);
}
[McpServerTool(Name = "mcc_items_pickup"), Description("Move to and pick up nearby dropped items of a given item type.")]
public object ItemsPickup(string itemType, double radius = 32, int maxItems = 20, bool allowUnsafe = false, int timeoutMs = 0)
{
return capabilities.PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs);
}
[McpServerTool(Name = "mcc_world_block_at"), Description("Get block information at world coordinates.")]
public object WorldBlockAt(int x, int y, int z)
{

View file

@ -1569,6 +1569,7 @@ namespace MinecraftClient.Protocol.Handlers
var dataSize = dataTypes.ReadNextVarInt(packetData); // Size
pTerrain.ProcessChunkColumnData(chunkX, chunkZ, verticalStripBitmask, packetData);
ProcessChunkBlockEntityData(chunkX, chunkZ, packetData);
Interlocked.Decrement(ref handler.GetWorld().chunkLoadNotCompleted);
// Block Entity data: ignored
@ -2957,17 +2958,16 @@ namespace MinecraftClient.Protocol.Handlers
// TODO: Use
break;
case PacketTypesIn.BlockEntityData:
if (handler.GetTerrainEnabled() && protocolVersion >= MC_1_17_Version)
{
var location_ = dataTypes.ReadNextLocation(packetData);
dataTypes.ReadNextVarInt(packetData); // Block entity type registry id
var nbt = dataTypes.ReadNextNbt(packetData);
handler.OnBlockEntityData(location_, nbt);
}
// Temporarily disabled until I find a fix
/*case PacketTypesIn.BlockEntityData:
var location_ = dataTypes.ReadNextLocation(packetData);
var type_ = dataTypes.ReadNextInt(packetData);
var nbt = dataTypes.ReadNextNbt(packetData);
var nbtJson = JsonConvert.SerializeObject(nbt["messages"]);
//log.Info($"BLOCK ENTITY DATA -> {location_.ToString()} [{type_}] -> NBT: {nbtJson}");
break;*/
break;
case PacketTypesIn.SetTickingState:
dataTypes.ReadNextFloat(packetData);
@ -3162,6 +3162,24 @@ namespace MinecraftClient.Protocol.Handlers
SendPacket(packetPalette.GetOutgoingIdByType(packet), packetData);
}
private void ProcessChunkBlockEntityData(int chunkX, int chunkZ, Queue<byte> packetData)
{
if (protocolVersion < MC_1_17_Version || packetData.Count == 0)
return;
int blockEntityCount = dataTypes.ReadNextVarInt(packetData);
for (int i = 0; i < blockEntityCount; i++)
{
int packedXZ = dataTypes.ReadNextByte(packetData);
int y = dataTypes.ReadNextShort(packetData);
dataTypes.ReadNextVarInt(packetData); // Block entity type registry id
Dictionary<string, object>? nbt = dataTypes.ReadNextNbt(packetData);
int blockX = chunkX * Chunk.SizeX + ((packedXZ >> 4) & 0x0F);
int blockZ = chunkZ * Chunk.SizeZ + (packedXZ & 0x0F);
handler.OnBlockEntityData(new Location(blockX, y, blockZ), nbt);
}
}
/// <summary>
/// Send a configuration packet to the server. Packet ID, compression, and encryption will be handled automatically.
/// </summary>

View file

@ -508,6 +508,13 @@ namespace MinecraftClient.Protocol
/// <param name="block">The block</param>
public void OnBlockChange(Location location, Block block);
/// <summary>
/// Called when block entity update data is received for a loaded block.
/// </summary>
/// <param name="location">The block location.</param>
/// <param name="nbt">The block entity NBT payload.</param>
public void OnBlockEntityData(Location location, Dictionary<string, object>? nbt);
/// <summary>
/// Called when "AutoComplete" completes.
/// </summary>