diff --git a/MinecraftClient/ChatBots/McpServer.cs b/MinecraftClient/ChatBots/McpServer.cs index 3d8f7308..f9f7394d 100644 --- a/MinecraftClient/ChatBots/McpServer.cs +++ b/MinecraftClient/ChatBots/McpServer.cs @@ -87,7 +87,7 @@ namespace MinecraftClient.ChatBots public override bool OnDisconnect(DisconnectReason reason, string message) { - MccMcpRecentEventStore.Add("disconnect", new + MccObservedStateStore.AddRecentEvent("disconnect", new { reason = reason.ToString(), message @@ -128,7 +128,7 @@ namespace MinecraftClient.ChatBots message = parsedMessage; } - MccMcpChatHistoryStore.Add(new MccMcpChatHistoryEntry + MccObservedStateStore.AddChatHistoryEntry(new MccChatHistoryEntry { TimestampUtc = DateTimeOffset.UtcNow, Kind = kind, @@ -141,34 +141,34 @@ namespace MinecraftClient.ChatBots public override void OnTimeUpdate(long WorldAge, long TimeOfDay) { - MccMcpRuntimeStateStore.SetTime(WorldAge, TimeOfDay); + MccObservedStateStore.SetTime(WorldAge, TimeOfDay); } public override void OnRainLevelChange(float level) { - MccMcpRuntimeStateStore.SetRainLevel(level); - MccMcpRecentEventStore.Add("weather_rain", new { level }); + MccObservedStateStore.SetRainLevel(level); + MccObservedStateStore.AddRecentEvent("weather_rain", new { level }); } public override void OnThunderLevelChange(float level) { - MccMcpRuntimeStateStore.SetThunderLevel(level); - MccMcpRecentEventStore.Add("weather_thunder", new { level }); + MccObservedStateStore.SetThunderLevel(level); + MccObservedStateStore.AddRecentEvent("weather_thunder", new { level }); } public override void OnDeath() { - MccMcpRecentEventStore.Add("death"); + MccObservedStateStore.AddRecentEvent("death"); } public override void OnRespawn() { - MccMcpRecentEventStore.Add("respawn"); + MccObservedStateStore.AddRecentEvent("respawn"); } public override void OnPlayerJoin(Guid uuid, string name) { - MccMcpRecentEventStore.Add("player_join", new + MccObservedStateStore.AddRecentEvent("player_join", new { uuid, name @@ -177,7 +177,7 @@ namespace MinecraftClient.ChatBots public override void OnPlayerLeave(Guid uuid, string? name) { - MccMcpRecentEventStore.Add("player_leave", new + MccObservedStateStore.AddRecentEvent("player_leave", new { uuid, name @@ -186,19 +186,19 @@ namespace MinecraftClient.ChatBots public override void OnInventoryOpen(int inventoryId) { - MccMcpRecentEventStore.Add("inventory_open", new { inventoryId }); + MccObservedStateStore.AddRecentEvent("inventory_open", new { inventoryId }); } public override void OnInventoryClose(int inventoryId) { - MccMcpRecentEventStore.Add("inventory_close", new { inventoryId }); + MccObservedStateStore.AddRecentEvent("inventory_close", new { inventoryId }); } public override void OnTitle(int action, string titletext, string subtitletext, string actionbartext, int fadein, int stay, int fadeout, string json) { if (action == 2) { - MccMcpRecentEventStore.Add("actionbar", new + MccObservedStateStore.AddRecentEvent("actionbar", new { action, text = actionbartext, @@ -212,7 +212,7 @@ namespace MinecraftClient.ChatBots if (action is 0 or 1) { - MccMcpRecentEventStore.Add("title", new + MccObservedStateStore.AddRecentEvent("title", new { action, titleText = titletext, @@ -227,7 +227,7 @@ namespace MinecraftClient.ChatBots public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage) { - MccMcpRecentEventStore.Add("block_break_animation", new + MccObservedStateStore.AddRecentEvent("block_break_animation", new { entityId = entity.ID, entityType = entity.Type.ToString(), @@ -243,7 +243,7 @@ namespace MinecraftClient.ChatBots public override void OnEntityAnimation(Entity entity, byte animation) { - MccMcpRecentEventStore.Add("entity_animation", new + MccObservedStateStore.AddRecentEvent("entity_animation", new { entityId = entity.ID, entityType = entity.Type.ToString(), @@ -266,9 +266,7 @@ namespace MinecraftClient.ChatBots private static void ClearStores() { - MccMcpChatHistoryStore.Clear(); - MccMcpRuntimeStateStore.Clear(); - MccMcpRecentEventStore.Clear(); + MccObservedStateStore.ClearAll(); } } } diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs index cce37911..0b600235 100644 --- a/MinecraftClient/Mcp/MccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/MccMcpCapabilities.cs @@ -78,10 +78,12 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities } private readonly Func togglesProvider; + private readonly MccGameApi game; public MccMcpCapabilities(Func togglesProvider) { this.togglesProvider = togglesProvider; + game = new MccGameApi(GetClient); } private static McClient? GetClient() @@ -94,6 +96,20 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("disconnected"); } + private static MccMcpResult ToMcpResult(MccGameResult result) + { + return result.Success + ? MccMcpResult.Ok(message: result.Message) + : MccMcpResult.Fail(result.ErrorCode ?? "unknown", result.Message); + } + + private static MccMcpResult ToMcpResult(MccGameResult result) + { + return result.Success + ? MccMcpResult.Ok(result.Data, result.Message) + : MccMcpResult.Fail(result.ErrorCode ?? "unknown", result.Message, result.Data); + } + private bool IsCategoryEnabled(Func selector) { return selector(togglesProvider()); @@ -191,7 +207,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities Location location = client.GetCurrentLocation(); World world = client.GetWorld(); Dimension dimension = World.GetDimension(); - MccMcpRuntimeStateSnapshot runtimeState = MccMcpRuntimeStateStore.GetSnapshot(); + MccRuntimeStateSnapshot runtimeState = game.GetRuntimeState(); int totalChunkCount = world.chunkCnt; int pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted); int loadedChunkCount = GetLoadedChunkCount(world); @@ -344,57 +360,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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? 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 - }); + return ToMcpResult(game.PreviewPath(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs, maxWaypoints)); } public MccMcpResult GetPlayersList() @@ -416,79 +382,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { if (!IsCategoryEnabled(t => t.SessionStatus)) return MccMcpResult.Fail("capability_disabled"); - - McClient? client = GetClient(); - 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(); - - 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 - }); - }); + return ToMcpResult(game.GetPlayersDetailed(includeSelf, includeCoordinates)); } public MccMcpResult GetPlayerStats() @@ -563,20 +457,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities 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() - }); + return MccMcpResult.Ok(game.GetRecentEvents(afterId, maxCount, typeFilter)); } public MccMcpResult GetLoadedBots() @@ -613,21 +494,10 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities 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() - }); + if (GetClient() is null) + return NotConnected(); + + return MccMcpResult.Ok(game.GetChatHistory(maxCount, includeJson)); } public MccMcpResult GetInternalCommands() @@ -967,71 +837,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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); - }); + return ToMcpResult(game.SelectHotbarItem(itemType, preferLowestSlot)); } public MccMcpResult UseItemOnBlock(double x, double y, double z) @@ -1450,189 +1256,21 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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 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 - }); - }); + return ToMcpResult(game.IsPlayerNearby(playerName, radius, includeSelf)); } 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 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() - .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 - }); - }); + return ToMcpResult(game.LocatePlayer(playerName, includeSelf)); } 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 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 - }); - }); + return ToMcpResult(game.FindNearestEntity(typeFilter, nameFilter, radius, includePlayers)); } public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) @@ -1694,93 +1332,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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 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() - .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); + return ToMcpResult(game.MoveToPlayer(playerName, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs)); } public MccMcpResult LookAt(double x, double y, double z) @@ -1858,140 +1410,21 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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 inventories = client.GetInventories(); - if (!inventories.TryGetValue(inventoryId, out Container? inventory)) - return MccMcpResult.Fail("invalid_state"); - - var slots = inventory.Items - .Where(item => IsSnapshotInventorySlot(inventory, item.Key)) - .OrderBy(item => item.Key) - .Select(item => new - { - slot = item.Key, - type = item.Value.Type.ToString(), - count = item.Value.Count - }) - .ToArray(); - object? cursor = TryBuildCursorSnapshot(inventory); - - return MccMcpResult.Ok(new - { - id = inventory.ID, - type = inventory.Type.ToString(), - title = inventory.Title, - slotCount = inventory.Type.SlotCount(), - slots, - cursor - }); - }); + return ToMcpResult(game.GetInventorySnapshot(inventoryId)); } 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 - }); - }); + return ToMcpResult(game.SearchInventories(query, maxCount, exactMatch, includeContainers)); } 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(item => IsSnapshotInventorySlot(entry.Value, item.Key)), - active = entry.Key > 0 && entry.Key == GetActiveContainerId(client) - }) - .ToArray(); - - return MccMcpResult.Ok(new - { - count = inventories.Length, - inventories - }); - }); + return ToMcpResult(game.ListInventories()); } public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) @@ -2243,196 +1676,21 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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 entities = client.GetEntities(); - Dictionary 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 - }); - }); + return ToMcpResult(game.QueryEntities(maxCount)); } 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 entities = client.GetEntities(); - Location playerLocation = client.GetCurrentLocation(); - Dictionary 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 - }); - }); + return ToMcpResult(game.ListEntities(maxCount, typeFilter, radius)); } 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 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 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 - }); - }); + return ToMcpResult(game.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects)); } public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) @@ -2526,144 +1784,14 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities { 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() - }); - }); + return ToMcpResult(game.ListItemEntities(itemType, radius, maxCount)); } 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 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); + return ToMcpResult(game.PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs)); } public MccMcpResult GetWorldBlockAt(int x, int y, int z) diff --git a/MinecraftClient/Mcp/MccMcpChatHistory.cs b/MinecraftClient/Mcp/MccMcpChatHistory.cs index a5acda58..6cf314f1 100644 --- a/MinecraftClient/Mcp/MccMcpChatHistory.cs +++ b/MinecraftClient/Mcp/MccMcpChatHistory.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Linq; +using MinecraftClient.Scripting; namespace MinecraftClient.Mcp; @@ -16,34 +16,36 @@ public sealed class MccMcpChatHistoryEntry public static class MccMcpChatHistoryStore { - private static readonly object historyLock = new(); - private static readonly List history = new(); - private const int MaxEntries = 500; - public static void Add(MccMcpChatHistoryEntry entry) { - lock (historyLock) + MccObservedStateStore.AddChatHistoryEntry(new MccChatHistoryEntry { - history.Add(entry); - if (history.Count > MaxEntries) - history.RemoveRange(0, history.Count - MaxEntries); - } + TimestampUtc = entry.TimestampUtc, + Kind = entry.Kind, + Text = entry.Text, + Sender = entry.Sender, + Message = entry.Message, + Json = entry.Json + }); } public static MccMcpChatHistoryEntry[] GetLatest(int maxCount) { - int count = Math.Clamp(maxCount, 1, MaxEntries); - lock (historyLock) - { - return history.TakeLast(count).ToArray(); - } + return MccObservedStateStore.GetLatestChatHistory(maxCount) + .Select(entry => new MccMcpChatHistoryEntry + { + TimestampUtc = entry.TimestampUtc, + Kind = entry.Kind, + Text = entry.Text, + Sender = entry.Sender, + Message = entry.Message, + Json = entry.Json + }) + .ToArray(); } public static void Clear() { - lock (historyLock) - { - history.Clear(); - } + MccObservedStateStore.ClearChatHistory(); } } diff --git a/MinecraftClient/Mcp/MccMcpRecentEventStore.cs b/MinecraftClient/Mcp/MccMcpRecentEventStore.cs index 49fd9b86..69da1b57 100644 --- a/MinecraftClient/Mcp/MccMcpRecentEventStore.cs +++ b/MinecraftClient/Mcp/MccMcpRecentEventStore.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Linq; +using MinecraftClient.Scripting; namespace MinecraftClient.Mcp; @@ -14,62 +14,31 @@ public sealed class MccMcpRecentEventEntry public static class MccMcpRecentEventStore { - private static readonly object historyLock = new(); - private static readonly List history = new(); - private const int MaxEntries = 500; - private static long nextId = 1; - public static long Add(string type, object? data = null) { - ArgumentException.ThrowIfNullOrEmpty(type); - - lock (historyLock) - { - long id = nextId++; - history.Add(new MccMcpRecentEventEntry - { - Id = id, - TimestampUtc = DateTimeOffset.UtcNow, - Type = type, - Data = data - }); - - if (history.Count > MaxEntries) - history.RemoveRange(0, history.Count - MaxEntries); - - return id; - } + return MccObservedStateStore.AddRecentEvent(type, data); } public static long GetLatestId() { - lock (historyLock) - { - return history.Count > 0 ? history[^1].Id : 0; - } + return MccObservedStateStore.GetLatestRecentEventId(); } public static MccMcpRecentEventEntry[] GetAfter(long afterId, int maxCount, string? typeFilter = null) { - int count = Math.Clamp(maxCount, 1, MaxEntries); - string? normalizedFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim(); - - lock (historyLock) - { - return history - .Where(entry => entry.Id > afterId) - .Where(entry => normalizedFilter is null - || entry.Type.Contains(normalizedFilter, StringComparison.OrdinalIgnoreCase)) - .Take(count) - .ToArray(); - } + return MccObservedStateStore.GetRecentEventsAfter(afterId, maxCount, typeFilter) + .Select(entry => new MccMcpRecentEventEntry + { + Id = entry.Id, + TimestampUtc = entry.TimestampUtc, + Type = entry.Type, + Data = entry.Data + }) + .ToArray(); } public static void Clear() { - lock (historyLock) - { - history.Clear(); - } + MccObservedStateStore.ClearRecentEvents(); } } diff --git a/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs b/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs index 0c602752..c9c3daf0 100644 --- a/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs +++ b/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs @@ -1,4 +1,5 @@ using System; +using MinecraftClient.Scripting; namespace MinecraftClient.Mcp; @@ -12,59 +13,35 @@ public sealed class MccMcpRuntimeStateSnapshot public static class MccMcpRuntimeStateStore { - private static readonly object stateLock = new(); - private static long? worldAge; - private static long? timeOfDay; - private static float? rainLevel; - private static float? thunderLevel; - public static void SetTime(long newWorldAge, long newTimeOfDay) { - lock (stateLock) - { - worldAge = newWorldAge; - timeOfDay = newTimeOfDay; - } + MccObservedStateStore.SetTime(newWorldAge, newTimeOfDay); } public static void SetRainLevel(float level) { - lock (stateLock) - { - rainLevel = level; - } + MccObservedStateStore.SetRainLevel(level); } public static void SetThunderLevel(float level) { - lock (stateLock) - { - thunderLevel = level; - } + MccObservedStateStore.SetThunderLevel(level); } public static MccMcpRuntimeStateSnapshot GetSnapshot() { - lock (stateLock) + MccRuntimeStateSnapshot snapshot = MccObservedStateStore.GetRuntimeStateSnapshot(); + return new MccMcpRuntimeStateSnapshot { - return new MccMcpRuntimeStateSnapshot - { - WorldAge = worldAge, - TimeOfDay = timeOfDay, - RainLevel = rainLevel, - ThunderLevel = thunderLevel - }; - } + WorldAge = snapshot.WorldAge, + TimeOfDay = snapshot.TimeOfDay, + RainLevel = snapshot.RainLevel, + ThunderLevel = snapshot.ThunderLevel + }; } public static void Clear() { - lock (stateLock) - { - worldAge = null; - timeOfDay = null; - rainLevel = null; - thunderLevel = null; - } + MccObservedStateStore.ClearRuntimeState(); } } diff --git a/MinecraftClient/Scripting/CSharpRunner.cs b/MinecraftClient/Scripting/CSharpRunner.cs index 90cb3fcf..eebd61ec 100644 --- a/MinecraftClient/Scripting/CSharpRunner.cs +++ b/MinecraftClient/Scripting/CSharpRunner.cs @@ -215,6 +215,11 @@ namespace MinecraftClient.Scripting this.localVars = localVars; } + /// + /// Access the shared MCC gameplay API used by bots and the embedded MCP server. + /// + new public MccGameApi Game => base.Game; + /* == Wrappers for ChatBot API with public visibility and call limit to one per tick for safety == */ /// diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index f62e1377..4946277a 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -48,6 +48,7 @@ namespace MinecraftClient.Scripting private readonly List registeredChatBotCommands = new(); private readonly Lock delayTasksLock = new(); private readonly List delayedTasks = new(); + private MccGameApi? _game; protected McClient Handler { get @@ -60,6 +61,11 @@ namespace MinecraftClient.Scripting } } + /// + /// Shared gameplay and observed-state API used by MCP and available to built-in bots and scripts. + /// + protected MccGameApi Game => _game ??= new MccGameApi(() => Handler); + /// /// Will be called every client tick (~50ms at 20 TPS). /// diff --git a/MinecraftClient/Scripting/MccGameApi.cs b/MinecraftClient/Scripting/MccGameApi.cs new file mode 100644 index 00000000..b708f842 --- /dev/null +++ b/MinecraftClient/Scripting/MccGameApi.cs @@ -0,0 +1,1310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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. + /// + 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); + } + + /// + /// 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 + }) + .ToArray(); + + return MccGameResult.Ok(new MccInventorySnapshotResult + { + Id = inventory.ID, + Type = inventory.Type.ToString(), + Title = inventory.Title, + 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, + 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 + }; + }); + }) + .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, + 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. + /// + 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); + } + + 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; + } +} diff --git a/MinecraftClient/Scripting/MccGameCommon.cs b/MinecraftClient/Scripting/MccGameCommon.cs new file mode 100644 index 00000000..0088dc80 --- /dev/null +++ b/MinecraftClient/Scripting/MccGameCommon.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MinecraftClient.Inventory; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Scripting; + +/// +/// Represents a world coordinate rounded for tool and script consumption. +/// +public sealed class MccCoordinate +{ + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } +} + +/// +/// Represents a block state snapshot. +/// +public sealed class MccBlockStateSnapshot +{ + public required string Material { get; init; } + public required string TypeLabel { get; init; } + public required int BlockId { get; init; } + public required int BlockMeta { get; init; } +} + +/// +/// Represents a simple item stack snapshot. +/// +public sealed class MccItemStackSnapshot +{ + public required string Type { get; init; } + public required int Count { get; init; } +} + +/// +/// Shared normalization and formatting helpers for gameplay operations. +/// +public static class MccGameCommon +{ + private const int CoordinateRoundingPrecision = 2; + + public 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()) + { + if (candidate is ItemType.Unknown or ItemType.Null) + continue; + + if (NormalizeToken(candidate.ToString()) == normalized) + { + itemType = candidate; + return true; + } + } + + itemType = ItemType.Unknown; + return false; + } + + public 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); + } + + public static bool TextEqualsFilter(string text, string filter) + { + return text.Equals(filter, StringComparison.OrdinalIgnoreCase) + || NormalizeToken(text) == NormalizeToken(filter); + } + + public 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); + } + + public 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); + } + + public static double RoundCoordinate(double value) + { + return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero); + } + + public static MccCoordinate ToCoordinate(Location location) + { + return ToCoordinate(location.X, location.Y, location.Z); + } + + public static MccCoordinate ToCoordinate(double x, double y, double z) + { + return new MccCoordinate + { + X = RoundCoordinate(x), + Y = RoundCoordinate(y), + Z = RoundCoordinate(z) + }; + } + + public static Location ToBlockLocation(double x, double y, double z) + { + return new Location(Math.Floor(x), Math.Floor(y), Math.Floor(z)); + } + + public static MccBlockStateSnapshot ToBlockState(Block block) + { + return new MccBlockStateSnapshot + { + Material = block.Type.ToString(), + TypeLabel = block.GetTypeString(), + BlockId = block.BlockId, + BlockMeta = block.BlockMeta + }; + } + + public 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 => ToItemStack(item), + byte[] data => new { bytes = data.Length }, + _ => value.ToString() + }; + } + + public static MccItemStackSnapshot ToItemStack(Item item) + { + return new MccItemStackSnapshot + { + Type = item.Type.ToString(), + Count = item.Count + }; + } + + public static bool TryParseBlockQuery(string? query, out int? blockId, out int? blockMeta) + { + blockId = null; + blockMeta = null; + if (string.IsNullOrWhiteSpace(query)) + return false; + + 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 true; + } + + return false; + } + + if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out int blockStateId)) + { + blockId = blockStateId; + return true; + } + + return false; + } +} diff --git a/MinecraftClient/Scripting/MccGameModels.cs b/MinecraftClient/Scripting/MccGameModels.cs new file mode 100644 index 00000000..0c6b9c52 --- /dev/null +++ b/MinecraftClient/Scripting/MccGameModels.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Scripting; + +public sealed class MccRecentEventsResult +{ + public required long AfterId { get; init; } + public required long LatestId { get; init; } + public required int Count { get; init; } + public required MccRecentEventEntry[] Events { get; init; } +} + +public sealed class MccChatHistoryResult +{ + public required int Count { get; init; } + public required MccChatHistoryEntry[] Entries { get; init; } +} + +public sealed class MccPathPreviewResult +{ + public required bool PathFound { get; init; } + public required bool ExactReachable { get; init; } + public required MccCoordinate Target { get; init; } + public required MccCoordinate StartLocation { get; init; } + public MccCoordinate? FinalWaypoint { get; init; } + public double? FinalDistance { get; init; } + public required int WaypointCount { get; init; } + public required bool Truncated { get; init; } + public required MccCoordinate[] Waypoints { get; init; } + public required bool AllowUnsafe { get; init; } + public required int MaxOffset { get; init; } + public required int MinOffset { get; init; } + public required int TimeoutMs { get; init; } +} + +public sealed class MccReachabilityResult +{ + public required bool Reachable { get; init; } + public required bool ExactReachable { get; init; } + public required MccCoordinate Target { get; init; } + public required MccCoordinate StartLocation { get; init; } + public MccCoordinate? FinalWaypoint { get; init; } + public double? FinalDistance { get; init; } + public required int WaypointCount { get; init; } + public required bool AllowUnsafe { get; init; } + public required int MaxOffset { get; init; } + public required int MinOffset { get; init; } + public required int TimeoutMs { get; init; } +} + +public sealed class MccPlayersDetailedEntry +{ + public string? Name { get; init; } + public required Guid Uuid { get; init; } + public required int Ping { get; init; } + public required int Gamemode { get; init; } + public required bool Listed { get; init; } + public string? DisplayName { get; init; } + public int? EntityId { get; init; } + public double? X { get; init; } + public double? Y { get; init; } + public double? Z { get; init; } +} + +public sealed class MccPlayersDetailedResult +{ + public required bool IncludeSelf { get; init; } + public required bool IncludeCoordinates { get; init; } + public required int Count { get; init; } + public required MccPlayersDetailedEntry[] Players { get; init; } +} + +public sealed class MccNearbyPlayerEntry +{ + public required int EntityId { get; init; } + public required Guid Uuid { get; init; } + public string? Name { get; init; } + 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; } +} + +public sealed class MccPlayerNearbyResult +{ + public required double Radius { get; init; } + public string? PlayerName { get; init; } + public required bool IncludeSelf { get; init; } + public required bool AnyNearby { get; init; } + public required int Count { get; init; } + public required MccNearbyPlayerEntry[] Players { get; init; } +} + +public sealed class MccLocatedPlayerResult +{ + public required string PlayerName { get; init; } + public string? MatchedName { get; init; } + public required int EntityId { get; init; } + public required Guid Uuid { 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 sealed class MccEntitySummary +{ + public required int Id { get; init; } + public required string Type { get; init; } + public required string TypeLabel { get; init; } + public required Guid Uuid { get; init; } + public string? Name { get; init; } + public string? CustomName { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } + public double? Distance { get; init; } + public float? Health { get; init; } + public string? Pose { get; init; } + public int? Latency { get; init; } +} + +public sealed class MccQueryEntitiesResult +{ + public required int Count { get; init; } + public required MccEntitySummary[] Entities { get; init; } +} + +public sealed class MccListEntitiesResult +{ + public required int TotalTracked { get; init; } + public required int Count { get; init; } + public required MccEntitySummary[] Entities { get; init; } +} + +public sealed class MccEntityEquipmentEntry +{ + public required int Slot { get; init; } + public required string Type { get; init; } + public required int Count { get; init; } +} + +public sealed class MccEffectSnapshot +{ + public required string Id { get; init; } + public string? Name { get; init; } + public required int Amplifier { get; init; } + public required int RemainingSeconds { get; init; } + public required bool IsInfinite { get; init; } +} + +public sealed class MccEntityInfoResult +{ + public required int Id { get; init; } + public required string Type { get; init; } + public required string TypeLabel { get; init; } + public required Guid Uuid { get; init; } + public string? Name { get; init; } + public string? CustomName { get; init; } + public required bool CustomNameVisible { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } + public required float Yaw { get; init; } + public required float Pitch { get; init; } + public required float Health { get; init; } + public string? Pose { get; init; } + public int? Latency { get; init; } + public int? ObjectData { get; init; } + public Dictionary? Metadata { get; init; } + public MccEntityEquipmentEntry[]? Equipment { get; init; } + public MccEffectSnapshot[]? ActiveEffects { get; init; } +} + +public sealed class MccMoveToPlayerTarget +{ + public string? PlayerName { get; init; } + public required int EntityId { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } +} + +public sealed class MccMoveToPlayerResult +{ + public required bool PathFound { get; init; } + public required bool Arrived { get; init; } + public required double Tolerance { get; init; } + public required int VerifyWaitMs { get; init; } + public required MccMoveToPlayerTarget Target { get; init; } + public required MccCoordinate StartLocation { get; init; } + public required MccCoordinate FinalLocation { get; init; } + public required double FinalDistance { get; init; } + public required double DistanceMoved { get; init; } + public required bool AllowUnsafe { get; init; } + public required bool AllowDirectTeleport { get; init; } + public required int MaxOffset { get; init; } + public required int MinOffset { get; init; } + public required int TimeoutMs { get; init; } +} + +public sealed class MccHotbarSelectionResult +{ + public required bool Success { get; init; } + public required string ItemType { get; init; } + public required int InventorySlot { get; init; } + public required int SelectedSlot { get; init; } + public required int Count { get; init; } +} + +public sealed class MccInventorySnapshotSlot +{ + public required int Slot { get; init; } + public required string Type { get; init; } + public required int Count { get; init; } +} + +public sealed class MccInventorySnapshotResult +{ + public required int Id { get; init; } + public required string Type { get; init; } + public required string Title { get; init; } + public required int SlotCount { get; init; } + public required MccInventorySnapshotSlot[] Slots { get; init; } + public MccItemStackSnapshot? Cursor { get; init; } +} + +public sealed class MccInventorySearchMatch +{ + public required int InventoryId { get; init; } + public required string InventoryType { get; init; } + public required string InventoryTitle { get; init; } + public required int Slot { get; init; } + public required string ItemType { get; init; } + public required string TypeLabel { get; init; } + public required int Count { get; init; } + public required bool IsPlayerInventory { get; init; } + public int? HotbarSlot { get; init; } +} + +public sealed class MccInventorySearchResult +{ + public required string Query { get; init; } + public required bool ExactMatch { get; init; } + public required bool IncludeContainers { get; init; } + public required int Count { get; init; } + public required MccInventorySearchMatch[] Matches { get; init; } +} + +public sealed class MccInventoryListEntry +{ + public required int Id { get; init; } + public required string Type { get; init; } + public required string Title { get; init; } + public required int SlotCount { get; init; } + public required int NonEmptySlots { get; init; } + public required bool Active { get; init; } +} + +public sealed class MccInventoryListResult +{ + public required int Count { get; init; } + public required MccInventoryListEntry[] Inventories { get; init; } +} + +public sealed class MccItemEntityEntry +{ + public required int EntityId { get; init; } + public required string 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 sealed class MccItemEntitiesResult +{ + public string? ItemType { get; init; } + public required double Radius { get; init; } + public required int Count { get; init; } + public required MccItemEntityEntry[] Items { get; init; } +} + +public sealed class MccPickupAttempt +{ + public required int EntityId { get; init; } + public required string ItemType { get; init; } + public required string TypeLabel { get; init; } + public required int ExpectedCount { get; init; } + public required MccCoordinate Target { get; init; } + public required bool PathFound { get; init; } + public required bool Arrived { get; init; } + public required bool EntityGone { get; init; } + public required int InventoryDelta { get; init; } + public required MccCoordinate StartLocation { get; init; } + public required MccCoordinate FinalLocation { get; init; } + public required double FinalDistance { get; init; } +} + +public sealed class MccPickupItemsResult +{ + public required string ItemType { get; init; } + public required double Radius { get; init; } + public required int MaxItems { get; init; } + public required bool AllowUnsafe { get; init; } + public required int TimeoutMs { get; init; } + public required int Attempted { get; init; } + public required int SuccessfulPickups { get; init; } + public required int CollectedCount { get; init; } + public int? InitialInventoryCount { get; init; } + public int? FinalInventoryCount { get; init; } + public required int RemainingNearby { get; init; } + public required MccPickupAttempt[] Attempts { get; init; } +} diff --git a/MinecraftClient/Scripting/MccGameResult.cs b/MinecraftClient/Scripting/MccGameResult.cs new file mode 100644 index 00000000..5639dfc0 --- /dev/null +++ b/MinecraftClient/Scripting/MccGameResult.cs @@ -0,0 +1,63 @@ +using System; + +namespace MinecraftClient.Scripting; + +/// +/// Represents the outcome of a shared MCC game operation. +/// +public class MccGameResult +{ + public bool Success { get; init; } + public string? ErrorCode { get; init; } + public string? Message { get; init; } + + public static MccGameResult Ok(string? message = null) + { + return new MccGameResult + { + Success = true, + Message = message + }; + } + + public static MccGameResult Fail(string errorCode, string? message = null) + { + ArgumentException.ThrowIfNullOrEmpty(errorCode); + return new MccGameResult + { + Success = false, + ErrorCode = errorCode, + Message = message + }; + } +} + +/// +/// Represents the outcome of a shared MCC game operation with typed payload data. +/// +public sealed class MccGameResult : MccGameResult +{ + public T? Data { get; init; } + + public static MccGameResult Ok(T? data, string? message = null) + { + return new MccGameResult + { + Success = true, + Data = data, + Message = message + }; + } + + public static MccGameResult Fail(string errorCode, string? message = null, T? data = default) + { + ArgumentException.ThrowIfNullOrEmpty(errorCode); + return new MccGameResult + { + Success = false, + ErrorCode = errorCode, + Message = message, + Data = data + }; + } +} diff --git a/MinecraftClient/Scripting/MccObservedState.cs b/MinecraftClient/Scripting/MccObservedState.cs new file mode 100644 index 00000000..5386e3a3 --- /dev/null +++ b/MinecraftClient/Scripting/MccObservedState.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace MinecraftClient.Scripting; + +/// +/// Represents a recent high-signal runtime event observed by MCC. +/// +public sealed class MccRecentEventEntry +{ + public required long Id { get; init; } + public required DateTimeOffset TimestampUtc { get; init; } + public required string Type { get; init; } + public object? Data { get; init; } +} + +/// +/// Represents a recent chat or system line observed by MCC. +/// +public sealed class MccChatHistoryEntry +{ + public required DateTimeOffset TimestampUtc { get; init; } + public required string Kind { get; init; } + public required string Text { get; init; } + public string? Sender { get; init; } + public string? Message { get; init; } + public string? Json { get; init; } +} + +/// +/// Represents the latest observed world time and weather values. +/// +public sealed class MccRuntimeStateSnapshot +{ + public long? WorldAge { get; init; } + public long? TimeOfDay { get; init; } + public float? RainLevel { get; init; } + public float? ThunderLevel { get; init; } +} + +/// +/// Shared observed-state store populated from ChatBot callbacks and consumed by MCP and bots/scripts. +/// +public static class MccObservedStateStore +{ + private const int MaxEntries = 500; + + private static readonly Lock s_recentEventsLock = new(); + private static readonly Lock s_chatHistoryLock = new(); + private static readonly Lock s_runtimeStateLock = new(); + + private static readonly List s_recentEvents = []; + private static readonly List s_chatHistory = []; + + private static long s_nextRecentEventId = 1; + private static long? s_worldAge; + private static long? s_timeOfDay; + private static float? s_rainLevel; + private static float? s_thunderLevel; + + public static long AddRecentEvent(string type, object? data = null) + { + ArgumentException.ThrowIfNullOrEmpty(type); + + lock (s_recentEventsLock) + { + long id = s_nextRecentEventId++; + s_recentEvents.Add(new MccRecentEventEntry + { + Id = id, + TimestampUtc = DateTimeOffset.UtcNow, + Type = type, + Data = data + }); + + TrimToMaxEntries(s_recentEvents); + return id; + } + } + + public static long GetLatestRecentEventId() + { + lock (s_recentEventsLock) + { + return s_recentEvents.Count > 0 ? s_recentEvents[^1].Id : 0; + } + } + + public static MccRecentEventEntry[] GetRecentEventsAfter(long afterId, int maxCount, string? typeFilter = null) + { + int count = Math.Clamp(maxCount, 1, MaxEntries); + string? normalizedFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim(); + + lock (s_recentEventsLock) + { + return s_recentEvents + .Where(entry => entry.Id > afterId) + .Where(entry => normalizedFilter is null + || entry.Type.Contains(normalizedFilter, StringComparison.OrdinalIgnoreCase)) + .Take(count) + .ToArray(); + } + } + + public static void AddChatHistoryEntry(MccChatHistoryEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + lock (s_chatHistoryLock) + { + s_chatHistory.Add(entry); + TrimToMaxEntries(s_chatHistory); + } + } + + public static MccChatHistoryEntry[] GetLatestChatHistory(int maxCount) + { + int count = Math.Clamp(maxCount, 1, MaxEntries); + lock (s_chatHistoryLock) + { + return s_chatHistory.TakeLast(count).ToArray(); + } + } + + public static void SetTime(long worldAge, long timeOfDay) + { + lock (s_runtimeStateLock) + { + s_worldAge = worldAge; + s_timeOfDay = timeOfDay; + } + } + + public static void SetRainLevel(float level) + { + lock (s_runtimeStateLock) + { + s_rainLevel = level; + } + } + + public static void SetThunderLevel(float level) + { + lock (s_runtimeStateLock) + { + s_thunderLevel = level; + } + } + + public static MccRuntimeStateSnapshot GetRuntimeStateSnapshot() + { + lock (s_runtimeStateLock) + { + return new MccRuntimeStateSnapshot + { + WorldAge = s_worldAge, + TimeOfDay = s_timeOfDay, + RainLevel = s_rainLevel, + ThunderLevel = s_thunderLevel + }; + } + } + + public static void ClearRecentEvents() + { + lock (s_recentEventsLock) + { + s_recentEvents.Clear(); + } + } + + public static void ClearChatHistory() + { + lock (s_chatHistoryLock) + { + s_chatHistory.Clear(); + } + } + + public static void ClearRuntimeState() + { + lock (s_runtimeStateLock) + { + s_worldAge = null; + s_timeOfDay = null; + s_rainLevel = null; + s_thunderLevel = null; + } + } + + public static void ClearAll() + { + ClearChatHistory(); + ClearRuntimeState(); + ClearRecentEvents(); + } + + private static void TrimToMaxEntries(List entries) + { + if (entries.Count > MaxEntries) + entries.RemoveRange(0, entries.Count - MaxEntries); + } +}