mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
3920 lines
145 KiB
C#
3920 lines
145 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;
|
|
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 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;
|
|
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()
|
|
});
|
|
});
|
|
}
|
|
|
|
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()
|
|
}));
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (!IsCategoryEnabled(t => t.Movement))
|
|
return MccMcpResult.Fail("capability_disabled");
|
|
|
|
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();
|
|
|
|
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);
|
|
}
|
|
|
|
public MccMcpResult ToggleSneak(bool enabled)
|
|
{
|
|
if (!IsCategoryEnabled(t => t.Movement))
|
|
return MccMcpResult.Fail("capability_disabled");
|
|
|
|
McClient? client = GetClient();
|
|
if (client is null)
|
|
return NotConnected();
|
|
|
|
EntityActionType action = enabled ? EntityActionType.StartSneaking : EntityActionType.StopSneaking;
|
|
bool ok = client.InvokeOnMainThread(() =>
|
|
{
|
|
bool actionResult = client.SendEntityAction(action);
|
|
if (actionResult)
|
|
client.IsSneaking = enabled;
|
|
return actionResult;
|
|
});
|
|
|
|
object resultData = new { success = ok, enabled };
|
|
return ok
|
|
? MccMcpResult.Ok(resultData)
|
|
: MccMcpResult.Fail("action_failed", data: resultData);
|
|
}
|
|
|
|
public MccMcpResult ToggleSprint(bool enabled)
|
|
{
|
|
if (!IsCategoryEnabled(t => t.Movement))
|
|
return MccMcpResult.Fail("capability_disabled");
|
|
|
|
McClient? client = GetClient();
|
|
if (client is null)
|
|
return NotConnected();
|
|
|
|
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 });
|
|
}
|
|
|
|
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() });
|
|
}
|
|
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|