using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MinecraftClient.Inventory; using MinecraftClient.Mapping; using MinecraftClient.Protocol; namespace MinecraftClient.Scripting; /// /// Transport-neutral shared gameplay and observed-state API used by MCP and bots/scripts. /// public sealed class MccGameApi { private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase; private const double SelfEntityDistanceThreshold = 0.2; private const int MaxPathPreviewWaypoints = 1000; 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 readonly Func clientProvider; 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; } } public MccGameApi(Func clientProvider) { ArgumentNullException.ThrowIfNull(clientProvider); this.clientProvider = clientProvider; } /// /// Get the latest shared world time and weather snapshot. /// public MccRuntimeStateSnapshot GetRuntimeState() { return MccObservedStateStore.GetRuntimeStateSnapshot(); } /// /// Get recent high-signal runtime events recorded by MCC. /// public MccRecentEventsResult GetRecentEvents(long afterId = 0, int maxCount = 50, string? typeFilter = null) { MccRecentEventEntry[] events = MccObservedStateStore.GetRecentEventsAfter(afterId, maxCount, typeFilter); return new MccRecentEventsResult { AfterId = afterId, LatestId = MccObservedStateStore.GetLatestRecentEventId(), Count = events.Length, Events = events }; } /// /// Get recent chat and system lines observed by MCC. /// public MccChatHistoryResult GetChatHistory(int maxCount = 50, bool includeJson = false) { MccChatHistoryEntry[] entries = MccObservedStateStore.GetLatestChatHistory(maxCount); if (!includeJson) { entries = entries .Select(entry => new MccChatHistoryEntry { TimestampUtc = entry.TimestampUtc, Kind = entry.Kind, Text = entry.Text, Sender = entry.Sender, Message = entry.Message }) .ToArray(); } return new MccChatHistoryResult { Count = entries.Length, Entries = entries }; } /// /// Compute a path preview to a location without moving there. /// public MccGameResult PreviewPath(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0, int maxWaypoints = 128) { if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0 || maxWaypoints <= 0) { return MccGameResult.Fail("invalid_args"); } McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetTerrainEnabled()) return MccGameResult.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? 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 MccGameResult.Ok(new MccPathPreviewResult { PathFound = path is not null, ExactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(), Target = MccGameCommon.ToCoordinate(goal), StartLocation = MccGameCommon.ToCoordinate(startLocation), FinalWaypoint = finalWaypoint is Location waypoint ? MccGameCommon.ToCoordinate(waypoint) : null, FinalDistance = finalWaypoint is Location endWaypoint ? MccGameCommon.GetDistance(endWaypoint, goal) : null, WaypointCount = path?.Count ?? 0, Truncated = path is not null && path.Count > waypointLimit, Waypoints = waypoints.Select(MccGameCommon.ToCoordinate).ToArray(), AllowUnsafe = allowUnsafe, MaxOffset = maxOffset, MinOffset = minOffset, TimeoutMs = effectiveTimeoutMs }); } /// /// Check whether MCC can currently path to a location without moving there. /// public MccGameResult CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) { if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetTerrainEnabled()) return MccGameResult.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? path = Movement.CalculatePath( world, startLocation, goal, allowUnsafe, maxOffset, minOffset, TimeSpan.FromMilliseconds(effectiveTimeoutMs)); Location? finalWaypoint = path?.LastOrDefault(); double? finalDistance = finalWaypoint is Location waypoint ? MccGameCommon.GetDistance(waypoint, goal) : null; return MccGameResult.Ok(new MccReachabilityResult { Reachable = path is not null, ExactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(), Target = MccGameCommon.ToCoordinate(goal), StartLocation = MccGameCommon.ToCoordinate(startLocation), FinalWaypoint = finalWaypoint is Location finalLocation ? MccGameCommon.ToCoordinate(finalLocation) : null, FinalDistance = finalDistance, WaypointCount = path?.Count ?? 0, AllowUnsafe = allowUnsafe, MaxOffset = maxOffset, MinOffset = minOffset, TimeoutMs = effectiveTimeoutMs }); } /// /// Get online players with tracked coordinates and latency when available. /// public MccGameResult GetPlayersDetailed(bool includeSelf = false, bool includeCoordinates = true) { McClient? client = clientProvider(); if (client is null) return NotConnected(); return client.InvokeOnMainThread(() => { Dictionary onlinePlayers = client.GetOnlinePlayersWithUUID(); Dictionary? trackedPlayers = client.GetEntityHandlingEnabled() ? BuildTrackedPlayerSnapshots(client, includeSelf: true).ToDictionary(player => player.Uuid) : null; Guid selfUuid = client.GetUserUuid(); string selfName = client.GetUsername(); MccPlayersDetailedEntry[] 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 ? MccGameCommon.RoundCoordinate(trackedX) : selfLocation.HasValue ? MccGameCommon.RoundCoordinate(selfLocation.Value.X) : null : null; double? y = includeCoordinates ? trackedPlayer?.Y is double trackedY ? MccGameCommon.RoundCoordinate(trackedY) : selfLocation.HasValue ? MccGameCommon.RoundCoordinate(selfLocation.Value.Y) : null : null; double? z = includeCoordinates ? trackedPlayer?.Z is double trackedZ ? MccGameCommon.RoundCoordinate(trackedZ) : selfLocation.HasValue ? MccGameCommon.RoundCoordinate(selfLocation.Value.Z) : null : null; return new MccPlayersDetailedEntry { Name = playerInfo?.Name ?? pair.Value, Uuid = uuid, Ping = playerInfo?.Ping ?? trackedPlayer?.Latency ?? 0, Gamemode = playerInfo?.Gamemode ?? -1, Listed = playerInfo?.Listed ?? true, DisplayName = playerInfo?.DisplayName, EntityId = entityId, X = x, Y = y, Z = z }; }) .Where(player => player is not null) .Select(player => player!) .OrderBy(player => player.Name, NameComparer) .ToArray(); return MccGameResult.Ok(new MccPlayersDetailedResult { IncludeSelf = includeSelf, IncludeCoordinates = includeCoordinates, Count = players.Length, Players = players }); }); } /// /// Check whether any player, or a specific player, is nearby. /// public MccGameResult IsPlayerNearby(string? playerName = null, double radius = 32, bool includeSelf = false) { if (radius <= 0 || radius > 1024) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); string? nameFilter = string.IsNullOrWhiteSpace(playerName) ? null : playerName.Trim(); return client.InvokeOnMainThread(() => { double radiusValue = radius; MccNearbyPlayerEntry[] players = BuildTrackedPlayerSnapshots(client, includeSelf) .Where(player => player.Distance <= radiusValue) .Where(player => nameFilter is null || PlayerNameMatches(player, nameFilter)) .OrderBy(player => player.Distance) .Select(player => new MccNearbyPlayerEntry { EntityId = player.EntityId, Uuid = player.Uuid, Name = player.Name, CustomName = player.CustomName, X = MccGameCommon.RoundCoordinate(player.X), Y = MccGameCommon.RoundCoordinate(player.Y), Z = MccGameCommon.RoundCoordinate(player.Z), Distance = player.Distance, Latency = player.Latency }) .ToArray(); return MccGameResult.Ok(new MccPlayerNearbyResult { Radius = radiusValue, PlayerName = nameFilter, IncludeSelf = includeSelf, AnyNearby = players.Length > 0, Count = players.Length, Players = players }); }); } /// /// Locate a tracked player by name. /// public MccGameResult LocatePlayer(string playerName, bool includeSelf = false) { if (string.IsNullOrWhiteSpace(playerName)) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); string nameFilter = playerName.Trim(); return client.InvokeOnMainThread(() => { NearbyPlayerSnapshot[] matches = BuildTrackedPlayerSnapshots(client, includeSelf) .Where(player => PlayerNameMatches(player, nameFilter)) .OrderBy(player => player.Distance) .ToArray(); if (matches.Length == 0) { string[] trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf) .Select(player => player.Name) .OfType() .Distinct(NameComparer) .ToArray(); return MccGameResult.Fail("invalid_state", data: new MccLocatedPlayerResult { PlayerName = nameFilter, MatchedName = trackedPlayers.Length > 0 ? string.Join(", ", trackedPlayers) : null, EntityId = 0, Uuid = Guid.Empty, X = 0, Y = 0, Z = 0, Distance = 0 }); } NearbyPlayerSnapshot selected = matches[0]; return MccGameResult.Ok(new MccLocatedPlayerResult { PlayerName = nameFilter, MatchedName = selected.Name, EntityId = selected.EntityId, Uuid = selected.Uuid, X = MccGameCommon.RoundCoordinate(selected.X), Y = MccGameCommon.RoundCoordinate(selected.Y), Z = MccGameCommon.RoundCoordinate(selected.Z), Distance = selected.Distance }); }); } /// /// Return tracked entities without distance filtering. /// public MccGameResult QueryEntities(int maxCount = 200) { McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); int count = Math.Clamp(maxCount, 1, 1000); return client.InvokeOnMainThread(() => { Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) .ToDictionary(player => player.EntityId, player => player.Name); MccEntitySummary[] data = client.GetEntities() .Take(count) .Select(pair => { string? resolvedName = pair.Value.Type == EntityType.Player && playerNamesByEntityId.TryGetValue(pair.Key, out string? mappedName) ? mappedName : pair.Value.Name; return BuildEntitySummary(pair.Value, resolvedName); }) .ToArray(); return MccGameResult.Ok(new MccQueryEntitiesResult { Count = client.GetEntities().Count, Entities = data }); }); } /// /// List tracked entities with optional filtering. /// public MccGameResult ListEntities(int maxCount = 200, string? typeFilter = null, double radius = 0) { McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.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 entities = client.GetEntities(); Location playerLocation = client.GetCurrentLocation(); Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) .ToDictionary(player => player.EntityId, player => player.Name); MccEntitySummary[] 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, resolvedName, distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) }; }) .Where(item => radiusValue <= 0 || item.distance <= radiusValue) .Where(item => filter is null || item.entity.Type.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) || item.entity.GetTypeString().Contains(filter, StringComparison.OrdinalIgnoreCase)) .OrderBy(item => item.distance) .Take(count) .Select(item => BuildEntitySummary(item.entity, item.resolvedName, item.distance)) .ToArray(); return MccGameResult.Ok(new MccListEntitiesResult { TotalTracked = entities.Count, Count = data.Length, Entities = data }); }); } /// /// Get detailed information for a tracked entity. /// public MccGameResult GetEntityInfo(int entityId, bool includeMetadata = false, bool includeEquipment = false, bool includeEffects = false) { McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); return client.InvokeOnMainThread(() => { Dictionary entities = client.GetEntities(); if (!entities.TryGetValue(entityId, out Entity? entity)) return MccGameResult.Fail("invalid_state"); string? resolvedName = entity.Name; if (entity.Type == EntityType.Player) { Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) .ToDictionary(player => player.EntityId, player => player.Name); if (playerNamesByEntityId.TryGetValue(entityId, out string? mappedName)) resolvedName = mappedName; } Dictionary? metadata = includeMetadata ? entity.Metadata?.ToDictionary( pair => pair.Key.ToString(), pair => MccGameCommon.DescribeMetadataValue(pair.Value)) : null; MccEntityEquipmentEntry[]? equipment = includeEquipment ? entity.Equipment .Select(pair => new MccEntityEquipmentEntry { Slot = pair.Key, Type = pair.Value.Type.ToString(), Count = pair.Value.Count }) .ToArray() : null; MccEffectSnapshot[]? activeEffects = includeEffects ? entity.ActiveEffects.Values .Select(effect => new MccEffectSnapshot { Id = effect.Effect.ToString(), Amplifier = effect.Amplifier, RemainingSeconds = effect.RemainingSeconds, IsInfinite = effect.IsInfinite }) .ToArray() : null; return MccGameResult.Ok(new MccEntityInfoResult { Id = entity.ID, Type = entity.Type.ToString(), TypeLabel = entity.GetTypeString(), Uuid = entity.UUID, Name = resolvedName, CustomName = entity.CustomName, CustomNameVisible = entity.IsCustomNameVisible, X = MccGameCommon.RoundCoordinate(entity.Location.X), Y = MccGameCommon.RoundCoordinate(entity.Location.Y), Z = MccGameCommon.RoundCoordinate(entity.Location.Z), Yaw = entity.Yaw, Pitch = entity.Pitch, Health = entity.Health, Pose = entity.Pose.ToString(), Latency = entity.Latency, ObjectData = entity.ObjectData, Metadata = metadata, Equipment = equipment, ActiveEffects = activeEffects }); }); } /// /// Return the nearest tracked entity matching the requested filters. /// public MccGameResult FindNearestEntity(string? typeFilter = null, string? nameFilter = null, double radius = 64.0, bool includePlayers = true) { if (radius <= 0 || radius > 1024) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.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 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 || MccGameCommon.TextMatchesFilter(item.entity.Type.ToString(), normalizedTypeFilter) || MccGameCommon.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 MccGameResult.Fail("invalid_state"); return MccGameResult.Ok(BuildEntitySummary(nearest.entity, nearest.resolvedName, nearest.distance)); }); } /// /// Move to a tracked player by name and verify arrival. /// Callers running on ChatBot update callbacks should prefer /// so the main MCC updater thread is not blocked while pathfinding completes. /// public MccGameResult MoveToPlayer(string playerName, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) { if (string.IsNullOrWhiteSpace(playerName)) return MccGameResult.Fail("invalid_args"); if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetTerrainEnabled() || !client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); string nameFilter = playerName.Trim(); NearbyPlayerSnapshot? target = client.InvokeOnMainThread(() => { return BuildTrackedPlayerSnapshots(client, includeSelf: false) .Where(player => PlayerNameMatches(player, nameFilter)) .OrderBy(player => player.Distance) .FirstOrDefault(); }); if (target is null) return MccGameResult.Fail("invalid_state"); 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); MccMoveToPlayerResult resultData = new() { PathFound = pathFound, Arrived = arrived, Tolerance = tolerance, VerifyWaitMs = verifyWaitMs, Target = new MccMoveToPlayerTarget { PlayerName = target.Name, EntityId = target.EntityId, X = MccGameCommon.RoundCoordinate(target.X), Y = MccGameCommon.RoundCoordinate(target.Y), Z = MccGameCommon.RoundCoordinate(target.Z) }, StartLocation = MccGameCommon.ToCoordinate(startLocation), FinalLocation = MccGameCommon.ToCoordinate(finalLocation.Value), FinalDistance = MccGameCommon.GetDistance(finalLocation.Value, goal), DistanceMoved = MccGameCommon.GetDistance(startLocation, finalLocation.Value), AllowUnsafe = allowUnsafe, AllowDirectTeleport = allowDirectTeleport, MaxOffset = maxOffset, MinOffset = minOffset, TimeoutMs = timeoutMs }; return pathFound && arrived ? MccGameResult.Ok(resultData) : MccGameResult.Fail("action_incomplete", data: resultData); } /// /// Run on a worker thread so ChatBot callbacks can poll the result without blocking MCC updates. /// public Task> MoveToPlayerAsync(string playerName, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) { return Task.Run(() => MoveToPlayer(playerName, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs)); } /// /// Select a hotbar item by item type without moving items around. /// public MccGameResult SelectHotbarItem(string itemType, bool preferLowestSlot = true) { if (string.IsNullOrWhiteSpace(itemType)) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetInventoryEnabled()) return MccGameResult.Fail("feature_disabled"); if (!MccGameCommon.TryParseItemType(itemType, out ItemType parsedItemType)) return MccGameResult.Fail("invalid_args"); return client.InvokeOnMainThread(() => { Container? inventory = client.GetInventory(0); if (inventory is null) return MccGameResult.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 = hotbar, IsHotbar = isHotbar, Count = pair.Value.Count }; }) .Where(match => match.IsHotbar) .OrderBy(match => preferLowestSlot ? match.Hotbar : -match.Hotbar) .ToArray(); if (matches.Length == 0) return MccGameResult.Fail("invalid_state"); var selected = matches[0]; bool ok = client.ChangeSlot((short)selected.Hotbar); MccHotbarSelectionResult resultData = new() { Success = ok, ItemType = parsedItemType.ToString(), InventorySlot = selected.InventorySlot, SelectedSlot = selected.Hotbar + 1, Count = selected.Count }; return ok ? MccGameResult.Ok(resultData) : MccGameResult.Fail("action_failed", data: resultData); }); } /// /// Get a snapshot of an inventory. /// public MccGameResult GetInventorySnapshot(int inventoryId) { McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetInventoryEnabled()) return MccGameResult.Fail("feature_disabled"); return client.InvokeOnMainThread(() => { Dictionary inventories = client.GetInventories(); if (!inventories.TryGetValue(inventoryId, out Container? inventory)) return MccGameResult.Fail("invalid_state"); MccInventorySnapshotSlot[] slots = inventory.Items .Where(item => IsSnapshotInventorySlot(inventory, item.Key)) .OrderBy(item => item.Key) .Select(item => new MccInventorySnapshotSlot { Slot = item.Key, Type = item.Value.Type.ToString(), Count = item.Value.Count, Nbt = MccGameCommon.BuildNbt(item.Value) }) .ToArray(); return MccGameResult.Ok(new MccInventorySnapshotResult { Id = inventory.ID, Type = inventory.Type.ToString(), Title = inventory.Title ?? string.Empty, SlotCount = inventory.Type.SlotCount(), Slots = slots, Cursor = TryBuildCursorSnapshot(inventory) }); }); } /// /// Search player and container inventories for matching items. /// public MccGameResult SearchInventories(string query, int maxCount = 100, bool exactMatch = false, bool includeContainers = false) { if (string.IsNullOrWhiteSpace(query)) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetInventoryEnabled()) return MccGameResult.Fail("feature_disabled"); string normalizedQuery = query.Trim(); ItemType? parsedItemType = exactMatch && MccGameCommon.TryParseItemType(normalizedQuery, out ItemType exactItemType) ? exactItemType : null; int limit = Math.Clamp(maxCount, 1, 1000); return client.InvokeOnMainThread(() => { MccInventorySearchMatch[] 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 MccInventorySearchMatch { InventoryId = entry.Key, InventoryType = inventory.Type.ToString(), InventoryTitle = inventory.Title ?? string.Empty, Slot = pair.Key, ItemType = pair.Value.Type.ToString(), TypeLabel = pair.Value.GetTypeString(), Count = pair.Value.Count, IsPlayerInventory = entry.Key == 0, HotbarSlot = isHotbar ? hotbar + 1 : null, Nbt = MccGameCommon.BuildNbt(pair.Value) }; }); }) .Take(limit) .ToArray(); return MccGameResult.Ok(new MccInventorySearchResult { Query = normalizedQuery, ExactMatch = exactMatch, IncludeContainers = includeContainers, Count = matches.Length, Matches = matches }); }); } /// /// List active inventories. /// public MccGameResult ListInventories() { McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetInventoryEnabled()) return MccGameResult.Fail("feature_disabled"); return client.InvokeOnMainThread(() => { MccInventoryListEntry[] inventories = client.GetInventories() .OrderBy(entry => entry.Key) .Select(entry => new MccInventoryListEntry { Id = entry.Key, Type = entry.Value.Type.ToString(), Title = entry.Value.Title ?? string.Empty, SlotCount = entry.Value.Type.SlotCount(), NonEmptySlots = entry.Value.Items.Count(item => IsSnapshotInventorySlot(entry.Value, item.Key)), Active = entry.Key > 0 && entry.Key == GetActiveContainerId(client) }) .ToArray(); return MccGameResult.Ok(new MccInventoryListResult { Count = inventories.Length, Inventories = inventories }); }); } /// /// List nearby item entities. /// public MccGameResult ListItemEntities(string? itemType = null, double radius = 32, int maxCount = 50) { if (radius <= 0 || radius > 1024) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); ItemType? parsedItemType = null; string? itemTypeFilter = null; if (!string.IsNullOrWhiteSpace(itemType)) { itemTypeFilter = itemType.Trim(); if (!MccGameCommon.TryParseItemType(itemTypeFilter, out ItemType resolvedType)) return MccGameResult.Fail("invalid_args"); parsedItemType = resolvedType; } int limit = Math.Clamp(maxCount, 1, 500); return client.InvokeOnMainThread(() => { MccItemEntityEntry[] items = BuildNearbyItemSnapshots(client, parsedItemType, radius, limit) .Select(item => new MccItemEntityEntry { EntityId = item.EntityId, ItemType = item.ItemType.ToString(), TypeLabel = item.TypeLabel, Count = item.Count, X = MccGameCommon.RoundCoordinate(item.X), Y = MccGameCommon.RoundCoordinate(item.Y), Z = MccGameCommon.RoundCoordinate(item.Z), Distance = item.Distance }) .ToArray(); return MccGameResult.Ok(new MccItemEntitiesResult { ItemType = parsedItemType?.ToString() ?? itemTypeFilter, Radius = radius, Count = items.Length, Items = items }); }); } /// /// Move to nearby dropped items and verify pickup completion. /// Callers running on ChatBot update callbacks should prefer /// so the main MCC updater thread is not blocked while movement and pickup verification complete. /// public MccGameResult PickupItems(string itemType, double radius = 16, int maxItems = 10, bool allowUnsafe = false, int timeoutMs = 0) { if (string.IsNullOrWhiteSpace(itemType) || radius <= 0 || radius > 1024 || maxItems < 1 || timeoutMs < 0) return MccGameResult.Fail("invalid_args"); if (!MccGameCommon.TryParseItemType(itemType.Trim(), out ItemType parsedItemType)) return MccGameResult.Fail("invalid_args"); McClient? client = clientProvider(); if (client is null) return NotConnected(); if (!client.GetTerrainEnabled() || !client.GetEntityHandlingEnabled()) return MccGameResult.Fail("feature_disabled"); int limit = Math.Clamp(maxItems, 1, 50); NearbyItemSnapshot[] targets = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, limit)); if (targets.Length == 0) return MccGameResult.Fail("invalid_state"); 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 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 MccPickupAttempt { EntityId = target.EntityId, ItemType = target.ItemType.ToString(), TypeLabel = target.TypeLabel, ExpectedCount = target.Count, Target = MccGameCommon.ToCoordinate(target.X, target.Y, target.Z), PathFound = pathFound, Arrived = arrived, EntityGone = entityGone, InventoryDelta = inventoryDelta, StartLocation = MccGameCommon.ToCoordinate(startLocation), FinalLocation = MccGameCommon.ToCoordinate(finalLocation.Value), FinalDistance = MccGameCommon.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; MccPickupItemsResult resultData = new() { ItemType = parsedItemType.ToString(), Radius = radius, MaxItems = limit, AllowUnsafe = allowUnsafe, TimeoutMs = verifyWaitMs, Attempted = attempts.Count, SuccessfulPickups = successfulPickups, CollectedCount = collectedCount, InitialInventoryCount = inventoryEnabled ? initialCount : null, FinalInventoryCount = inventoryEnabled ? beforeCount : null, RemainingNearby = remainingNearby, Attempts = attempts.ToArray() }; return successfulPickups > 0 || collectedCount > 0 ? MccGameResult.Ok(resultData) : MccGameResult.Fail("action_incomplete", data: resultData); } /// /// Run on a worker thread so ChatBot callbacks can poll the result without blocking MCC updates. /// public Task> PickupItemsAsync(string itemType, double radius = 16, int maxItems = 10, bool allowUnsafe = false, int timeoutMs = 0) { return Task.Run(() => PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs)); } private static MccGameResult NotConnected() { return MccGameResult.Fail("disconnected"); } private static MccEntitySummary BuildEntitySummary(Entity entity, string? resolvedName, double? distance = null) { return new MccEntitySummary { Id = entity.ID, Type = entity.Type.ToString(), TypeLabel = entity.GetTypeString(), Uuid = entity.UUID, Name = resolvedName, CustomName = entity.CustomName, X = MccGameCommon.RoundCoordinate(entity.Location.X), Y = MccGameCommon.RoundCoordinate(entity.Location.Y), Z = MccGameCommon.RoundCoordinate(entity.Location.Z), Distance = distance, Health = entity.Health, Pose = entity.Pose.ToString(), Latency = entity.Latency }; } private static bool IsSnapshotInventorySlot(Container inventory, int slotId) { return slotId >= 0 && slotId < inventory.Type.SlotCount(); } private static MccItemStackSnapshot? TryBuildCursorSnapshot(Container inventory) { return inventory.Items.TryGetValue(-1, out Item? cursorItem) && cursorItem.Count > 0 ? MccGameCommon.ToItemStack(cursorItem) : null; } private static int GetActiveContainerId(McClient client) { return client.GetInventories().Keys.Where(id => id > 0).DefaultIfEmpty(0).Max(); } 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 ? MccGameCommon.TextEqualsFilter(typeName, query) || MccGameCommon.TextEqualsFilter(typeLabel, query) : MccGameCommon.TextMatchesFilter(typeName, query) || MccGameCommon.TextMatchesFilter(typeLabel, query); } 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; if (MccGameCommon.GetDistance(location, goal) <= tolerance) return true; if (DateTime.UtcNow >= deadline) return false; Thread.Sleep(ArrivalPollIntervalMs); } } 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 int GetPathQueryTimeoutMs(int timeoutMs) { if (timeoutMs <= 0) return DefaultPathQueryTimeoutMs; return Math.Clamp(timeoutMs, MinPathQueryTimeoutMs, MaxPathQueryTimeoutMs); } 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 bool AreValidPathOffsets(int maxOffset, int minOffset) { return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset; } private static bool EntityNameMatches(string? name, string? customName, string filter) { return (!string.IsNullOrWhiteSpace(name) && MccGameCommon.TextMatchesFilter(name, filter)) || (!string.IsNullOrWhiteSpace(customName) && MccGameCommon.TextMatchesFilter(customName, filter)); } 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 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 List BuildTrackedPlayerSnapshots(McClient client, bool includeSelf) { Location playerLocation = client.GetCurrentLocation(); string username = client.GetUsername(); Dictionary uuidToName = client.GetOnlinePlayersWithUUID(); string[] onlinePlayers = client.GetOnlinePlayers(); List 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 unnamedTracked = trackedPlayers .Where(player => string.IsNullOrWhiteSpace(player.Name)) .OrderBy(player => player.Distance) .ToList(); if (unnamedTracked.Count == 0) return trackedPlayers; HashSet assignedNames = trackedPlayers .Select(player => player.Name) .OfType() .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 string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary 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; } }