Minecraft-Console-Client/MinecraftClient/Mcp/MccMcpCapabilities.cs

3921 lines
145 KiB
C#
Raw Normal View History

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
2026-03-30 23:14:28 +02:00
using MinecraftClient.Protocol;
using MinecraftClient.Protocol.Message;
using MinecraftClient.Scripting;
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;
2026-03-30 23:14:28 +02:00
private const double MaxRaycastDistance = 128.0;
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 const int DefaultContainerWaitMs = 5000;
private const int MinContainerWaitMs = 250;
private const int MaxContainerWaitMs = 20000;
private const int DefaultInventoryActionWaitMs = 3500;
2026-03-30 23:14:28 +02:00
private const int MaxPathPreviewWaypoints = 1000;
private sealed class InternalCommandInfo
{
public required string Name { get; init; }
public required string Usage { get; init; }
public required string Description { get; init; }
}
private sealed class NearbyPlayerSnapshot
{
public required int EntityId { get; init; }
public required Guid Uuid { get; init; }
public string? Name { get; set; }
public string? CustomName { 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; }
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 enum InventoryTransferDirection
{
Deposit,
Withdraw
}
private readonly Func<MccMcpCapabilityToggles> togglesProvider;
public MccMcpCapabilities(Func<MccMcpCapabilityToggles> togglesProvider)
{
this.togglesProvider = togglesProvider;
}
private static McClient? GetClient()
{
return McClient.Instance as McClient;
}
private static MccMcpResult NotConnected()
{
return MccMcpResult.Fail("disconnected");
}
private bool IsCategoryEnabled(Func<MccMcpCapabilityToggles, bool> selector)
{
return selector(togglesProvider());
}
public MccMcpResult GetSessionStatus()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
Location location = client.GetCurrentLocation();
return MccMcpResult.Ok(new
{
host = client.GetServerHost(),
port = client.GetServerPort(),
username = client.GetUsername(),
protocolVersion = client.GetProtocolVersion(),
terrainEnabled = client.GetTerrainEnabled(),
inventoryEnabled = client.GetInventoryEnabled(),
entityEnabled = client.GetEntityHandlingEnabled(),
location = ToCoordinate(location)
});
});
}
public MccMcpResult GetServerInfo()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() => MccMcpResult.Ok(new
{
host = client.GetServerHost(),
port = client.GetServerPort(),
tps = client.GetServerTPS()
}));
}
public MccMcpResult GetPlayerState()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
Location location = client.GetCurrentLocation();
Dictionary<Effects, EffectData> effects = client.GetPlayerEffects();
return MccMcpResult.Ok(new
{
nickname = client.GetUsername(),
username = client.GetUsername(),
health = client.GetHealth(),
saturation = client.GetSaturation(),
gamemode = client.GetGamemode(),
currentSlot = client.GetCurrentSlot() + 1,
yaw = client.GetYaw(),
pitch = client.GetPitch(),
location = ToCoordinate(location),
effects = effects.Values.Select(effect => new
{
id = effect.Effect.ToString(),
amplifier = effect.Amplifier,
remainingSeconds = effect.RemainingSeconds,
isInfinite = effect.IsInfinite
}).ToArray()
});
});
}
2026-03-30 23:14:28 +02:00
public MccMcpResult GetWorldState()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
Location location = client.GetCurrentLocation();
World world = client.GetWorld();
Dimension dimension = World.GetDimension();
MccMcpRuntimeStateSnapshot runtimeState = MccMcpRuntimeStateStore.GetSnapshot();
int totalChunkCount = world.chunkCnt;
int pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted);
int loadedChunkCount = GetLoadedChunkCount(world);
return MccMcpResult.Ok(new
{
host = client.GetServerHost(),
port = client.GetServerPort(),
username = client.GetUsername(),
protocol = client.GetProtocolVersion(),
protocolVersion = client.GetProtocolVersion(),
terrainEnabled = client.GetTerrainEnabled(),
inventoryEnabled = client.GetInventoryEnabled(),
entityEnabled = client.GetEntityHandlingEnabled(),
entityHandlingEnabled = client.GetEntityHandlingEnabled(),
location = ToCoordinate(location),
tps = client.GetServerTPS(),
dimension = dimension.Name,
dimensionDetails = new
{
name = dimension.Name,
minY = dimension.minY,
maxY = dimension.maxY,
height = dimension.height,
logicalHeight = dimension.logicalHeight,
coordinateScale = dimension.coordinateScale,
hasSkylight = dimension.hasSkylight,
hasCeiling = dimension.hasCeiling,
fixedTime = dimension.fixedTime >= 0 ? dimension.fixedTime : (long?)null
},
loadedChunkCount,
pendingChunkCount,
totalChunkCount,
loadRatio = GetChunkLoadRatio(world),
worldAge = runtimeState.WorldAge,
timeOfDay = runtimeState.TimeOfDay,
rainLevel = runtimeState.RainLevel,
thunderLevel = runtimeState.ThunderLevel
});
});
}
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (!HasCompleteCoordinateTriple(x, y, z))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Location queryLocation = x.HasValue && y.HasValue && z.HasValue
? new Location(x.Value, y.Value, z.Value)
: client.GetCurrentLocation();
World world = client.GetWorld();
ChunkColumn? chunkColumn = world.GetChunkColumn(queryLocation);
return MccMcpResult.Ok(new
{
location = ToCoordinate(queryLocation),
chunk = new
{
x = queryLocation.ChunkX,
z = queryLocation.ChunkZ
},
chunkX = queryLocation.ChunkX,
chunkZ = queryLocation.ChunkZ,
loaded = chunkColumn is not null,
fullyLoaded = chunkColumn?.FullyLoaded ?? false,
loadedChunkCount = GetLoadedChunkCount(world),
pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted),
totalChunkCount = world.chunkCnt,
loadRatio = GetChunkLoadRatio(world)
});
});
}
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (maxDistance <= 0 || maxDistance > MaxRaycastDistance)
{
return MccMcpResult.Fail("invalid_args", data: new
{
parameter = "maxDistance",
minExclusive = 0,
max = MaxRaycastDistance
});
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Location playerLocation = client.GetCurrentLocation();
Location eyeLocation = playerLocation.EyesLocation();
Tuple<bool, Location, Block> raycast = RaycastHelper.RaycastBlock(client, maxDistance, includeFluids: false);
if (!raycast.Item1)
{
return MccMcpResult.Ok(new
{
hit = false,
maxDistance,
playerLocation = ToCoordinate(playerLocation),
eyeLocation = ToCoordinate(eyeLocation),
location = (object?)null,
block = (object?)null,
distance = (double?)null,
eyeDistance = (double?)null,
neighbors = (object?)null
});
}
Location blockLocation = raycast.Item2;
Block block = raycast.Item3;
Location targetCenter = blockLocation.ToCenter();
object? neighbors = includeNeighbors ? GetNeighborBlockSnapshot(client.GetWorld(), blockLocation) : null;
return MccMcpResult.Ok(new
{
hit = true,
maxDistance,
playerLocation = ToCoordinate(playerLocation),
eyeLocation = ToCoordinate(eyeLocation),
location = ToCoordinate(blockLocation),
block = ToBlockState(block),
distance = playerLocation.Distance(targetCenter),
eyeDistance = eyeLocation.Distance(targetCenter),
neighbors
});
});
}
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0 || maxWaypoints <= 0)
{
return MccMcpResult.Fail("invalid_args", data: new
{
maxOffset,
minOffset,
timeoutMs,
maxWaypoints
});
}
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);
int waypointLimit = Math.Clamp(maxWaypoints, 1, MaxPathPreviewWaypoints);
Queue<Location>? path = Movement.CalculatePath(
world,
startLocation,
goal,
allowUnsafe,
maxOffset,
minOffset,
TimeSpan.FromMilliseconds(effectiveTimeoutMs));
Location[] waypoints = path?.Take(waypointLimit).ToArray() ?? [];
Location? finalWaypoint = path is not null && path.Count > 0 ? path.Last() : null;
return MccMcpResult.Ok(new
{
pathFound = path is not null,
exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(),
target = ToCoordinate(goal),
startLocation = ToCoordinate(startLocation),
finalWaypoint = finalWaypoint is Location waypoint ? ToCoordinate(waypoint) : (object?)null,
finalDistance = finalWaypoint is Location endWaypoint ? GetDistance(endWaypoint, goal) : (double?)null,
waypointCount = path?.Count ?? 0,
truncated = path is not null && path.Count > waypointLimit,
waypoints = waypoints.Select(ToCoordinate).ToArray(),
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = effectiveTimeoutMs
});
}
public MccMcpResult GetPlayersList()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() => MccMcpResult.Ok(new
{
players = client.GetOnlinePlayers()
}));
}
2026-03-30 23:14:28 +02:00
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
Dictionary<string, string> onlinePlayers = client.GetOnlinePlayersWithUUID();
Dictionary<Guid, NearbyPlayerSnapshot>? trackedPlayers = client.GetEntityHandlingEnabled()
? BuildTrackedPlayerSnapshots(client, includeSelf: true).ToDictionary(player => player.Uuid)
: null;
Guid selfUuid = client.GetUserUuid();
string selfName = client.GetUsername();
var players = onlinePlayers
.Select(pair =>
{
if (!Guid.TryParse(pair.Key, out Guid uuid))
return null;
bool isSelf = uuid == selfUuid || NameComparer.Equals(pair.Value, selfName);
if (!includeSelf && isSelf)
return null;
PlayerInfo? playerInfo = client.GetPlayerInfo(uuid);
NearbyPlayerSnapshot? trackedPlayer = trackedPlayers is not null
&& trackedPlayers.TryGetValue(uuid, out NearbyPlayerSnapshot? resolvedTrackedPlayer)
? resolvedTrackedPlayer
: null;
Location? selfLocation = isSelf ? client.GetCurrentLocation() : null;
int? entityId = trackedPlayer?.EntityId ?? (isSelf ? client.GetPlayerEntityID() : null);
double? x = includeCoordinates
? trackedPlayer?.X is double trackedX ? RoundCoordinate(trackedX)
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.X)
: (double?)null
: null;
double? y = includeCoordinates
? trackedPlayer?.Y is double trackedY ? RoundCoordinate(trackedY)
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Y)
: (double?)null
: null;
double? z = includeCoordinates
? trackedPlayer?.Z is double trackedZ ? RoundCoordinate(trackedZ)
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Z)
: (double?)null
: null;
return new
{
name = playerInfo?.Name ?? pair.Value,
uuid,
ping = playerInfo?.Ping ?? trackedPlayer?.Latency ?? 0,
gamemode = playerInfo?.Gamemode ?? -1,
listed = playerInfo?.Listed ?? true,
displayName = playerInfo?.DisplayName,
entityId,
x,
y,
z
};
})
.Where(player => player is not null)
.OrderBy(player => player!.name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return MccMcpResult.Ok(new
{
includeSelf,
includeCoordinates,
count = players.Length,
players
});
});
}
public MccMcpResult GetPlayerStats()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
Location location = client.GetCurrentLocation();
return MccMcpResult.Ok(new
{
username = client.GetUsername(),
health = client.GetHealth(),
saturation = client.GetSaturation(),
level = client.GetLevel(),
totalExperience = client.GetTotalExperience(),
gamemode = client.GetGamemode(),
playerEntityId = client.GetPlayerEntityID(),
currentSlot = client.GetCurrentSlot() + 1,
yaw = client.GetYaw(),
pitch = client.GetPitch(),
location = ToCoordinate(location),
tps = client.GetServerTPS()
});
});
}
public MccMcpResult GetStatusEffects()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
var effects = client.GetPlayerEffects()
.Values
.Where(effect => !effect.IsExpired)
.OrderBy(effect => effect.Effect)
.Select(effect => new
{
id = effect.Effect.ToString(),
name = effect.GetDisplayName(),
amplifier = effect.Amplifier,
remainingSeconds = effect.RemainingSeconds,
isInfinite = effect.IsInfinite
})
.ToArray();
return MccMcpResult.Ok(new
{
count = effects.Length,
effects
});
});
}
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
MccMcpRecentEventEntry[] events = MccMcpRecentEventStore.GetAfter(afterId, maxCount, typeFilter);
return MccMcpResult.Ok(new
{
afterId,
latestId = MccMcpRecentEventStore.GetLatestId(),
count = events.Length,
events = events.Select(entry => new
{
id = entry.Id,
timestampUtc = entry.TimestampUtc,
type = entry.Type,
data = entry.Data
}).ToArray()
});
}
public MccMcpResult GetLoadedBots()
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return client.InvokeOnMainThread(() =>
{
var bots = client.GetLoadedChatBots()
.Select(bot => new
{
name = bot.GetType().Name,
fullTypeName = bot.GetType().FullName,
isScript = bot is MinecraftClient.ChatBots.Script
})
.OrderBy(bot => bot.name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return MccMcpResult.Ok(new
{
count = bots.Length,
bots
});
});
}
public MccMcpResult GetChatHistory(int maxCount, bool includeJson)
{
if (!IsCategoryEnabled(t => t.SessionStatus))
return MccMcpResult.Fail("capability_disabled");
int count = Math.Clamp(maxCount, 1, 500);
MccMcpChatHistoryEntry[] entries = MccMcpChatHistoryStore.GetLatest(count);
return MccMcpResult.Ok(new
{
count = entries.Length,
entries = entries.Select(entry => new
{
timestampUtc = entry.TimestampUtc,
kind = entry.Kind,
text = entry.Text,
sender = entry.Sender,
message = entry.Message,
json = includeJson ? entry.Json : null
}).ToArray()
});
}
public MccMcpResult GetInternalCommands()
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
Type[] commandTypes = Program.GetTypesInNamespace("MinecraftClient.Commands");
List<InternalCommandInfo> commands = new();
foreach (Type type in commandTypes)
{
if (!type.IsSubclassOf(typeof(Command)))
continue;
try
{
if (Activator.CreateInstance(type) is Command cmd)
{
commands.Add(new InternalCommandInfo
{
Name = cmd.CmdName,
Usage = cmd.CmdUsage,
Description = ChatBot.GetVerbatim(cmd.CmdDesc)
});
}
}
catch
{
// ignore command constructors that fail for reflection-only list generation.
}
}
InternalCommandInfo[] ordered = commands
.OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return MccMcpResult.Ok(new
{
count = ordered.Length,
commands = ordered.Select(command => new
{
name = command.Name,
usage = command.Usage,
description = command.Description
}).ToArray()
});
}
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
});
}
2026-03-30 23:14:28 +02:00
public MccMcpResult SendChat(string text)
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(text))
return MccMcpResult.Fail("invalid_args");
string normalized = text.Trim();
if (normalized.Equals("quit", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
return MccMcpResult.Fail("internal_command_text_blocked");
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
bool sent = client.InvokeOnMainThread(() =>
{
client.SendText(normalized);
return true;
});
return sent ? MccMcpResult.Ok() : MccMcpResult.Fail("action_failed");
}
public MccMcpResult QuitClient()
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
_ = Task.Run(async () =>
{
await Task.Delay(150).ConfigureAwait(false);
Program.Exit();
});
return MccMcpResult.Ok(new { quitting = true });
}
public MccMcpResult DisconnectClient()
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
_ = Task.Run(async () =>
{
await Task.Delay(150).ConfigureAwait(false);
client.Disconnect();
});
return MccMcpResult.Ok(new { disconnecting = true });
}
public MccMcpResult Respawn()
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
float health = client.InvokeOnMainThread(client.GetHealth);
if (health > 0)
{
return MccMcpResult.Fail("invalid_state", data: new
{
health
});
}
bool ok = client.InvokeOnMainThread(client.SendRespawnPacket);
return ok
? MccMcpResult.Ok(new { success = true })
: MccMcpResult.Fail("action_failed", data: new { success = false });
}
public MccMcpResult RunInternalCommand(string command)
{
if (!IsCategoryEnabled(t => t.ChatAndCommands))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(command))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
return ExecuteInternalCommand(client, command.Trim());
}
public MccMcpResult PlayAnimation(string hand)
{
2026-03-30 23:14:28 +02:00
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
2026-03-30 23:14:28 +02:00
if (string.IsNullOrWhiteSpace(hand) || !Enum.TryParse(hand, true, out Hand parsedHand))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
2026-03-30 23:14:28 +02:00
int animation = parsedHand == Hand.MainHand ? 1 : 0;
bool ok = client.DoAnimation(animation);
object resultData = new { success = ok, hand = parsedHand.ToString() };
return ok
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_failed", data: resultData);
}
2026-03-30 23:14:28 +02:00
public MccMcpResult ToggleSneak(bool enabled)
{
2026-03-30 23:14:28 +02:00
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
2026-03-30 23:14:28 +02:00
EntityActionType action = enabled ? EntityActionType.StartSneaking : EntityActionType.StopSneaking;
bool ok = client.InvokeOnMainThread(() =>
{
2026-03-30 23:14:28 +02:00
bool actionResult = client.SendEntityAction(action);
if (actionResult)
client.IsSneaking = enabled;
return actionResult;
});
2026-03-30 23:14:28 +02:00
object resultData = new { success = ok, enabled };
return ok
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_failed", data: resultData);
}
2026-03-30 23:14:28 +02:00
public MccMcpResult ToggleSprint(bool enabled)
{
2026-03-30 23:14:28 +02:00
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
2026-03-30 23:14:28 +02:00
EntityActionType action = enabled ? EntityActionType.StartSprinting : EntityActionType.StopSprinting;
bool ok = client.SendEntityAction(action);
object resultData = new { success = ok, enabled };
return ok
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_failed", data: resultData);
}
public MccMcpResult UseItemOnHand()
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
bool ok = client.InvokeOnMainThread(() => client.UseItemOnHand());
return MccMcpResult.Ok(new { success = ok });
}
public MccMcpResult ChangeHotbarSlot(int slot)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (slot is < 1 or > 9)
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
bool ok = client.InvokeOnMainThread(() => client.ChangeSlot((short)(slot - 1)));
return MccMcpResult.Ok(new { success = ok, slot });
}
2026-03-30 23:14:28 +02:00
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(itemType))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!TryParseItemType(itemType, out ItemType parsedItemType))
{
return MccMcpResult.Fail("invalid_args", data: new
{
itemType = itemType.Trim()
});
}
return client.InvokeOnMainThread(() =>
{
Container? inventory = client.GetInventory(0);
if (inventory is null)
return MccMcpResult.Fail("invalid_state");
var matches = inventory.Items
.Where(pair => pair.Value.Type == parsedItemType && pair.Value.Count > 0)
.Select(pair =>
{
bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar);
return new
{
inventorySlot = pair.Key,
hotbar,
isHotbar,
count = pair.Value.Count
};
})
.Where(match => match.isHotbar)
.OrderBy(match => preferLowestSlot ? match.hotbar : -match.hotbar)
.ToArray();
if (matches.Length == 0)
{
return MccMcpResult.Fail("invalid_state", data: new
{
itemType = parsedItemType.ToString()
});
}
var selected = matches[0];
bool ok = client.ChangeSlot((short)selected.hotbar);
object resultData = new
{
success = ok,
itemType = parsedItemType.ToString(),
inventorySlot = selected.inventorySlot,
selectedSlot = selected.hotbar + 1,
count = selected.count
};
return ok
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_failed", data: resultData);
});
}
public MccMcpResult UseItemOnBlock(double x, double y, double z)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
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);
return ExecuteInternalCommand(client, $"useitem {sx} {sy} {sz}");
}
public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (durationSeconds < 0)
{
return MccMcpResult.Fail("invalid_args", data: new
{
parameter = "durationSeconds",
min = 0
});
}
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
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)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!Enum.TryParse(face, true, out Direction parsedFace))
return MccMcpResult.Fail("invalid_args");
if (!Enum.TryParse(hand, true, out Hand parsedHand))
return MccMcpResult.Fail("invalid_args");
Location location = new(x, y, z);
bool ok = client.InvokeOnMainThread(() => client.PlaceBlock(location, parsedFace, parsedHand, lookAtBlock));
return MccMcpResult.Ok(new { success = ok, x, y, z, face = parsedFace.ToString(), hand = parsedHand.ToString(), lookAtBlock });
}
public MccMcpResult InteractEntity(int entityId, string interaction, string hand)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!Enum.TryParse(interaction, true, out InteractType interactType))
return MccMcpResult.Fail("invalid_args");
if (!Enum.TryParse(hand, true, out Hand parsedHand))
return MccMcpResult.Fail("invalid_args");
bool ok = client.InvokeOnMainThread(() => client.InteractEntity(entityId, interactType, parsedHand));
return MccMcpResult.Ok(new { success = ok, entityId, interaction = interactType.ToString(), hand = parsedHand.ToString() });
}
2026-03-30 23:14:28 +02:00
public MccMcpResult AttackEntity(int entityId)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
if (!client.GetEntities().ContainsKey(entityId))
return MccMcpResult.Fail("invalid_state", data: new { entityId });
bool ok = client.InteractEntity(entityId, InteractType.Attack);
object resultData = new
{
success = ok,
entityId,
interaction = InteractType.Attack.ToString()
};
return ok
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_failed", data: resultData);
});
}
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
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)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
int limit = Math.Clamp(maxCount, 1, 2000);
string? filter = string.IsNullOrWhiteSpace(materialFilter) ? null : materialFilter.Trim();
return client.InvokeOnMainThread(() =>
{
Location playerLocation = client.GetCurrentLocation();
int cx = (int)Math.Floor(playerLocation.X);
int cy = (int)Math.Floor(playerLocation.Y) - 1;
int cz = (int)Math.Floor(playerLocation.Z);
List<object> found = new();
World world = client.GetWorld();
for (int y = cy - radius; y <= cy + radius && found.Count < limit; y++)
{
for (int z = cz - radius; z <= cz + radius && found.Count < limit; z++)
{
for (int x = cx - radius; x <= cx + radius && found.Count < limit; x++)
{
Block block = world.GetBlock(new Location(x, y, z));
if (block.Type == Material.Air)
continue;
string material = block.Type.ToString();
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;
double dz = z + 0.5 - playerLocation.Z;
found.Add(new
{
x,
y,
z,
material,
typeLabel,
blockId = block.BlockId,
blockMeta = block.BlockMeta,
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
});
}
}
}
return MccMcpResult.Ok(new
{
center = new { x = cx, y = cy, z = cz },
radius,
count = found.Count,
blocks = found.ToArray()
});
});
}
public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
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)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
int limit = Math.Clamp(maxCount, 1, 5000);
string? filter = string.IsNullOrWhiteSpace(query) ? null : query.Trim();
ParseBlockQuery(filter, out int? blockIdFilter, out int? blockMetaFilter);
return client.InvokeOnMainThread(() =>
{
Location playerLocation = client.GetCurrentLocation();
int cx = (int)Math.Floor(playerLocation.X);
int cy = (int)Math.Floor(playerLocation.Y) - 1;
int cz = (int)Math.Floor(playerLocation.Z);
List<(int x, int y, int z, string material, string typeLabel, int blockId, byte blockMeta, double distance)> found = new();
World world = client.GetWorld();
for (int y = cy - radius; y <= cy + radius && found.Count < limit; y++)
{
for (int z = cz - radius; z <= cz + radius && found.Count < limit; z++)
{
for (int x = cx - radius; x <= cx + radius && found.Count < limit; x++)
{
Block block = world.GetBlock(new Location(x, y, z));
if (block.Type == Material.Air)
continue;
if (!BlockMatches(block, filter, exactMatch, blockIdFilter, blockMetaFilter))
continue;
double dx = x + 0.5 - playerLocation.X;
double dy = y + 0.5 - playerLocation.Y;
double dz = z + 0.5 - playerLocation.Z;
found.Add((
x,
y,
z,
block.Type.ToString(),
block.GetTypeString(),
block.BlockId,
block.BlockMeta,
Math.Sqrt(dx * dx + dy * dy + dz * dz)));
}
}
}
return MccMcpResult.Ok(new
{
center = new { x = cx, y = cy, z = cz },
radius,
query = filter,
exactMatch,
count = found.Count,
blocks = found
.OrderBy(entry => entry.distance)
.Select(entry => new
{
entry.x,
entry.y,
entry.z,
entry.material,
entry.typeLabel,
entry.blockId,
entry.blockMeta,
entry.distance
})
.ToArray()
});
});
}
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))
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");
string? nameFilter = string.IsNullOrWhiteSpace(playerName) ? null : playerName.Trim();
return client.InvokeOnMainThread(() =>
{
double radiusValue = radius;
List<NearbyPlayerSnapshot> trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf);
var players = trackedPlayers
.Where(player => player.Distance <= radiusValue)
.Where(player =>
{
if (nameFilter is null)
return true;
return PlayerNameMatches(player, nameFilter);
})
.OrderBy(player => player.Distance)
.Select(player => new
{
entityId = player.EntityId,
uuid = player.Uuid,
name = player.Name,
customName = player.CustomName,
x = RoundCoordinate(player.X),
y = RoundCoordinate(player.Y),
z = RoundCoordinate(player.Z),
distance = player.Distance,
latency = player.Latency
})
.ToArray();
return MccMcpResult.Ok(new
{
radius = radiusValue,
playerName = nameFilter,
includeSelf,
anyNearby = players.Length > 0,
count = players.Length,
players
});
});
}
public MccMcpResult LocatePlayer(string playerName, bool includeSelf)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(playerName))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
string nameFilter = playerName.Trim();
return client.InvokeOnMainThread(() =>
{
List<NearbyPlayerSnapshot> trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf);
NearbyPlayerSnapshot[] matches = trackedPlayers
.Where(player => PlayerNameMatches(player, nameFilter))
.OrderBy(player => player.Distance)
.ToArray();
if (matches.Length == 0)
{
return MccMcpResult.Fail("invalid_state", data: new
{
playerName = nameFilter,
trackedPlayers = trackedPlayers
.Select(player => player.Name)
.OfType<string>()
.Distinct(NameComparer)
.ToArray()
});
}
NearbyPlayerSnapshot selected = matches[0];
return MccMcpResult.Ok(new
{
playerName = nameFilter,
matchedName = selected.Name,
entityId = selected.EntityId,
uuid = selected.Uuid,
x = RoundCoordinate(selected.X),
y = RoundCoordinate(selected.Y),
z = RoundCoordinate(selected.Z),
distance = selected.Distance
});
});
}
2026-03-30 23:14:28 +02:00
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
{
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");
string? normalizedTypeFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim();
string? normalizedNameFilter = string.IsNullOrWhiteSpace(nameFilter) ? null : nameFilter.Trim();
return client.InvokeOnMainThread(() =>
{
Location playerLocation = client.GetCurrentLocation();
Dictionary<int, string?> playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true)
.ToDictionary(player => player.EntityId, player => player.Name);
var nearest = client.GetEntities().Values
.Where(entity => includePlayers || entity.Type != EntityType.Player)
.Select(entity =>
{
double dx = entity.Location.X - playerLocation.X;
double dy = entity.Location.Y - playerLocation.Y;
double dz = entity.Location.Z - playerLocation.Z;
string? resolvedName = entity.Type == EntityType.Player
&& playerNamesByEntityId.TryGetValue(entity.ID, out string? mappedName)
? mappedName
: entity.Name;
return new
{
entity,
resolvedName,
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
};
})
.Where(item => item.distance <= radius)
.Where(item => normalizedTypeFilter is null
|| TextMatchesFilter(item.entity.Type.ToString(), normalizedTypeFilter)
|| TextMatchesFilter(item.entity.GetTypeString(), normalizedTypeFilter))
.Where(item => normalizedNameFilter is null || EntityNameMatches(item.resolvedName, item.entity.CustomName, normalizedNameFilter))
.OrderBy(item => item.distance)
.FirstOrDefault();
if (nearest is null)
{
return MccMcpResult.Fail("invalid_state", data: new
{
typeFilter = normalizedTypeFilter,
nameFilter = normalizedNameFilter,
radius,
includePlayers
});
}
return MccMcpResult.Ok(new
{
id = nearest.entity.ID,
type = nearest.entity.Type.ToString(),
typeLabel = nearest.entity.GetTypeString(),
uuid = nearest.entity.UUID,
name = nearest.resolvedName,
customName = nearest.entity.CustomName,
x = RoundCoordinate(nearest.entity.Location.X),
y = RoundCoordinate(nearest.entity.Location.Y),
z = RoundCoordinate(nearest.entity.Location.Z),
distance = nearest.distance,
health = nearest.entity.Health,
pose = nearest.entity.Pose.ToString(),
latency = nearest.entity.Latency
});
});
}
public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, 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);
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 = ToCoordinate(goal),
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)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
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();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
string nameFilter = playerName.Trim();
NearbyPlayerSnapshot? target = client.InvokeOnMainThread(() =>
{
List<NearbyPlayerSnapshot> trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf: false);
return trackedPlayers
.Where(player => PlayerNameMatches(player, nameFilter))
.OrderBy(player => player.Distance)
.FirstOrDefault();
});
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)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
Location target = new(x, y, z);
client.InvokeOnMainThread(() => client.UpdateLocation(client.GetCurrentLocation(), target));
return MccMcpResult.Ok();
}
2026-03-30 23:14:28 +02:00
public MccMcpResult LookDirection(string direction)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(direction) || !Enum.TryParse(direction, true, out Direction parsedDirection) || !IsSupportedLookDirection(parsedDirection))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Location current = client.GetCurrentLocation();
client.UpdateLocation(current, parsedDirection);
return MccMcpResult.Ok(new
{
direction = parsedDirection.ToString(),
yaw = client.GetYaw(),
pitch = client.GetPitch(),
location = ToCoordinate(current)
});
});
}
public MccMcpResult LookAngles(float yaw, float pitch)
{
if (!IsCategoryEnabled(t => t.Movement))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Location current = client.GetCurrentLocation();
client.UpdateLocation(current, yaw, pitch);
return MccMcpResult.Ok(new
{
yaw = client.GetYaw(),
pitch = client.GetPitch(),
location = ToCoordinate(current)
});
});
}
public MccMcpResult GetInventorySnapshot(int inventoryId)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Dictionary<int, Container> inventories = client.GetInventories();
if (!inventories.TryGetValue(inventoryId, out Container? inventory))
return MccMcpResult.Fail("invalid_state");
var slots = inventory.Items.Select(item => new
{
slot = item.Key,
type = item.Value.Type.ToString(),
count = item.Value.Count
}).ToArray();
return MccMcpResult.Ok(new
{
id = inventory.ID,
type = inventory.Type.ToString(),
title = inventory.Title,
slotCount = inventory.Type.SlotCount(),
slots
});
});
}
2026-03-30 23:14:28 +02:00
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(query))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
string normalizedQuery = query.Trim();
ItemType? parsedItemType = exactMatch && TryParseItemType(normalizedQuery, out ItemType exactItemType)
? exactItemType
: null;
int limit = Math.Clamp(maxCount, 1, 1000);
return client.InvokeOnMainThread(() =>
{
var matches = client.GetInventories()
.Where(entry => includeContainers || entry.Key == 0)
.OrderBy(entry => entry.Key)
.SelectMany(entry =>
{
Container inventory = entry.Value;
return inventory.Items
.Where(pair => pair.Key >= 0 && pair.Value.Count > 0)
.Where(pair => ItemMatches(pair.Value, normalizedQuery, exactMatch, parsedItemType))
.Select(pair =>
{
bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar);
return new
{
inventoryId = entry.Key,
inventoryType = inventory.Type.ToString(),
inventoryTitle = inventory.Title,
slot = pair.Key,
itemType = pair.Value.Type.ToString(),
typeLabel = pair.Value.GetTypeString(),
count = pair.Value.Count,
isPlayerInventory = entry.Key == 0,
hotbarSlot = isHotbar ? hotbar + 1 : (int?)null
};
});
})
.Take(limit)
.ToArray();
return MccMcpResult.Ok(new
{
query = normalizedQuery,
exactMatch,
includeContainers,
count = matches.Length,
matches
});
});
}
public MccMcpResult ListInventories()
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
var inventories = client.GetInventories()
.OrderBy(entry => entry.Key)
.Select(entry => new
{
id = entry.Key,
type = entry.Value.Type.ToString(),
title = entry.Value.Title,
slotCount = entry.Value.Type.SlotCount(),
nonEmptySlots = entry.Value.Items.Count,
active = entry.Key > 0 && entry.Key == GetActiveContainerId(client)
})
.ToArray();
return MccMcpResult.Ok(new
{
count = inventories.Length,
inventories
});
});
}
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled() || !client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
Location location = new(x, y, z);
int waitMs = GetContainerWaitMs(timeoutMs);
(Block block, int activeContainerId) state = client.InvokeOnMainThread(() =>
{
Block block = client.GetWorld().GetBlock(location);
return (block, GetActiveContainerId(client));
});
if (!IsInteractableContainerMaterial(state.block.Type))
{
return MccMcpResult.Fail("invalid_state", data: new
{
x,
y,
z,
block = ToBlockState(state.block),
activeContainerId = state.activeContainerId
});
}
return OpenContainerCore(client, location, state.block, state.activeContainerId, waitMs, closeCurrent);
}
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
int waitMs = GetContainerWaitMs(timeoutMs);
int resolvedInventoryId = client.InvokeOnMainThread(() => ResolveContainerInventoryId(client, inventoryId));
if (resolvedInventoryId <= 0)
{
if (inventoryId < 0)
{
return MccMcpResult.Ok(new
{
success = true,
closed = false
});
}
return MccMcpResult.Fail("invalid_state", data: new { inventoryId });
}
bool closeAccepted = client.CloseInventory(resolvedInventoryId);
bool closed = closeAccepted && WaitForContainerClose(client, resolvedInventoryId, waitMs);
var resultData = new
{
success = closeAccepted && closed,
closeAccepted,
closed,
inventoryId = resolvedInventoryId,
timeoutMs = waitMs
};
return closeAccepted && closed
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(actionType))
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!TryParseWindowAction(actionType, out WindowActionType parsedAction))
return MccMcpResult.Fail("invalid_args");
bool ok = client.InvokeOnMainThread(() => client.DoWindowAction(inventoryId, slotId, parsedAction));
return MccMcpResult.Ok(new { success = ok, normalizedActionType = parsedAction.ToString() });
}
public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(itemType) || count <= 0)
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!TryParseItemType(itemType, out ItemType parsedItemType))
{
return MccMcpResult.Fail("invalid_args", data: new
{
itemType = itemType.Trim()
});
}
return client.InvokeOnMainThread(() =>
{
Dictionary<int, Container> inventories = client.GetInventories();
if (!inventories.TryGetValue(inventoryId, out Container? inventory))
return MccMcpResult.Fail("invalid_state");
var matchingSlotQuery = inventory.Items
.Where(pair => pair.Value.Type == parsedItemType && pair.Value.Count > 0)
.Select(pair => new { slot = pair.Key, count = pair.Value.Count });
var matchingSlots = (preferStack
? matchingSlotQuery.OrderByDescending(pair => pair.count).ThenBy(pair => pair.slot)
: matchingSlotQuery.OrderBy(pair => pair.count).ThenBy(pair => pair.slot))
.ToArray();
int beforeCount = matchingSlots.Sum(pair => pair.count);
if (beforeCount < count)
{
return MccMcpResult.Fail("invalid_state", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
availableCount = beforeCount,
inventoryId
});
}
int remaining = count;
List<int> touchedSlots = new();
foreach (var entry in matchingSlots)
{
if (remaining <= 0)
break;
if (!inventory.Items.TryGetValue(entry.slot, out Item? currentItem) || currentItem.Count <= 0)
continue;
int dropFromSlot = Math.Min(remaining, currentItem.Count);
touchedSlots.Add(entry.slot);
bool ok = true;
if (dropFromSlot == currentItem.Count)
{
ok = client.DoWindowAction(inventoryId, entry.slot, WindowActionType.DropItemStack);
}
else
{
for (int i = 0; i < dropFromSlot; i++)
{
if (!client.DoWindowAction(inventoryId, entry.slot, WindowActionType.DropItem))
{
ok = false;
break;
}
}
}
if (!ok)
{
return MccMcpResult.Fail("action_failed", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
droppedCount = count - remaining,
remainingCount = remaining,
inventoryId,
touchedSlots = touchedSlots.ToArray()
});
}
remaining -= dropFromSlot;
}
int afterCount = inventory.Items
.Where(pair => pair.Value.Type == parsedItemType)
.Sum(pair => pair.Value.Count);
int droppedCount = beforeCount - afterCount;
if (remaining > 0)
{
return MccMcpResult.Fail("action_failed", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
droppedCount,
remainingCount = remaining,
beforeCount,
afterCount,
inventoryId,
touchedSlots = touchedSlots.ToArray()
});
}
return MccMcpResult.Ok(new
{
success = true,
itemType = parsedItemType.ToString(),
requestedCount = count,
droppedCount,
beforeCount,
afterCount,
inventoryId,
touchedSlots = touchedSlots.ToArray()
});
});
}
public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack)
{
return TransferContainerItem(itemType, count, inventoryId, preferLargestStack, InventoryTransferDirection.Deposit);
}
public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack)
{
return TransferContainerItem(itemType, count, inventoryId, preferLargestStack, InventoryTransferDirection.Withdraw);
}
public MccMcpResult QueryEntities(int maxCount)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
int count = Math.Clamp(maxCount, 1, 1000);
return client.InvokeOnMainThread(() =>
{
Dictionary<int, Entity> entities = client.GetEntities();
Dictionary<int, string?> playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true)
.ToDictionary(player => player.EntityId, player => player.Name);
var data = entities.Take(count)
.Select(pair => new
{
id = pair.Key,
type = pair.Value.Type.ToString(),
name = pair.Value.Type == EntityType.Player
&& playerNamesByEntityId.TryGetValue(pair.Key, out string? mappedName)
? mappedName
: pair.Value.Name,
uuid = pair.Value.UUID,
x = RoundCoordinate(pair.Value.Location.X),
y = RoundCoordinate(pair.Value.Location.Y),
z = RoundCoordinate(pair.Value.Location.Z)
})
.ToArray();
return MccMcpResult.Ok(new
{
count = entities.Count,
entities = data
});
});
}
public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
int count = Math.Clamp(maxCount, 1, 1000);
string? filter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim();
double radiusValue = Math.Max(radius, 0);
return client.InvokeOnMainThread(() =>
{
Dictionary<int, Entity> entities = client.GetEntities();
Location playerLocation = client.GetCurrentLocation();
Dictionary<int, string?> playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true)
.ToDictionary(player => player.EntityId, player => player.Name);
var data = entities.Values
.Select(entity =>
{
double dx = entity.Location.X - playerLocation.X;
double dy = entity.Location.Y - playerLocation.Y;
double dz = entity.Location.Z - playerLocation.Z;
string? resolvedName = entity.Type == EntityType.Player
&& playerNamesByEntityId.TryGetValue(entity.ID, out string? mappedName)
? mappedName
: entity.Name;
return new
{
entity,
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz),
resolvedName
};
})
.Where(item => radiusValue <= 0 || item.distance <= radiusValue)
.Where(item =>
{
if (filter is null)
return true;
return item.entity.Type.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase)
|| item.entity.GetTypeString().Contains(filter, StringComparison.OrdinalIgnoreCase);
})
.OrderBy(item => item.distance)
.Take(count)
.Select(item => new
{
id = item.entity.ID,
type = item.entity.Type.ToString(),
typeLabel = item.entity.GetTypeString(),
uuid = item.entity.UUID,
name = item.resolvedName,
customName = item.entity.CustomName,
x = RoundCoordinate(item.entity.Location.X),
y = RoundCoordinate(item.entity.Location.Y),
z = RoundCoordinate(item.entity.Location.Z),
distance = item.distance,
health = item.entity.Health,
pose = item.entity.Pose.ToString(),
latency = item.entity.Latency
})
.ToArray();
return MccMcpResult.Ok(new
{
totalTracked = entities.Count,
count = data.Length,
entities = data
});
});
}
public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects)
{
if (!IsCategoryEnabled(t => t.EntityWorld))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetEntityHandlingEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Dictionary<int, Entity> entities = client.GetEntities();
if (!entities.TryGetValue(entityId, out Entity? entity))
return MccMcpResult.Fail("invalid_state");
string? resolvedName = entity.Name;
if (entity.Type == EntityType.Player)
{
Dictionary<int, string?> playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true)
.ToDictionary(player => player.EntityId, player => player.Name);
if (playerNamesByEntityId.TryGetValue(entityId, out string? mappedName))
resolvedName = mappedName;
}
object? metadata = includeMetadata
? entity.Metadata?.ToDictionary(
pair => pair.Key.ToString(CultureInfo.InvariantCulture),
pair => DescribeMetadataValue(pair.Value))
: null;
object? equipment = includeEquipment
? entity.Equipment.Select(pair => new
{
slot = pair.Key,
type = pair.Value.Type.ToString(),
count = pair.Value.Count
}).ToArray()
: null;
object? activeEffects = includeEffects
? entity.ActiveEffects.Values.Select(effect => new
{
id = effect.Effect.ToString(),
amplifier = effect.Amplifier,
remainingSeconds = effect.RemainingSeconds,
isInfinite = effect.IsInfinite
}).ToArray()
: null;
return MccMcpResult.Ok(new
{
id = entity.ID,
type = entity.Type.ToString(),
typeLabel = entity.GetTypeString(),
uuid = entity.UUID,
name = resolvedName,
customName = entity.CustomName,
customNameVisible = entity.IsCustomNameVisible,
x = RoundCoordinate(entity.Location.X),
y = RoundCoordinate(entity.Location.Y),
z = RoundCoordinate(entity.Location.Z),
yaw = entity.Yaw,
pitch = entity.Pitch,
health = entity.Health,
pose = entity.Pose.ToString(),
latency = entity.Latency,
objectData = entity.ObjectData,
metadata,
equipment,
activeEffects
});
});
}
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))
return MccMcpResult.Fail("capability_disabled");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetTerrainEnabled())
return MccMcpResult.Fail("feature_disabled");
return client.InvokeOnMainThread(() =>
{
Location location = new(x, y, z);
Block block = client.GetWorld().GetBlock(location);
return MccMcpResult.Ok(new
{
x,
y,
z,
material = block.Type.ToString(),
blockId = block.BlockId,
blockMeta = block.BlockMeta
});
});
}
private static MccMcpResult OpenContainerCore(McClient client, Location location, Block block, int activeContainerId, int waitMs, bool closeCurrent)
{
if (activeContainerId > 0)
{
if (!closeCurrent)
{
return MccMcpResult.Fail("invalid_state", data: new
{
reason = "container_already_open",
activeContainerId,
x = location.X,
y = location.Y,
z = location.Z,
block = ToBlockState(block)
});
}
bool closeAccepted = client.CloseInventory(activeContainerId);
bool closed = closeAccepted && WaitForContainerClose(client, activeContainerId, waitMs);
if (!closeAccepted || !closed)
{
return MccMcpResult.Fail("action_incomplete", data: new
{
action = "close_previous_container",
activeContainerId,
closeAccepted,
closed,
timeoutMs = waitMs
});
}
}
HashSet<int> beforeIds = client.InvokeOnMainThread(() => client.GetInventories().Keys.Where(id => id > 0).ToHashSet());
int openedInventoryId = 0;
Container? openedInventory = null;
bool openAccepted = client.InvokeOnMainThread(() => client.PlaceBlock(location, Direction.Down, Hand.MainHand, lookAtBlock: true));
bool opened = openAccepted && WaitForContainerOpen(client, beforeIds, waitMs, out openedInventoryId, out openedInventory);
var resultData = new
{
success = openAccepted && opened && openedInventory is not null,
openAccepted,
opened,
timeoutMs = waitMs,
x = location.X,
y = location.Y,
z = location.Z,
block = ToBlockState(block),
inventory = openedInventory is null
? null
: new
{
id = openedInventoryId,
type = openedInventory.Type.ToString(),
title = openedInventory.Title,
slotCount = openedInventory.Type.SlotCount(),
nonEmptySlots = openedInventory.Items.Count
}
};
return openAccepted && opened && openedInventory is not null
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
private MccMcpResult TransferContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack, InventoryTransferDirection direction)
{
if (!IsCategoryEnabled(t => t.Inventory))
return MccMcpResult.Fail("capability_disabled");
if (string.IsNullOrWhiteSpace(itemType) || count <= 0)
return MccMcpResult.Fail("invalid_args");
McClient? client = GetClient();
if (client is null)
return NotConnected();
if (!client.GetInventoryEnabled())
return MccMcpResult.Fail("feature_disabled");
if (!TryParseItemType(itemType, out ItemType parsedItemType))
{
return MccMcpResult.Fail("invalid_args", data: new
{
itemType = itemType.Trim()
});
}
if (TryGetCursorItem(client, out Item? cursorItem))
{
return MccMcpResult.Fail("invalid_state", data: new
{
reason = "cursor_item_present",
cursor = new { type = cursorItem!.Type.ToString(), count = cursorItem.Count }
});
}
int resolvedInventoryId = client.InvokeOnMainThread(() => ResolveContainerInventoryId(client, inventoryId));
if (resolvedInventoryId <= 0)
{
return MccMcpResult.Fail("invalid_state", data: new
{
inventoryId
});
}
Container? initialInventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
if (initialInventory is null)
return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId });
if (!TryGetContainerSlotRanges(initialInventory.Type, out int containerStart, out int containerEnd, out int playerStart, out int playerEnd))
{
return MccMcpResult.Fail("invalid_state", data: new
{
reason = "unsupported_container_type",
inventoryId = resolvedInventoryId,
type = initialInventory.Type.ToString()
});
}
int sourceStart = direction == InventoryTransferDirection.Deposit ? playerStart : containerStart;
int sourceEnd = direction == InventoryTransferDirection.Deposit ? playerEnd : containerEnd;
int targetStart = direction == InventoryTransferDirection.Deposit ? containerStart : playerStart;
int targetEnd = direction == InventoryTransferDirection.Deposit ? containerEnd : playerEnd;
int beforePlayerCount = CountItemInRange(initialInventory, parsedItemType, playerStart, playerEnd);
int beforeContainerCount = CountItemInRange(initialInventory, parsedItemType, containerStart, containerEnd);
int availableCount = CountItemInRange(initialInventory, parsedItemType, sourceStart, sourceEnd);
if (availableCount < count)
{
return MccMcpResult.Fail("invalid_state", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
availableCount,
inventoryId = resolvedInventoryId,
direction = direction.ToString()
});
}
int remaining = count;
List<int> touchedSourceSlots = new();
List<int> touchedTargetSlots = new();
while (remaining > 0)
{
Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
if (inventory is null)
return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId });
if (TryGetCursorItem(client, out cursorItem))
{
return MccMcpResult.Fail("invalid_state", data: new
{
reason = "cursor_item_present_mid_transfer",
cursor = new { type = cursorItem!.Type.ToString(), count = cursorItem.Count }
});
}
var sourceSlots = GetOrderedItemSlots(inventory, parsedItemType, sourceStart, sourceEnd, preferLargestStack);
if (sourceSlots.Length == 0)
break;
int beforeSourceCount = CountItemInRange(inventory, parsedItemType, sourceStart, sourceEnd);
int beforeTargetCount = CountItemInRange(inventory, parsedItemType, targetStart, targetEnd);
(int slot, int sourceCount) = sourceSlots[0];
touchedSourceSlots.Add(slot);
int movedCount;
List<int> usedTargetSlots = new();
if (sourceCount <= remaining || direction == InventoryTransferDirection.Withdraw)
{
if (!client.DoWindowAction(resolvedInventoryId, slot, WindowActionType.ShiftClick))
{
return MccMcpResult.Fail("action_failed", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
remainingCount = remaining,
inventoryId = resolvedInventoryId,
sourceSlot = slot,
direction = direction.ToString()
});
}
if (direction == InventoryTransferDirection.Withdraw)
{
if (!WaitForRangeCount(client, resolvedInventoryId, parsedItemType, sourceStart, sourceEnd, countAfterShift => countAfterShift < beforeSourceCount, DefaultInventoryActionWaitMs, out Container? afterShift, out int afterSourceCount))
{
afterShift = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
afterSourceCount = afterShift is null ? beforeSourceCount : CountItemInRange(afterShift, parsedItemType, sourceStart, sourceEnd);
}
movedCount = beforeSourceCount - afterSourceCount;
}
else
{
if (!WaitForRangeCount(client, resolvedInventoryId, parsedItemType, targetStart, targetEnd, countAfterShift => countAfterShift > beforeTargetCount, DefaultInventoryActionWaitMs, out Container? afterShift, out int afterTargetCount))
{
afterShift = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
afterTargetCount = afterShift is null ? beforeTargetCount : CountItemInRange(afterShift, parsedItemType, targetStart, targetEnd);
}
movedCount = afterTargetCount - beforeTargetCount;
}
if (direction == InventoryTransferDirection.Withdraw && movedCount > remaining)
{
int excessCount = movedCount - remaining;
MccMcpResult returnExcess = TransferContainerItem(parsedItemType.ToString(), excessCount, resolvedInventoryId, preferLargestStack, InventoryTransferDirection.Deposit);
if (!returnExcess.Success)
{
return MccMcpResult.Fail("action_incomplete", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
remainingCount = remaining,
inventoryId = resolvedInventoryId,
sourceSlot = slot,
direction = direction.ToString(),
excessCount,
returnExcess
});
}
movedCount -= excessCount;
}
}
else
{
movedCount = TransferPartialFromSlot(
client,
resolvedInventoryId,
slot,
parsedItemType,
remaining,
sourceStart,
sourceEnd,
targetStart,
targetEnd,
usedTargetSlots);
}
if (movedCount <= 0)
{
Container? afterFailure = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
return MccMcpResult.Fail("action_incomplete", data: new
{
itemType = parsedItemType.ToString(),
requestedCount = count,
remainingCount = remaining,
inventoryId = resolvedInventoryId,
sourceSlot = slot,
direction = direction.ToString(),
playerCount = afterFailure is null ? 0 : CountItemInRange(afterFailure, parsedItemType, playerStart, playerEnd),
containerCount = afterFailure is null ? 0 : CountItemInRange(afterFailure, parsedItemType, containerStart, containerEnd)
});
}
remaining -= movedCount;
touchedTargetSlots.AddRange(usedTargetSlots);
}
Container? finalInventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId));
if (finalInventory is null)
return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId });
int afterPlayerCount = CountItemInRange(finalInventory, parsedItemType, playerStart, playerEnd);
int afterContainerCount = CountItemInRange(finalInventory, parsedItemType, containerStart, containerEnd);
int playerDelta = afterPlayerCount - beforePlayerCount;
int containerDelta = afterContainerCount - beforeContainerCount;
int movedTotal = direction == InventoryTransferDirection.Deposit
? afterContainerCount - beforeContainerCount
: beforeContainerCount - afterContainerCount;
bool countsVerified = direction == InventoryTransferDirection.Deposit
? containerDelta == count
: containerDelta == -count;
bool playerCountsMatchExpected = direction == InventoryTransferDirection.Deposit
? playerDelta == -count
: playerDelta == count;
bool succeeded = remaining == 0 && countsVerified;
var resultData = new
{
success = succeeded,
direction = direction.ToString().ToLowerInvariant(),
itemType = parsedItemType.ToString(),
requestedCount = count,
movedCount = movedTotal,
beforePlayerCount,
afterPlayerCount,
beforeContainerCount,
afterContainerCount,
playerDelta,
containerDelta,
playerCountsMatchExpected,
verificationBasis = "container_delta",
inventoryId = resolvedInventoryId,
containerType = finalInventory.Type.ToString(),
touchedSourceSlots = touchedSourceSlots.Distinct().OrderBy(slot => slot).ToArray(),
touchedTargetSlots = touchedTargetSlots.Distinct().OrderBy(slot => slot).ToArray()
};
return succeeded
? MccMcpResult.Ok(resultData)
: MccMcpResult.Fail("action_incomplete", data: resultData);
}
private static MccMcpResult ExecuteInternalCommand(McClient client, string command)
{
return client.InvokeOnMainThread(() =>
{
CmdResult result = new();
bool ok = client.PerformInternalCommand(command, ref result);
return MccMcpResult.Ok(new
{
success = ok,
status = result.status.ToString(),
output = result.ToString()
});
});
}
private static bool TryGetCursorItem(McClient client, out Item? cursorItem)
{
cursorItem = client.InvokeOnMainThread(() =>
{
Container? playerInventory = client.GetInventory(0);
return playerInventory is not null && playerInventory.Items.TryGetValue(-1, out Item? item) ? item : null;
});
return cursorItem is not null;
}
private static int ResolveContainerInventoryId(McClient client, int inventoryId)
{
if (inventoryId > 0)
{
Container? inventory = client.GetInventory(inventoryId);
return inventory is not null && inventoryId != 0 ? inventoryId : 0;
}
return GetActiveContainerId(client);
}
private static int GetActiveContainerId(McClient client)
{
return client.GetInventories().Keys.Where(id => id > 0).DefaultIfEmpty(0).Max();
}
private static bool WaitForContainerOpen(McClient client, ISet<int> beforeIds, int waitMs, out int inventoryId, out Container? inventory)
{
inventoryId = 0;
inventory = null;
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
(int activeId, Container? activeInventory) state = client.InvokeOnMainThread(() =>
{
int activeId = GetActiveContainerId(client);
Container? activeInventory = activeId > 0 ? client.GetInventory(activeId) : null;
return (activeId, activeInventory);
});
if (state.activeId > 0 && (!beforeIds.Contains(state.activeId) || beforeIds.Count == 0) && state.activeInventory is not null)
{
inventoryId = state.activeId;
inventory = state.activeInventory;
return true;
}
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static bool WaitForContainerClose(McClient client, int inventoryId, int waitMs)
{
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
bool stillOpen = client.InvokeOnMainThread(() => client.GetInventories().ContainsKey(inventoryId));
if (!stillOpen)
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static int GetContainerWaitMs(int timeoutMs)
{
if (timeoutMs <= 0)
return DefaultContainerWaitMs;
return Math.Clamp(timeoutMs, MinContainerWaitMs, MaxContainerWaitMs);
}
private static bool TryGetContainerSlotRanges(ContainerType type, out int containerStart, out int containerEnd, out int playerStart, out int playerEnd)
{
containerStart = 0;
containerEnd = -1;
playerStart = 0;
playerEnd = -1;
int containerSlots = type switch
{
ContainerType.Generic_9x1 => 9,
ContainerType.Generic_9x2 => 18,
ContainerType.Generic_9x3 => 27,
ContainerType.Generic_9x4 => 36,
ContainerType.Generic_9x5 => 45,
ContainerType.Generic_9x6 => 54,
ContainerType.Generic_3x3 => 9,
ContainerType.Hopper => 5,
ContainerType.ShulkerBox => 27,
ContainerType.Furnace or ContainerType.BlastFurnace or ContainerType.Smoker => 3,
ContainerType.Crafter => 9,
_ => -1
};
if (containerSlots <= 0)
return false;
int slotCount = type.SlotCount();
if (slotCount <= containerSlots)
return false;
containerEnd = containerSlots - 1;
playerStart = containerSlots;
playerEnd = slotCount - 1;
return true;
}
private static int CountItemInRange(Container inventory, ItemType itemType, int startSlot, int endSlot)
{
return inventory.Items
.Where(entry => entry.Key >= startSlot && entry.Key <= endSlot)
.Where(entry => entry.Value.Type == itemType)
.Sum(entry => entry.Value.Count);
}
private static (int slot, int count)[] GetOrderedItemSlots(Container inventory, ItemType itemType, int startSlot, int endSlot, bool preferLargestStack)
{
var query = inventory.Items
.Where(entry => entry.Key >= startSlot && entry.Key <= endSlot)
.Where(entry => entry.Value.Type == itemType && entry.Value.Count > 0)
.Select(entry => (slot: entry.Key, count: entry.Value.Count));
return (preferLargestStack
? query.OrderByDescending(entry => entry.count).ThenBy(entry => entry.slot)
: query.OrderBy(entry => entry.count).ThenBy(entry => entry.slot))
.ToArray();
}
private static int TransferPartialFromSlot(McClient client, int inventoryId, int sourceSlot, ItemType itemType, int requestedCount, int sourceStart, int sourceEnd, int targetStart, int targetEnd, List<int> touchedTargetSlots)
{
Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId));
if (inventory is null || !inventory.Items.TryGetValue(sourceSlot, out Item? sourceItem) || sourceItem.Count <= 0)
return 0;
int amountToMove = Math.Min(requestedCount, sourceItem.Count);
if (!client.DoWindowAction(inventoryId, sourceSlot, WindowActionType.LeftClick))
return 0;
if (!WaitForCursorItem(client, itemType, DefaultInventoryActionWaitMs, out _))
return 0;
int moved = 0;
while (moved < amountToMove)
{
inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId));
if (inventory is null || !TryGetCursorItem(client, out Item? cursorItem) || cursorItem is null || cursorItem.Type != itemType)
break;
if (!TryFindTransferTargetSlot(inventory, itemType, targetStart, targetEnd, out int targetSlot, out int capacity))
break;
int step = Math.Min(amountToMove - moved, Math.Min(capacity, cursorItem.Count));
int beforeTargetCount = GetSlotItemCount(inventory, targetSlot, itemType);
int beforeCursorCount = cursorItem.Count;
if (step <= 0 || !PlaceItemsFromCursor(client, inventoryId, targetSlot, step))
break;
if (!WaitForPlacement(client, inventoryId, targetSlot, itemType, beforeTargetCount, beforeCursorCount, step))
break;
touchedTargetSlots.Add(targetSlot);
moved += step;
}
if (TryGetCursorItem(client, out Item? remainingCursor) && remainingCursor is not null && remainingCursor.Count > 0)
{
inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId));
if (inventory is null)
return 0;
int returnSlot = GetReturnSlot(inventory, itemType, sourceStart, sourceEnd, sourceSlot);
if (!client.DoWindowAction(inventoryId, returnSlot, WindowActionType.LeftClick))
return 0;
if (!WaitForCursorClear(client, DefaultInventoryActionWaitMs))
return 0;
}
return TryGetCursorItem(client, out _)
? 0
: moved;
}
private static bool TryFindTransferTargetSlot(Container inventory, ItemType itemType, int startSlot, int endSlot, out int targetSlot, out int capacity)
{
int maxStack = itemType.StackCount();
for (int slot = startSlot; slot <= endSlot; slot++)
{
if (inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType && item.Count < maxStack)
{
targetSlot = slot;
capacity = maxStack - item.Count;
return true;
}
}
for (int slot = startSlot; slot <= endSlot; slot++)
{
if (!inventory.Items.ContainsKey(slot))
{
targetSlot = slot;
capacity = maxStack;
return true;
}
}
targetSlot = -1;
capacity = 0;
return false;
}
private static bool PlaceItemsFromCursor(McClient client, int inventoryId, int targetSlot, int count)
{
if (count <= 0 || !TryGetCursorItem(client, out Item? cursorItem) || cursorItem is null)
return false;
if (count == cursorItem.Count)
return client.DoWindowAction(inventoryId, targetSlot, WindowActionType.LeftClick);
for (int i = 0; i < count; i++)
{
if (!client.DoWindowAction(inventoryId, targetSlot, WindowActionType.RightClick))
return false;
}
return true;
}
private static int GetReturnSlot(Container inventory, ItemType itemType, int startSlot, int endSlot, int originalSourceSlot)
{
if (originalSourceSlot != 0)
return originalSourceSlot;
int maxStack = itemType.StackCount();
for (int slot = startSlot; slot <= endSlot; slot++)
{
if (slot == 0)
continue;
if (inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType && item.Count < maxStack)
return slot;
}
for (int slot = startSlot; slot <= endSlot; slot++)
{
if (slot == 0)
continue;
if (!inventory.Items.ContainsKey(slot))
return slot;
}
return originalSourceSlot;
}
private static int GetSlotItemCount(Container inventory, int slot, ItemType itemType)
{
return inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType ? item.Count : 0;
}
private static bool WaitForCursorItem(McClient client, ItemType itemType, int waitMs, out Item? cursorItem)
{
cursorItem = null;
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
if (TryGetCursorItem(client, out cursorItem) && cursorItem is not null && cursorItem.Type == itemType)
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static bool WaitForCursorClear(McClient client, int waitMs)
{
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
if (!TryGetCursorItem(client, out _))
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static bool WaitForPlacement(McClient client, int inventoryId, int targetSlot, ItemType itemType, int beforeTargetCount, int beforeCursorCount, int placedCount)
{
DateTime deadline = DateTime.UtcNow.AddMilliseconds(DefaultInventoryActionWaitMs);
while (true)
{
bool targetUpdated = false;
bool cursorUpdated = false;
Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId));
if (inventory is not null)
{
int currentTargetCount = GetSlotItemCount(inventory, targetSlot, itemType);
targetUpdated = currentTargetCount >= beforeTargetCount + placedCount;
}
if (placedCount >= beforeCursorCount)
{
cursorUpdated = !TryGetCursorItem(client, out _);
}
else if (TryGetCursorItem(client, out Item? cursorItem) && cursorItem is not null && cursorItem.Type == itemType)
{
cursorUpdated = cursorItem.Count <= beforeCursorCount - placedCount;
}
if (targetUpdated && cursorUpdated)
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static bool WaitForRangeCount(McClient client, int inventoryId, ItemType itemType, int startSlot, int endSlot, Func<int, bool> predicate, int waitMs, out Container? inventory, out int itemCount)
{
inventory = null;
itemCount = 0;
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId));
if (inventory is not null)
{
itemCount = CountItemInRange(inventory, itemType, startSlot, endSlot);
if (predicate(itemCount))
return true;
}
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static List<NearbyPlayerSnapshot> BuildTrackedPlayerSnapshots(McClient client, bool includeSelf)
{
Location playerLocation = client.GetCurrentLocation();
string username = client.GetUsername();
Dictionary<string, string> uuidToName = client.GetOnlinePlayersWithUUID();
string[] onlinePlayers = client.GetOnlinePlayers();
List<NearbyPlayerSnapshot> trackedPlayers = client.GetEntities().Values
.Where(entity => entity.Type == EntityType.Player)
.Select(entity =>
{
double dx = entity.Location.X - playerLocation.X;
double dy = entity.Location.Y - playerLocation.Y;
double dz = entity.Location.Z - playerLocation.Z;
double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
string? rawName = ResolvePlayerEntityName(entity, uuidToName);
return new NearbyPlayerSnapshot
{
EntityId = entity.ID,
Uuid = entity.UUID,
Name = rawName,
CustomName = entity.CustomName,
X = entity.Location.X,
Y = entity.Location.Y,
Z = entity.Location.Z,
Distance = distance,
Latency = entity.Latency
};
})
.ToList();
if (!includeSelf)
{
trackedPlayers = trackedPlayers
.Where(player => !string.Equals(player.Name, username, StringComparison.OrdinalIgnoreCase))
.Where(player => player.Distance > SelfEntityDistanceThreshold)
.ToList();
}
List<NearbyPlayerSnapshot> unnamedTracked = trackedPlayers
.Where(player => string.IsNullOrWhiteSpace(player.Name))
.OrderBy(player => player.Distance)
.ToList();
if (unnamedTracked.Count == 0)
return trackedPlayers;
HashSet<string> assignedNames = trackedPlayers
.Select(player => player.Name)
.OfType<string>()
.ToHashSet(NameComparer);
string[] unmatchedOnline = onlinePlayers
.Where(name => includeSelf || !string.Equals(name, username, StringComparison.OrdinalIgnoreCase))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Where(name => !assignedNames.Contains(name))
.Distinct(NameComparer)
.ToArray();
if (unmatchedOnline.Length == 0)
return trackedPlayers;
if (unnamedTracked.Count == 1 && unmatchedOnline.Length == 1)
{
unnamedTracked[0].Name = unmatchedOnline[0];
return trackedPlayers;
}
int pairCount = Math.Min(unnamedTracked.Count, unmatchedOnline.Length);
string[] sortedNames = unmatchedOnline
.OrderBy(name => name, NameComparer)
.ToArray();
for (int i = 0; i < pairCount; i++)
unnamedTracked[i].Name = sortedNames[i];
return trackedPlayers;
}
private static object? DescribeMetadataValue(object? value)
{
return value switch
{
null => null,
string s => s,
bool b => b,
byte b => b,
sbyte b => b,
short s => s,
ushort s => s,
int i => i,
uint i => i,
long l => l,
ulong l => l,
float f => f,
double d => d,
decimal d => d,
Enum e => e.ToString(),
Location location => ToCoordinate(location),
Item item => new { type = item.Type.ToString(), count = item.Count },
byte[] data => new { bytes = data.Length },
_ => value.ToString()
};
}
private static bool PlayerNameMatches(NearbyPlayerSnapshot player, string filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
string trimmed = filter.Trim();
if (!string.IsNullOrWhiteSpace(player.Name) && player.Name.Contains(trimmed, StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(player.CustomName) && player.CustomName.Contains(trimmed, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static bool TryParseWindowAction(string rawActionType, out WindowActionType actionType)
{
if (Enum.TryParse(rawActionType, true, out actionType))
return true;
string normalized = NormalizeToken(rawActionType);
if (normalized.Length == 0)
return false;
return normalized switch
{
"left" or "leftclick" => SetAction(WindowActionType.LeftClick, out actionType),
"right" or "rightclick" => SetAction(WindowActionType.RightClick, out actionType),
"middle" or "mid" or "middleclick" => SetAction(WindowActionType.MiddleClick, out actionType),
"shift" or "shiftclick" => SetAction(WindowActionType.ShiftClick, out actionType),
"shiftright" or "shiftrightclick" => SetAction(WindowActionType.ShiftRightClick, out actionType),
"drop" or "dropitem" or "q" => SetAction(WindowActionType.DropItem, out actionType),
"dropstack" or "dropall" or "dropitemstack" or "ctrlq" or "ctrldrop" => SetAction(WindowActionType.DropItemStack, out actionType),
_ => false
};
}
private static bool SetAction(WindowActionType value, out WindowActionType actionType)
{
actionType = value;
return true;
}
private static bool TryParseItemType(string rawItemType, out ItemType itemType)
{
if (Enum.TryParse(rawItemType, true, out itemType) && itemType is not (ItemType.Unknown or ItemType.Null))
return true;
string normalized = NormalizeToken(rawItemType);
if (normalized.Length == 0)
{
itemType = ItemType.Unknown;
return false;
}
foreach (ItemType candidate in Enum.GetValues<ItemType>())
{
if (candidate is ItemType.Unknown or ItemType.Null)
continue;
if (NormalizeToken(candidate.ToString()) == normalized)
{
itemType = candidate;
return true;
}
}
itemType = ItemType.Unknown;
return false;
}
private static string NormalizeToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
char[] buffer = value
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray();
return new string(buffer);
}
private static bool WaitForArrival(McClient client, Location goal, int waitMs, double tolerance, out Location? finalLocation)
{
finalLocation = null;
DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs);
while (true)
{
Location location = client.InvokeOnMainThread(client.GetCurrentLocation);
finalLocation = location;
double distance = GetDistance(location, goal);
if (distance <= tolerance)
return true;
if (DateTime.UtcNow >= deadline)
return false;
Thread.Sleep(ArrivalPollIntervalMs);
}
}
private static double GetDistance(Location from, Location to)
{
double dx = from.X - to.X;
double dy = from.Y - to.Y;
double dz = from.Z - to.Z;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
private static int GetArrivalWaitMs(int timeoutMs)
{
if (timeoutMs <= 0)
return DefaultArrivalWaitMs;
return Math.Clamp(timeoutMs, MinArrivalWaitMs, MaxArrivalWaitMs);
}
private static double GetArrivalTolerance(int maxOffset, int minOffset)
{
double toleranceFromOffset = Math.Max(maxOffset, minOffset) + 1.0;
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;
}
2026-03-30 23:14:28 +02:00
private static bool HasCompleteCoordinateTriple(double? x, double? y, double? z)
{
return x.HasValue == y.HasValue && y.HasValue == z.HasValue;
}
private static int GetLoadedChunkCount(World world)
{
return Math.Max(0, world.chunkCnt - Math.Max(0, world.chunkLoadNotCompleted));
}
private static double GetChunkLoadRatio(World world)
{
return world.chunkCnt > 0
? GetLoadedChunkCount(world) / (double)world.chunkCnt
: 0.0;
}
private static object GetNeighborBlockSnapshot(World world, Location location)
{
Location blockLocation = location.ToFloor();
Location north = new(blockLocation.X, blockLocation.Y, blockLocation.Z - 1);
Location south = new(blockLocation.X, blockLocation.Y, blockLocation.Z + 1);
Location east = new(blockLocation.X + 1, blockLocation.Y, blockLocation.Z);
Location west = new(blockLocation.X - 1, blockLocation.Y, blockLocation.Z);
Location above = new(blockLocation.X, blockLocation.Y + 1, blockLocation.Z);
Location below = new(blockLocation.X, blockLocation.Y - 1, blockLocation.Z);
return new
{
north = new { location = ToCoordinate(north), block = ToBlockState(world.GetBlock(north)) },
south = new { location = ToCoordinate(south), block = ToBlockState(world.GetBlock(south)) },
east = new { location = ToCoordinate(east), block = ToBlockState(world.GetBlock(east)) },
west = new { location = ToCoordinate(west), block = ToBlockState(world.GetBlock(west)) },
above = new { location = ToCoordinate(above), block = ToBlockState(world.GetBlock(above)) },
below = new { location = ToCoordinate(below), block = ToBlockState(world.GetBlock(below)) }
};
}
private static bool ItemMatches(Item item, string query, bool exactMatch, ItemType? exactItemType)
{
if (exactItemType.HasValue)
return item.Type == exactItemType.Value;
string typeName = item.Type.ToString();
string typeLabel = item.GetTypeString();
return exactMatch
? TextEqualsFilter(typeName, query) || TextEqualsFilter(typeLabel, query)
: TextMatchesFilter(typeName, query) || TextMatchesFilter(typeLabel, query);
}
private static bool EntityNameMatches(string? name, string? customName, string filter)
{
return (!string.IsNullOrWhiteSpace(name) && TextMatchesFilter(name, filter))
|| (!string.IsNullOrWhiteSpace(customName) && TextMatchesFilter(customName, filter));
}
private static bool IsSupportedLookDirection(Direction direction)
{
return direction is Direction.Up or Direction.Down or Direction.North or Direction.South or Direction.East or Direction.West;
}
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);
}
private static object ToCoordinate(double x, double y, double z)
{
return new
{
x = RoundCoordinate(x),
y = RoundCoordinate(y),
z = RoundCoordinate(z)
};
}
private static double RoundCoordinate(double value)
{
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 bool IsInteractableContainerMaterial(Material material)
{
string name = material.ToString();
return name.Contains("Chest", StringComparison.Ordinal)
|| name.Contains("Barrel", StringComparison.Ordinal)
|| name.Contains("ShulkerBox", StringComparison.Ordinal)
|| name.Contains("Hopper", StringComparison.Ordinal)
|| name.Contains("Dispenser", StringComparison.Ordinal)
|| name.Contains("Dropper", StringComparison.Ordinal)
|| name.Contains("Furnace", StringComparison.Ordinal)
|| name.Contains("Smoker", StringComparison.Ordinal)
|| name.Contains("BlastFurnace", StringComparison.Ordinal)
|| name.Contains("Crafter", StringComparison.Ordinal);
}
private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary<string, string> uuidToName)
{
if (!string.IsNullOrWhiteSpace(entity.Name))
return entity.Name;
if (entity.UUID != Guid.Empty
&& uuidToName.TryGetValue(entity.UUID.ToString(), out string? mappedName)
&& !string.IsNullOrWhiteSpace(mappedName))
{
return mappedName;
}
if (!string.IsNullOrWhiteSpace(entity.CustomName))
return entity.CustomName;
return null;
}
private static bool BlockMatches(Block block, string? filter, bool exactMatch, int? blockIdFilter, int? blockMetaFilter)
{
if (blockIdFilter.HasValue)
{
if (block.BlockId != blockIdFilter.Value)
return false;
if (blockMetaFilter.HasValue && block.BlockMeta != blockMetaFilter.Value)
return false;
return true;
}
if (filter is null)
return true;
string material = block.Type.ToString();
string typeLabel = block.GetTypeString();
if (exactMatch)
{
return TextEqualsFilter(material, filter)
|| TextEqualsFilter(typeLabel, filter);
}
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)
{
blockId = null;
blockMeta = null;
if (string.IsNullOrWhiteSpace(query))
return;
string trimmed = query.Trim();
int separator = trimmed.IndexOf(':');
if (separator >= 0)
{
string idPart = trimmed[..separator].Trim();
string metaPart = trimmed[(separator + 1)..].Trim();
if (int.TryParse(idPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedId))
{
blockId = parsedId;
if (int.TryParse(metaPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedMeta))
blockMeta = parsedMeta;
}
return;
}
if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out int blockStateId))
blockId = blockStateId;
}
}