mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
2230 lines
80 KiB
C#
2230 lines
80 KiB
C#
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;
|
|
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;
|
|
private const double DigReachDistance = 5.0;
|
|
private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance;
|
|
private const int DefaultPathQueryTimeoutMs = 5000;
|
|
private const int MinPathQueryTimeoutMs = 250;
|
|
private const int MaxPathQueryTimeoutMs = 15000;
|
|
private const int DefaultArrivalWaitMs = 3500;
|
|
private const int MinArrivalWaitMs = 250;
|
|
private const int MaxArrivalWaitMs = 15000;
|
|
private const double DefaultArrivalTolerance = 1.5;
|
|
private const int ArrivalPollIntervalMs = 125;
|
|
private const int MaxBlockVerifyWaitMs = 12000;
|
|
|
|
private sealed class InternalCommandInfo
|
|
{
|
|
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 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()
|
|
});
|
|
});
|
|
}
|
|
|
|
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()
|
|
}));
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
|
|
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 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 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 });
|
|
}
|
|
|
|
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() });
|
|
}
|
|
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
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 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 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 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;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
}
|