From 71322f880406206a645c0caf94010a8652b2fa39 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 11:50:32 -0400 Subject: [PATCH 001/190] Add relics and potions to map state player summary The map screen's player summary only included hp, gold, and potion slot counts. This adds full relic and potion data to BuildMapState so external tools can display them outside of combat. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index c5007f4d..fd13a9ad 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -706,6 +706,40 @@ public static partial class McpMod { int totalSlots = player.PotionSlots.Count; int openSlots = player.PotionSlots.Count(s => s == null); + + var relics = new List>(); + foreach (var relic in player.Relics) + { + relics.Add(new Dictionary + { + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["counter"] = relic.ShowCounter ? relic.DisplayAmount : null, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + + var potions = new List>(); + int slotIndex = 0; + foreach (var potion in player.PotionSlots) + { + if (potion != null) + { + potions.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["slot"] = slotIndex, + ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, + ["target_type"] = potion.TargetType.ToString(), + ["keywords"] = BuildHoverTips(potion.HoverTips) + }); + } + slotIndex++; + } + state["player"] = new Dictionary { ["character"] = SafeGetText(() => player.Character.Title), @@ -713,7 +747,9 @@ public static partial class McpMod ["max_hp"] = player.Creature.MaxHp, ["gold"] = player.Gold, ["potion_slots"] = totalSlots, - ["open_potion_slots"] = openSlots + ["open_potion_slots"] = openSlots, + ["relics"] = relics, + ["potions"] = potions }; } From 3cec6c4c511f5a271c16d2d9b53355280cd8f1d4 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 12:50:21 -0400 Subject: [PATCH 002/190] Include map data in all state responses Map data is now always returned alongside any game state (combat, event, shop, etc.) so external tools can display the map without caching. Also adds a version marker for verifying mod DLL is loaded. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index fd13a9ad..09e3c5e8 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -208,6 +208,22 @@ public static partial class McpMod ["ascension"] = runState.AscensionLevel }; + // Version marker to verify DLL is loaded + result["_mod_version"] = "patched-v2"; + + // Always include map data so external tools can display it regardless of current screen + if (result["state_type"] as string != "map") + { + try + { + result["map"] = BuildMapState(runState); + } + catch (System.Exception e) + { + result["map_error"] = e.Message; + } + } + return result; } From 8d4108888ad699d369ef72be93cc9aea112fd649 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 13:46:01 -0400 Subject: [PATCH 003/190] Include full player data in all state responses Always attach BuildPlayerState output at the top level of every response so external tools have access to HP, gold, relics, potions, deck piles, and powers regardless of which screen is active. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 09e3c5e8..84c7f701 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -208,8 +208,12 @@ public static partial class McpMod ["ascension"] = runState.AscensionLevel }; - // Version marker to verify DLL is loaded - result["_mod_version"] = "patched-v2"; + // Always include full player data so external tools have it on every screen + var _player = LocalContext.GetMe(runState); + if (_player != null) + { + result["player"] = BuildPlayerState(_player); + } // Always include map data so external tools can display it regardless of current screen if (result["state_type"] as string != "map") From bd6cf9a46657c4294a308b6bf744d92fd26a97d4 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:01:14 -0400 Subject: [PATCH 004/190] Add master deck to player state Exposes player.Deck.Cards in BuildPlayerState so the full deck composition is available on every screen, not just during combat. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 84c7f701..1dc9be20 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -384,6 +384,29 @@ public static partial class McpMod } state["potions"] = potions; + // Master deck (full card collection, always available) + var deck = new List>(); + foreach (var card in player.Deck.Cards) + { + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); + + deck.Add(new Dictionary + { + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["description"] = SafeGetCardDescription(card), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded + }); + } + state["deck"] = deck; + return state; } From b46b9ccfd337eb52f2b1f28e7131112e27c8c349 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:26:49 -0400 Subject: [PATCH 005/190] Add try-catch around top-level BuildPlayerState to prevent crashes Some screens (e.g. treasure) can cause null reference exceptions when BuildPlayerState accesses combat-only properties. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 1dc9be20..bcfcdfaa 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -212,7 +212,14 @@ public static partial class McpMod var _player = LocalContext.GetMe(runState); if (_player != null) { - result["player"] = BuildPlayerState(_player); + try + { + result["player"] = BuildPlayerState(_player); + } + catch (System.Exception e) + { + result["player_error"] = e.Message; + } } // Always include map data so external tools can display it regardless of current screen From 7c1b08b36d76e114919b20aa2af2b3d1337a01ac Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:40:49 -0400 Subject: [PATCH 006/190] Derive next map options from graph data when map screen is closed When NMapScreen is not open (during combat, events, etc.), compute next_options from the current map point's children so external tools always have the available path choices. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index bcfcdfaa..575e3dbe 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -829,7 +829,8 @@ public static partial class McpMod } state["visited"] = visited; - // Next options — read travelable state from UI nodes + // Next options — read travelable state from UI nodes when map is open, + // otherwise compute from current position's children in the map graph var nextOptions = new List>(); var mapScreen = NMapScreen.Instance; if (mapScreen != null) @@ -866,6 +867,39 @@ public static partial class McpMod index++; } } + else if (visitedCoords.Count > 0) + { + // Map screen not open — derive next options from current map point's children + var curCoord = visitedCoords[visitedCoords.Count - 1]; + var curPoint = map.GetPoint(curCoord); + if (curPoint != null) + { + int index = 0; + foreach (var child in curPoint.Children.OrderBy(c => c.coord.col)) + { + var option = new Dictionary + { + ["index"] = index, + ["col"] = child.coord.col, + ["row"] = child.coord.row, + ["type"] = child.PointType.ToString() + }; + + var grandchildren = child.Children + .OrderBy(c => c.coord.col) + .Select(c => new Dictionary + { + ["col"] = c.coord.col, ["row"] = c.coord.row, + ["type"] = c.PointType.ToString() + }).ToList(); + if (grandchildren.Count > 0) + option["leads_to"] = grandchildren; + + nextOptions.Add(option); + index++; + } + } + } state["next_options"] = nextOptions; // Full map — all nodes organized for planning From f427a0fef45302914bea2d66277addf5593ef9d5 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:43:06 -0400 Subject: [PATCH 007/190] debug: add _debug_next to diagnose empty next_options --- McpMod.StateBuilder.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 575e3dbe..1d81125f 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -872,6 +872,13 @@ public static partial class McpMod // Map screen not open — derive next options from current map point's children var curCoord = visitedCoords[visitedCoords.Count - 1]; var curPoint = map.GetPoint(curCoord); + state["_debug_next"] = new Dictionary + { + ["cur_col"] = curCoord.col, + ["cur_row"] = curCoord.row, + ["point_found"] = curPoint != null, + ["children_count"] = curPoint?.Children?.Count ?? -1 + }; if (curPoint != null) { int index = 0; From b40986d79b32c2a8ebe5f1f7ab83e32b93b8a977 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:45:09 -0400 Subject: [PATCH 008/190] Revert "debug: add _debug_next to diagnose empty next_options" This reverts commit f427a0fef45302914bea2d66277addf5593ef9d5. --- McpMod.StateBuilder.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 1d81125f..575e3dbe 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -872,13 +872,6 @@ public static partial class McpMod // Map screen not open — derive next options from current map point's children var curCoord = visitedCoords[visitedCoords.Count - 1]; var curPoint = map.GetPoint(curCoord); - state["_debug_next"] = new Dictionary - { - ["cur_col"] = curCoord.col, - ["cur_row"] = curCoord.row, - ["point_found"] = curPoint != null, - ["children_count"] = curPoint?.Children?.Count ?? -1 - }; if (curPoint != null) { int index = 0; From 3c7f8bf1b117c2523b97e1e971773a95ca28f961 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 14:46:55 -0400 Subject: [PATCH 009/190] Revert "Derive next map options from graph data when map screen is closed" This reverts commit 7c1b08b36d76e114919b20aa2af2b3d1337a01ac. --- McpMod.StateBuilder.cs | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 575e3dbe..bcfcdfaa 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -829,8 +829,7 @@ public static partial class McpMod } state["visited"] = visited; - // Next options — read travelable state from UI nodes when map is open, - // otherwise compute from current position's children in the map graph + // Next options — read travelable state from UI nodes var nextOptions = new List>(); var mapScreen = NMapScreen.Instance; if (mapScreen != null) @@ -867,39 +866,6 @@ public static partial class McpMod index++; } } - else if (visitedCoords.Count > 0) - { - // Map screen not open — derive next options from current map point's children - var curCoord = visitedCoords[visitedCoords.Count - 1]; - var curPoint = map.GetPoint(curCoord); - if (curPoint != null) - { - int index = 0; - foreach (var child in curPoint.Children.OrderBy(c => c.coord.col)) - { - var option = new Dictionary - { - ["index"] = index, - ["col"] = child.coord.col, - ["row"] = child.coord.row, - ["type"] = child.PointType.ToString() - }; - - var grandchildren = child.Children - .OrderBy(c => c.coord.col) - .Select(c => new Dictionary - { - ["col"] = c.coord.col, ["row"] = c.coord.row, - ["type"] = c.PointType.ToString() - }).ToList(); - if (grandchildren.Count > 0) - option["leads_to"] = grandchildren; - - nextOptions.Add(option); - index++; - } - } - } state["next_options"] = nextOptions; // Full map — all nodes organized for planning From 28fc52357254e0bfa860973c8ecf69d29c467c8e Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 18:33:14 -0400 Subject: [PATCH 010/190] Add glossary API endpoints for cards, relics, potions, keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/glossary — returns all categories GET /api/v1/glossary/cards — all cards with pool grouping GET /api/v1/glossary/relics — all relics with pool grouping GET /api/v1/glossary/potions — all potions with pool grouping GET /api/v1/glossary/keywords — unique keywords from all items Uses current character's pools during a run, plus colorless/curse. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 156 +++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 25 +++++++ 2 files changed, 181 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index bcfcdfaa..ef180008 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -31,6 +31,7 @@ using MegaCrit.Sts2.Core.Rewards; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Models.CardPools; namespace STS2_MCP; @@ -1403,4 +1404,159 @@ public static partial class McpMod } return powers; } + + private static object BuildGlossary(string subPath) + { + // Get pools from the current player's character, plus shared/colorless pools + var cardPools = new List(); + var relicPools = new List(); + var potionPools = new List(); + + // If a run is active, get the current character's pools + if (RunManager.Instance.IsInProgress) + { + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState != null) + { + var player = LocalContext.GetMe(runState); + if (player != null) + { + cardPools.Add(player.Character.CardPool); + relicPools.Add(player.Character.RelicPool); + potionPools.Add(player.Character.PotionPool); + } + } + } + + // Always add shared/colorless pools + try { cardPools.Add(new ColorlessCardPool()); } catch { } + try { cardPools.Add(new CurseCardPool()); } catch { } + + if (subPath == "cards") + return BuildGlossaryCards(cardPools); + if (subPath == "relics") + return BuildGlossaryRelics(relicPools); + if (subPath == "potions") + return BuildGlossaryPotions(potionPools); + if (subPath == "keywords") + return BuildGlossaryKeywords(cardPools, relicPools, potionPools); + + // Return all categories + return new Dictionary + { + ["cards"] = BuildGlossaryCards(cardPools), + ["relics"] = BuildGlossaryRelics(relicPools), + ["potions"] = BuildGlossaryPotions(potionPools), + ["keywords"] = BuildGlossaryKeywords(cardPools, relicPools, potionPools) + }; + } + + private static List> BuildGlossaryCards(List pools) + { + var result = new List>(); + foreach (var pool in pools) + { + foreach (var card in pool.AllCards) + { + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); + + result.Add(new Dictionary + { + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["description"] = SafeGetCardDescription(card), + ["rarity"] = card.Rarity.ToString(), + ["pool"] = SafeGetText(() => pool.Title), + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + } + } + return result; + } + + private static List> BuildGlossaryRelics(List pools) + { + var result = new List>(); + foreach (var pool in pools) + { + foreach (var relic in pool.AllRelics) + { + result.Add(new Dictionary + { + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), + ["pool"] = pool.Id.Category, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + } + return result; + } + + private static List> BuildGlossaryPotions(List pools) + { + var result = new List>(); + foreach (var pool in pools) + { + foreach (var potion in pool.AllPotions) + { + result.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["rarity"] = potion.Rarity.ToString(), + ["pool"] = pool.Id.Category, + ["target_type"] = potion.TargetType.ToString(), + ["usage"] = potion.Usage.ToString(), + ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) + }); + } + } + return result; + } + + private static List> BuildGlossaryKeywords( + List cardPools, List relicPools, List potionPools) + { + // Collect unique keywords from all items + var keywords = new Dictionary(); + + foreach (var pool in cardPools) + foreach (var card in pool.AllCards) + foreach (var tip in card.HoverTips) + if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) + keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; + + foreach (var pool in relicPools) + foreach (var relic in pool.AllRelics) + foreach (var tip in relic.HoverTips) + if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) + keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; + + foreach (var pool in potionPools) + foreach (var potion in pool.AllPotions) + foreach (var tip in potion.HoverTips) + if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) + keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; + + var result = new List>(); + foreach (var kv in keywords.OrderBy(k => k.Key)) + { + result.Add(new Dictionary + { + ["name"] = kv.Key, + ["description"] = kv.Value + }); + } + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index 7cc0736b..701f19d8 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -165,6 +165,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path.StartsWith("/api/v1/glossary")) + { + if (request.HttpMethod == "GET") + HandleGetGlossary(path, request, response); + else + SendError(response, 405, "Method not allowed"); + } else { SendError(response, 404, "Not found"); @@ -280,6 +287,24 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } + private static void HandleGetGlossary(string path, HttpListenerRequest request, HttpListenerResponse response) + { + try + { + string subPath = path.Length > "/api/v1/glossary".Length + ? path.Substring("/api/v1/glossary".Length).TrimStart('/') + : ""; + + var dataTask = RunOnMainThread(() => BuildGlossary(subPath)); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + } + } + private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) { string body; From ac30c2a9c2afa6043161691735d2f4adb8728b88 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 18:35:42 -0400 Subject: [PATCH 011/190] Fix glossary: use runState.Players instead of direct pool construction --- McpMod.StateBuilder.cs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index ef180008..c9fb122a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1412,25 +1412,28 @@ private static object BuildGlossary(string subPath) var relicPools = new List(); var potionPools = new List(); - // If a run is active, get the current character's pools - if (RunManager.Instance.IsInProgress) + // Requires an active run to access character pools + if (!RunManager.Instance.IsInProgress) { - var runState = RunManager.Instance.DebugOnlyGetState(); - if (runState != null) - { - var player = LocalContext.GetMe(runState); - if (player != null) - { - cardPools.Add(player.Character.CardPool); - relicPools.Add(player.Character.RelicPool); - potionPools.Add(player.Character.PotionPool); - } - } + return new Dictionary { ["error"] = "No run in progress. Start a run to access the glossary." }; + } + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + { + return new Dictionary { ["error"] = "Could not read run state." }; } - // Always add shared/colorless pools - try { cardPools.Add(new ColorlessCardPool()); } catch { } - try { cardPools.Add(new CurseCardPool()); } catch { } + // Get pools from all players in the run + foreach (var player in runState.Players) + { + if (player.Character?.CardPool != null) + cardPools.Add(player.Character.CardPool); + if (player.Character?.RelicPool != null) + relicPools.Add(player.Character.RelicPool); + if (player.Character?.PotionPool != null) + potionPools.Add(player.Character.PotionPool); + } if (subPath == "cards") return BuildGlossaryCards(cardPools); From 958960638953eba0120912bf151567e71b1d4557 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 18:37:07 -0400 Subject: [PATCH 012/190] Revert "Fix glossary: use runState.Players instead of direct pool construction" This reverts commit ac30c2a9c2afa6043161691735d2f4adb8728b88. --- McpMod.StateBuilder.cs | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index c9fb122a..ef180008 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1412,28 +1412,25 @@ private static object BuildGlossary(string subPath) var relicPools = new List(); var potionPools = new List(); - // Requires an active run to access character pools - if (!RunManager.Instance.IsInProgress) - { - return new Dictionary { ["error"] = "No run in progress. Start a run to access the glossary." }; - } - - var runState = RunManager.Instance.DebugOnlyGetState(); - if (runState == null) + // If a run is active, get the current character's pools + if (RunManager.Instance.IsInProgress) { - return new Dictionary { ["error"] = "Could not read run state." }; + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState != null) + { + var player = LocalContext.GetMe(runState); + if (player != null) + { + cardPools.Add(player.Character.CardPool); + relicPools.Add(player.Character.RelicPool); + potionPools.Add(player.Character.PotionPool); + } + } } - // Get pools from all players in the run - foreach (var player in runState.Players) - { - if (player.Character?.CardPool != null) - cardPools.Add(player.Character.CardPool); - if (player.Character?.RelicPool != null) - relicPools.Add(player.Character.RelicPool); - if (player.Character?.PotionPool != null) - potionPools.Add(player.Character.PotionPool); - } + // Always add shared/colorless pools + try { cardPools.Add(new ColorlessCardPool()); } catch { } + try { cardPools.Add(new CurseCardPool()); } catch { } if (subPath == "cards") return BuildGlossaryCards(cardPools); From 50465122bc080d3cc50138064bec24fe5deae319 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Thu, 26 Mar 2026 18:37:22 -0400 Subject: [PATCH 013/190] =?UTF-8?q?Revert=20glossary=20endpoints=20?= =?UTF-8?q?=E2=80=94=20caused=20mod=20crash=20on=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- McpMod.StateBuilder.cs | 156 ----------------------------------------- McpMod.cs | 25 ------- 2 files changed, 181 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index ef180008..bcfcdfaa 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -31,7 +31,6 @@ using MegaCrit.Sts2.Core.Rewards; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; -using MegaCrit.Sts2.Core.Models.CardPools; namespace STS2_MCP; @@ -1404,159 +1403,4 @@ public static partial class McpMod } return powers; } - - private static object BuildGlossary(string subPath) - { - // Get pools from the current player's character, plus shared/colorless pools - var cardPools = new List(); - var relicPools = new List(); - var potionPools = new List(); - - // If a run is active, get the current character's pools - if (RunManager.Instance.IsInProgress) - { - var runState = RunManager.Instance.DebugOnlyGetState(); - if (runState != null) - { - var player = LocalContext.GetMe(runState); - if (player != null) - { - cardPools.Add(player.Character.CardPool); - relicPools.Add(player.Character.RelicPool); - potionPools.Add(player.Character.PotionPool); - } - } - } - - // Always add shared/colorless pools - try { cardPools.Add(new ColorlessCardPool()); } catch { } - try { cardPools.Add(new CurseCardPool()); } catch { } - - if (subPath == "cards") - return BuildGlossaryCards(cardPools); - if (subPath == "relics") - return BuildGlossaryRelics(relicPools); - if (subPath == "potions") - return BuildGlossaryPotions(potionPools); - if (subPath == "keywords") - return BuildGlossaryKeywords(cardPools, relicPools, potionPools); - - // Return all categories - return new Dictionary - { - ["cards"] = BuildGlossaryCards(cardPools), - ["relics"] = BuildGlossaryRelics(relicPools), - ["potions"] = BuildGlossaryPotions(potionPools), - ["keywords"] = BuildGlossaryKeywords(cardPools, relicPools, potionPools) - }; - } - - private static List> BuildGlossaryCards(List pools) - { - var result = new List>(); - foreach (var pool in pools) - { - foreach (var card in pool.AllCards) - { - string costDisplay; - if (card.EnergyCost.CostsX) - costDisplay = "X"; - else - costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); - - result.Add(new Dictionary - { - ["id"] = card.Id.Entry, - ["name"] = SafeGetText(() => card.Title), - ["type"] = card.Type.ToString(), - ["cost"] = costDisplay, - ["description"] = SafeGetCardDescription(card), - ["rarity"] = card.Rarity.ToString(), - ["pool"] = SafeGetText(() => pool.Title), - ["keywords"] = BuildHoverTips(card.HoverTips) - }); - } - } - return result; - } - - private static List> BuildGlossaryRelics(List pools) - { - var result = new List>(); - foreach (var pool in pools) - { - foreach (var relic in pool.AllRelics) - { - result.Add(new Dictionary - { - ["id"] = relic.Id.Entry, - ["name"] = SafeGetText(() => relic.Title), - ["description"] = SafeGetText(() => relic.DynamicDescription), - ["rarity"] = relic.Rarity.ToString(), - ["pool"] = pool.Id.Category, - ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) - }); - } - } - return result; - } - - private static List> BuildGlossaryPotions(List pools) - { - var result = new List>(); - foreach (var pool in pools) - { - foreach (var potion in pool.AllPotions) - { - result.Add(new Dictionary - { - ["id"] = potion.Id.Entry, - ["name"] = SafeGetText(() => potion.Title), - ["description"] = SafeGetText(() => potion.DynamicDescription), - ["rarity"] = potion.Rarity.ToString(), - ["pool"] = pool.Id.Category, - ["target_type"] = potion.TargetType.ToString(), - ["usage"] = potion.Usage.ToString(), - ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) - }); - } - } - return result; - } - - private static List> BuildGlossaryKeywords( - List cardPools, List relicPools, List potionPools) - { - // Collect unique keywords from all items - var keywords = new Dictionary(); - - foreach (var pool in cardPools) - foreach (var card in pool.AllCards) - foreach (var tip in card.HoverTips) - if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) - keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; - - foreach (var pool in relicPools) - foreach (var relic in pool.AllRelics) - foreach (var tip in relic.HoverTips) - if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) - keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; - - foreach (var pool in potionPools) - foreach (var potion in pool.AllPotions) - foreach (var tip in potion.HoverTips) - if (tip is HoverTip ht && !string.IsNullOrEmpty(ht.Title)) - keywords[SafeGetText(() => ht.Title) ?? ""] = SafeGetText(() => ht.Description) ?? ""; - - var result = new List>(); - foreach (var kv in keywords.OrderBy(k => k.Key)) - { - result.Add(new Dictionary - { - ["name"] = kv.Key, - ["description"] = kv.Value - }); - } - return result; - } } diff --git a/McpMod.cs b/McpMod.cs index 701f19d8..7cc0736b 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -165,13 +165,6 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } - else if (path.StartsWith("/api/v1/glossary")) - { - if (request.HttpMethod == "GET") - HandleGetGlossary(path, request, response); - else - SendError(response, 405, "Method not allowed"); - } else { SendError(response, 404, "Not found"); @@ -287,24 +280,6 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } - private static void HandleGetGlossary(string path, HttpListenerRequest request, HttpListenerResponse response) - { - try - { - string subPath = path.Length > "/api/v1/glossary".Length - ? path.Substring("/api/v1/glossary".Length).TrimStart('/') - : ""; - - var dataTask = RunOnMainThread(() => BuildGlossary(subPath)); - var data = dataTask.GetAwaiter().GetResult(); - SendJson(response, data); - } - catch (System.Exception ex) - { - SendError(response, 500, $"Failed to build glossary: {ex.Message}"); - } - } - private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) { string body; From 01b4131ad29ef44843b9c25674c7266502c2d9c4 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 27 Mar 2026 00:59:21 -0400 Subject: [PATCH 014/190] Bind HTTP server to all interfaces when URL ACL is available Tries http://+:15526/ first for network access, falls back to localhost-only if the URL ACL is not configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 7cc0736b..f4ba6892 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -40,9 +40,20 @@ public static void Initialize() tree.Connect(SceneTree.SignalName.ProcessFrame, Callable.From(ProcessMainThreadQueue)); _listener = new HttpListener(); - _listener.Prefixes.Add("http://localhost:15526/"); - _listener.Prefixes.Add("http://127.0.0.1:15526/"); - _listener.Start(); + try + { + // Try binding to all interfaces first (requires URL ACL: netsh http add urlacl url=http://+:15526/ user=Everyone) + _listener.Prefixes.Add("http://+:15526/"); + _listener.Start(); + } + catch + { + // Fall back to localhost-only if ACL not configured + _listener = new HttpListener(); + _listener.Prefixes.Add("http://localhost:15526/"); + _listener.Prefixes.Add("http://127.0.0.1:15526/"); + _listener.Start(); + } _serverThread = new Thread(ServerLoop) { From e992cb3afbfc528c44c97c785ab0a3a1519d4ec0 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 03:39:29 -0400 Subject: [PATCH 015/190] Add GET /api/v1/glossary/cards endpoint Returns all cards from the current character's card pool during an active run. Includes id, name, type, cost, description, rarity, pool, and keywords for each card. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 47 ++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 21 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index bcfcdfaa..abf2f73d 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1403,4 +1403,51 @@ public static partial class McpMod } return powers; } + + internal static object BuildGlossaryCards() + { + if (!RunManager.Instance.IsInProgress) + return new Dictionary { ["error"] = "No run in progress." }; + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return new Dictionary { ["error"] = "Could not read run state." }; + + var result = new List>(); + var seen = new HashSet(); + + foreach (var player in runState.Players) + { + var pool = player.Character?.CardPool; + if (pool == null) continue; + var poolName = SafeGetText(() => pool.Title) ?? "Unknown"; + + foreach (var card in pool.AllCards) + { + var id = card.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["description"] = SafeGetCardDescription(card), + ["rarity"] = card.Rarity.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + } + } + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index f4ba6892..8e84e271 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -176,6 +176,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/glossary/cards") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryCards(response); + else + SendError(response, 405, "Method not allowed"); + } else { SendError(response, 404, "Not found"); @@ -291,6 +298,20 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } + private static void HandleGetGlossaryCards(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryCards()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + } + } + private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) { string body; From eaf67f0369bc2b8a8f86a12bcbc33ab4e0662158 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 04:15:20 -0400 Subject: [PATCH 016/190] Add GET /api/v1/glossary/relics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns relics from the current character's relic pool. Shared/event relics not yet accessible — canonical instance lookup returns null at runtime. Will revisit access pattern later. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 74 ++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 21 ++++++++++++ 2 files changed, 95 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index abf2f73d..6b2f20a5 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -31,6 +31,7 @@ using MegaCrit.Sts2.Core.Rewards; using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Models.RelicPools; namespace STS2_MCP; @@ -1450,4 +1451,77 @@ internal static object BuildGlossaryCards() return result; } + + internal static object BuildGlossaryRelics() + { + if (!RunManager.Instance.IsInProgress) + return new Dictionary { ["error"] = "No run in progress." }; + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return new Dictionary { ["error"] = "Could not read run state." }; + + var result = new List>(); + var seen = new HashSet(); + + // Get relics from player's character pool + foreach (var player in runState.Players) + { + var pool = player.Character?.RelicPool; + if (pool == null) continue; + var poolName = SafeGetText(() => player.Character.Title) ?? "Unknown"; + + foreach (var relic in pool.AllRelics) + { + var id = relic.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + } + + // Get shared relics from the grab bag (all relics available in this run) + var grabBag = runState.SharedRelicGrabBag; + if (grabBag != null && grabBag.IsPopulated) + { + // The grab bag doesn't expose a list, but we can get relics from the player's owned list + // Fall back to enumerating all RelicModel subtypes with CanonicalInstance + } + + // Enumerate all concrete RelicModel subtypes for a complete list + foreach (var type in typeof(RelicModel).Assembly.GetTypes()) + { + if (type.IsAbstract || !type.IsSubclassOf(typeof(RelicModel))) continue; + try + { + var instance = (RelicModel)System.Activator.CreateInstance(type)!; + if (instance.CanonicalInstance is not { } canonical) continue; + var id = canonical.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => canonical.Title), + ["description"] = SafeGetText(() => canonical.DynamicDescription), + ["rarity"] = canonical.Rarity.ToString(), + ["pool"] = canonical.Pool?.Id.Category ?? "Shared", + ["keywords"] = BuildHoverTips(canonical.HoverTipsExcludingRelic) + }); + } + catch { } + } + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index 8e84e271..8b0aa1fc 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -183,6 +183,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/glossary/relics") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryRelics(response); + else + SendError(response, 405, "Method not allowed"); + } else { SendError(response, 404, "Not found"); @@ -312,6 +319,20 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) } } + private static void HandleGetGlossaryRelics(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryRelics()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + } + } + private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) { string body; From 9c8cee1fe90cc4c38d643bba12b3008bc71047eb Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 04:17:26 -0400 Subject: [PATCH 017/190] Add GET /api/v1/glossary/potions endpoint Returns potions from the current character's potion pool. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 41 +++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 21 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 6b2f20a5..76d5de60 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1524,4 +1524,45 @@ internal static object BuildGlossaryRelics() return result; } + + internal static object BuildGlossaryPotions() + { + if (!RunManager.Instance.IsInProgress) + return new Dictionary { ["error"] = "No run in progress." }; + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return new Dictionary { ["error"] = "Could not read run state." }; + + var result = new List>(); + var seen = new HashSet(); + + foreach (var player in runState.Players) + { + var pool = player.Character?.PotionPool; + if (pool == null) continue; + var poolName = SafeGetText(() => player.Character.Title) ?? "Unknown"; + + foreach (var potion in pool.AllPotions) + { + var id = potion.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["rarity"] = potion.Rarity.ToString(), + ["target_type"] = potion.TargetType.ToString(), + ["usage"] = potion.Usage.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) + }); + } + } + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index 8b0aa1fc..d753cf4e 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -190,6 +190,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/glossary/potions") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryPotions(response); + else + SendError(response, 405, "Method not allowed"); + } else { SendError(response, 404, "Not found"); @@ -319,6 +326,20 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) } } + private static void HandleGetGlossaryPotions(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryPotions()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + } + } + private static void HandleGetGlossaryRelics(HttpListenerResponse response) { try From 68b5fdfd22a07ea68d3802063ccf6aaefcd48dd9 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 04:19:19 -0400 Subject: [PATCH 018/190] Add GET /api/v1/glossary/keywords endpoint Harvests unique keywords from all cards, relics, and potions in the current character's pools. Returns name and description for each. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 69 ++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 21 +++++++++++++ 2 files changed, 90 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 76d5de60..69c4d477 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1565,4 +1565,73 @@ internal static object BuildGlossaryPotions() return result; } + + internal static object BuildGlossaryKeywords() + { + if (!RunManager.Instance.IsInProgress) + return new Dictionary { ["error"] = "No run in progress." }; + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return new Dictionary { ["error"] = "Could not read run state." }; + + var keywords = new Dictionary(); + + foreach (var player in runState.Players) + { + // From cards + var cardPool = player.Character?.CardPool; + if (cardPool != null) + { + foreach (var card in cardPool.AllCards) + foreach (var tip in card.HoverTips) + if (tip is HoverTip ht) + { + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + } + } + + // From relics + var relicPool = player.Character?.RelicPool; + if (relicPool != null) + { + foreach (var relic in relicPool.AllRelics) + foreach (var tip in relic.HoverTips) + if (tip is HoverTip ht) + { + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + } + } + + // From potions + var potionPool = player.Character?.PotionPool; + if (potionPool != null) + { + foreach (var potion in potionPool.AllPotions) + foreach (var tip in potion.HoverTips) + if (tip is HoverTip ht) + { + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + } + } + } + + var result = new List>(); + foreach (var kv in keywords.OrderBy(k => k.Key)) + { + result.Add(new Dictionary + { + ["name"] = kv.Key, + ["description"] = kv.Value + }); + } + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index d753cf4e..0112f188 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -197,6 +197,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/glossary/keywords") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryKeywords(response); + else + SendError(response, 405, "Method not allowed"); + } else { SendError(response, 404, "Not found"); @@ -326,6 +333,20 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) } } + private static void HandleGetGlossaryKeywords(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryKeywords()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + } + } + private static void HandleGetGlossaryPotions(HttpListenerResponse response) { try From 76a805c4dae3cba2bcce6756fdb1b657a1770021 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 05:19:07 -0400 Subject: [PATCH 019/190] Detect main menu options (singleplayer, multiplayer, etc.) Shows visible menu buttons as numbered options in player stats widget. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 79 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 69c4d477..8b1f580a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -32,6 +32,9 @@ using MegaCrit.Sts2.Core.Rooms; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Models.RelicPools; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using Godot; namespace STS2_MCP; @@ -44,7 +47,81 @@ public static partial class McpMod if (!RunManager.Instance.IsInProgress) { result["state_type"] = "menu"; - result["message"] = "No run in progress. Player is in the main menu."; + + // Detect which menu screen is active + var tree = (Godot.Engine.GetMainLoop()) as SceneTree; + if (tree?.Root != null) + { + // Check for singleplayer submenu (Standard / Daily / Custom) + var spSubmenu = FindFirst(tree.Root); + if (spSubmenu != null && spSubmenu.Visible) + { + result["menu_screen"] = "singleplayer"; + result["message"] = "Singleplayer mode select: Standard, Daily, Custom."; + result["options"] = new List { "standard", "daily", "custom" }; + } + // Check for character select screen + else + { + var charSelect = FindFirst(tree.Root); + if (charSelect != null && charSelect.Visible) + { + result["menu_screen"] = "character_select"; + result["message"] = "Character select screen is active."; + + var buttons = FindAll(charSelect); + var characters = new List>(); + foreach (var btn in buttons) + { + try + { + var charModel = btn.GetType().GetField("_character", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(btn); + if (charModel is CharacterModel cm) + { + characters.Add(new Dictionary + { + ["name"] = SafeGetText(() => cm.Title), + ["id"] = cm.Id.Entry + }); + } + } + catch { } + } + if (characters.Count > 0) + result["characters"] = characters; + } + else + { + result["menu_screen"] = "main"; + result["message"] = "Main menu."; + + var mainMenu = FindFirst(tree.Root); + if (mainMenu != null) + { + var options = new List(); + var fields = new[] { "_continueButton", "_singleplayerButton", "_multiplayerButton", "_compendiumButton", "_timelineButton", "_settingsButton", "_quitButton" }; + var labels = new[] { "continue", "singleplayer", "multiplayer", "compendium", "timeline", "settings", "quit" }; + for (int i = 0; i < fields.Length; i++) + { + try + { + var btn = mainMenu.GetType().GetField(fields[i], System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(mainMenu) as Control; + if (btn != null && btn.Visible) + options.Add(labels[i]); + } + catch { } + } + if (options.Count > 0) + result["options"] = options; + } + } + } + } + else + { + result["message"] = "No run in progress."; + } + return result; } From 0678f392cb40e4beaac861203b73e3e37bf83b65 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 05:56:09 -0400 Subject: [PATCH 020/190] Add menu screen detection and character select details Detects main menu, singleplayer mode select, and character select screens. Shows available options with enabled/locked state. Character select includes HP, gold, energy, starting relics, starting deck, and pool counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 89 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 8b1f580a..56726338 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -57,8 +57,28 @@ public static partial class McpMod if (spSubmenu != null && spSubmenu.Visible) { result["menu_screen"] = "singleplayer"; - result["message"] = "Singleplayer mode select: Standard, Daily, Custom."; - result["options"] = new List { "standard", "daily", "custom" }; + result["message"] = "Select game mode."; + + var modeOptions = new List>(); + var modeFields = new[] { ("_standardButton", "standard"), ("_dailyButton", "daily"), ("_customButton", "custom") }; + foreach (var (fieldName, label) in modeFields) + { + try + { + var btn = spSubmenu.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(spSubmenu); + if (btn is Control ctrl && ctrl.Visible) + { + var isEnabled = btn.GetType().GetProperty("IsEnabled")?.GetValue(btn) as bool?; + modeOptions.Add(new Dictionary + { + ["name"] = label, + ["enabled"] = isEnabled ?? true + }); + } + } + catch { } + } + result["options"] = modeOptions; } // Check for character select screen else @@ -67,7 +87,7 @@ public static partial class McpMod if (charSelect != null && charSelect.Visible) { result["menu_screen"] = "character_select"; - result["message"] = "Character select screen is active."; + result["message"] = "Select a character."; var buttons = FindAll(charSelect); var characters = new List>(); @@ -75,14 +95,67 @@ public static partial class McpMod { try { - var charModel = btn.GetType().GetField("_character", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(btn); - if (charModel is CharacterModel cm) + if (btn.Character is { } cm) { - characters.Add(new Dictionary + var charData = new Dictionary { ["name"] = SafeGetText(() => cm.Title), - ["id"] = cm.Id.Entry - }); + ["id"] = cm.Id.Entry, + ["locked"] = btn.IsLocked, + ["hp"] = cm.StartingHp, + ["gold"] = cm.StartingGold, + ["energy"] = cm.MaxEnergy, + ["description"] = SafeGetText(() => cm.CardsModifierDescription), + }; + + // Starting relics + var startRelics = new List>(); + foreach (var relic in cm.StartingRelics) + { + startRelics.Add(new Dictionary + { + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription) + }); + } + if (startRelics.Count > 0) + charData["starting_relics"] = startRelics; + + // Starting deck summary + var deckCards = new List(); + foreach (var card in cm.StartingDeck) + deckCards.Add(SafeGetText(() => card.Title) ?? "?"); + if (deckCards.Count > 0) + charData["starting_deck"] = deckCards; + + // Known cards count from card pool + try + { + var allCards = cm.CardPool?.AllCards; + if (allCards != null) + charData["total_cards"] = System.Linq.Enumerable.Count(allCards); + } + catch { } + + // Known relics count from relic pool + try + { + var allRelics = cm.RelicPool?.AllRelics; + if (allRelics != null) + charData["total_relics"] = System.Linq.Enumerable.Count(allRelics); + } + catch { } + + // Known potions count from potion pool + try + { + var allPotions = cm.PotionPool?.AllPotions; + if (allPotions != null) + charData["total_potions"] = System.Linq.Enumerable.Count(allPotions); + } + catch { } + + characters.Add(charData); } } catch { } From 88d939f397655069f80a4c8ef7da21c9cd559e74 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 06:17:20 -0400 Subject: [PATCH 021/190] Add menu_select action for programmatic menu navigation Supports navigating Main Menu, Singleplayer Mode Select, and Character Select screens. Includes back button, character selection with lock detection, and embark/confirm to start a run. Actions: menu_select with options: - Main menu: singleplayer, multiplayer, settings, quit, etc. - Mode select: standard, daily, custom, back - Character select: IRONCLAD, SILENT, etc., confirm, back Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 124 ++++++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 17 +++++++ 2 files changed, 141 insertions(+) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 9217c7c7..70328756 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -29,6 +29,9 @@ using MegaCrit.Sts2.Core.Entities.Potions; using MegaCrit.Sts2.Core.GameActions; using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using Godot; namespace STS2_MCP; @@ -822,4 +825,125 @@ public static partial class McpMod return null; } + + internal static Dictionary ExecuteMenuSelect(string option) + { + var tree = (Engine.GetMainLoop()) as SceneTree; + if (tree?.Root == null) + return Error("Cannot access scene tree"); + + // Main menu — click a menu button + var mainMenu = FindFirst(tree.Root); + if (mainMenu != null) + { + // Check if we're on singleplayer submenu + var spSubmenu = FindFirst(tree.Root); + if (spSubmenu != null && spSubmenu.Visible) + { + if (string.Equals(option, "back", System.StringComparison.OrdinalIgnoreCase)) + { + var backBtn = spSubmenu.GetType().GetField("_backButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(spSubmenu); + if (backBtn is NClickableControl backClickable && backClickable.IsEnabled) + { + backClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Going back" }; + } + return Error("Back button not available"); + } + + var fieldName = option.ToLower() switch + { + "standard" => "_standardButton", + "daily" => "_dailyButton", + "custom" => "_customButton", + _ => null + }; + if (fieldName == null) + return Error($"Unknown singleplayer option: {option}. Use: standard, daily, custom, back"); + + var btn = spSubmenu.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(spSubmenu); + if (btn is NClickableControl clickable) + { + if (!clickable.IsEnabled) + return Error($"Option '{option}' is not available (locked)"); + clickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = $"Selected {option}" }; + } + return Error($"Could not find button for '{option}'"); + } + + // Check if we're on character select + var charSelect = FindFirst(tree.Root); + if (charSelect != null && charSelect.Visible) + { + // "back" clicks the back/unready button + if (string.Equals(option, "back", System.StringComparison.OrdinalIgnoreCase)) + { + var backBtn = charSelect.GetType().GetField("_backButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(charSelect) + ?? charSelect.GetType().GetField("_unreadyButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(charSelect); + if (backBtn is NClickableControl backClickable && backClickable.IsEnabled) + { + backClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Going back" }; + } + return Error("Back button not available"); + } + + // "confirm" or "embark" clicks the embark button to start the run + if (string.Equals(option, "confirm", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(option, "embark", System.StringComparison.OrdinalIgnoreCase)) + { + var embarkBtn = charSelect.GetType().GetField("_embarkButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(charSelect); + if (embarkBtn is NClickableControl embarkClickable && embarkClickable.IsEnabled) + { + embarkClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Embarking on run" }; + } + return Error("Embark button not available — select a character first"); + } + + var buttons = FindAll(charSelect); + foreach (var btn in buttons) + { + if (btn.Character != null && ( + string.Equals(btn.Character.Id.Entry, option, System.StringComparison.OrdinalIgnoreCase) || + string.Equals(SafeGetText(() => btn.Character.Title), option, System.StringComparison.OrdinalIgnoreCase))) + { + if (btn.IsLocked) + return Error($"Character '{option}' is locked"); + btn.Select(); + return new Dictionary { ["status"] = "ok", ["message"] = $"Selected {SafeGetText(() => btn.Character.Title)}. Use 'confirm' to embark." }; + } + } + return Error($"Character '{option}' not found. Available: {string.Join(", ", buttons.Where(b => !b.IsLocked).Select(b => b.Character?.Id.Entry))}"); + } + + // Main menu buttons + var menuFieldName = option.ToLower() switch + { + "singleplayer" => "_singleplayerButton", + "multiplayer" => "_multiplayerButton", + "compendium" => "_compendiumButton", + "timeline" => "_timelineButton", + "settings" => "_settingsButton", + "continue" => "_continueButton", + "quit" => "_quitButton", + _ => null + }; + if (menuFieldName == null) + return Error($"Unknown menu option: {option}"); + + var menuBtn = mainMenu.GetType().GetField(menuFieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(mainMenu); + if (menuBtn is NClickableControl menuClickable) + { + if (!menuClickable.IsEnabled) + return Error($"Option '{option}' is not available"); + menuClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = $"Selected {option}" }; + } + return Error($"Could not find button for '{option}'"); + } + + return Error("Not on a menu screen"); + } } diff --git a/McpMod.cs b/McpMod.cs index 0112f188..97b20031 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -400,6 +400,23 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe string action = actionElem.GetString() ?? ""; + // Handle menu actions separately (no run required) + if (action == "menu_select") + { + try + { + var option = parsed.TryGetValue("option", out var optElem) ? optElem.GetString() ?? "" : ""; + var resultTask = RunOnMainThread(() => ExecuteMenuSelect(option)); + var result = resultTask.GetAwaiter().GetResult(); + SendJson(response, result); + } + catch (Exception ex) + { + SendError(response, 500, $"Menu action failed: {ex.Message}"); + } + return; + } + try { var resultTask = RunOnMainThread(() => ExecuteAction(action, parsed)); From 694a6ecefc4a64e61cfad8035e3685d4827266ff Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 06:23:54 -0400 Subject: [PATCH 022/190] Add game_over state detection and continue/main_menu actions Detects NGameOverScreen overlay as state_type: game_over with continue and main_menu options. Handles via menu_select action. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 23 +++++++++++++++++++++++ McpMod.StateBuilder.cs | 10 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 70328756..d2ec0d44 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -31,6 +31,7 @@ using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; using Godot; namespace STS2_MCP; @@ -832,6 +833,28 @@ public static partial class McpMod if (tree?.Root == null) return Error("Cannot access scene tree"); + // Game over screen + var gameOver = FindFirst(tree.Root); + if (gameOver != null) + { + var fieldName = option.ToLower() switch + { + "continue" => "_continueButton", + "main_menu" => "_mainMenuButton", + _ => null + }; + if (fieldName == null) + return Error($"Unknown game over option: {option}. Use: continue, main_menu"); + + var btn = gameOver.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(gameOver); + if (btn is NClickableControl clickable && clickable.IsEnabled) + { + clickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = $"Clicked {option}" }; + } + return Error($"Button '{option}' not available"); + } + // Main menu — click a menu button var mainMenu = FindFirst(tree.Root); if (mainMenu != null) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 56726338..7c66f232 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -34,6 +34,7 @@ using MegaCrit.Sts2.Core.Models.RelicPools; using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; using Godot; namespace STS2_MCP; @@ -223,6 +224,15 @@ public static partial class McpMod result["state_type"] = "relic_select"; result["relic_select"] = BuildRelicSelectState(relicSelectScreen, runState); } + else if (topOverlay is NGameOverScreen gameOverScreen) + { + result["state_type"] = "game_over"; + result["game_over"] = new Dictionary + { + ["message"] = "Run ended.", + ["options"] = new List { "continue", "main_menu" } + }; + } else if (topOverlay is IOverlayScreen && topOverlay is not NRewardsScreen && topOverlay is not NCardRewardSelectionScreen) From 468b2cfbdd7c668578ab6c9e7f091ace061d758c Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 06:33:35 -0400 Subject: [PATCH 023/190] Add GET /api/v1/profile endpoint Exposes player profile data: character stats (wins, losses, ascension, streaks), card stats (pick/skip/win/loss), encounter and enemy stats, discovered items, total playtime, score, and floors climbed. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 87 ++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 21 ++++++++++ 2 files changed, 108 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 7c66f232..16cd9f4a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using MegaCrit.Sts2.Core.Saves; using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Context; using MegaCrit.Sts2.Core.Entities.Cards; @@ -1794,4 +1795,90 @@ internal static object BuildGlossaryKeywords() return result; } + + internal static object BuildProfile() + { + var progress = SaveManager.Instance?.Progress; + if (progress == null) + return new Dictionary { ["error"] = "No profile data available." }; + + var result = new Dictionary(); + + // Character stats + var characters = new List>(); + foreach (var kv in progress.CharacterStats) + { + var stats = kv.Value; + characters.Add(new Dictionary + { + ["id"] = kv.Key.Entry, + ["max_ascension"] = stats.MaxAscension, + ["preferred_ascension"] = stats.PreferredAscension, + ["total_wins"] = stats.TotalWins, + ["total_losses"] = stats.TotalLosses, + ["fastest_win_time"] = stats.FastestWinTime, + ["best_win_streak"] = stats.BestWinStreak, + ["current_win_streak"] = stats.CurrentWinStreak, + ["playtime"] = stats.Playtime + }); + } + result["characters"] = characters; + + // Card stats (pick/skip/win/loss rates) + var cards = new List>(); + foreach (var kv in progress.CardStats) + { + var stats = kv.Value; + cards.Add(new Dictionary + { + ["id"] = kv.Key.Entry, + ["times_picked"] = stats.TimesPicked, + ["times_skipped"] = stats.TimesSkipped, + ["times_won"] = stats.TimesWon, + ["times_lost"] = stats.TimesLost + }); + } + result["card_stats"] = cards; + + // Encounter stats + var encounters = new List>(); + foreach (var kv in progress.EncounterStats) + { + encounters.Add(new Dictionary + { + ["id"] = kv.Key.Entry, + ["total_wins"] = kv.Value.TotalWins, + ["total_losses"] = kv.Value.TotalLosses + }); + } + result["encounter_stats"] = encounters; + + // Enemy stats + var enemies = new List>(); + foreach (var kv in progress.EnemyStats) + { + enemies.Add(new Dictionary + { + ["id"] = kv.Key.Entry, + ["total_wins"] = kv.Value.TotalWins, + ["total_losses"] = kv.Value.TotalLosses + }); + } + result["enemy_stats"] = enemies; + + // Discovered items + result["discovered_cards"] = progress.DiscoveredCards.Select(id => id.Entry).ToList(); + result["discovered_relics"] = progress.DiscoveredRelics.Select(id => id.Entry).ToList(); + result["discovered_potions"] = progress.DiscoveredPotions.Select(id => id.Entry).ToList(); + result["discovered_events"] = progress.DiscoveredEvents.Select(id => id.Entry).ToList(); + result["discovered_acts"] = progress.DiscoveredActs.Select(id => id.Entry).ToList(); + + // Summary + result["total_playtime"] = progress.TotalPlaytime; + result["total_unlocks"] = progress.TotalUnlocks; + result["current_score"] = progress.CurrentScore; + result["floors_climbed"] = progress.FloorsClimbed; + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index 97b20031..b3208593 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -176,6 +176,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/profile") + { + if (request.HttpMethod == "GET") + HandleGetProfile(response); + else + SendError(response, 405, "Method not allowed"); + } else if (path == "/api/v1/glossary/cards") { if (request.HttpMethod == "GET") @@ -319,6 +326,20 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } + private static void HandleGetProfile(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildProfile()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build profile: {ex.Message}"); + } + } + private static void HandleGetGlossaryCards(HttpListenerResponse response) { try From 014acff7565d05561cdb6fdce8c916a6b2b7dc92 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 06:56:45 -0400 Subject: [PATCH 024/190] Add per-character breakdown to encounter and enemy stats Profile endpoint now includes by_character win/loss for each encounter and enemy. Widgets show the breakdown under each entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 16cd9f4a..49136a3d 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1840,29 +1840,55 @@ internal static object BuildProfile() } result["card_stats"] = cards; - // Encounter stats + // Encounter stats (with per-character breakdown) var encounters = new List>(); foreach (var kv in progress.EncounterStats) { - encounters.Add(new Dictionary + var enc = new Dictionary { ["id"] = kv.Key.Entry, ["total_wins"] = kv.Value.TotalWins, ["total_losses"] = kv.Value.TotalLosses - }); + }; + var fightStats = new List>(); + foreach (var fs in kv.Value.FightStats) + { + fightStats.Add(new Dictionary + { + ["character"] = fs.Character.Entry, + ["wins"] = fs.Wins, + ["losses"] = fs.Losses + }); + } + if (fightStats.Count > 0) + enc["by_character"] = fightStats; + encounters.Add(enc); } result["encounter_stats"] = encounters; - // Enemy stats + // Enemy stats (with per-character breakdown) var enemies = new List>(); foreach (var kv in progress.EnemyStats) { - enemies.Add(new Dictionary + var enemy = new Dictionary { ["id"] = kv.Key.Entry, ["total_wins"] = kv.Value.TotalWins, ["total_losses"] = kv.Value.TotalLosses - }); + }; + var fightStats = new List>(); + foreach (var fs in kv.Value.FightStats) + { + fightStats.Add(new Dictionary + { + ["character"] = fs.Character.Entry, + ["wins"] = fs.Wins, + ["losses"] = fs.Losses + }); + } + if (fightStats.Count > 0) + enemy["by_character"] = fightStats; + enemies.Add(enemy); } result["enemy_stats"] = enemies; From 1853c7b22eab8b5c470eef6d8f919d19770c1210 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 07:02:29 -0400 Subject: [PATCH 025/190] Exhaust profile data: ancients, epochs, achievements, global stats Adds ancient stats with per-character breakdown, epoch milestones, unlocked achievements, and global stats (wins, losses, runs, best streak, fastest victory, architect damage). Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 55 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 49136a3d..5cd4a4f2 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1892,6 +1892,33 @@ internal static object BuildProfile() } result["enemy_stats"] = enemies; + // Ancient stats + var ancients = new List>(); + foreach (var kv in progress.AncientStats) + { + var anc = new Dictionary + { + ["id"] = kv.Key.Entry, + ["total_visits"] = kv.Value.TotalVisits, + ["total_wins"] = kv.Value.TotalWins, + ["total_losses"] = kv.Value.TotalLosses + }; + var charStats = new List>(); + foreach (var cs in kv.Value.CharStats) + { + charStats.Add(new Dictionary + { + ["character"] = cs.Character.Entry, + ["wins"] = cs.Wins, + ["losses"] = cs.Losses + }); + } + if (charStats.Count > 0) + anc["by_character"] = charStats; + ancients.Add(anc); + } + result["ancient_stats"] = ancients; + // Discovered items result["discovered_cards"] = progress.DiscoveredCards.Select(id => id.Entry).ToList(); result["discovered_relics"] = progress.DiscoveredRelics.Select(id => id.Entry).ToList(); @@ -1899,11 +1926,37 @@ internal static object BuildProfile() result["discovered_events"] = progress.DiscoveredEvents.Select(id => id.Entry).ToList(); result["discovered_acts"] = progress.DiscoveredActs.Select(id => id.Entry).ToList(); - // Summary + // Achievements + var achievements = new List>(); + foreach (var kv in progress.UnlockedAchievements) + { + achievements.Add(new Dictionary + { + ["id"] = kv.Key, + ["unlocked_at"] = kv.Value + }); + } + result["achievements"] = achievements; + + // Epochs (progression milestones) + result["epochs"] = progress.Epochs.Select(e => new Dictionary + { + ["id"] = e.Id, + ["state"] = e.State.ToString(), + ["obtained"] = e.ObtainDate + }).ToList(); + + // Global stats result["total_playtime"] = progress.TotalPlaytime; result["total_unlocks"] = progress.TotalUnlocks; result["current_score"] = progress.CurrentScore; result["floors_climbed"] = progress.FloorsClimbed; + result["architect_damage"] = progress.ArchitectDamage; + result["total_wins"] = progress.Wins; + result["total_losses"] = progress.Losses; + result["fastest_victory"] = progress.FastestVictory; + result["best_win_streak"] = progress.BestWinStreak; + result["number_of_runs"] = progress.NumberOfRuns; return result; } From cb53a6bb5d1ad279b9bd8da8ba0ba79fd662134d Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 07:18:04 -0400 Subject: [PATCH 026/190] Add bestiary endpoint and widget GET /api/v1/bestiary returns 126 monsters (HP range, moves) and 95 encounters (type, tier). Widget merges with profile win/loss stats. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 74 ++++++++++++++++++++++++++++++++++++++++++ McpMod.cs | 18 ++++++++++ 2 files changed, 92 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 5cd4a4f2..bb73ac81 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -9,6 +9,7 @@ using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Entities.Potions; using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Models.Monsters; using MegaCrit.Sts2.Core.MonsterMoves.Intents; using MegaCrit.Sts2.Core.MonsterMoves.MonsterMoveStateMachine; using MegaCrit.Sts2.Core.Entities.Merchant; @@ -1960,4 +1961,77 @@ internal static object BuildProfile() return result; } + + internal static object BuildBestiary() + { + var result = new Dictionary(); + var bindFlags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + // All monsters — use reflection to read properties without full instantiation + var monsters = new List>(); + foreach (var type in typeof(MonsterModel).Assembly.GetTypes()) + { + if (type.IsAbstract || !type.IsSubclassOf(typeof(MonsterModel)) || type.FullName!.Contains("+")) continue; + + var entry = new Dictionary + { + ["id"] = ModelId.SlugifyCategory(type.Name), + ["class"] = type.Name, + }; + + // Try to get HP from overridden properties + try + { + var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); + var minHp = type.GetProperty("MinInitialHp")?.GetValue(instance); + var maxHp = type.GetProperty("MaxInitialHp")?.GetValue(instance); + if (minHp != null) entry["min_hp"] = minHp; + if (maxHp != null) entry["max_hp"] = maxHp; + } + catch { } + + // Get move names from method signatures + var moves = new List(); + foreach (var m in type.GetMethods(bindFlags)) + { + if (m.Name.EndsWith("Move") && m.DeclaringType == type + && m.Name != "PerformMove" && m.Name != "RollMove" + && m.Name != "SetMoveImmediate") + moves.Add(m.Name.Replace("Move", "")); + } + if (moves.Count > 0) + entry["moves"] = moves; + + monsters.Add(entry); + } + result["monsters"] = monsters; + + // All encounters — use reflection + var encounters = new List>(); + foreach (var type in typeof(EncounterModel).Assembly.GetTypes()) + { + if (type.IsAbstract || !type.IsSubclassOf(typeof(EncounterModel)) || type.FullName!.Contains("+")) continue; + + var entry = new Dictionary + { + ["id"] = ModelId.SlugifyCategory(type.Name), + ["class"] = type.Name, + }; + + try + { + var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); + var roomType = type.GetProperty("RoomType")?.GetValue(instance); + var isWeak = type.GetProperty("IsWeak")?.GetValue(instance); + if (roomType != null) entry["room_type"] = roomType.ToString(); + if (isWeak != null) entry["is_weak"] = isWeak; + } + catch { } + + encounters.Add(entry); + } + result["encounters"] = encounters; + + return result; + } } diff --git a/McpMod.cs b/McpMod.cs index b3208593..b36634ad 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -183,6 +183,24 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/bestiary") + { + if (request.HttpMethod == "GET") + { + try + { + var dataTask = RunOnMainThread(() => BuildBestiary()); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to build bestiary: {ex.Message}"); + } + } + else + SendError(response, 405, "Method not allowed"); + } else if (path == "/api/v1/glossary/cards") { if (request.HttpMethod == "GET") From 23cf68af3fcc9e58348b86b29393f3524d76e51a Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 07:22:15 -0400 Subject: [PATCH 027/190] Add gold rewards and monster mapping to bestiary encounters Encounters now include min/max gold reward and likely_monsters inferred from encounter name. Widget shows gold and monster columns. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index bb73ac81..c391034e 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2023,11 +2023,54 @@ internal static object BuildBestiary() var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); var roomType = type.GetProperty("RoomType")?.GetValue(instance); var isWeak = type.GetProperty("IsWeak")?.GetValue(instance); + var minGold = type.GetProperty("MinGoldReward")?.GetValue(instance); + var maxGold = type.GetProperty("MaxGoldReward")?.GetValue(instance); if (roomType != null) entry["room_type"] = roomType.ToString(); if (isWeak != null) entry["is_weak"] = isWeak; + if (minGold != null) entry["min_gold"] = minGold; + if (maxGold != null) entry["max_gold"] = maxGold; } catch { } + // Get possible monsters from AllPossibleMonsters property override + try + { + var allMonstersMethod = type.GetProperty("AllPossibleMonsters"); + if (allMonstersMethod != null) + { + // Read the method body to find monster type references + var monsterTypes = new List(); + foreach (var m in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) + { + if (m.Name == "GenerateMonsters" && m.DeclaringType == type) + { + // Check constructor parameters or fields for monster references + break; + } + } + // Fall back: check fields that reference MonsterModel types + foreach (var f in type.GetFields(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) + { + if (f.FieldType.IsSubclassOf(typeof(MonsterModel)) || (f.FieldType.IsGenericType && f.FieldType.GetGenericArguments().Any(a => a.IsSubclassOf(typeof(MonsterModel))))) + monsterTypes.Add(f.FieldType.Name); + } + // Also check which monster types the encounter name suggests + } + } + catch { } + + // Infer monsters from encounter name pattern (e.g., NibbitsWeak -> Nibbit) + var baseName = type.Name.Replace("Normal", "").Replace("Weak", "").Replace("Elite", "").Replace("Boss", ""); + var matchingMonsters = new List(); + foreach (var monsterEntry in monsters) + { + var mClass = monsterEntry["class"] as string ?? ""; + if (baseName.Contains(mClass) || mClass.Contains(baseName.TrimEnd('s'))) + matchingMonsters.Add(mClass); + } + if (matchingMonsters.Count > 0) + entry["likely_monsters"] = matchingMonsters; + encounters.Add(entry); } result["encounters"] = encounters; From 350fe6b480187c8df94366ffe5110277da96920b Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 09:52:31 -0400 Subject: [PATCH 028/190] Add timeline epoch data and multiplayer/settings/compendium detection Timeline shows epoch names, descriptions, and unlock requirements. Multiplayer submenu shows host/join/load options. Settings, compendium, and timeline screens properly detected. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 133 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 3 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index c391034e..3e005890 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -37,6 +37,8 @@ using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Nodes.Screens.Settings; using Godot; namespace STS2_MCP; @@ -83,8 +85,70 @@ public static partial class McpMod } result["options"] = modeOptions; } - // Check for character select screen + // Check for multiplayer host submenu (Standard / Daily / Custom for multiplayer) else + { + var mpHostSubmenu = FindFirst(tree.Root); + if (mpHostSubmenu != null && mpHostSubmenu.Visible) + { + result["menu_screen"] = "multiplayer_host"; + result["message"] = "Multiplayer host: select game mode."; + + var modeOptions = new List>(); + var modeFields = new[] { ("_standardButton", "standard"), ("_dailyButton", "daily"), ("_customButton", "custom") }; + foreach (var (fieldName, label) in modeFields) + { + try + { + var btn = mpHostSubmenu.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(mpHostSubmenu); + if (btn is Control ctrl && ctrl.Visible) + { + var isEnabled = btn.GetType().GetProperty("IsEnabled")?.GetValue(btn) as bool?; + modeOptions.Add(new Dictionary + { + ["name"] = label, + ["enabled"] = isEnabled ?? true + }); + } + } + catch { } + } + result["options"] = modeOptions; + } + else + { + // Check for multiplayer submenu (Host / Join / Load / Abandon) + var mpSubmenu = FindFirst(tree.Root); + if (mpSubmenu != null && mpSubmenu.Visible) + { + result["menu_screen"] = "multiplayer"; + result["message"] = "Multiplayer menu."; + + var mpOptions = new List>(); + var mpFields = new[] { ("_hostButton", "host"), ("_joinButton", "join"), ("_loadButton", "load"), ("_abandonButton", "abandon") }; + foreach (var (fieldName, label) in mpFields) + { + try + { + var btn = mpSubmenu.GetType().GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(mpSubmenu); + if (btn is Control ctrl && ctrl.Visible) + { + var isEnabled = btn.GetType().GetProperty("IsEnabled")?.GetValue(btn) as bool?; + mpOptions.Add(new Dictionary + { + ["name"] = label, + ["enabled"] = isEnabled ?? true + }); + } + } + catch { } + } + result["options"] = mpOptions; + } + } + } + // Check for character select screen + if (result.ContainsKey("menu_screen") == false) { var charSelect = FindFirst(tree.Root); if (charSelect != null && charSelect.Visible) @@ -168,8 +232,70 @@ public static partial class McpMod } else { - result["menu_screen"] = "main"; - result["message"] = "Main menu."; + // Check for other screens + var timelineScreen = FindFirst(tree.Root); + var compendiumSubmenu = FindFirst(tree.Root); + var settingsScreen = FindFirst(tree.Root); + + if (timelineScreen != null && timelineScreen.Visible) + { + result["menu_screen"] = "timeline"; + result["message"] = "Timeline screen."; + + // Read epoch slots from the timeline + try + { + var slotsContainer = timelineScreen.GetType().GetField("_epochSlotContainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen) as Control; + if (slotsContainer == null) + slotsContainer = timelineScreen.GetType().GetField("_slotsContainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen) as Control; + + if (slotsContainer != null) + { + var slots = FindAll(slotsContainer); + var epochList = new List>(); + foreach (var slot in slots) + { + try + { + var era = slot.GetType().GetField("_era", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); + var hoverTip = slot.GetType().GetField("_hoverTip", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); + var state_val = slot.State; + + var epochData = new Dictionary + { + ["state"] = state_val.ToString(), + }; + + if (hoverTip is HoverTip ht) + { + epochData["name"] = SafeGetText(() => ht.Title); + epochData["description"] = SafeGetText(() => ht.Description); + } + + epochList.Add(epochData); + } + catch { } + } + if (epochList.Count > 0) + result["epochs"] = epochList; + } + } + catch { } + } + else if (compendiumSubmenu != null && compendiumSubmenu.Visible) + { + result["menu_screen"] = "compendium"; + result["message"] = "Compendium screen."; + } + else if (settingsScreen != null && settingsScreen.Visible) + { + result["menu_screen"] = "settings"; + result["message"] = "Settings screen."; + } + else + { + result["menu_screen"] = "main"; + result["message"] = "Main menu."; var mainMenu = FindFirst(tree.Root); if (mainMenu != null) @@ -190,6 +316,7 @@ public static partial class McpMod if (options.Count > 0) result["options"] = options; } + } } } } From dc62748e1572d7b7ecedeac7c6b807791e17fa2e Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 09:55:57 -0400 Subject: [PATCH 029/190] Show completed epochs with era name, separate locked/completed sections Timeline widget now groups epochs into Completed (green), Locked (yellow with unlock requirements), and hidden count. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 58 ++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 3e005890..ef1fc4e9 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -242,43 +242,45 @@ public static partial class McpMod result["menu_screen"] = "timeline"; result["message"] = "Timeline screen."; - // Read epoch slots from the timeline + // Read epoch slots from UI + progress save try { - var slotsContainer = timelineScreen.GetType().GetField("_epochSlotContainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen) as Control; - if (slotsContainer == null) - slotsContainer = timelineScreen.GetType().GetField("_slotsContainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen) as Control; + var epochList = new List>(); - if (slotsContainer != null) + // Get slots from UI for hover tip data + var allSlots = FindAll(timelineScreen); + foreach (var slot in allSlots) { - var slots = FindAll(slotsContainer); - var epochList = new List>(); - foreach (var slot in slots) + try { - try + var era = slot.GetType().GetField("_era", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); + var hoverTip = slot.GetType().GetField("_hoverTip", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); + var stateVal = slot.State; + + var epochData = new Dictionary { - var era = slot.GetType().GetField("_era", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); - var hoverTip = slot.GetType().GetField("_hoverTip", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); - var state_val = slot.State; - - var epochData = new Dictionary - { - ["state"] = state_val.ToString(), - }; - - if (hoverTip is HoverTip ht) - { - epochData["name"] = SafeGetText(() => ht.Title); - epochData["description"] = SafeGetText(() => ht.Description); - } - - epochList.Add(epochData); + ["state"] = stateVal.ToString(), + ["era"] = era?.ToString(), + }; + + if (hoverTip is HoverTip ht) + { + epochData["name"] = SafeGetText(() => ht.Title); + epochData["description"] = SafeGetText(() => ht.Description); } - catch { } + else if (stateVal.ToString() == "Complete" || stateVal.ToString() == "Obtained") + { + // For completed epochs, use era name as fallback + epochData["name"] = era?.ToString()?.Replace("0", "").Replace("1", "").Replace("2", "").Replace("3", "").Replace("4", "").Replace("5", "").Replace("6", "").Replace("7", ""); + } + + epochList.Add(epochData); } - if (epochList.Count > 0) - result["epochs"] = epochList; + catch { } } + + if (epochList.Count > 0) + result["epochs"] = epochList; } catch { } } From bb0af514316d8d6a26fbb9cae12883b07aa3bd30 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 09:58:44 -0400 Subject: [PATCH 030/190] Fix timeline to use stable ProgressState data instead of hover tips No more flickering from mouse hover. Shows earned epochs from save data with slot counts for remaining. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 54 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index ef1fc4e9..eb4b921c 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -242,45 +242,39 @@ public static partial class McpMod result["menu_screen"] = "timeline"; result["message"] = "Timeline screen."; - // Read epoch slots from UI + progress save + // Read epochs from ProgressState (stable, not hover-dependent) try { - var epochList = new List>(); - - // Get slots from UI for hover tip data - var allSlots = FindAll(timelineScreen); - foreach (var slot in allSlots) + var progress = SaveManager.Instance?.Progress; + if (progress != null) { - try + var epochList = new List>(); + foreach (var epoch in progress.Epochs) { - var era = slot.GetType().GetField("_era", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); - var hoverTip = slot.GetType().GetField("_hoverTip", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(slot); - var stateVal = slot.State; - - var epochData = new Dictionary - { - ["state"] = stateVal.ToString(), - ["era"] = era?.ToString(), - }; + var eraName = epoch.Id; + // Clean up ID to readable name + var name = System.Text.RegularExpressions.Regex.Replace(eraName, @"(\d+)$", ""); + name = System.Text.RegularExpressions.Regex.Replace(name, @"(?<=[a-z])(?=[A-Z])", " "); - if (hoverTip is HoverTip ht) - { - epochData["name"] = SafeGetText(() => ht.Title); - epochData["description"] = SafeGetText(() => ht.Description); - } - else if (stateVal.ToString() == "Complete" || stateVal.ToString() == "Obtained") + epochList.Add(new Dictionary { - // For completed epochs, use era name as fallback - epochData["name"] = era?.ToString()?.Replace("0", "").Replace("1", "").Replace("2", "").Replace("3", "").Replace("4", "").Replace("5", "").Replace("6", "").Replace("7", ""); - } - - epochList.Add(epochData); + ["id"] = eraName, + ["name"] = name, + ["state"] = epoch.State.ToString(), + ["obtained"] = epoch.ObtainDate + }); } - catch { } - } - if (epochList.Count > 0) + // Count total slots from UI for hidden count + var allSlots = FindAll(timelineScreen); + var completedCount = allSlots.Count(s => s.State.ToString() == "Complete" || s.State.ToString() == "Obtained"); + var lockedVisible = allSlots.Count(s => s.State.ToString() == "NotObtained"); + result["epochs"] = epochList; + result["total_slots"] = allSlots.Count; + result["completed_count"] = completedCount; + result["locked_count"] = lockedVisible; + } } catch { } } From 21cb10320e1ddd519b03548bd3e2ce9f022a3fc8 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 11:12:01 -0400 Subject: [PATCH 031/190] Add timeline_advance action for auto-clicking epoch reveals menu_select with option "advance" on timeline screen: - Closes epoch inspect screens - Opens queued unlock screens - Clicks obtained epoch slots via reflection - Returns done:true when no more epochs to advance Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index d2ec0d44..f9fe8cbf 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -32,6 +32,7 @@ using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; using Godot; namespace STS2_MCP; @@ -855,6 +856,62 @@ public static partial class McpMod return Error($"Button '{option}' not available"); } + // Timeline screen — advance through epoch reveals + var timelineScreen = FindFirst(tree.Root); + if (timelineScreen != null && timelineScreen.Visible) + { + if (string.Equals(option, "advance", System.StringComparison.OrdinalIgnoreCase)) + { + // Check for inspect screen (epoch detail view) — close it + var inspectScreen = FindFirst(tree.Root); + if (inspectScreen != null && inspectScreen.Visible) + { + var closeBtn = inspectScreen.GetType().GetField("_closeButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(inspectScreen); + if (closeBtn is NClickableControl closeClickable && closeClickable.IsEnabled) + { + closeClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Closed epoch inspect screen" }; + } + inspectScreen.Close(); + return new Dictionary { ["status"] = "ok", ["message"] = "Closed epoch inspect screen" }; + } + + // Check for queued unlock screens + if (timelineScreen.IsScreenQueued()) + { + timelineScreen.OpenQueuedScreen(); + return new Dictionary { ["status"] = "ok", ["message"] = "Opening queued unlock screen" }; + } + + // Find an obtained but not-yet-revealed epoch slot to click + var slots = FindAll(timelineScreen); + foreach (var slot in slots) + { + if (slot.State.ToString() == "Obtained") + { + // OnPress is private — use reflection + var onPress = slot.GetType().GetMethod("OnPress", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + onPress?.Invoke(slot, null); + return new Dictionary { ["status"] = "ok", ["message"] = "Clicking obtained epoch slot" }; + } + } + + // Nothing to advance — go back + return new Dictionary { ["status"] = "ok", ["message"] = "No more epochs to advance", ["done"] = true }; + } + else if (string.Equals(option, "back", System.StringComparison.OrdinalIgnoreCase)) + { + var backBtn = timelineScreen.GetType().GetField("_backButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen); + if (backBtn is NClickableControl backClickable && backClickable.IsEnabled) + { + backClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Going back from timeline" }; + } + return Error("Back button not available on timeline"); + } + return Error($"Unknown timeline option: {option}. Use: advance, back"); + } + // Main menu — click a menu button var mainMenu = FindFirst(tree.Root); if (mainMenu != null) From 849cf3ec39bf06101d1ee07df95138014dcffac5 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 12:02:15 -0400 Subject: [PATCH 032/190] Fix timeline auto-advance: RevealEpoch, confirm, tutorial proceed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect NTimelineTutorial with NAcknowledgeButton for first-time proceed - Use NConfirmButton for epoch story confirm screens - Use RevealEpoch() instead of OnPress() to properly transition slot state - Remove overly broad NClickableControl fallback that clicked wrong elements - Full flow: tutorial proceed → reveal epochs → confirm → unlock screens → back Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 68 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index f9fe8cbf..28c8dd03 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -860,8 +860,37 @@ public static partial class McpMod var timelineScreen = FindFirst(tree.Root); if (timelineScreen != null && timelineScreen.Visible) { - if (string.Equals(option, "advance", System.StringComparison.OrdinalIgnoreCase)) + if (string.Equals(option, "advance", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(option, "proceed", System.StringComparison.OrdinalIgnoreCase)) { + // Check for timeline tutorial screen (first-time "Proceed" button) + var tutorial = FindFirst(tree.Root); + if (tutorial != null && tutorial.Visible) + { + var ackBtn = tutorial.GetType().GetField("_acknowledgeButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tutorial); + if (ackBtn is NClickableControl ackClickable && ackClickable.IsEnabled) + { + ackClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Clicked tutorial proceed button" }; + } + } + + // Check for confirm button + var confirmBtn = FindFirst(tree.Root); + if (confirmBtn != null && confirmBtn.Visible && confirmBtn.IsEnabled) + { + confirmBtn.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Clicked confirm button" }; + } + + // Check for any clickable proceed button + var proceedBtn = FindFirst(tree.Root); + if (proceedBtn != null && proceedBtn.Visible && proceedBtn.IsEnabled) + { + proceedBtn.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Clicked proceed button" }; + } + // Check for inspect screen (epoch detail view) — close it var inspectScreen = FindFirst(tree.Root); if (inspectScreen != null && inspectScreen.Visible) @@ -883,29 +912,48 @@ public static partial class McpMod return new Dictionary { ["status"] = "ok", ["message"] = "Opening queued unlock screen" }; } - // Find an obtained but not-yet-revealed epoch slot to click + // Find an obtained epoch slot to click var slots = FindAll(timelineScreen); foreach (var slot in slots) { if (slot.State.ToString() == "Obtained") { - // OnPress is private — use reflection - var onPress = slot.GetType().GetMethod("OnPress", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - onPress?.Invoke(slot, null); - return new Dictionary { ["status"] = "ok", ["message"] = "Clicking obtained epoch slot" }; + // Try RevealEpoch to properly transition the state + var revealMethod = slot.GetType().GetMethod("RevealEpoch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + if (revealMethod != null) + { + revealMethod.Invoke(slot, null); + return new Dictionary { ["status"] = "ok", ["message"] = "Revealing epoch" }; + } + // Fallback: set state directly and open inspect + slot.SetState(EpochSlotState.Complete); + timelineScreen.OpenInspectScreen(slot, true); + return new Dictionary { ["status"] = "ok", ["message"] = "Force-revealing epoch" }; } } - // Nothing to advance — go back + // Nothing to advance return new Dictionary { ["status"] = "ok", ["message"] = "No more epochs to advance", ["done"] = true }; } else if (string.Equals(option, "back", System.StringComparison.OrdinalIgnoreCase)) { + // Try NBackButton directly on NTimelineScreen var backBtn = timelineScreen.GetType().GetField("_backButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen); - if (backBtn is NClickableControl backClickable && backClickable.IsEnabled) + if (backBtn is NClickableControl backClickable) + { + if (backClickable.IsEnabled) + { + backClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Going back from timeline" }; + } + } + // Try the submenu stack + var stack = timelineScreen.GetType().GetField("_stack", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(timelineScreen); + if (stack != null) { - backClickable.ForceClick(); - return new Dictionary { ["status"] = "ok", ["message"] = "Going back from timeline" }; + var popMethod = stack.GetType().GetMethod("Pop"); + popMethod?.Invoke(stack, null); + return new Dictionary { ["status"] = "ok", ["message"] = "Popped timeline from stack" }; } return Error("Back button not available on timeline"); } From 523484cd3233871f4de189342f1d0c8e3ac44b1a Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 12:08:28 -0400 Subject: [PATCH 033/190] Add profile management API: list, switch, delete profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/profiles — lists all profiles with current indicator POST /api/v1/profiles — switch or delete profiles action: "switch", profile_id: 1-3 action: "delete", profile_id: 1-3 Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.cs | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/McpMod.cs b/McpMod.cs index b36634ad..13024129 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -12,6 +12,8 @@ using Godot; using MegaCrit.Sts2.Core.Modding; using MegaCrit.Sts2.Core.Multiplayer.Game; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Saves; namespace STS2_MCP; @@ -176,6 +178,15 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/profiles") + { + if (request.HttpMethod == "GET") + HandleGetProfiles(response); + else if (request.HttpMethod == "POST") + HandlePostProfiles(request, response); + else + SendError(response, 405, "Method not allowed"); + } else if (path == "/api/v1/profile") { if (request.HttpMethod == "GET") @@ -344,6 +355,128 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } + private static void HandleGetProfiles(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => + { + var sm = SaveManager.Instance; + var result = new Dictionary + { + ["current_profile_id"] = sm.CurrentProfileId, + ["profiles"] = new List>() + }; + + // Check which profiles exist by looking for save directories + var profiles = (List>)result["profiles"]!; + for (int i = 1; i <= 3; i++) + { + var profileData = new Dictionary + { + ["id"] = i, + ["is_current"] = i == sm.CurrentProfileId, + }; + + // Check if profile has progress data + try + { + var path = MegaCrit.Sts2.Core.Saves.Managers.ProgressSaveManager.GetProgressPathForProfile(i); + profileData["has_data"] = System.IO.File.Exists(path); + profileData["path"] = path; + } + catch + { + profileData["has_data"] = false; + } + + profiles.Add(profileData); + } + + return result; + }); + var data = dataTask.GetAwaiter().GetResult(); + SendJson(response, data); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to get profiles: {ex.Message}"); + } + } + + private static void HandlePostProfiles(HttpListenerRequest request, HttpListenerResponse response) + { + string body; + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding)) + body = reader.ReadToEnd(); + + Dictionary? parsed; + try + { + parsed = JsonSerializer.Deserialize>(body); + } + catch + { + SendError(response, 400, "Invalid JSON"); + return; + } + + if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) + { + SendError(response, 400, "Missing 'action' field. Use: switch, delete"); + return; + } + + string action = actionElem.GetString() ?? ""; + int profileId = parsed.TryGetValue("profile_id", out var idElem) ? idElem.GetInt32() : 0; + + try + { + var resultTask = RunOnMainThread(() => + { + var sm = SaveManager.Instance; + + if (action == "switch") + { + if (profileId < 1 || profileId > 3) + return new Dictionary { ["error"] = "profile_id must be 1-3" }; + if (RunManager.Instance.IsInProgress) + return new Dictionary { ["error"] = "Cannot switch profiles during a run" }; + + sm.SwitchProfileId(profileId); + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Switched to profile {profileId}", + ["current_profile_id"] = sm.CurrentProfileId + }; + } + else if (action == "delete") + { + if (profileId < 1 || profileId > 3) + return new Dictionary { ["error"] = "profile_id must be 1-3" }; + if (profileId == sm.CurrentProfileId) + return new Dictionary { ["error"] = "Cannot delete the active profile" }; + + sm.DeleteProfile(profileId); + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Deleted profile {profileId}" + }; + } + + return new Dictionary { ["error"] = $"Unknown action: {action}. Use: switch, delete" }; + }); + var result = resultTask.GetAwaiter().GetResult(); + SendJson(response, result); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Profile action failed: {ex.Message}"); + } + } + private static void HandleGetProfile(HttpListenerResponse response) { try From ba2d5094b9bc4ead9b0e7e6c4c584fbe90edc5d3 Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 12:14:53 -0400 Subject: [PATCH 034/190] Add GET /api/v1/settings endpoint Exposes display (resolution, fps, vsync, fullscreen), audio (master, bgm, sfx, ambience), gameplay (fast mode, screen shake, card indices), mod status, language, and skip intro settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.cs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/McpMod.cs b/McpMod.cs index 13024129..6224c70a 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -178,6 +178,65 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/settings") + { + if (request.HttpMethod == "GET") + { + try + { + var dataTask = RunOnMainThread(() => + { + var sm = SaveManager.Instance; + var settings = sm.SettingsSave; + var prefs = sm.PrefsSave; + + return new Dictionary + { + ["display"] = new Dictionary + { + ["fullscreen"] = settings.Fullscreen, + ["resolution"] = $"{settings.WindowSize.X}x{settings.WindowSize.Y}", + ["fps_limit"] = settings.FpsLimit, + ["vsync"] = settings.VSync.ToString(), + ["msaa"] = settings.Msaa, + ["aspect_ratio"] = settings.AspectRatioSetting.ToString(), + ["target_display"] = settings.TargetDisplay, + ["limit_fps_background"] = settings.LimitFpsInBackground, + }, + ["audio"] = new Dictionary + { + ["master"] = settings.VolumeMaster, + ["bgm"] = settings.VolumeBgm, + ["sfx"] = settings.VolumeSfx, + ["ambience"] = settings.VolumeAmbience, + }, + ["gameplay"] = new Dictionary + { + ["fast_mode"] = prefs.FastMode.ToString(), + ["screen_shake"] = prefs.ScreenShakeOptionIndex, + ["show_run_timer"] = prefs.ShowRunTimer, + ["show_card_indices"] = prefs.ShowCardIndices, + ["text_effects"] = prefs.TextEffectsEnabled, + ["long_press"] = prefs.IsLongPressEnabled, + }, + ["mods"] = new Dictionary + { + ["enabled"] = settings.ModSettings?.PlayerAgreedToModLoading ?? false, + }, + ["language"] = settings.Language, + ["skip_intro"] = settings.SkipIntroLogo, + }; + }); + SendJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (System.Exception ex) + { + SendError(response, 500, $"Failed to read settings: {ex.Message}"); + } + } + else + SendError(response, 405, "Method not allowed"); + } else if (path == "/api/v1/profiles") { if (request.HttpMethod == "GET") From 19bd6b2ef3d6fe376d34bfb8b557b288acc7093c Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 12:25:14 -0400 Subject: [PATCH 035/190] Fix profile switch to use proper UI flow like a real player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens NProfileScreen, finds NProfileButton by ID, calls SwitchToThisProfile() — no game restart needed. Also detects profile_select screen in state builder. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.StateBuilder.cs | 11 ++++++++++ McpMod.cs | 49 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index eb4b921c..50229491 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -38,6 +38,7 @@ using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; using MegaCrit.Sts2.Core.Nodes.Screens.Timeline; +using MegaCrit.Sts2.Core.Nodes.Screens.ProfileScreen; using MegaCrit.Sts2.Core.Nodes.Screens.Settings; using Godot; @@ -289,6 +290,16 @@ public static partial class McpMod result["message"] = "Settings screen."; } else + { + var profileScreen = FindFirst(tree.Root); + if (profileScreen != null && profileScreen.Visible) + { + result["menu_screen"] = "profile_select"; + result["message"] = "Profile select screen."; + result["current_profile_id"] = SaveManager.Instance?.CurrentProfileId; + } + } + if (!result.ContainsKey("menu_screen")) { result["menu_screen"] = "main"; result["message"] = "Main menu."; diff --git a/McpMod.cs b/McpMod.cs index 6224c70a..6d4d9541 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -502,11 +502,58 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener if (RunManager.Instance.IsInProgress) return new Dictionary { ["error"] = "Cannot switch profiles during a run" }; + // Use the proper UI flow: find profile screen and click the button + var tree = (Godot.Engine.GetMainLoop()) as Godot.SceneTree; + if (tree?.Root != null) + { + var profileScreen = FindFirst(tree.Root); + if (profileScreen != null) + { + var buttons = profileScreen.GetType().GetField("_profileButtons", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetValue(profileScreen) as System.Collections.IList; + if (buttons != null) + { + foreach (var btn in buttons) + { + var btnId = btn.GetType().GetField("_profileId", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetValue(btn); + if (btnId is int id && id == profileId) + { + var switchMethod = btn.GetType().GetMethod("SwitchToThisProfile", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + switchMethod?.Invoke(btn, null); + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Switching to profile {profileId} (via UI)", + ["current_profile_id"] = profileId + }; + } + } + } + } + + // Fallback: open profile screen first if not already open + var mainMenu = FindFirst(tree.Root); + if (mainMenu != null) + { + mainMenu.OpenProfileScreen(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Opened profile screen. Send switch again to select profile.", + }; + } + } + + // Last resort: direct switch (requires restart) sm.SwitchProfileId(profileId); return new Dictionary { ["status"] = "ok", - ["message"] = $"Switched to profile {profileId}", + ["message"] = $"Switched to profile {profileId} (requires restart)", ["current_profile_id"] = sm.CurrentProfileId }; } From 821e6a68c241ec81d806729bc27d918d6f80bfbf Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 12:50:32 -0400 Subject: [PATCH 036/190] Detect and dismiss tutorial prompts, handle all FTUE popups - NAcceptTutorialsFtue detection as menu_screen: tutorial_prompt - menu_select with "no"/"yes" clicks the tutorial popup buttons - Generic NFtue detection for other tutorial popups (combat rules, etc) - Profile screen detection as menu_screen: profile_select Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 32 ++++++++++++++++++++++++++++++++ McpMod.StateBuilder.cs | 27 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 28c8dd03..0f1af09d 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -856,6 +856,38 @@ public static partial class McpMod return Error($"Button '{option}' not available"); } + // Tutorial/FTUE popup — "Enable Tutorials?" dialog + var tutorialFtue = FindFirst(tree.Root); + if (tutorialFtue != null && tutorialFtue.Visible) + { + var popup = tutorialFtue.GetType().GetField("_verticalPopup", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(tutorialFtue); + if (popup != null) + { + // "no" clicks No, "yes" clicks Yes, default to No + var isYes = string.Equals(option, "yes", System.StringComparison.OrdinalIgnoreCase); + var btnField = isYes ? "k__BackingField" : "k__BackingField"; + var btn = popup.GetType().GetField(btnField, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(popup); + if (btn is NClickableControl clickable && clickable.IsEnabled) + { + clickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = $"Tutorials: {(isYes ? "enabled" : "disabled")}" }; + } + } + return Error("Tutorial popup visible but buttons not accessible"); + } + + // Any other FTUE/tutorial popup with a confirm button + var ftue = FindFirst(tree.Root); + if (ftue != null && ftue.Visible) + { + var confirmBtn = ftue.GetType().GetField("_confirmButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(ftue); + if (confirmBtn is NClickableControl ftueClickable && ftueClickable.IsEnabled) + { + ftueClickable.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Dismissed tutorial popup" }; + } + } + // Timeline screen — advance through epoch reveals var timelineScreen = FindFirst(tree.Root); if (timelineScreen != null && timelineScreen.Visible) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 50229491..f5920950 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -58,6 +58,32 @@ public static partial class McpMod var tree = (Godot.Engine.GetMainLoop()) as SceneTree; if (tree?.Root != null) { + // Check for tutorial FTUE popup + var tutorialFtue = FindFirst(tree.Root); + if (tutorialFtue != null && tutorialFtue.Visible) + { + result["menu_screen"] = "tutorial_prompt"; + result["message"] = "Enable Tutorials? Choose yes or no."; + result["options"] = new List> + { + new() { ["name"] = "no", ["enabled"] = true }, + new() { ["name"] = "yes", ["enabled"] = true } + }; + } + + // Check for any other FTUE popup + if (!result.ContainsKey("menu_screen")) + { + var ftue = FindFirst(tree.Root); + if (ftue != null && ftue.Visible) + { + result["menu_screen"] = "tutorial"; + result["message"] = "Tutorial popup active. Use advance to dismiss."; + } + } + + if (!result.ContainsKey("menu_screen")) + { // Check for singleplayer submenu (Standard / Daily / Custom) var spSubmenu = FindFirst(tree.Root); if (spSubmenu != null && spSubmenu.Visible) @@ -327,6 +353,7 @@ public static partial class McpMod } } } + } // close if (!result.ContainsKey("menu_screen")) else { result["message"] = "No run in progress."; From 8b129d9e6b548a2f2d6e945bfb11d5f0d496836b Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Sun, 29 Mar 2026 13:02:11 -0400 Subject: [PATCH 037/190] Add seed support when embarking on a run menu_select with option "confirm" now accepts optional "seed" field. Sets seed via StartRunLobby.SetSeed() before clicking embark. Omit seed for random. Co-Authored-By: Claude Opus 4.6 (1M context) --- McpMod.Actions.cs | 11 +++++++++-- McpMod.cs | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 0f1af09d..6c3889c7 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -828,7 +828,7 @@ public static partial class McpMod return null; } - internal static Dictionary ExecuteMenuSelect(string option) + internal static Dictionary ExecuteMenuSelect(string option, string? seed = null) { var tree = (Engine.GetMainLoop()) as SceneTree; if (tree?.Root == null) @@ -1053,11 +1053,18 @@ public static partial class McpMod if (string.Equals(option, "confirm", System.StringComparison.OrdinalIgnoreCase) || string.Equals(option, "embark", System.StringComparison.OrdinalIgnoreCase)) { + // Set seed before embarking if provided + if (!string.IsNullOrEmpty(seed) && charSelect.Lobby != null) + { + charSelect.Lobby.SetSeed(seed); + } + var embarkBtn = charSelect.GetType().GetField("_embarkButton", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(charSelect); if (embarkBtn is NClickableControl embarkClickable && embarkClickable.IsEnabled) { + var msg = string.IsNullOrEmpty(seed) ? "Embarking on run" : $"Embarking on run (seed: {seed})"; embarkClickable.ForceClick(); - return new Dictionary { ["status"] = "ok", ["message"] = "Embarking on run" }; + return new Dictionary { ["status"] = "ok", ["message"] = msg }; } return Error("Embark button not available — select a character first"); } diff --git a/McpMod.cs b/McpMod.cs index 6d4d9541..5131a566 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -684,7 +684,8 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe try { var option = parsed.TryGetValue("option", out var optElem) ? optElem.GetString() ?? "" : ""; - var resultTask = RunOnMainThread(() => ExecuteMenuSelect(option)); + var seed = parsed.TryGetValue("seed", out var seedElem) ? seedElem.GetString() : null; + var resultTask = RunOnMainThread(() => ExecuteMenuSelect(option, seed)); var result = resultTask.GetAwaiter().GetResult(); SendJson(response, result); } From 500e2b844168899550345eadf6ad6587559078cb Mon Sep 17 00:00:00 2001 From: Timothy Gregg Date: Fri, 3 Apr 2026 14:42:11 -0400 Subject: [PATCH 038/190] Reapply fork-specific changes after upstream sync --- .gitignore | 3 ++ AGENTS.md | 20 ++++++++++ McpMod.cs | 103 +++++++++++++++++++++++++++++++++++++++++--------- README.md | 6 +-- mcp/server.py | 8 +++- 5 files changed, 117 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 825ceace..88e29010 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ GUIDE.md # Qoder .qoder.json + +# Local planning +todo.md diff --git a/AGENTS.md b/AGENTS.md index e49c8580..6aefe867 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,3 +59,23 @@ - Not checking card indices after playing — indices shift left. - Taking too long to kill bosses — enemies scale every turn. - Adding mediocre cards that dilute the deck before boss fights. + +## Development Workflow + +When starting any new feature, functionality, or meaningful change: + +1. Create an appropriate feature branch before making changes. +2. Use a branch name with the `codex/` prefix when appropriate, for example `codex/add-spectator-delay`. +3. Reference a `todo.md` during the work. +4. Stay on the branch that matches the current task and do not mix unrelated changes across branches. +5. Before switching branches, make sure work is committed or stashed and the working tree is clean. +6. When working on multiple branches at once, keep one task per branch and use separate worktrees when practical. +7. Ensure the thread is associated with the active working branch before committing. If the thread was previously on `main` and work is now on `codex/...`, switch or confirm the thread to that feature branch. Never commit to `main` unless explicitly instructed. +8. Keep branches small and single-purpose so review, testing, and rollback are easier. +9. Rebase or merge from `main` regularly to keep long-lived branches current. +10. Do the implementation work on that branch. +11. Run the relevant tests and checks before committing. +12. Commit and push only after the work has been tested. +13. If the change is valid, merge it. + +Goal: keep `main` stable and use feature branches as the default workflow. \ No newline at end of file diff --git a/McpMod.cs b/McpMod.cs index b3ca707e..20a4fe12 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -27,6 +29,7 @@ public static partial class McpMod private static HttpListener? _listener; private static Thread? _serverThread; + private static string[] _boundPrefixes = Array.Empty(); private static readonly ConcurrentQueue _mainThreadQueue = new(); internal static readonly JsonSerializerOptions _jsonOptions = new() { @@ -86,22 +89,7 @@ public static void Initialize() tree.Connect(SceneTree.SignalName.ProcessFrame, Callable.From(ProcessMainThreadQueue)); int port = LoadPort(); - - _listener = new HttpListener(); - try - { - // Try binding to all interfaces first (requires URL ACL: netsh http add urlacl url=http://+:{port}/ user=Everyone) - _listener.Prefixes.Add($"http://+:{port}/"); - _listener.Start(); - } - catch - { - // Fall back to localhost-only if ACL not configured - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://localhost:{port}/"); - _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); - _listener.Start(); - } + StartHttpServer(port); _serverThread = new Thread(ServerLoop) { @@ -110,7 +98,7 @@ public static void Initialize() }; _serverThread.Start(); - GD.Print($"[STS2 MCP] v{Version} server started on http://localhost:{port}/"); + GD.Print($"[STS2 MCP] v{Version} server started on {string.Join(", ", _boundPrefixes)}"); } catch (Exception ex) { @@ -118,6 +106,85 @@ public static void Initialize() } } + private static void StartHttpServer(int port) + { + var attempts = new List + { + new[] { $"http://+:{port}/" } + }; + + var ipv4Prefixes = GetIpv4Prefixes(port); + if (ipv4Prefixes.Count > 0) + attempts.Add(ipv4Prefixes.ToArray()); + + attempts.Add(new[] + { + $"http://localhost:{port}/", + $"http://127.0.0.1:{port}/" + }); + + Exception? lastError = null; + + foreach (var prefixes in attempts) + { + HttpListener? candidate = null; + try + { + candidate = new HttpListener(); + foreach (var prefix in prefixes) + candidate.Prefixes.Add(prefix); + candidate.Start(); + + _listener = candidate; + _boundPrefixes = prefixes; + return; + } + catch (Exception ex) + { + lastError = ex; + try { candidate?.Close(); } catch { } + } + } + + throw new InvalidOperationException( + $"Could not bind STS2 MCP on port {port}. Tried wildcard, explicit IPv4, and loopback prefixes.", + lastError); + } + + private static List GetIpv4Prefixes(int port) + { + var prefixes = new List(); + + try + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up) + continue; + + foreach (var addressInfo in nic.GetIPProperties().UnicastAddresses) + { + if (addressInfo.Address.AddressFamily != AddressFamily.InterNetwork) + continue; + + var address = addressInfo.Address.ToString(); + if (address == "127.0.0.1" || address.StartsWith("169.254.", StringComparison.Ordinal)) + continue; + + var prefix = $"http://{address}:{port}/"; + if (!prefixes.Contains(prefix)) + prefixes.Add(prefix); + } + } + } + catch (Exception ex) + { + GD.PrintErr($"[STS2 MCP] Failed to enumerate IPv4 listeners: {ex.Message}"); + } + + return prefixes; + } + private static void ProcessMainThreadQueue() { int processed = 0; @@ -187,7 +254,7 @@ private static void HandleRequest(HttpListenerContext context) if (path == "/") { - SendJson(response, new { message = $"Hello from STS2 MCP v{Version}", status = "ok" }); + SendJson(response, new { message = $"Hello from STS2 MCP v{Version}", status = "ok", bound_prefixes = _boundPrefixes }); } else if (path == "/api/v1/singleplayer") { diff --git a/README.md b/README.md index cebb15f3..b53a6934 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@

An Experimental Research Project to Fully-Automate your Slay the Spire 2 Runs

-A mod for [**Slay the Spire 2**](https://store.steampowered.com/app/2868840/Slay_the_Spire_2/) that lets AI agents play the game. Exposes game state and actions via a localhost REST API, with an optional MCP server for Claude Desktop / Claude Code integration. +A mod for [**Slay the Spire 2**](https://store.steampowered.com/app/2868840/Slay_the_Spire_2/) that lets AI agents play the game. Exposes game state and actions via a local REST API, with an optional MCP server for Claude Desktop / Claude Code integration. Singleplayer and multiplayer (co-op) supported. Tested against STS2 `v0.99.1`. > [!warning] -> This mod allows external programs to read and control your game via a localhost API. Use at your own risk with runs you care less about. +> This mod allows external programs to read and control your game via a local API. When the Windows listener can bind an IPv4 interface, other machines on the same network can reach it. Use at your own risk with runs you care less about. > [!caution] > Multiplayer support is in **beta** — expect bugs. Any multiplayer issues encountered with this mod installed are very likely caused by the mod, not the game. Please disable the mod and verify the issue persists before reporting bugs to the STS2 developers. @@ -22,7 +22,7 @@ Grab the [latest release](https://github.com/Gennadiyev/STS2MCP/releases/latest) 1. Copy `STS2_MCP.dll` and `STS2_MCP.json` to `/mods/` 2. Launch the game and enable mods in settings (a consent dialog appears on first launch) -3. The mod starts an HTTP server on `localhost:15526` automatically +3. The mod starts an HTTP server on port `15526` automatically. It first tries a wildcard bind, then explicit local IPv4 addresses, and finally loopback-only (`localhost`) if broader binds are unavailable. ### 2. Give Your AI Instructions to Interact with the Game diff --git a/mcp/server.py b/mcp/server.py index db5ada70..85ec156d 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -6,6 +6,7 @@ import argparse import json +import os import sys import httpx @@ -15,6 +16,9 @@ _base_url: str = "http://localhost:15526" _trust_env: bool = True +DEFAULT_HOST = os.environ.get("STS2_HOST", "localhost") +DEFAULT_PORT = int(os.environ.get("STS2_PORT", "15526")) +_base_url: str = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}" def _sp_url() -> str: @@ -889,8 +893,8 @@ async def mp_crystal_sphere_proceed() -> str: def main(): parser = argparse.ArgumentParser(description="STS2 MCP Server") - parser.add_argument("--port", type=int, default=15526, help="Game HTTP server port") - parser.add_argument("--host", type=str, default="localhost", help="Game HTTP server host") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Game HTTP server port") + parser.add_argument("--host", type=str, default=DEFAULT_HOST, help="Game HTTP server host") parser.add_argument("--no-trust-env", action="store_true", help="Ignore HTTP_PROXY/HTTPS_PROXY environment variables") args = parser.parse_args() From c87a11d3c4dd2291bd3bf00c7a7b0864ae9e120c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Fri, 8 May 2026 22:58:36 -0400 Subject: [PATCH 039/190] Add compendium endpoint --- McpMod.Compendium.cs | 437 +++++++++++++++++++++++++++++++++++++++++ McpMod.Profile.cs | 4 +- McpMod.cs | 7 + docs/raw-full.md | 38 ++++ docs/raw-simplified.md | 12 ++ mcp/server.py | 25 +++ 6 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 McpMod.Compendium.cs diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs new file mode 100644 index 00000000..e87e0ddb --- /dev/null +++ b/McpMod.Compendium.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text.Json; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; + +namespace STS2_MCP; + +public static partial class McpMod +{ + private static void HandleGetCompendium(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(BuildCompendium); + SendJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build compendium: {ex.Message}"); + } + } + + internal static object BuildCompendium() + { + var progress = SaveManager.Instance?.Progress; + var saveManager = SaveManager.Instance; + if (progress == null || saveManager == null) + return new Dictionary { ["error"] = "No profile data available." }; + + var cardStats = progress.CardStats.Select(kv => new Dictionary + { + ["id"] = kv.Key.Entry, + ["times_picked"] = kv.Value.TimesPicked, + ["times_skipped"] = kv.Value.TimesSkipped, + ["times_won"] = kv.Value.TimesWon, + ["times_lost"] = kv.Value.TimesLost + }).ToList(); + + var characterStats = progress.CharacterStats.Select(kv => new Dictionary + { + ["id"] = kv.Key.Entry, + ["max_ascension"] = kv.Value.MaxAscension, + ["preferred_ascension"] = kv.Value.PreferredAscension, + ["total_wins"] = kv.Value.TotalWins, + ["total_losses"] = kv.Value.TotalLosses, + ["fastest_win_time"] = kv.Value.FastestWinTime, + ["best_win_streak"] = kv.Value.BestWinStreak, + ["current_win_streak"] = kv.Value.CurrentWinStreak, + ["playtime"] = kv.Value.Playtime + }).ToList(); + + var runHistory = BuildRunHistorySection(progress, saveManager.CurrentProfileId); + + return new Dictionary + { + ["profile_id"] = saveManager.CurrentProfileId, + ["source"] = "SaveManager.Progress plus model metadata endpoints", + ["sections"] = new Dictionary + { + ["card_library"] = new Dictionary + { + ["ui_label"] = "Card Library", + ["status"] = "exposed", + ["source"] = "/api/v1/profile card_stats and discovered_cards", + ["detail_endpoint"] = "/api/v1/glossary/cards", + ["detail_endpoint_requires_run"] = true, + ["discovered_ids"] = progress.DiscoveredCards.Select(id => id.Entry).ToList(), + ["stats"] = cardStats + }, + ["relic_collection"] = new Dictionary + { + ["ui_label"] = "Relic Collection", + ["status"] = "partially_exposed", + ["source"] = "/api/v1/profile discovered_relics", + ["detail_endpoint"] = "/api/v1/glossary/relics", + ["detail_endpoint_requires_run"] = true, + ["discovered_ids"] = progress.DiscoveredRelics.Select(id => id.Entry).ToList(), + ["limitation"] = "Profile exposes discovered relic IDs; per-relic obtained counts are not exposed by a typed API here." + }, + ["potion_lab"] = new Dictionary + { + ["ui_label"] = "Potion Lab", + ["status"] = "partially_exposed", + ["source"] = "/api/v1/profile discovered_potions", + ["detail_endpoint"] = "/api/v1/glossary/potions", + ["detail_endpoint_requires_run"] = true, + ["discovered_ids"] = progress.DiscoveredPotions.Select(id => id.Entry).ToList(), + ["limitation"] = "Profile exposes discovered potion IDs; per-potion lab UI metadata is not exposed by a typed API here." + }, + ["bestiary"] = new Dictionary + { + ["ui_label"] = "Bestiary", + ["status"] = "locked_in_ui", + ["source"] = "/api/v1/bestiary model metadata", + ["detail_endpoint"] = "/api/v1/bestiary", + ["encounter_stats"] = BuildEncounterStats(progress), + ["enemy_stats"] = BuildEnemyStats(progress), + ["limitation"] = "The current game UI labels Bestiary as future/locked; the endpoint exposes reflected model metadata and profile fight stats when available." + }, + ["character_stats"] = new Dictionary + { + ["ui_label"] = "Character Stats", + ["status"] = "exposed", + ["source"] = "/api/v1/profile characters and global totals", + ["characters"] = characterStats, + ["global"] = new Dictionary + { + ["total_playtime"] = progress.TotalPlaytime, + ["total_unlocks"] = progress.TotalUnlocks, + ["current_score"] = progress.CurrentScore, + ["floors_climbed"] = progress.FloorsClimbed, + ["architect_damage"] = progress.ArchitectDamage, + ["total_wins"] = progress.Wins, + ["total_losses"] = progress.Losses, + ["fastest_victory"] = progress.FastestVictory, + ["best_win_streak"] = progress.BestWinStreak, + ["number_of_runs"] = progress.NumberOfRuns + } + }, + ["run_history"] = runHistory + } + }; + } + + private static List> BuildEncounterStats(dynamic progress) + { + var result = new List>(); + foreach (var kv in progress.EncounterStats) + result.Add(BuildFightStatsEntry(kv.Key.Entry, kv.Value)); + return result; + } + + private static List> BuildEnemyStats(dynamic progress) + { + var result = new List>(); + foreach (var kv in progress.EnemyStats) + result.Add(BuildFightStatsEntry(kv.Key.Entry, kv.Value)); + return result; + } + + private static Dictionary BuildFightStatsEntry(string id, dynamic stats) + { + var entry = new Dictionary + { + ["id"] = id, + ["total_wins"] = stats.TotalWins, + ["total_losses"] = stats.TotalLosses + }; + + var byCharacter = new List>(); + foreach (var fs in stats.FightStats) + { + byCharacter.Add(new Dictionary + { + ["character"] = fs.Character.Entry, + ["wins"] = fs.Wins, + ["losses"] = fs.Losses + }); + } + if (byCharacter.Count > 0) + entry["by_character"] = byCharacter; + + return entry; + } + + private static Dictionary BuildRunHistorySection(object progress, int profileId) + { + var members = progress.GetType() + .GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(member => member.Name.Contains("RunHistory", StringComparison.OrdinalIgnoreCase) + || member.Name.Contains("RunHistories", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var values = new Dictionary(); + foreach (var member in members) + { + try + { + object? value = member switch + { + PropertyInfo property when property.GetIndexParameters().Length == 0 => property.GetValue(progress), + FieldInfo field => field.GetValue(progress), + _ => null + }; + if (value != null) + values[member.Name] = ToJsonSafe(value, 0, 50); + } + catch { } + } + + var historyDirectory = FindRunHistoryDirectory(profileId); + if (historyDirectory != null) + { + var files = Directory.GetFiles(historyDirectory, "*.run") + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ToList(); + + return new Dictionary + { + ["ui_label"] = "Run History", + ["status"] = files.Count > 0 ? "exposed" : "exposed_empty", + ["source"] = "Profile save history files", + ["history_path"] = historyDirectory, + ["entry_count"] = files.Count, + ["entries"] = files.Take(20).Select(BuildRunHistoryEntry).ToList(), + ["progress_members"] = values.Count > 0 ? values : null, + ["limitation"] = files.Count > 20 + ? "Only the 20 most recent run files are summarized; use history_path to inspect older local run files." + : "Run history is summarized from the active profile's saved .run files." + }; + } + + return new Dictionary + { + ["ui_label"] = "Run History", + ["status"] = values.Count > 0 ? "partially_exposed" : "exposed_empty", + ["source"] = "SaveManager.Progress reflected run-history members", + ["entries"] = values, + ["limitation"] = values.Count > 0 + ? "Run history is serialized from discovered progress members and capped for response size." + : "No run-history files or typed run-history members were found for the active profile." + }; + } + + private static Dictionary BuildRunHistoryEntry(FileInfo file) + { + var entry = new Dictionary + { + ["id"] = Path.GetFileNameWithoutExtension(file.Name), + ["file_name"] = file.Name, + ["size_bytes"] = file.Length, + ["last_write_time_utc"] = file.LastWriteTimeUtc + }; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(file.FullName)); + var root = document.RootElement; + + CopyJsonScalar(root, entry, "start_time"); + CopyJsonScalar(root, entry, "run_time"); + CopyJsonScalar(root, entry, "game_mode"); + CopyJsonScalar(root, entry, "ascension"); + CopyJsonScalar(root, entry, "win"); + CopyJsonScalar(root, entry, "was_abandoned"); + CopyJsonScalar(root, entry, "killed_by_encounter"); + CopyJsonScalar(root, entry, "killed_by_event"); + CopyJsonScalar(root, entry, "seed"); + CopyJsonScalar(root, entry, "build_id"); + + if (root.TryGetProperty("acts", out var acts) && acts.ValueKind == JsonValueKind.Array) + entry["acts"] = acts.EnumerateArray().Select(GetJsonValue).ToList(); + + if (root.TryGetProperty("players", out var players) && players.ValueKind == JsonValueKind.Array) + { + entry["players"] = players.EnumerateArray().Select(player => + { + var playerEntry = new Dictionary(); + CopyJsonScalar(player, playerEntry, "id"); + CopyJsonScalar(player, playerEntry, "character"); + if (player.TryGetProperty("deck", out var deck) && deck.ValueKind == JsonValueKind.Array) + playerEntry["deck_count"] = deck.GetArrayLength(); + if (player.TryGetProperty("relics", out var relics) && relics.ValueKind == JsonValueKind.Array) + playerEntry["relic_count"] = relics.GetArrayLength(); + if (player.TryGetProperty("potions", out var potions) && potions.ValueKind == JsonValueKind.Array) + playerEntry["potion_count"] = potions.GetArrayLength(); + return playerEntry; + }).ToList(); + } + + entry["map_point_count"] = CountMapPoints(root); + } + catch (Exception ex) + { + entry["parse_error"] = ex.Message; + } + + return entry; + } + + private static int CountMapPoints(JsonElement root) + { + if (!root.TryGetProperty("map_point_history", out var acts) || acts.ValueKind != JsonValueKind.Array) + return 0; + + var count = 0; + foreach (var act in acts.EnumerateArray()) + { + if (act.ValueKind == JsonValueKind.Array) + count += act.GetArrayLength(); + } + return count; + } + + private static void CopyJsonScalar(JsonElement source, Dictionary target, string propertyName) + { + if (source.TryGetProperty(propertyName, out var property)) + target[propertyName] = GetJsonValue(property); + } + + private static object? GetJsonValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + private static string? FindRunHistoryDirectory(int profileId) + { + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + + foreach (var saveRoot in EnumerateSaveRoots()) + { + var historyPath = Path.Combine(saveRoot, profileRoot, "saves", "history"); + if (Directory.Exists(historyPath)) + return historyPath; + } + + return null; + } + + private static string GetProfileRootFromProgressPath(string? progressPath, int profileId) + { + var normalized = progressPath?.Replace('\\', '/'); + if (!string.IsNullOrWhiteSpace(normalized)) + { + var marker = $"/profile{profileId}/"; + var index = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (index >= 0) + return normalized[..(index + marker.Length - 1)]; + + if (normalized.StartsWith($"profile{profileId}/", StringComparison.OrdinalIgnoreCase)) + return $"profile{profileId}"; + if (normalized.StartsWith($"modded/profile{profileId}/", StringComparison.OrdinalIgnoreCase)) + return $"modded/profile{profileId}"; + } + + return $"profile{profileId}"; + } + + private static IEnumerable EnumerateSaveRoots() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrWhiteSpace(appData)) + yield break; + + var steamRoot = Path.Combine(appData, "SlayTheSpire2", "steam"); + if (!Directory.Exists(steamRoot)) + yield break; + + foreach (var accountRoot in Directory.GetDirectories(steamRoot)) + yield return accountRoot; + } + + private static string? GetProfileProgressPath(int profileId) + { + try { return ProgressSaveManager.GetProgressPathForProfile(profileId); } + catch { return null; } + } + + private static string? ResolveProfileProgressPath(int profileId) + { + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + + foreach (var saveRoot in EnumerateSaveRoots()) + { + var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "progress.save"); + if (File.Exists(absolutePath)) + return absolutePath; + } + + return progressPath; + } + + private static object? ToJsonSafe(object? value, int depth, int maxItems) + { + if (value == null || depth > 4) return value?.ToString(); + if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal) + return value; + if (value is Enum) return value.ToString(); + + var type = value.GetType(); + var entryProperty = type.GetProperty("Entry", BindingFlags.Instance | BindingFlags.Public); + if (entryProperty?.GetValue(value) is string entry) + return entry; + + if (value is IDictionary dictionary) + { + var result = new Dictionary(); + var count = 0; + foreach (DictionaryEntry item in dictionary) + { + if (count++ >= maxItems) break; + result[item.Key?.ToString() ?? "null"] = ToJsonSafe(item.Value, depth + 1, maxItems); + } + return result; + } + + if (value is IEnumerable enumerable && value is not string) + { + var result = new List(); + var count = 0; + foreach (var item in enumerable) + { + if (count++ >= maxItems) break; + result.Add(ToJsonSafe(item, depth + 1, maxItems)); + } + return result; + } + + var obj = new Dictionary(); + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetIndexParameters().Length != 0) + continue; + try { obj[property.Name] = ToJsonSafe(property.GetValue(value), depth + 1, maxItems); } + catch { } + } + return obj.Count > 0 ? obj : value.ToString(); + } +} diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index d725495a..d429ba86 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -98,8 +98,10 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener try { var path = ProgressSaveManager.GetProgressPathForProfile(i); - profileData["has_data"] = File.Exists(path); + var resolvedPath = ResolveProfileProgressPath(i); + profileData["has_data"] = resolvedPath != null && File.Exists(resolvedPath); profileData["path"] = path; + profileData["resolved_path"] = resolvedPath; } catch { diff --git a/McpMod.cs b/McpMod.cs index 936d34b1..2d48fe6a 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -331,6 +331,13 @@ private static void HandleRequest(HttpListenerContext context) else SendError(response, 405, "Method not allowed"); } + else if (path == "/api/v1/compendium") + { + if (request.HttpMethod == "GET") + HandleGetCompendium(response); + else + SendError(response, 405, "Method not allowed"); + } else if (path == "/api/v1/bestiary") { if (request.HttpMethod == "GET") diff --git a/docs/raw-full.md b/docs/raw-full.md index b01ab1f6..2ceb45e3 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -8,6 +8,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `GET /api/v1/multiplayer` — read multiplayer game state - `POST /api/v1/multiplayer` — perform a multiplayer action - `GET /api/v1/profile` — read current profile progress +- `GET /api/v1/compendium` — read Compendium-shaped profile progress - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots @@ -876,6 +877,43 @@ Profile endpoints are independent of the singleplayer and multiplayer run endpoi Returns the active profile's persistent progress summary, including character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. +### `GET /api/v1/compendium` + +Returns the active profile's progress grouped by the in-game Compendium cards: + +- `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. +- `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. +- `potion_lab`: discovered potion IDs. Detailed potion metadata lives at `/api/v1/glossary/potions`, which currently requires a run context. +- `bestiary`: profile encounter/enemy fight stats plus a pointer to `/api/v1/bestiary` for reflected model metadata. The in-game Bestiary card is currently marked future/locked. +- `character_stats`: per-character and global profile totals. +- `run_history`: summaries of the active profile's saved `saves/history/*.run` files. If more than 20 files exist, the response includes the 20 most recent entries and the local `history_path`. + +```jsonc +{ + "profile_id": 1, + "sections": { + "card_library": { "status": "exposed", "discovered_ids": ["BASH"], "stats": [] }, + "relic_collection": { "status": "partially_exposed", "discovered_ids": ["BURNING_BLOOD"] }, + "potion_lab": { "status": "partially_exposed", "discovered_ids": [] }, + "bestiary": { "status": "locked_in_ui", "detail_endpoint": "/api/v1/bestiary" }, + "character_stats": { "status": "exposed", "characters": [], "global": {} }, + "run_history": { + "status": "exposed", + "entry_count": 10, + "entries": [ + { + "id": "1774869148", + "players": [{ "id": 1, "character": "CHARACTER.IRONCLAD" }], + "ascension": 0, + "win": false, + "run_time": 6541 + } + ] + } + } +} +``` + ### `GET /api/v1/profiles` Lists the three profile slots and identifies the active slot. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 1ff7b9ba..802f0ba2 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -7,6 +7,7 @@ HTTP API on `localhost:15526`. No authentication. - `GET /api/v1/multiplayer` — read multiplayer state - `POST /api/v1/multiplayer` — perform multiplayer action - `GET /api/v1/profile` — read current profile progress +- `GET /api/v1/compendium` — read Compendium-shaped profile progress - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots @@ -62,6 +63,17 @@ All POST requests use JSON body with `"action"` field. All responses include `{ `GET /api/v1/profile` returns persistent progress for the active profile, including character stats, discoveries, achievements, epochs, and global run totals. +`GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: + +| Section | Status | +|---|---| +| `card_library` | Discovered cards and card stats; `/api/v1/glossary/cards` adds metadata when a run context exists. | +| `relic_collection` | Discovered relic IDs; `/api/v1/glossary/relics` adds metadata when a run context exists. | +| `potion_lab` | Discovered potion IDs; `/api/v1/glossary/potions` adds metadata when a run context exists. | +| `bestiary` | Encounter/enemy profile stats plus `/api/v1/bestiary` model metadata. The game UI marks Bestiary as future/locked. | +| `character_stats` | Per-character and global totals. | +| `run_history` | Summaries of the active profile's saved `saves/history/*.run` files, capped to the 20 most recent entries. | + `GET /api/v1/profiles` returns the three profile slots: ```json diff --git a/mcp/server.py b/mcp/server.py index f6292ef4..3b89ae0a 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -35,6 +35,10 @@ def _profile_url() -> str: return f"{_base_url}/api/v1/profile" +def _compendium_url() -> str: + return f"{_base_url}/api/v1/compendium" + + def _profiles_url() -> str: return f"{_base_url}/api/v1/profiles" @@ -76,6 +80,12 @@ async def _profile_get() -> str: return r.text +async def _compendium_get() -> str: + r = await _get_client().get(_compendium_url()) + r.raise_for_status() + return r.text + + async def _profiles_get() -> str: r = await _get_client().get(_profiles_url()) r.raise_for_status() @@ -203,6 +213,21 @@ async def get_profile() -> str: return _handle_error(e) +@mcp.tool() +async def get_compendium() -> str: + """Get the active profile's Compendium-shaped progress summary. + + Mirrors the visible Compendium cards: Card Library, Relic Collection, + Potion Lab, Bestiary, Character Stats, and Run History. Some sections point + to detail endpoints such as glossary or bestiary when model-level metadata is + separate from profile discovery/progress data. + """ + try: + return await _compendium_get() + except Exception as e: + return _handle_error(e) + + @mcp.tool() async def list_profiles() -> str: """List the three profile slots and identify the active slot. From f2b80a945092ece2bcbfe31c5d23e25b1a549e8f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Fri, 8 May 2026 23:20:52 -0400 Subject: [PATCH 040/190] Add compendium run identity --- McpMod.Compendium.cs | 85 +++++++++++++++++++++++++++++++++++++++++- docs/raw-full.md | 11 ++++++ docs/raw-simplified.md | 2 + 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index e87e0ddb..25c65473 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -6,6 +6,7 @@ using System.Net; using System.Reflection; using System.Text.Json; +using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; using MegaCrit.Sts2.Core.Saves.Managers; @@ -60,6 +61,7 @@ internal static object BuildCompendium() return new Dictionary { ["profile_id"] = saveManager.CurrentProfileId, + ["current_run"] = BuildCurrentRunContext(saveManager.CurrentProfileId), ["source"] = "SaveManager.Progress plus model metadata endpoints", ["sections"] = new Dictionary { @@ -209,7 +211,7 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property ["source"] = "Profile save history files", ["history_path"] = historyDirectory, ["entry_count"] = files.Count, - ["entries"] = files.Take(20).Select(BuildRunHistoryEntry).ToList(), + ["entries"] = files.Take(20).Select(file => BuildRunHistoryEntry(file, profileId)).ToList(), ["progress_members"] = values.Count > 0 ? values : null, ["limitation"] = files.Count > 20 ? "Only the 20 most recent run files are summarized; use history_path to inspect older local run files." @@ -229,11 +231,14 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property }; } - private static Dictionary BuildRunHistoryEntry(FileInfo file) + private static Dictionary BuildRunHistoryEntry(FileInfo file, int profileId) { + var profileRoot = GetProfileRootFromProgressPath(GetProfileProgressPath(profileId), profileId); + var saveScope = GetSaveScope(profileRoot); var entry = new Dictionary { ["id"] = Path.GetFileNameWithoutExtension(file.Name), + ["run_id"] = $"{saveScope}:profile{profileId}:{Path.GetFileNameWithoutExtension(file.Name)}", ["file_name"] = file.Name, ["size_bytes"] = file.Length, ["last_write_time_utc"] = file.LastWriteTimeUtc @@ -285,6 +290,60 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property return entry; } + private static Dictionary? BuildCurrentRunContext(int profileId) + { + if (RunManager.Instance?.IsInProgress != true) + return null; + + var profileRoot = GetProfileRootFromProgressPath(GetProfileProgressPath(profileId), profileId); + var saveScope = GetSaveScope(profileRoot); + var result = new Dictionary + { + ["is_in_progress"] = true, + ["profile_id"] = profileId, + ["save_scope"] = saveScope, + ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" + }; + + var currentRunPath = ResolveCurrentRunPath(profileId); + if (currentRunPath == null || !File.Exists(currentRunPath)) + { + result["limitation"] = "Run is in progress, but current_run.save was not found yet."; + return result; + } + + result["save_path"] = currentRunPath; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(currentRunPath)); + var root = document.RootElement; + + CopyJsonScalar(root, result, "start_time"); + CopyJsonScalar(root, result, "save_time"); + CopyJsonScalar(root, result, "run_time"); + CopyJsonScalar(root, result, "game_mode"); + CopyJsonScalar(root, result, "ascension"); + CopyJsonScalar(root, result, "current_act_index"); + CopyJsonScalar(root, result, "schema_version"); + CopyJsonScalar(root, result, "platform_type"); + + if (root.TryGetProperty("rng", out var rng) + && rng.ValueKind == JsonValueKind.Object + && rng.TryGetProperty("seed", out var seed)) + result["seed"] = GetJsonValue(seed); + + if (result.TryGetValue("start_time", out var startTime) && startTime != null) + result["run_id"] = $"{saveScope}:profile{profileId}:{startTime}"; + } + catch (Exception ex) + { + result["parse_error"] = ex.Message; + } + + return result; + } + private static int CountMapPoints(JsonElement root) { if (!root.TryGetProperty("map_point_history", out var acts) || acts.ValueKind != JsonValueKind.Array) @@ -353,6 +412,13 @@ private static string GetProfileRootFromProgressPath(string? progressPath, int p return $"profile{profileId}"; } + private static string GetSaveScope(string profileRoot) + { + return profileRoot.StartsWith("modded/", StringComparison.OrdinalIgnoreCase) + ? "modded" + : "vanilla"; + } + private static IEnumerable EnumerateSaveRoots() { var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); @@ -388,6 +454,21 @@ private static IEnumerable EnumerateSaveRoots() return progressPath; } + private static string? ResolveCurrentRunPath(int profileId) + { + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + + foreach (var saveRoot in EnumerateSaveRoots()) + { + var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "current_run.save"); + if (File.Exists(absolutePath)) + return absolutePath; + } + + return null; + } + private static object? ToJsonSafe(object? value, int depth, int maxItems) { if (value == null || depth > 4) return value?.ToString(); diff --git a/docs/raw-full.md b/docs/raw-full.md index 2ceb45e3..3b0dcf49 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -881,6 +881,7 @@ Returns the active profile's persistent progress summary, including character st Returns the active profile's progress grouped by the in-game Compendium cards: +- `current_run`: present while a run is active. Includes a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. - `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. - `potion_lab`: discovered potion IDs. Detailed potion metadata lives at `/api/v1/glossary/potions`, which currently requires a run context. @@ -891,6 +892,15 @@ Returns the active profile's progress grouped by the in-game Compendium cards: ```jsonc { "profile_id": 1, + "current_run": { + "is_in_progress": true, + "profile_id": 1, + "save_scope": "modded", + "run_id": "modded:profile1:1778295706", + "start_time": 1778295706, + "seed": "2450ZAR9EF", + "run_time": 137 + }, "sections": { "card_library": { "status": "exposed", "discovered_ids": ["BASH"], "stats": [] }, "relic_collection": { "status": "partially_exposed", "discovered_ids": ["BURNING_BLOOD"] }, @@ -903,6 +913,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: "entries": [ { "id": "1774869148", + "run_id": "modded:profile1:1774869148", "players": [{ "id": 1, "character": "CHARACTER.IRONCLAD" }], "ascension": 0, "win": false, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 802f0ba2..a550785c 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -65,6 +65,8 @@ All POST requests use JSON body with `"action"` field. All responses include `{ `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: +When a run is active, the response includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content. + | Section | Status | |---|---| | `card_library` | Discovered cards and card stats; `/api/v1/glossary/cards` adds metadata when a run context exists. | From 217159a0d5606abdde60e98064f751e1135decdd Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 02:45:09 -0400 Subject: [PATCH 041/190] Address compendium review feedback --- McpMod.Compendium.cs | 179 +++++++++++++++++++++++++++++-------------- 1 file changed, 122 insertions(+), 57 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 25c65473..0a618384 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -14,12 +14,17 @@ namespace STS2_MCP; public static partial class McpMod { + private static readonly object _runHistoryMembersLock = new(); + private static Type? _runHistoryMembersType; + private static MemberInfo[]? _runHistoryMembers; + private static void HandleGetCompendium(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(BuildCompendium); - SendJson(response, dataTask.GetAwaiter().GetResult()); + var snapshotTask = RunOnMainThread(BuildCompendiumSnapshot); + var snapshot = snapshotTask.GetAwaiter().GetResult(); + SendJson(response, BuildCompendiumResponse(snapshot)); } catch (Exception ex) { @@ -28,11 +33,20 @@ private static void HandleGetCompendium(HttpListenerResponse response) } internal static object BuildCompendium() + { + return BuildCompendiumResponse(BuildCompendiumSnapshot()); + } + + private static CompendiumSnapshot BuildCompendiumSnapshot() { var progress = SaveManager.Instance?.Progress; var saveManager = SaveManager.Instance; if (progress == null || saveManager == null) - return new Dictionary { ["error"] = "No profile data available." }; + return new CompendiumSnapshot { Error = "No profile data available." }; + + var profileId = saveManager.CurrentProfileId; + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); var cardStats = progress.CardStats.Select(kv => new Dictionary { @@ -56,12 +70,48 @@ internal static object BuildCompendium() ["playtime"] = kv.Value.Playtime }).ToList(); - var runHistory = BuildRunHistorySection(progress, saveManager.CurrentProfileId); + return new CompendiumSnapshot + { + ProfileId = profileId, + ProgressPath = progressPath, + ProfileRoot = profileRoot, + SaveScope = GetSaveScope(profileRoot), + IsRunInProgress = RunManager.Instance?.IsInProgress == true, + DiscoveredCards = progress.DiscoveredCards.Select(id => id.Entry).ToList(), + DiscoveredRelics = progress.DiscoveredRelics.Select(id => id.Entry).ToList(), + DiscoveredPotions = progress.DiscoveredPotions.Select(id => id.Entry).ToList(), + CardStats = cardStats, + CharacterStats = characterStats, + EncounterStats = BuildEncounterStats(progress), + EnemyStats = BuildEnemyStats(progress), + RunHistoryProgressMembers = BuildRunHistoryProgressMembers(progress), + GlobalStats = new Dictionary + { + ["total_playtime"] = progress.TotalPlaytime, + ["total_unlocks"] = progress.TotalUnlocks, + ["current_score"] = progress.CurrentScore, + ["floors_climbed"] = progress.FloorsClimbed, + ["architect_damage"] = progress.ArchitectDamage, + ["total_wins"] = progress.Wins, + ["total_losses"] = progress.Losses, + ["fastest_victory"] = progress.FastestVictory, + ["best_win_streak"] = progress.BestWinStreak, + ["number_of_runs"] = progress.NumberOfRuns + } + }; + } + + private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) + { + if (snapshot.Error != null) + return new Dictionary { ["error"] = snapshot.Error }; + + var runHistory = BuildRunHistorySection(snapshot); return new Dictionary { - ["profile_id"] = saveManager.CurrentProfileId, - ["current_run"] = BuildCurrentRunContext(saveManager.CurrentProfileId), + ["profile_id"] = snapshot.ProfileId, + ["current_run"] = BuildCurrentRunContext(snapshot), ["source"] = "SaveManager.Progress plus model metadata endpoints", ["sections"] = new Dictionary { @@ -72,8 +122,8 @@ internal static object BuildCompendium() ["source"] = "/api/v1/profile card_stats and discovered_cards", ["detail_endpoint"] = "/api/v1/glossary/cards", ["detail_endpoint_requires_run"] = true, - ["discovered_ids"] = progress.DiscoveredCards.Select(id => id.Entry).ToList(), - ["stats"] = cardStats + ["discovered_ids"] = snapshot.DiscoveredCards, + ["stats"] = snapshot.CardStats }, ["relic_collection"] = new Dictionary { @@ -82,7 +132,7 @@ internal static object BuildCompendium() ["source"] = "/api/v1/profile discovered_relics", ["detail_endpoint"] = "/api/v1/glossary/relics", ["detail_endpoint_requires_run"] = true, - ["discovered_ids"] = progress.DiscoveredRelics.Select(id => id.Entry).ToList(), + ["discovered_ids"] = snapshot.DiscoveredRelics, ["limitation"] = "Profile exposes discovered relic IDs; per-relic obtained counts are not exposed by a typed API here." }, ["potion_lab"] = new Dictionary @@ -92,7 +142,7 @@ internal static object BuildCompendium() ["source"] = "/api/v1/profile discovered_potions", ["detail_endpoint"] = "/api/v1/glossary/potions", ["detail_endpoint_requires_run"] = true, - ["discovered_ids"] = progress.DiscoveredPotions.Select(id => id.Entry).ToList(), + ["discovered_ids"] = snapshot.DiscoveredPotions, ["limitation"] = "Profile exposes discovered potion IDs; per-potion lab UI metadata is not exposed by a typed API here." }, ["bestiary"] = new Dictionary @@ -101,8 +151,8 @@ internal static object BuildCompendium() ["status"] = "locked_in_ui", ["source"] = "/api/v1/bestiary model metadata", ["detail_endpoint"] = "/api/v1/bestiary", - ["encounter_stats"] = BuildEncounterStats(progress), - ["enemy_stats"] = BuildEnemyStats(progress), + ["encounter_stats"] = snapshot.EncounterStats, + ["enemy_stats"] = snapshot.EnemyStats, ["limitation"] = "The current game UI labels Bestiary as future/locked; the endpoint exposes reflected model metadata and profile fight stats when available." }, ["character_stats"] = new Dictionary @@ -110,26 +160,33 @@ internal static object BuildCompendium() ["ui_label"] = "Character Stats", ["status"] = "exposed", ["source"] = "/api/v1/profile characters and global totals", - ["characters"] = characterStats, - ["global"] = new Dictionary - { - ["total_playtime"] = progress.TotalPlaytime, - ["total_unlocks"] = progress.TotalUnlocks, - ["current_score"] = progress.CurrentScore, - ["floors_climbed"] = progress.FloorsClimbed, - ["architect_damage"] = progress.ArchitectDamage, - ["total_wins"] = progress.Wins, - ["total_losses"] = progress.Losses, - ["fastest_victory"] = progress.FastestVictory, - ["best_win_streak"] = progress.BestWinStreak, - ["number_of_runs"] = progress.NumberOfRuns - } + ["characters"] = snapshot.CharacterStats, + ["global"] = snapshot.GlobalStats }, ["run_history"] = runHistory } }; } + private sealed class CompendiumSnapshot + { + public string? Error { get; init; } + public int ProfileId { get; init; } + public string? ProgressPath { get; init; } + public string ProfileRoot { get; init; } = ""; + public string SaveScope { get; init; } = "vanilla"; + public bool IsRunInProgress { get; init; } + public List DiscoveredCards { get; init; } = new(); + public List DiscoveredRelics { get; init; } = new(); + public List DiscoveredPotions { get; init; } = new(); + public List> CardStats { get; init; } = new(); + public List> CharacterStats { get; init; } = new(); + public List> EncounterStats { get; init; } = new(); + public List> EnemyStats { get; init; } = new(); + public Dictionary RunHistoryProgressMembers { get; init; } = new(); + public Dictionary GlobalStats { get; init; } = new(); + } + private static List> BuildEncounterStats(dynamic progress) { var result = new List>(); @@ -171,16 +228,10 @@ internal static object BuildCompendium() return entry; } - private static Dictionary BuildRunHistorySection(object progress, int profileId) + private static Dictionary BuildRunHistoryProgressMembers(object progress) { - var members = progress.GetType() - .GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .Where(member => member.Name.Contains("RunHistory", StringComparison.OrdinalIgnoreCase) - || member.Name.Contains("RunHistories", StringComparison.OrdinalIgnoreCase)) - .ToList(); - var values = new Dictionary(); - foreach (var member in members) + foreach (var member in GetRunHistoryMembers(progress.GetType())) { try { @@ -195,8 +246,30 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property } catch { } } + return values; + } + + private static MemberInfo[] GetRunHistoryMembers(Type progressType) + { + lock (_runHistoryMembersLock) + { + if (_runHistoryMembersType == progressType && _runHistoryMembers != null) + return _runHistoryMembers; + + _runHistoryMembersType = progressType; + _runHistoryMembers = progressType + .GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(member => member.Name.Contains("RunHistory", StringComparison.OrdinalIgnoreCase) + || member.Name.Contains("RunHistories", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return _runHistoryMembers; + } + } - var historyDirectory = FindRunHistoryDirectory(profileId); + private static Dictionary BuildRunHistorySection(CompendiumSnapshot snapshot) + { + var values = snapshot.RunHistoryProgressMembers; + var historyDirectory = FindRunHistoryDirectory(snapshot.ProfileRoot); if (historyDirectory != null) { var files = Directory.GetFiles(historyDirectory, "*.run") @@ -211,7 +284,7 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property ["source"] = "Profile save history files", ["history_path"] = historyDirectory, ["entry_count"] = files.Count, - ["entries"] = files.Take(20).Select(file => BuildRunHistoryEntry(file, profileId)).ToList(), + ["entries"] = files.Take(20).Select(file => BuildRunHistoryEntry(file, snapshot.ProfileId, snapshot.SaveScope)).ToList(), ["progress_members"] = values.Count > 0 ? values : null, ["limitation"] = files.Count > 20 ? "Only the 20 most recent run files are summarized; use history_path to inspect older local run files." @@ -231,10 +304,8 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property }; } - private static Dictionary BuildRunHistoryEntry(FileInfo file, int profileId) + private static Dictionary BuildRunHistoryEntry(FileInfo file, int profileId, string saveScope) { - var profileRoot = GetProfileRootFromProgressPath(GetProfileProgressPath(profileId), profileId); - var saveScope = GetSaveScope(profileRoot); var entry = new Dictionary { ["id"] = Path.GetFileNameWithoutExtension(file.Name), @@ -246,7 +317,8 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property try { - using var document = JsonDocument.Parse(File.ReadAllText(file.FullName)); + using var stream = file.OpenRead(); + using var document = JsonDocument.Parse(stream); var root = document.RootElement; CopyJsonScalar(root, entry, "start_time"); @@ -290,22 +362,20 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property return entry; } - private static Dictionary? BuildCurrentRunContext(int profileId) + private static Dictionary? BuildCurrentRunContext(CompendiumSnapshot snapshot) { - if (RunManager.Instance?.IsInProgress != true) + if (!snapshot.IsRunInProgress) return null; - var profileRoot = GetProfileRootFromProgressPath(GetProfileProgressPath(profileId), profileId); - var saveScope = GetSaveScope(profileRoot); var result = new Dictionary { ["is_in_progress"] = true, - ["profile_id"] = profileId, - ["save_scope"] = saveScope, + ["profile_id"] = snapshot.ProfileId, + ["save_scope"] = snapshot.SaveScope, ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" }; - var currentRunPath = ResolveCurrentRunPath(profileId); + var currentRunPath = ResolveCurrentRunPath(snapshot.ProfileRoot); if (currentRunPath == null || !File.Exists(currentRunPath)) { result["limitation"] = "Run is in progress, but current_run.save was not found yet."; @@ -316,7 +386,8 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property try { - using var document = JsonDocument.Parse(File.ReadAllText(currentRunPath)); + using var stream = File.OpenRead(currentRunPath); + using var document = JsonDocument.Parse(stream); var root = document.RootElement; CopyJsonScalar(root, result, "start_time"); @@ -334,7 +405,7 @@ PropertyInfo property when property.GetIndexParameters().Length == 0 => property result["seed"] = GetJsonValue(seed); if (result.TryGetValue("start_time", out var startTime) && startTime != null) - result["run_id"] = $"{saveScope}:profile{profileId}:{startTime}"; + result["run_id"] = $"{snapshot.SaveScope}:profile{snapshot.ProfileId}:{startTime}"; } catch (Exception ex) { @@ -378,11 +449,8 @@ JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleVal }; } - private static string? FindRunHistoryDirectory(int profileId) + private static string? FindRunHistoryDirectory(string profileRoot) { - var progressPath = GetProfileProgressPath(profileId); - var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); - foreach (var saveRoot in EnumerateSaveRoots()) { var historyPath = Path.Combine(saveRoot, profileRoot, "saves", "history"); @@ -454,11 +522,8 @@ private static IEnumerable EnumerateSaveRoots() return progressPath; } - private static string? ResolveCurrentRunPath(int profileId) + private static string? ResolveCurrentRunPath(string profileRoot) { - var progressPath = GetProfileProgressPath(profileId); - var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); - foreach (var saveRoot in EnumerateSaveRoots()) { var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "current_run.save"); From 82bebee9c77fd02926585ae4a30badd3c5e1f8f3 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 03:34:59 -0400 Subject: [PATCH 042/190] Restrict save resolution to active Steam account --- McpMod.Compendium.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 0a618384..2258422f 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -6,6 +6,7 @@ using System.Net; using System.Reflection; using System.Text.Json; +using MegaCrit.Sts2.Core.Platform.Steam; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; using MegaCrit.Sts2.Core.Saves.Managers; @@ -497,10 +498,39 @@ private static IEnumerable EnumerateSaveRoots() if (!Directory.Exists(steamRoot)) yield break; + var activeSteamSaveRoot = GetActiveSteamSaveRoot(steamRoot); + if (activeSteamSaveRoot != null) + { + yield return activeSteamSaveRoot; + yield break; + } + foreach (var accountRoot in Directory.GetDirectories(steamRoot)) yield return accountRoot; } + private static string? GetActiveSteamSaveRoot(string steamRoot) + { + try + { + if (!SteamInitializer.Initialized) + return null; + + var steamUtil = typeof(SteamInitializer).Assembly.GetType("MegaCrit.Sts2.Core.Platform.Steam.SteamUtil"); + var getSteamId = steamUtil?.GetMethod("GetSteamID64", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + var steamId = getSteamId?.Invoke(null, null)?.ToString(); + if (string.IsNullOrWhiteSpace(steamId) || steamId == "0") + return null; + + var accountRoot = Path.Combine(steamRoot, steamId); + return Directory.Exists(accountRoot) ? accountRoot : null; + } + catch + { + return null; + } + } + private static string? GetProfileProgressPath(int profileId) { try { return ProgressSaveManager.GetProgressPathForProfile(profileId); } From edafee9e2e5b1fbbde85a81bd5dba157ced3f1db Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 03:48:50 -0400 Subject: [PATCH 043/190] Probe XDG save data roots --- McpMod.Compendium.cs | 116 +++++++++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 27 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 2258422f..85a833ca 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -270,7 +270,7 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) private static Dictionary BuildRunHistorySection(CompendiumSnapshot snapshot) { var values = snapshot.RunHistoryProgressMembers; - var historyDirectory = FindRunHistoryDirectory(snapshot.ProfileRoot); + var historyDirectory = FindRunHistoryDirectory(snapshot); if (historyDirectory != null) { var files = Directory.GetFiles(historyDirectory, "*.run") @@ -376,7 +376,7 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" }; - var currentRunPath = ResolveCurrentRunPath(snapshot.ProfileRoot); + var currentRunPath = ResolveCurrentRunPath(snapshot); if (currentRunPath == null || !File.Exists(currentRunPath)) { result["limitation"] = "Run is in progress, but current_run.save was not found yet."; @@ -450,11 +450,19 @@ JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleVal }; } - private static string? FindRunHistoryDirectory(string profileRoot) + private static string? FindRunHistoryDirectory(CompendiumSnapshot snapshot) { + var saveDirectory = GetSaveDirectoryFromProgressPath(snapshot.ProgressPath); + if (saveDirectory != null) + { + var historyPath = Path.Combine(saveDirectory, "history"); + if (Directory.Exists(historyPath)) + return historyPath; + } + foreach (var saveRoot in EnumerateSaveRoots()) { - var historyPath = Path.Combine(saveRoot, profileRoot, "saves", "history"); + var historyPath = Path.Combine(saveRoot, snapshot.ProfileRoot, "saves", "history"); if (Directory.Exists(historyPath)) return historyPath; } @@ -467,15 +475,16 @@ private static string GetProfileRootFromProgressPath(string? progressPath, int p var normalized = progressPath?.Replace('\\', '/'); if (!string.IsNullOrWhiteSpace(normalized)) { - var marker = $"/profile{profileId}/"; - var index = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (index >= 0) - return normalized[..(index + marker.Length - 1)]; + var moddedProfile = $"modded/profile{profileId}"; + if (normalized.Contains($"{moddedProfile}/", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(moddedProfile, StringComparison.OrdinalIgnoreCase)) + return $"modded/profile{profileId}"; - if (normalized.StartsWith($"profile{profileId}/", StringComparison.OrdinalIgnoreCase)) + var profile = $"profile{profileId}"; + if (normalized.Contains($"/{profile}/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith($"{profile}/", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(profile, StringComparison.OrdinalIgnoreCase)) return $"profile{profileId}"; - if (normalized.StartsWith($"modded/profile{profileId}/", StringComparison.OrdinalIgnoreCase)) - return $"modded/profile{profileId}"; } return $"profile{profileId}"; @@ -490,26 +499,58 @@ private static string GetSaveScope(string profileRoot) private static IEnumerable EnumerateSaveRoots() { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (string.IsNullOrWhiteSpace(appData)) - yield break; + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + var steamRoots = EnumerateSteamDataRoots().ToList(); - var steamRoot = Path.Combine(appData, "SlayTheSpire2", "steam"); - if (!Directory.Exists(steamRoot)) + var activeSteamId = GetActiveSteamId(); + if (!string.IsNullOrWhiteSpace(activeSteamId)) + { + foreach (var steamRoot in steamRoots) + { + var accountRoot = Path.Combine(steamRoot, activeSteamId); + if (Directory.Exists(accountRoot) && yielded.Add(accountRoot)) + yield return accountRoot; + } + } + + if (yielded.Count > 0) yield break; - var activeSteamSaveRoot = GetActiveSteamSaveRoot(steamRoot); - if (activeSteamSaveRoot != null) + foreach (var steamRoot in steamRoots) { - yield return activeSteamSaveRoot; - yield break; + foreach (var accountRoot in Directory.GetDirectories(steamRoot)) + { + if (yielded.Add(accountRoot)) + yield return accountRoot; + } } + } - foreach (var accountRoot in Directory.GetDirectories(steamRoot)) - yield return accountRoot; + private static IEnumerable EnumerateSteamDataRoots() + { + var candidates = new List(); + + candidates.Add(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)); + candidates.Add(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + candidates.Add(Environment.GetEnvironmentVariable("XDG_DATA_HOME")); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + candidates.Add(Path.Combine(home, ".local", "share")); + + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var root in candidates) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + + var steamRoot = Path.Combine(root, "SlayTheSpire2", "steam"); + if (Directory.Exists(steamRoot) && yielded.Add(steamRoot)) + yield return steamRoot; + } } - private static string? GetActiveSteamSaveRoot(string steamRoot) + private static string? GetActiveSteamId() { try { @@ -522,8 +563,7 @@ private static IEnumerable EnumerateSaveRoots() if (string.IsNullOrWhiteSpace(steamId) || steamId == "0") return null; - var accountRoot = Path.Combine(steamRoot, steamId); - return Directory.Exists(accountRoot) ? accountRoot : null; + return steamId; } catch { @@ -540,6 +580,9 @@ private static IEnumerable EnumerateSaveRoots() private static string? ResolveProfileProgressPath(int profileId) { var progressPath = GetProfileProgressPath(profileId); + if (!string.IsNullOrWhiteSpace(progressPath) && Path.IsPathRooted(progressPath)) + return progressPath; + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); foreach (var saveRoot in EnumerateSaveRoots()) @@ -552,11 +595,19 @@ private static IEnumerable EnumerateSaveRoots() return progressPath; } - private static string? ResolveCurrentRunPath(string profileRoot) + private static string? ResolveCurrentRunPath(CompendiumSnapshot snapshot) { + var saveDirectory = GetSaveDirectoryFromProgressPath(snapshot.ProgressPath); + if (saveDirectory != null) + { + var currentRunPath = Path.Combine(saveDirectory, "current_run.save"); + if (File.Exists(currentRunPath)) + return currentRunPath; + } + foreach (var saveRoot in EnumerateSaveRoots()) { - var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "current_run.save"); + var absolutePath = Path.Combine(saveRoot, snapshot.ProfileRoot, "saves", "current_run.save"); if (File.Exists(absolutePath)) return absolutePath; } @@ -564,6 +615,17 @@ private static IEnumerable EnumerateSaveRoots() return null; } + private static string? GetSaveDirectoryFromProgressPath(string? progressPath) + { + if (string.IsNullOrWhiteSpace(progressPath) || !Path.IsPathRooted(progressPath)) + return null; + + var saveDirectory = Path.GetDirectoryName(progressPath); + return !string.IsNullOrWhiteSpace(saveDirectory) && Directory.Exists(saveDirectory) + ? saveDirectory + : null; + } + private static object? ToJsonSafe(object? value, int depth, int maxItems) { if (value == null || depth > 4) return value?.ToString(); From 4fe201b9660fc80f6a78b3fc48756146b00c7b57 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 04:52:54 -0400 Subject: [PATCH 044/190] Expose read-only metadata MCP tools --- docs/raw-full.md | 23 +++++++++ docs/raw-simplified.md | 12 +++++ mcp/README.md | 7 +++ mcp/server.py | 108 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+) diff --git a/docs/raw-full.md b/docs/raw-full.md index 3b0dcf49..8cb6eeef 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -7,8 +7,14 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `POST /api/v1/singleplayer` — perform a game action - `GET /api/v1/multiplayer` — read multiplayer game state - `POST /api/v1/multiplayer` — perform a multiplayer action +- `GET /api/v1/settings` — read settings and preferences - `GET /api/v1/profile` — read current profile progress - `GET /api/v1/compendium` — read Compendium-shaped profile progress +- `GET /api/v1/bestiary` — read monster and encounter metadata +- `GET /api/v1/glossary/cards` — read active-run card pool metadata +- `GET /api/v1/glossary/relics` — read active-run relic pool metadata +- `GET /api/v1/glossary/potions` — read active-run potion pool metadata +- `GET /api/v1/glossary/keywords` — read active-run keyword metadata - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots @@ -873,6 +879,10 @@ Prevents soft-locks when an unrecognized overlay is active. Profile endpoints are independent of the singleplayer and multiplayer run endpoints. +### `GET /api/v1/settings` + +Returns current settings and preferences, including display, audio, gameplay, language, skip-intro, and mod-loading status. + ### `GET /api/v1/profile` Returns the active profile's persistent progress summary, including character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. @@ -925,6 +935,19 @@ Returns the active profile's progress grouped by the in-game Compendium cards: } ``` +### `GET /api/v1/bestiary` + +Returns reflected monster and encounter metadata. Profile-specific encounter and enemy fight stats are also summarized under `/api/v1/compendium`. + +### `GET /api/v1/glossary/*` + +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. + +- `GET /api/v1/glossary/cards`: active-run card pool metadata. +- `GET /api/v1/glossary/relics`: active-run relic pool metadata. +- `GET /api/v1/glossary/potions`: active-run potion pool metadata. +- `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. + ### `GET /api/v1/profiles` Lists the three profile slots and identifies the active slot. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index a550785c..dfe82100 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -6,8 +6,14 @@ HTTP API on `localhost:15526`. No authentication. - `POST /api/v1/singleplayer` — perform action - `GET /api/v1/multiplayer` — read multiplayer state - `POST /api/v1/multiplayer` — perform multiplayer action +- `GET /api/v1/settings` — read settings and preferences - `GET /api/v1/profile` — read current profile progress - `GET /api/v1/compendium` — read Compendium-shaped profile progress +- `GET /api/v1/bestiary` — read monster and encounter metadata +- `GET /api/v1/glossary/cards` — read active-run card pool metadata +- `GET /api/v1/glossary/relics` — read active-run relic pool metadata +- `GET /api/v1/glossary/potions` — read active-run potion pool metadata +- `GET /api/v1/glossary/keywords` — read active-run keyword metadata - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots @@ -63,6 +69,12 @@ All POST requests use JSON body with `"action"` field. All responses include `{ `GET /api/v1/profile` returns persistent progress for the active profile, including character stats, discoveries, achievements, epochs, and global run totals. +`GET /api/v1/settings` returns display, audio, gameplay, language, and mod-loading preferences. + +`GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. + +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. + `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: When a run is active, the response includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content. diff --git a/mcp/README.md b/mcp/README.md index d36f9048..cdba4b9e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -6,7 +6,14 @@ |---|---|---| | `get_game_state(format?)` | General | Get current game state (`markdown` or `json`) | | `menu_select(option, seed?)` | General | Select a visible menu/game-over option | +| `get_settings()` | General | Get current settings and preferences | | `get_profile()` | Profiles | Get active profile progress | +| `get_compendium()` | Profiles | Get Compendium-shaped profile progress | +| `get_bestiary()` | Profiles | Get monster and encounter metadata | +| `get_glossary_cards()` | Active Pool | Get active-run card pool metadata | +| `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata | +| `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata | +| `get_glossary_keywords()` | Active Pool | Get active-run keyword metadata | | `list_profiles()` | Profiles | List profile slots and active slot | | `switch_profile(profile_id)` | Profiles | Switch to a profile slot through the game UI | | `delete_profile(profile_id)` | Profiles | Delete an inactive profile slot | diff --git a/mcp/server.py b/mcp/server.py index 3b89ae0a..164b2f40 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -39,6 +39,18 @@ def _compendium_url() -> str: return f"{_base_url}/api/v1/compendium" +def _settings_url() -> str: + return f"{_base_url}/api/v1/settings" + + +def _bestiary_url() -> str: + return f"{_base_url}/api/v1/bestiary" + + +def _glossary_url(kind: str) -> str: + return f"{_base_url}/api/v1/glossary/{kind}" + + def _profiles_url() -> str: return f"{_base_url}/api/v1/profiles" @@ -86,6 +98,24 @@ async def _compendium_get() -> str: return r.text +async def _settings_get() -> str: + r = await _get_client().get(_settings_url()) + r.raise_for_status() + return r.text + + +async def _bestiary_get() -> str: + r = await _get_client().get(_bestiary_url()) + r.raise_for_status() + return r.text + + +async def _glossary_get(kind: str) -> str: + r = await _get_client().get(_glossary_url(kind)) + r.raise_for_status() + return r.text + + async def _profiles_get() -> str: r = await _get_client().get(_profiles_url()) r.raise_for_status() @@ -228,6 +258,84 @@ async def get_compendium() -> str: return _handle_error(e) +@mcp.tool() +async def get_settings() -> str: + """Get current game settings and preferences. + + Includes display, audio, gameplay preferences, language, and mod-loading + status as exposed by the STS2_MCP HTTP settings endpoint. + """ + try: + return await _settings_get() + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_bestiary() -> str: + """Get reflected monster and encounter metadata. + + This is model/reference metadata and is separate from Compendium profile + encounter stats. The in-game Bestiary card is currently marked locked/future. + """ + try: + return await _bestiary_get() + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_glossary_cards() -> str: + """Get active-run card pool metadata. + + Requires a run in progress. This is scoped to the current run/character pool, + not profile-wide discovered cards; use get_compendium() for profile progress. + """ + try: + return await _glossary_get("cards") + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_glossary_relics() -> str: + """Get active-run relic pool metadata. + + Requires a run in progress. This is scoped to the current run/character pool, + not profile-wide discovered relics; use get_compendium() for profile progress. + """ + try: + return await _glossary_get("relics") + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_glossary_potions() -> str: + """Get active-run potion pool metadata. + + Requires a run in progress. This is scoped to the current run/character pool, + not profile-wide discovered potions; use get_compendium() for profile progress. + """ + try: + return await _glossary_get("potions") + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_glossary_keywords() -> str: + """Get active-run keyword metadata collected from active pools. + + Requires a run in progress. Keywords come from cards, relics, and potions + reachable in the current run context. + """ + try: + return await _glossary_get("keywords") + except Exception as e: + return _handle_error(e) + + @mcp.tool() async def list_profiles() -> str: """List the three profile slots and identify the active slot. From c1b3a95ae31f39e2f9840c2126dc21cb93564556 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 04:54:11 -0400 Subject: [PATCH 045/190] Route menu select during multiplayer runs --- mcp/README.md | 2 +- mcp/server.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index cdba4b9e..df26ce78 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,7 +5,7 @@ | Tool | Scope | Description | |---|---|---| | `get_game_state(format?)` | General | Get current game state (`markdown` or `json`) | -| `menu_select(option, seed?)` | General | Select a visible menu/game-over option | +| `menu_select(option, seed?)` | General | Select a visible menu/game-over option; automatically retries through the multiplayer route during MP runs | | `get_settings()` | General | Get current settings and preferences | | `get_profile()` | Profiles | Get active profile progress | | `get_compendium()` | Profiles | Get Compendium-shaped profile progress | diff --git a/mcp/server.py b/mcp/server.py index 164b2f40..d1ffd18a 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -226,6 +226,13 @@ async def menu_select(option: str, seed: str | None = None) -> str: body["seed"] = seed try: return await _post(body) + except httpx.HTTPStatusError as e: + if e.response.status_code == 409 and "Multiplayer run is active" in e.response.text: + try: + return await _mp_post(body) + except Exception as mp_e: + return _handle_error(mp_e) + return _handle_error(e) except Exception as e: return _handle_error(e) From 8cd63826b47e276eff1319ff308b8808c32e742e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:01:19 -0400 Subject: [PATCH 046/190] Include full card fields in pile state --- McpMod.StateBuilder.cs | 12 ++++-------- docs/raw-full.md | 8 +++++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 8c3eb2d8..48a119e0 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1263,16 +1263,12 @@ private static string GetCostDisplay(CardModel card) private static List> BuildPileCardList(IEnumerable cards, PileType pile) { var list = new List>(); + var index = 0; foreach (var card in cards) { - // Pile cards only need a subset - keep it lightweight - list.Add(new Dictionary - { - ["name"] = SafeGetText(() => card.Title), - ["cost"] = GetCostDisplay(card), - ["star_cost"] = GetStarCostDisplay(card), - ["description"] = SafeGetCardDescription(card, pile) - }); + var cardInfo = BuildCardInfo(card, pile); + cardInfo["index"] = index++; + list.Add(cardInfo); } return list; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 8cb6eeef..c61ab899 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -134,10 +134,16 @@ Always present at the top level (except `menu`). Contains everything about the l ```jsonc { + "index": 0, + "id": "STRIKE_R", "name": "Strike", + "type": "Attack", "cost": "1", // Energy cost as string ("X" for X-cost) "star_cost": null, // Regent star cost as string, null if N/A - "description": "Deal 6 damage." + "description": "Deal 6 damage.", + "rarity": "Basic", + "is_upgraded": false, + "keywords": [ /* Keyword Objects */ ] } ``` From a5839437dc82a066de503afd63e029c50bece48c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:05:58 -0400 Subject: [PATCH 047/190] Use structured route mismatch errors --- McpMod.Helpers.cs | 7 +++++-- McpMod.cs | 6 ++++-- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/server.py | 29 +++++++++++++++++++++-------- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 925b9c5a..39adf108 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -89,10 +89,13 @@ internal static void SendText(HttpListenerResponse response, string text, string response.Close(); } - internal static void SendError(HttpListenerResponse response, int statusCode, string message) + internal static void SendError(HttpListenerResponse response, int statusCode, string message, string? errorCode = null) { response.StatusCode = statusCode; - SendJson(response, new Dictionary { ["error"] = message }); + var data = new Dictionary { ["error"] = message }; + if (!string.IsNullOrWhiteSpace(errorCode)) + data["error_code"] = errorCode; + SendJson(response, data); } private static Dictionary Error(string message) diff --git a/McpMod.cs b/McpMod.cs index 2d48fe6a..154229dd 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -280,7 +280,8 @@ private static void HandleRequest(HttpListenerContext context) if (IsMultiplayerRun()) { SendError(response, 409, - "Multiplayer run is active. Use /api/v1/multiplayer instead."); + "Multiplayer run is active. Use /api/v1/multiplayer instead.", + "multiplayer_run_active"); return; } @@ -297,7 +298,8 @@ private static void HandleRequest(HttpListenerContext context) if (!IsMultiplayerRun()) { SendError(response, 409, - "Not in a multiplayer run. Use /api/v1/singleplayer instead."); + "Not in a multiplayer run. Use /api/v1/singleplayer instead.", + "not_multiplayer_run"); return; } diff --git a/docs/raw-full.md b/docs/raw-full.md index c61ab899..8fc236af 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -18,7 +18,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots -The endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. +The singleplayer and multiplayer endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. --- diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index dfe82100..0d74c393 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -17,7 +17,7 @@ HTTP API on `localhost:15526`. No authentication. - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots -Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). +Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. ## GET — Query Parameters diff --git a/mcp/server.py b/mcp/server.py index d1ffd18a..bed3b185 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -174,6 +174,26 @@ def _handle_error(e: Exception) -> str: return f"Error: {e}" +def _response_error_code(response: httpx.Response) -> str | None: + try: + data = response.json() + except ValueError: + return None + return data.get("error_code") if isinstance(data, dict) else None + + +async def _menu_select_post(body: dict) -> str: + try: + return await _post(body) + except httpx.HTTPStatusError as e: + if ( + e.response.status_code == 409 + and _response_error_code(e.response) == "multiplayer_run_active" + ): + return await _mp_post(body) + raise + + # --------------------------------------------------------------------------- # General # --------------------------------------------------------------------------- @@ -225,14 +245,7 @@ async def menu_select(option: str, seed: str | None = None) -> str: if seed is not None: body["seed"] = seed try: - return await _post(body) - except httpx.HTTPStatusError as e: - if e.response.status_code == 409 and "Multiplayer run is active" in e.response.text: - try: - return await _mp_post(body) - except Exception as mp_e: - return _handle_error(mp_e) - return _handle_error(e) + return await _menu_select_post(body) except Exception as e: return _handle_error(e) From 65eee1f8a03d9ba632019f725c31180352b9ce99 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:14:24 -0400 Subject: [PATCH 048/190] Return structured glossary run errors --- McpMod.ForkEndpoints.cs | 67 ++++++++++++++++++++++++++++++----------- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 +- mcp/README.md | 2 ++ 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 8c5c1e68..90239465 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -14,6 +14,9 @@ namespace STS2_MCP; public static partial class McpMod { + private const string RunNotInProgressErrorCode = "run_not_in_progress"; + private const string RunStateUnavailableErrorCode = "run_state_unavailable"; + private static void HandleGetSettings(HttpListenerResponse response) { try @@ -88,7 +91,7 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildGlossaryCards); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -101,7 +104,7 @@ private static void HandleGetGlossaryKeywords(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildGlossaryKeywords); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -114,7 +117,7 @@ private static void HandleGetGlossaryPotions(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildGlossaryPotions); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -127,7 +130,7 @@ private static void HandleGetGlossaryRelics(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildGlossaryRelics); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -135,14 +138,40 @@ private static void HandleGetGlossaryRelics(HttpListenerResponse response) } } + private static void SendGlossaryJson(HttpListenerResponse response, object data) + { + if (data is Dictionary error + && error.TryGetValue("error_code", out var errorCode)) + { + response.StatusCode = (errorCode as string) switch + { + RunNotInProgressErrorCode => 409, + RunStateUnavailableErrorCode => 503, + _ => 500 + }; + } + + SendJson(response, data); + } + + private static Dictionary GlossaryError(string message, string errorCode) + { + return new Dictionary + { + ["status"] = "error", + ["error"] = message, + ["error_code"] = errorCode + }; + } + internal static object BuildGlossaryCards() { if (!RunManager.Instance.IsInProgress) - return new Dictionary { ["error"] = "No run in progress." }; + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); if (runState == null) - return new Dictionary { ["error"] = "Could not read run state." }; + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); var result = new List>(); var seen = new HashSet(); @@ -185,20 +214,21 @@ internal static object BuildGlossaryCards() internal static object BuildGlossaryRelics() { if (!RunManager.Instance.IsInProgress) - return new Dictionary { ["error"] = "No run in progress." }; + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); if (runState == null) - return new Dictionary { ["error"] = "Could not read run state." }; + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); var result = new List>(); var seen = new HashSet(); foreach (var player in runState.Players) { - var pool = player.Character?.RelicPool; - if (pool == null) continue; - var poolName = SafeGetText(() => player.Character.Title) ?? "Unknown"; + var character = player.Character; + if (character == null || character.RelicPool == null) continue; + var pool = character.RelicPool; + var poolName = SafeGetText(() => character.Title) ?? "Unknown"; foreach (var relic in pool.AllRelics) { @@ -248,20 +278,21 @@ internal static object BuildGlossaryRelics() internal static object BuildGlossaryPotions() { if (!RunManager.Instance.IsInProgress) - return new Dictionary { ["error"] = "No run in progress." }; + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); if (runState == null) - return new Dictionary { ["error"] = "Could not read run state." }; + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); var result = new List>(); var seen = new HashSet(); foreach (var player in runState.Players) { - var pool = player.Character?.PotionPool; - if (pool == null) continue; - var poolName = SafeGetText(() => player.Character.Title) ?? "Unknown"; + var character = player.Character; + if (character == null || character.PotionPool == null) continue; + var pool = character.PotionPool; + var poolName = SafeGetText(() => character.Title) ?? "Unknown"; foreach (var potion in pool.AllPotions) { @@ -289,11 +320,11 @@ internal static object BuildGlossaryPotions() internal static object BuildGlossaryKeywords() { if (!RunManager.Instance.IsInProgress) - return new Dictionary { ["error"] = "No run in progress." }; + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); if (runState == null) - return new Dictionary { ["error"] = "Could not read run state." }; + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); var keywords = new Dictionary(); diff --git a/docs/raw-full.md b/docs/raw-full.md index 8fc236af..3c252a2e 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -954,6 +954,8 @@ The glossary endpoints expose active-run pool metadata. They require a run in pr - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. +If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"`. + ### `GET /api/v1/profiles` Lists the three profile slots and identifies the active slot. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 0d74c393..66023181 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -73,7 +73,7 @@ All POST requests use JSON body with `"action"` field. All responses include `{ `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index df26ce78..48a84e53 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -45,6 +45,8 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | +Glossary tools require an active run. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. + ## Multiplayer All multiplayer tools are prefixed with `mp_`. They route through `/api/v1/multiplayer` and are only available during multiplayer (co-op) runs. The endpoints automatically guard against cross-mode calls. From 0633f39d0482bb326a81aad7c4c598f3b22fa99e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:15:35 -0400 Subject: [PATCH 049/190] Avoid obsolete bestiary reflection API --- McpMod.ForkEndpoints.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 90239465..565c7004 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using MegaCrit.Sts2.Core.HoverTips; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Models.Events; @@ -403,7 +404,7 @@ internal static object BuildBestiary() try { - var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); + var instance = RuntimeHelpers.GetUninitializedObject(type); var minHp = type.GetProperty("MinInitialHp")?.GetValue(instance); var maxHp = type.GetProperty("MaxInitialHp")?.GetValue(instance); if (minHp != null) entry["min_hp"] = minHp; @@ -443,7 +444,7 @@ internal static object BuildBestiary() try { - var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(type); + var instance = RuntimeHelpers.GetUninitializedObject(type); var roomType = type.GetProperty("RoomType")?.GetValue(instance); var isWeak = type.GetProperty("IsWeak")?.GetValue(instance); var minGold = type.GetProperty("MinGoldReward")?.GetValue(instance); From 64f61fcfa9845afbe0dede9c6d0196da5d568f63 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:18:46 -0400 Subject: [PATCH 050/190] Normalize HTTP error response shape --- McpMod.Helpers.cs | 6 +++++- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 39adf108..ced7df8b 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -92,7 +92,11 @@ internal static void SendText(HttpListenerResponse response, string text, string internal static void SendError(HttpListenerResponse response, int statusCode, string message, string? errorCode = null) { response.StatusCode = statusCode; - var data = new Dictionary { ["error"] = message }; + var data = new Dictionary + { + ["status"] = "error", + ["error"] = message + }; if (!string.IsNullOrWhiteSpace(errorCode)) data["error_code"] = errorCode; SendJson(response, data); diff --git a/docs/raw-full.md b/docs/raw-full.md index 3c252a2e..4161d1c2 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -18,6 +18,8 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots +HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. + The singleplayer and multiplayer endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. --- diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 66023181..0c0968ec 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -17,6 +17,8 @@ HTTP API on `localhost:15526`. No authentication. - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots +HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. + Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. ## GET — Query Parameters @@ -57,7 +59,7 @@ Every JSON response includes: ## POST — Actions -All POST requests use JSON body with `"action"` field. All responses include `{ "status": "ok" | "error", "message": "..." }`. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. ### Menu / Game Over From 44f45501e82873267ceb0e02e2e1027b5db1088b Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:20:01 -0400 Subject: [PATCH 051/190] Align Crystal Sphere tool usability flags --- McpMod.StateBuilder.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 48a119e0..4e072392 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2171,16 +2171,16 @@ private static string GetCostDisplay(CardModel card) } state["revealed_items"] = revealedItems; - var bigButton = screen.GetNodeOrNull("%BigDivinationButton"); - var smallButton = screen.GetNodeOrNull("%SmallDivinationButton"); - bool bigVisible = bigButton?.Visible == true; - bool smallVisible = smallButton?.Visible == true; + var bigButton = screen.GetNodeOrNull("%BigDivinationButton"); + var smallButton = screen.GetNodeOrNull("%SmallDivinationButton"); + bool bigUsable = bigButton?.Visible == true && bigButton.IsEnabled; + bool smallUsable = smallButton?.Visible == true && smallButton.IsEnabled; bool bigActive = bigButton?.GetNodeOrNull("%Outline")?.Visible == true; bool smallActive = smallButton?.GetNodeOrNull("%Outline")?.Visible == true; state["tool"] = bigActive ? "big" : smallActive ? "small" : "none"; - state["can_use_big_tool"] = bigVisible; - state["can_use_small_tool"] = smallVisible; + state["can_use_big_tool"] = bigUsable; + state["can_use_small_tool"] = smallUsable; var divinationsLeft = screen.GetNodeOrNull("%DivinationsLeft"); if (divinationsLeft != null) From dbb00be511ffb2e8a4ddcc7cc00df8a22f5b4051 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:23:07 -0400 Subject: [PATCH 052/190] Include full hand selection card metadata --- McpMod.StateBuilder.cs | 9 ++++----- docs/raw-full.md | 12 +++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 4e072392..d69432bf 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2045,11 +2045,10 @@ private static string GetCostDisplay(CardModel card) { var card = holder.CardModel; if (card == null) continue; - selectedCards.Add(new Dictionary - { - ["index"] = selIdx, - ["name"] = SafeGetText(() => card.Title) - }); + var cardInfo = BuildCardInfo(card); + cardInfo["index"] = selIdx; + cardInfo["description"] = SafeGetCardDescription(card); + selectedCards.Add(cardInfo); selIdx++; } if (selectedCards.Count > 0) diff --git a/docs/raw-full.md b/docs/raw-full.md index 4161d1c2..5d0eaed9 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -403,7 +403,17 @@ Appears when a card effect prompts "Select a card to exhaust/discard/upgrade". * } ], "selected_cards": [ // Only present if cards have been selected - { "index": 0, "name": "Defend" } + { + "index": 0, + "id": "DEFEND_R", + "name": "Defend", + "type": "Skill", + "cost": "1", + "star_cost": null, + "description": "Gain 5 Block.", + "is_upgraded": false, + "keywords": [ /* Keyword Objects */ ] + } ], "can_confirm": false }, From d239ce79efdc981c0aa7dedcf533fc3f8fa642e6 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:24:43 -0400 Subject: [PATCH 053/190] Document abandon run menu option --- docs/raw-full.md | 3 ++- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index 5d0eaed9..3b5488f6 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -199,12 +199,13 @@ No run in progress. "state_type": "menu", "message": "No run in progress. Player is in the main menu.", "menu_screen": "main", - "options": ["continue", "singleplayer", "multiplayer", "compendium", "timeline", "settings", "quit"] + "options": ["continue", "abandon_run", "singleplayer", "multiplayer", "compendium", "timeline", "settings", "quit"] } ``` Use `menu_select` with one of the advertised options. Options are accepted case-insensitively. If a visible option is intentionally withheld from automation, the state may also include `blocked_options` with a reason. For example, Timeline can be blocked while obtained epochs still need manual reveal because opening that game state through automation can trigger invalid unlock-state errors. +`abandon_run` opens a confirmation popup; the next state exposes the popup's `confirm` / `cancel` options. Menu sub-screens expose their own options: diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 0c0968ec..21edc6f0 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -65,7 +65,7 @@ All POST requests use JSON body with `"action"` field. Action responses include | Action | Parameters | When to Use | |---|---|---| -| `menu_select` | `option`: string, `seed`?: string | Choose an advertised menu option. Options are case-insensitive. Submenus include `back` where visible, including `profile_select` options `profile_1`, `profile_2`, `profile_3`, and `back`. Blocking popups expose normalized button labels such as `ignore` or `back`. `game_over` supports `main_menu` only; `continue` returns an error. Supplying `seed` in unsupported contexts such as standard singleplayer character select returns an error and does not start a run. If Timeline has pending obtained epochs that require manual reveal, it may appear in `blocked_options`; selecting `timeline` returns `manual_action_required: true` with `pending_epoch_ids` instead of opening Timeline. Multiplayer flow: on `multiplayer_join` use `refresh` / `back` / `join_` / `join_`. On `multiplayer_load_lobby` use `confirm` (or `embark`) to ready up, `unready` to retract, `back` to leave. On `character_select` while in MP, an additional `unready` option becomes available after readying, plus a `lobby` block in state lists ascension, all_ready, and per-player roster. | +| `menu_select` | `option`: string, `seed`?: string | Choose an advertised menu option. Options are case-insensitive. Main menu may include `continue`, `abandon_run`, `singleplayer`, `multiplayer`, `compendium`, `timeline`, `settings`, and `quit`; `abandon_run` opens a confirmation popup. Submenus include `back` where visible, including `profile_select` options `profile_1`, `profile_2`, `profile_3`, and `back`. Blocking popups expose normalized button labels such as `ignore` or `back`. `game_over` supports `main_menu` only; `continue` returns an error. Supplying `seed` in unsupported contexts such as standard singleplayer character select returns an error and does not start a run. If Timeline has pending obtained epochs that require manual reveal, it may appear in `blocked_options`; selecting `timeline` returns `manual_action_required: true` with `pending_epoch_ids` instead of opening Timeline. Multiplayer flow: on `multiplayer_join` use `refresh` / `back` / `join_` / `join_`. On `multiplayer_load_lobby` use `confirm` (or `embark`) to ready up, `unready` to retract, `back` to leave. On `character_select` while in MP, an additional `unready` option becomes available after readying, plus a `lobby` block in state lists ascension, all_ready, and per-player roster. | ### Profiles diff --git a/mcp/README.md b/mcp/README.md index 48a84e53..ee59fd6f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,7 +5,7 @@ | Tool | Scope | Description | |---|---|---| | `get_game_state(format?)` | General | Get current game state (`markdown` or `json`) | -| `menu_select(option, seed?)` | General | Select a visible menu/game-over option; automatically retries through the multiplayer route during MP runs | +| `menu_select(option, seed?)` | General | Select a visible menu/game-over option, including `abandon_run` when advertised; automatically retries through the multiplayer route during MP runs | | `get_settings()` | General | Get current settings and preferences | | `get_profile()` | Profiles | Get active profile progress | | `get_compendium()` | Profiles | Get Compendium-shaped profile progress | From 16bfa3f8002f427eba1e0e8804f50a12fb10cd47 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:26:58 -0400 Subject: [PATCH 054/190] Advertise HTTP endpoint index at root --- McpMod.cs | 29 ++++++++++++++++++++++++++++- README.md | 10 +++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 154229dd..09f6a605 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -271,7 +271,13 @@ private static void HandleRequest(HttpListenerContext context) if (path == "/") { - SendJson(response, new { message = $"Hello from STS2 MCP v{Version}", status = "ok", bound_prefixes = _boundPrefixes }); + SendJson(response, new + { + message = $"Hello from STS2 MCP v{Version}", + status = "ok", + bound_prefixes = _boundPrefixes, + endpoints = BuildEndpointIndex() + }); } else if (path == "/api/v1/singleplayer") { @@ -390,6 +396,27 @@ private static void HandleRequest(HttpListenerContext context) } } + private static List> BuildEndpointIndex() + { + return new List> + { + new() { ["method"] = "GET", ["path"] = "/api/v1/singleplayer", ["description"] = "Read singleplayer state" }, + new() { ["method"] = "POST", ["path"] = "/api/v1/singleplayer", ["description"] = "Perform singleplayer action" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/multiplayer", ["description"] = "Read multiplayer state" }, + new() { ["method"] = "POST", ["path"] = "/api/v1/multiplayer", ["description"] = "Perform multiplayer action" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/settings", ["description"] = "Read settings and preferences" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/profile", ["description"] = "Read active profile progress" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/compendium", ["description"] = "Read Compendium-shaped profile progress" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/bestiary", ["description"] = "Read monster and encounter metadata" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/cards", ["description"] = "Read active-run card pool metadata" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/relics", ["description"] = "Read active-run relic pool metadata" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/potions", ["description"] = "Read active-run potion pool metadata" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/keywords", ["description"] = "Read active-run keyword metadata" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/profiles", ["description"] = "List profile slots" }, + new() { ["method"] = "POST", ["path"] = "/api/v1/profiles", ["description"] = "Switch or delete profile slots" } + }; + } + // Called on HTTP thread (not main thread) as a best-effort guard. // The try/catch handles race conditions during run transitions. // Authoritative checks happen inside RunOnMainThread lambdas. diff --git a/README.md b/README.md index 7fa1a21b..732e5928 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,15 @@ curl -s http://localhost:15526/ A successful response looks like: ```json -{"message": "Hello from STS2 MCP v0.3.4", "status": "ok"} +{ + "message": "Hello from STS2 MCP v0.4.0", + "status": "ok", + "bound_prefixes": ["http://localhost:15526/", "http://127.0.0.1:15526/"], + "endpoints": [ + { "method": "GET", "path": "/api/v1/singleplayer" }, + { "method": "POST", "path": "/api/v1/singleplayer" } + ] +} ``` If you get "Connection refused", the mod is not loaded — check that mods are enabled in the game's settings. From a2505cae8539652b2bf59b439e02373d671ff5a0 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:27:56 -0400 Subject: [PATCH 055/190] Expose endpoint index through MCP --- mcp/README.md | 1 + mcp/server.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index ee59fd6f..aa90a67c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -4,6 +4,7 @@ | Tool | Scope | Description | |---|---|---| +| `get_api_index()` | General | Get mod status, bound prefixes, and HTTP endpoint index | | `get_game_state(format?)` | General | Get current game state (`markdown` or `json`) | | `menu_select(option, seed?)` | General | Select a visible menu/game-over option, including `abandon_run` when advertised; automatically retries through the multiplayer route during MP runs | | `get_settings()` | General | Get current settings and preferences | diff --git a/mcp/server.py b/mcp/server.py index bed3b185..f7b80bbd 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -27,6 +27,10 @@ def _sp_url() -> str: return f"{_base_url}/api/v1/singleplayer" +def _root_url() -> str: + return f"{_base_url}/" + + def _mp_url() -> str: return f"{_base_url}/api/v1/multiplayer" @@ -68,6 +72,12 @@ async def _get(params: dict | None = None) -> str: return r.text +async def _root_get() -> str: + r = await _get_client().get(_root_url()) + r.raise_for_status() + return r.text + + async def _post(body: dict) -> str: r = await _get_client().post(_sp_url(), json=body) r.raise_for_status() @@ -199,6 +209,19 @@ async def _menu_select_post(body: dict) -> str: # --------------------------------------------------------------------------- +@mcp.tool() +async def get_api_index() -> str: + """Get the STS2_MCP HTTP server status and endpoint index. + + Returns the mod version greeting, bound listener prefixes, and the current + list of HTTP API routes exposed by the loaded mod. + """ + try: + return await _root_get() + except Exception as e: + return _handle_error(e) + + @mcp.tool() async def get_game_state(format: str = "markdown") -> str: """Get the current Slay the Spire 2 game state. From acc28fc4d64793a36b4f64217c83d4595262d3e4 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:29:09 -0400 Subject: [PATCH 056/190] Normalize profile no-data error bodies --- McpMod.Compendium.cs | 2 +- McpMod.Profile.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 85a833ca..c94a1faa 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -105,7 +105,7 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) { if (snapshot.Error != null) - return new Dictionary { ["error"] = snapshot.Error }; + return Error(snapshot.Error); var runHistory = BuildRunHistorySection(snapshot); diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index d429ba86..055eedb1 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -216,7 +216,7 @@ internal static object BuildProfile() { var progress = SaveManager.Instance?.Progress; if (progress == null) - return new Dictionary { ["error"] = "No profile data available." }; + return Error("No profile data available."); var result = new Dictionary(); From 4012c7238387af4ba516c2cac781cb2dc6627bca Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:32:00 -0400 Subject: [PATCH 057/190] Add endpoint surface audit script --- README.md | 6 ++ scripts/audit_endpoints.py | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 scripts/audit_endpoints.py diff --git a/README.md b/README.md index 732e5928..4800f9bf 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ A successful response looks like: If you get "Connection refused", the mod is not loaded — check that mods are enabled in the game's settings. +To audit the documented and live HTTP endpoint surface: + +```bash +python3 scripts/audit_endpoints.py +``` + ### 2. Give Your AI Instructions to Interact with the Game **Clone or download the repository**, then: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py new file mode 100644 index 00000000..f8b8730c --- /dev/null +++ b/scripts/audit_endpoints.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Audit the STS2_MCP HTTP endpoint surface. + +This script intentionally uses only the Python standard library so it can run +from a checkout without installing the MCP package. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +EXPECTED_ENDPOINTS: list[tuple[str, str]] = [ + ("GET", "/api/v1/singleplayer"), + ("POST", "/api/v1/singleplayer"), + ("GET", "/api/v1/multiplayer"), + ("POST", "/api/v1/multiplayer"), + ("GET", "/api/v1/settings"), + ("GET", "/api/v1/profile"), + ("GET", "/api/v1/compendium"), + ("GET", "/api/v1/bestiary"), + ("GET", "/api/v1/glossary/cards"), + ("GET", "/api/v1/glossary/relics"), + ("GET", "/api/v1/glossary/potions"), + ("GET", "/api/v1/glossary/keywords"), + ("GET", "/api/v1/profiles"), + ("POST", "/api/v1/profiles"), +] + + +def fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_json_url(url: str) -> tuple[int, object]: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as exc: + try: + return exc.code, json.loads(exc.read().decode("utf-8")) + except Exception: + raise RuntimeError(f"{url} returned HTTP {exc.code} with non-JSON body") from exc + + +def assert_error_body(path: str, status: int, data: object) -> None: + if status < 400: + return + if not isinstance(data, dict): + fail(f"{path} returned HTTP {status} with non-object JSON") + if data.get("status") != "error" or not data.get("error"): + fail(f"{path} returned HTTP {status} without status/error fields: {data}") + + +def audit_docs(repo: Path) -> None: + raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) + expected = set(EXPECTED_ENDPOINTS) + missing = sorted(expected - documented) + extra = sorted(documented - expected) + if missing: + fail(f"docs/raw-full.md is missing endpoints: {missing}") + if extra: + fail(f"docs/raw-full.md documents unexpected endpoints: {extra}") + print(f"docs: {len(documented)} endpoints documented") + + +def audit_live(base_url: str) -> None: + root_status, root = load_json_url(base_url.rstrip("/") + "/") + if root_status != 200 or not isinstance(root, dict): + fail(f"root returned HTTP {root_status}: {root}") + + endpoint_rows = root.get("endpoints") + if not isinstance(endpoint_rows, list): + fail("root response does not include endpoints list") + + live_index = { + (str(row.get("method")), str(row.get("path"))) + for row in endpoint_rows + if isinstance(row, dict) + } + expected = set(EXPECTED_ENDPOINTS) + if live_index != expected: + fail(f"root endpoint index mismatch. missing={sorted(expected - live_index)} extra={sorted(live_index - expected)}") + print(f"root: {len(live_index)} endpoints advertised") + + for method, path in EXPECTED_ENDPOINTS: + if method != "GET": + continue + status, data = load_json_url(base_url.rstrip("/") + path) + assert_error_body(path, status, data) + + if path in {"/api/v1/settings", "/api/v1/profile", "/api/v1/compendium", "/api/v1/bestiary", "/api/v1/profiles"} and status != 200: + fail(f"{path} expected HTTP 200, got {status}: {data}") + + if path.startswith("/api/v1/glossary/") and status not in {200, 409}: + fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") + + print("live: GET endpoint smoke checks passed") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://localhost:15526", help="STS2_MCP HTTP base URL") + parser.add_argument("--skip-live", action="store_true", help="Only check checked-in docs") + args = parser.parse_args() + + repo = Path(__file__).resolve().parents[1] + audit_docs(repo) + if not args.skip_live: + audit_live(args.base_url) + + +if __name__ == "__main__": + main() From cc7610e28ed2cc64e64e9da88ca3cde1da37a841 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:32:56 -0400 Subject: [PATCH 058/190] Audit safe POST endpoint validation --- scripts/audit_endpoints.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f8b8730c..3d5dfe62 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -39,8 +39,11 @@ def fail(message: str) -> None: raise SystemExit(1) -def load_json_url(url: str) -> tuple[int, object]: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) +def load_json_url(url: str, method: str = "GET", body: bytes | None = None) -> tuple[int, object]: + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=10) as response: return response.status, json.load(response) @@ -104,7 +107,25 @@ def audit_live(base_url: str) -> None: if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") + post_validation_checks = [ + ("/api/v1/singleplayer", b"{", 400), + ("/api/v1/singleplayer", b"{}", 400), + ("/api/v1/profiles", b"{", 400), + ("/api/v1/profiles", b"{}", 400), + ] + for path, body, expected_status in post_validation_checks: + status, data = load_json_url(base_url.rstrip("/") + path, "POST", body) + assert_error_body(path, status, data) + if status != expected_status: + fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/settings", "POST", b"{}") + assert_error_body("/api/v1/settings", status, data) + if status != 405: + fail(f"/api/v1/settings expected HTTP 405 for POST, got {status}: {data}") + print("live: GET endpoint smoke checks passed") + print("live: safe POST validation checks passed") def main() -> None: From 5672f9594e34d030912672e1c1879f66f5de3ae4 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:33:58 -0400 Subject: [PATCH 059/190] Expose upgraded flag for shop cards --- McpMod.StateBuilder.cs | 1 + docs/raw-full.md | 1 + 2 files changed, 2 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index d69432bf..f43a3a77 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1555,6 +1555,7 @@ private static string GetCostDisplay(CardModel card) item["card_star_cost"] = cardInfo["star_cost"]; item["card_rarity"] = cardInfo["rarity"]; item["card_description"] = cardInfo["description"]; + item["card_is_upgraded"] = cardInfo["is_upgraded"]; item["keywords"] = cardInfo["keywords"]; } items.Add(item); diff --git a/docs/raw-full.md b/docs/raw-full.md index 3b5488f6..2f187c76 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -604,6 +604,7 @@ Shop inventory is auto-opened when state is queried. "card_star_cost": null, // Regent star cost as string, null if N/A "card_rarity": "Rare", "card_description": "Lose 6 HP. Gain 2 Energy. Draw 3 cards.", + "card_is_upgraded": false, "keywords": [ /* Keyword Objects */ ] }, // Relic entry From b657d41107023829cb9716abc57c32a33e0de6ac Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:34:51 -0400 Subject: [PATCH 060/190] Expose full event relic option metadata --- McpMod.StateBuilder.cs | 3 +++ docs/raw-full.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index f43a3a77..e0de0471 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1376,8 +1376,11 @@ private static string GetCostDisplay(CardModel card) }; if (opt.Relic != null) { + optData["relic_id"] = opt.Relic.Id.Entry; optData["relic_name"] = SafeGetText(() => opt.Relic.Title); optData["relic_description"] = SafeGetText(() => opt.Relic.DynamicDescription); + optData["relic_rarity"] = opt.Relic.Rarity.ToString(); + optData["relic_keywords"] = BuildHoverTips(opt.Relic.HoverTipsExcludingRelic); } optData["keywords"] = BuildHoverTips(opt.HoverTips); options.Add(optData); diff --git a/docs/raw-full.md b/docs/raw-full.md index 2f187c76..c5579782 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -547,8 +547,11 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire "is_locked": false, "is_proceed": false, "was_chosen": false, + "relic_id": "RELIC_ID", // Only if option has a relic "relic_name": "Relic Name", // Only if option has a relic "relic_description": "Relic desc.", // Only if option has a relic + "relic_rarity": "Common", // Only if option has a relic + "relic_keywords": [ /* Keyword Objects */ ], "keywords": [ /* Keyword Objects */ ] } ] From 55e46bdcda6bbc00e6e1cd63922c8d1674842f12 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:35:41 -0400 Subject: [PATCH 061/190] Expose full shop item metadata --- McpMod.StateBuilder.cs | 5 +++++ docs/raw-full.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index e0de0471..39b46706 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1483,6 +1483,7 @@ private static string GetCostDisplay(CardModel card) item["relic_id"] = relic.Id.Entry; item["relic_name"] = SafeGetText(() => relic.Title); item["relic_description"] = SafeGetText(() => relic.DynamicDescription); + item["relic_rarity"] = relic.Rarity.ToString(); item["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic); } items.Add(item); @@ -1581,6 +1582,7 @@ private static string GetCostDisplay(CardModel card) item["relic_id"] = relic.Id.Entry; item["relic_name"] = SafeGetText(() => relic.Title); item["relic_description"] = SafeGetText(() => relic.DynamicDescription); + item["relic_rarity"] = relic.Rarity.ToString(); item["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic); } items.Add(item); @@ -1603,6 +1605,9 @@ private static string GetCostDisplay(CardModel card) item["potion_id"] = potion.Id.Entry; item["potion_name"] = SafeGetText(() => potion.Title); item["potion_description"] = SafeGetText(() => potion.DynamicDescription); + item["potion_rarity"] = potion.Rarity.ToString(); + item["potion_target_type"] = potion.TargetType.ToString(); + item["potion_usage"] = potion.Usage.ToString(); item["keywords"] = BuildHoverTips(potion.ExtraHoverTips); } items.Add(item); diff --git a/docs/raw-full.md b/docs/raw-full.md index c5579782..463dff9e 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -620,6 +620,7 @@ Shop inventory is auto-opened when state is queried. "relic_id": "VAJRA", "relic_name": "Vajra", "relic_description": "At the start of each combat, gain 1 Strength.", + "relic_rarity": "Common", "keywords": [ /* Keyword Objects */ ] }, // Potion entry @@ -632,6 +633,9 @@ Shop inventory is auto-opened when state is queried. "potion_id": "FIRE_POTION", "potion_name": "Fire Potion", "potion_description": "Deal 20 damage to target enemy.", + "potion_rarity": "Common", + "potion_target_type": "Enemy", + "potion_usage": "Manual", "keywords": [ /* Keyword Objects */ ] }, // Card removal entry @@ -673,6 +677,7 @@ A relic-only shop disguised as an event. Uses `shop_purchase` and `proceed` acti "relic_id": "VAJRA", "relic_name": "Vajra", "relic_description": "At the start of each combat, gain 1 Strength.", + "relic_rarity": "Common", "keywords": [ /* Keyword Objects */ ] } ], From fb7167f56c1882347891b735bd168bff1786ae5c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:36:28 -0400 Subject: [PATCH 062/190] Expose full potion reward metadata --- McpMod.StateBuilder.cs | 4 ++++ docs/raw-full.md | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 39b46706..d4127512 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1772,6 +1772,10 @@ private static string GetCostDisplay(CardModel card) item["potion_id"] = potionReward.Potion.Id.Entry; item["potion_name"] = SafeGetText(() => potionReward.Potion.Title); item["potion_description"] = SafeGetText(() => potionReward.Potion.DynamicDescription); + item["potion_rarity"] = potionReward.Potion.Rarity.ToString(); + item["potion_target_type"] = potionReward.Potion.TargetType.ToString(); + item["potion_usage"] = potionReward.Potion.Usage.ToString(); + item["keywords"] = BuildHoverTips(potionReward.Potion.ExtraHoverTips); } items.Add(item); diff --git a/docs/raw-full.md b/docs/raw-full.md index 463dff9e..afc6cb74 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -444,7 +444,12 @@ Appears after combat ends or when triggered by events (e.g. TheFutureOfPotions, "type": "potion", "description": "Obtain a potion.", "potion_id": "SWIFT_POTION", // Only for potion rewards - "potion_name": "Swift Potion" + "potion_name": "Swift Potion", + "potion_description": "Draw 3 cards.", + "potion_rarity": "Common", + "potion_target_type": "None", + "potion_usage": "Manual", + "keywords": [ /* Keyword Objects */ ] }, { "index": 2, From b0eff83e4a5ccfbc51622cba721f3e1bf43b498a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:38:41 -0400 Subject: [PATCH 063/190] Expose full relic reward metadata --- McpMod.StateBuilder.cs | 35 +++++++++++++++++++++++++++++++++++ docs/raw-full.md | 10 ++++++++++ 2 files changed, 45 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index d4127512..bbbbfde7 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1777,6 +1777,14 @@ private static string GetCostDisplay(CardModel card) item["potion_usage"] = potionReward.Potion.Usage.ToString(); item["keywords"] = BuildHoverTips(potionReward.Potion.ExtraHoverTips); } + else if (reward is RelicReward relicReward && GetRelicRewardModel(relicReward) is { } relic) + { + item["relic_id"] = relic.Id.Entry; + item["relic_name"] = SafeGetText(() => relic.Title); + item["relic_description"] = SafeGetText(() => relic.DynamicDescription); + item["relic_rarity"] = relic.Rarity.ToString(); + item["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic); + } items.Add(item); index++; @@ -1790,6 +1798,33 @@ private static string GetCostDisplay(CardModel card) return state; } + private static RelicModel? GetRelicRewardModel(RelicReward reward) + { + const System.Reflection.BindingFlags Flags = + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic; + + var type = reward.GetType(); + foreach (var property in type.GetProperties(Flags)) + { + if (!typeof(RelicModel).IsAssignableFrom(property.PropertyType)) + continue; + try { return property.GetValue(reward) as RelicModel; } + catch { } + } + + foreach (var field in type.GetFields(Flags)) + { + if (!typeof(RelicModel).IsAssignableFrom(field.FieldType)) + continue; + try { return field.GetValue(reward) as RelicModel; } + catch { } + } + + return null; + } + private static Dictionary BuildCardRewardState(NCardRewardSelectionScreen cardScreen) { var state = new Dictionary(); diff --git a/docs/raw-full.md b/docs/raw-full.md index afc6cb74..84ffd9d7 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -453,6 +453,16 @@ Appears after combat ends or when triggered by events (e.g. TheFutureOfPotions, }, { "index": 2, + "type": "relic", + "description": "Obtain a relic.", + "relic_id": "VAJRA", + "relic_name": "Vajra", + "relic_description": "At the start of each combat, gain 1 Strength.", + "relic_rarity": "Common", + "keywords": [ /* Keyword Objects */ ] + }, + { + "index": 3, "type": "card", "description": "Add a card to your deck." // Card rewards open the card_reward screen when claimed From 58adf8bab47349b29198203e40cdc7c1ad3db8cb Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:41:29 -0400 Subject: [PATCH 064/190] Audit MCP action surface coverage --- scripts/audit_endpoints.py | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3d5dfe62..62fa951d 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -76,6 +76,49 @@ def audit_docs(repo: Path) -> None: print(f"docs: {len(documented)} endpoints documented") +def extract_action_switches(source: str, marker: str) -> set[str]: + try: + section = source[source.index(marker):] + except ValueError: + fail(f"could not find {marker}") + + match = re.search(r"return action switch\s*\{(.*?)\n\s*_ =>", section, re.S) + if not match: + fail(f"could not parse action switch for {marker}") + return set(re.findall(r'"([a-z_]+)"\s*=>', match.group(1))) + + +def audit_action_surface(repo: Path) -> None: + sp_actions = extract_action_switches((repo / "McpMod.Actions.cs").read_text(encoding="utf-8"), "ExecuteAction") + mp_actions = extract_action_switches((repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8"), "ExecuteMultiplayerAction") + server = (repo / "mcp" / "server.py").read_text(encoding="utf-8") + docs = "\n".join( + [ + (repo / "docs" / "raw-simplified.md").read_text(encoding="utf-8"), + (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"), + (repo / "mcp" / "README.md").read_text(encoding="utf-8"), + ] + ) + + mcp_posts = set(re.findall(r'"action"\s*:\s*"([a-z_]+)"', server)) + doc_actions = set(re.findall(r"`([a-z_]+)`", docs)) + + missing_mcp = sorted((sp_actions | mp_actions) - mcp_posts) + if missing_mcp: + fail(f"implemented actions missing MCP wrappers: {missing_mcp}") + + allowed_non_switch_posts = {"menu_select", "switch", "delete"} + extra_mcp = sorted(mcp_posts - sp_actions - mp_actions - allowed_non_switch_posts) + if extra_mcp: + fail(f"MCP posts unknown actions: {extra_mcp}") + + missing_docs = sorted((sp_actions | mp_actions) - doc_actions) + if missing_docs: + fail(f"implemented actions missing docs: {missing_docs}") + + print(f"actions: {len(sp_actions)} singleplayer, {len(mp_actions)} multiplayer actions covered") + + def audit_live(base_url: str) -> None: root_status, root = load_json_url(base_url.rstrip("/") + "/") if root_status != 200 or not isinstance(root, dict): @@ -136,6 +179,7 @@ def main() -> None: repo = Path(__file__).resolve().parents[1] audit_docs(repo) + audit_action_surface(repo) if not args.skip_live: audit_live(args.base_url) From 76ba661c19b58177855a1f76390796252834aa0a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:44:38 -0400 Subject: [PATCH 065/190] Expose full run deck in player state --- McpMod.StateBuilder.cs | 62 ++++++++++++++++++++++++++++++++++++++++++ docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 +- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index bbbbfde7..49b8a25c 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1173,6 +1173,11 @@ private static void AddMultiplayerLoadLobbyMenuState( state["gold"] = player.Gold; + var deck = BuildDeckCardList(player); + state["deck_count"] = deck.Count; + if (deck.Count > 0) + state["deck"] = deck; + // Powers (status effects) state["status"] = BuildPowersState(creature); @@ -1273,6 +1278,63 @@ private static string GetCostDisplay(CardModel card) return list; } + private static List> BuildDeckCardList(Player player) + { + var list = new List>(); + var index = 0; + foreach (var card in GetPlayerDeckCards(player)) + { + var cardInfo = BuildCardInfo(card); + cardInfo["index"] = index++; + list.Add(cardInfo); + } + return list; + } + + private static IEnumerable GetPlayerDeckCards(Player player) + { + var deck = GetMemberValue(player, "Deck"); + if (deck == null) + yield break; + + var cards = GetMemberValue(deck, "Cards") ?? deck; + if (cards is not System.Collections.IEnumerable enumerable) + yield break; + + foreach (var item in enumerable) + { + if (item is CardModel card) + yield return card; + } + } + + private static object? GetMemberValue(object source, string memberName) + { + const System.Reflection.BindingFlags Flags = + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic; + + var type = source.GetType(); + try + { + var property = type.GetProperty(memberName, Flags); + if (property != null) + return property.GetValue(source); + } + catch { } + + try + { + var field = type.GetField(memberName, Flags); + if (field != null) + return field.GetValue(source); + } + catch { } + + return null; + } + private static Dictionary BuildEnemyState(Creature creature, Dictionary entityCounts) { var monster = creature.Monster; diff --git a/docs/raw-full.md b/docs/raw-full.md index 84ffd9d7..e6050770 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -89,6 +89,8 @@ Always present at the top level (except `menu`). Contains everything about the l // --- Always present --- "status": [ /* Power Objects */ ], + "deck_count": 10, + "deck": [ /* Pile Card Objects */ ], "relics": [ { "id": "BURNING_BLOOD", diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 21edc6f0..e05004ba 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -32,7 +32,7 @@ Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if misma Every JSON response includes: - `state_type` — which screen the game is on (see below) - `run` — `{ act, floor, ascension }` (absent for `menu`) -- `player` — full player state: character, HP, gold, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) +- `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) | `state_type` | Screen | Available Actions | |---|---|---| From 489b57e4ffc23ab7133aff9e07ecf36064a07bb0 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:46:54 -0400 Subject: [PATCH 066/190] Show run deck in markdown state --- McpMod.Formatting.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/McpMod.Formatting.cs b/McpMod.Formatting.cs index 350d591b..e88d176b 100644 --- a/McpMod.Formatting.cs +++ b/McpMod.Formatting.cs @@ -431,6 +431,7 @@ private static void FormatDeckPilesMarkdown(StringBuilder sb, Dictionary Date: Sat, 9 May 2026 05:47:35 -0400 Subject: [PATCH 067/190] Expose full player inventory metadata --- McpMod.StateBuilder.cs | 3 +++ docs/raw-full.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 49b8a25c..775052ca 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1190,6 +1190,7 @@ private static void AddMultiplayerLoadLobbyMenuState( ["id"] = relic.Id.Entry, ["name"] = SafeGetText(() => relic.Title), ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), ["counter"] = relic.ShowCounter ? relic.DisplayAmount : null, ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) }); @@ -1208,9 +1209,11 @@ private static void AddMultiplayerLoadLobbyMenuState( ["id"] = potion.Id.Entry, ["name"] = SafeGetText(() => potion.Title), ["description"] = SafeGetText(() => potion.DynamicDescription), + ["rarity"] = potion.Rarity.ToString(), ["slot"] = slotIndex, ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, ["target_type"] = potion.TargetType.ToString(), + ["usage"] = potion.Usage.ToString(), ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) }); } diff --git a/docs/raw-full.md b/docs/raw-full.md index e6050770..821da829 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -96,6 +96,7 @@ Always present at the top level (except `menu`). Contains everything about the l "id": "BURNING_BLOOD", "name": "Burning Blood", "description": "At the end of combat, heal 6 HP.", + "rarity": "Starter", "counter": null, // Number if relic shows a counter, null otherwise "keywords": [ /* Keyword Objects */ ] } @@ -105,9 +106,11 @@ Always present at the top level (except `menu`). Contains everything about the l "id": "SWIFT_POTION", "name": "Swift Potion", "description": "Draw 3 cards.", + "rarity": "Common", "slot": 0, // Potion slot index (use this for use_potion action) "can_use_in_combat": true, "target_type": "None", // None, Self, AnyEnemy, AnyAlly, AnyPlayer, etc. + "usage": "Manual", "keywords": [ /* Keyword Objects */ ] } ], From a5f6b1779861c073542ec0e2c07455e22793526e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:49:53 -0400 Subject: [PATCH 068/190] Render card keywords and upgrades in markdown --- McpMod.Formatting.cs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/McpMod.Formatting.cs b/McpMod.Formatting.cs index e88d176b..ec821c9c 100644 --- a/McpMod.Formatting.cs +++ b/McpMod.Formatting.cs @@ -371,10 +371,10 @@ private static void FormatBattleMarkdown(StringBuilder sb, Dictionary kwList && kwList.Count > 0 - ? $" [{string.Join(", ", kwList)}]" : ""; + string keywords = FormatKeywordNames(card); + string upgraded = FormatUpgradeMarker(card); string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; - sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} - {card["description"]} (target: {card["target_type"]})"); + sb.AppendLine($"- [{card["index"]}] **{card["name"]}**{upgraded} ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} - {card["description"]} (target: {card["target_type"]})"); } sb.AppendLine(); } @@ -472,7 +472,10 @@ private static void FormatPileMarkdown(StringBuilder sb, Dictionary card) + => card.TryGetValue("is_upgraded", out var upgraded) && upgraded is true ? "+" : ""; + + private static string FormatKeywordNames(Dictionary item) + { + if (!item.TryGetValue("keywords", out var kw) || kw is not List> keywords || keywords.Count == 0) + return ""; + + var names = keywords + .Select(keyword => keyword.TryGetValue("name", out var name) ? name?.ToString() : null) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToList(); + + return names.Count > 0 ? $" [{string.Join(", ", names)}]" : ""; + } + private static void FormatEventMarkdown(StringBuilder sb, Dictionary evt) { string name = evt.TryGetValue("event_name", out var n) && n != null ? n.ToString()! : "Unknown Event"; @@ -747,9 +766,9 @@ private static void FormatCardRewardMarkdown(StringBuilder sb, Dictionary kwList && kwList.Count > 0 - ? $" [{string.Join(", ", kwList)}]" : ""; - sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} - {card["description"]}"); + string keywords = FormatKeywordNames(card); + string upgraded = FormatUpgradeMarker(card); + sb.AppendLine($"- [{card["index"]}] **{card["name"]}**{upgraded} ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} - {card["description"]}"); } sb.AppendLine(); } From d7dbd5022c7d4419d2235921f99c75e4a6dc91b2 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:52:42 -0400 Subject: [PATCH 069/190] Audit explicit state response formats --- scripts/audit_endpoints.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 62fa951d..5c45154c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -54,6 +54,15 @@ def load_json_url(url: str, method: str = "GET", body: bytes | None = None) -> t raise RuntimeError(f"{url} returned HTTP {exc.code} with non-JSON body") from exc +def load_text_url(url: str) -> tuple[int, str]: + req = urllib.request.Request(url, headers={"Accept": "text/markdown"}) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return response.status, response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + + def assert_error_body(path: str, status: int, data: object) -> None: if status < 400: return @@ -150,6 +159,14 @@ def audit_live(base_url: str) -> None: if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") + if status != 200 or not isinstance(data, dict) or "state_type" not in data: + fail(f"/api/v1/singleplayer?format=json expected JSON state, got HTTP {status}: {data}") + + markdown_status, markdown = load_text_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=markdown") + if markdown_status != 200 or "# Game State:" not in markdown: + fail(f"/api/v1/singleplayer?format=markdown expected markdown state, got HTTP {markdown_status}: {markdown[:120]}") + post_validation_checks = [ ("/api/v1/singleplayer", b"{", 400), ("/api/v1/singleplayer", b"{}", 400), @@ -168,6 +185,7 @@ def audit_live(base_url: str) -> None: fail(f"/api/v1/settings expected HTTP 405 for POST, got {status}: {data}") print("live: GET endpoint smoke checks passed") + print("live: state format checks passed") print("live: safe POST validation checks passed") From 98db8003ff98aa6e7ebb4d40c7fb814e7bf8379c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 05:53:48 -0400 Subject: [PATCH 070/190] Render full item metadata in markdown --- McpMod.Formatting.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/McpMod.Formatting.cs b/McpMod.Formatting.cs index ec821c9c..a2ca9652 100644 --- a/McpMod.Formatting.cs +++ b/McpMod.Formatting.cs @@ -586,11 +586,12 @@ private static void FormatShopMarkdown(StringBuilder sb, Dictionary $"**{item.GetValueOrDefault("card_name")}**{cardEnergy} [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")} - {item.GetValueOrDefault("card_description")}", - "relic" => $"**{item.GetValueOrDefault("relic_name")}** - {item.GetValueOrDefault("relic_description")}", - "potion" => $"**{item.GetValueOrDefault("potion_name")}** - {item.GetValueOrDefault("potion_description")}", + "card" => $"**{item.GetValueOrDefault("card_name")}**{(item.GetValueOrDefault("card_is_upgraded") is true ? "+" : "")}{cardEnergy} [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")}{itemKeywords} - {item.GetValueOrDefault("card_description")}", + "relic" => $"**{item.GetValueOrDefault("relic_name")}** {item.GetValueOrDefault("relic_rarity")}{itemKeywords} - {item.GetValueOrDefault("relic_description")}", + "potion" => $"**{item.GetValueOrDefault("potion_name")}** {item.GetValueOrDefault("potion_rarity")} ({item.GetValueOrDefault("potion_usage")}, target: {item.GetValueOrDefault("potion_target_type")}){itemKeywords} - {item.GetValueOrDefault("potion_description")}", "card_removal" => "**Remove a card** from your deck", _ => "Unknown item" }; @@ -736,9 +737,11 @@ private static void FormatRewardsMarkdown(StringBuilder sb, Dictionary Date: Sat, 9 May 2026 05:58:02 -0400 Subject: [PATCH 071/190] Avoid cross-account save fallback --- McpMod.Compendium.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index c94a1faa..74da695f 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -516,14 +516,13 @@ private static IEnumerable EnumerateSaveRoots() if (yielded.Count > 0) yield break; - foreach (var steamRoot in steamRoots) - { - foreach (var accountRoot in Directory.GetDirectories(steamRoot)) - { - if (yielded.Add(accountRoot)) - yield return accountRoot; - } - } + var accountRoots = steamRoots + .SelectMany(Directory.GetDirectories) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (accountRoots.Count == 1 && yielded.Add(accountRoots[0])) + yield return accountRoots[0]; } private static IEnumerable EnumerateSteamDataRoots() From 1be80e111519249deca08a4465da3c64bfa10962 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:03:42 -0400 Subject: [PATCH 072/190] Normalize markdown item metadata --- McpMod.Formatting.cs | 75 ++++++++++++++++++-------------------- scripts/audit_endpoints.py | 14 +++++++ 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/McpMod.Formatting.cs b/McpMod.Formatting.cs index a2ca9652..2fb5942a 100644 --- a/McpMod.Formatting.cs +++ b/McpMod.Formatting.cs @@ -361,7 +361,7 @@ private static void FormatBattleMarkdown(StringBuilder sb, Dictionary { string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : ""; - return $"- **{r["name"]}**{counter}: {r["description"]}"; + return $"- {FormatRelicDetails(r, counter)}"; }); FormatPotionsSection(sb, player); @@ -371,10 +371,7 @@ private static void FormatBattleMarkdown(StringBuilder sb, Dictionary> pile && pile.Count > 0) { foreach (var card in pile) - { - string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; - string rarity = card.TryGetValue("rarity", out var r) && r != null ? $" [{r}]" : ""; - string keywords = FormatKeywordNames(card); - string upgraded = FormatUpgradeMarker(card); - sb.AppendLine($"- {card["name"]}{upgraded} ({card["cost"]}{starCost}){rarity}{keywords}: {card["description"]}"); - } + sb.AppendLine($"- {FormatCardDetails(card, energyLabel: false)}"); } else sb.AppendLine("- *(empty)*"); @@ -486,6 +477,30 @@ private static void FormatPileMarkdown(StringBuilder sb, Dictionary card) => card.TryGetValue("is_upgraded", out var upgraded) && upgraded is true ? "+" : ""; + private static string FormatCardDetails(Dictionary card, bool includeTarget = false, bool energyLabel = true) + { + string upgraded = FormatUpgradeMarker(card); + string cost = card.TryGetValue("cost", out var c) && c != null ? c.ToString()! : "?"; + string energy = energyLabel ? " energy" : ""; + string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; + string type = card.TryGetValue("type", out var t) && t != null ? $" [{t}]" : ""; + string rarity = card.TryGetValue("rarity", out var r) && r != null ? $" {r}" : ""; + string keywords = FormatKeywordNames(card); + string description = card.TryGetValue("description", out var d) && d != null ? $" - {d}" : ""; + string target = includeTarget && card.TryGetValue("target_type", out var targetObj) && targetObj != null + ? $" (target: {targetObj})" + : ""; + return $"**{card.GetValueOrDefault("name")}**{upgraded} ({cost}{energy}{starCost}){type}{rarity}{keywords}{description}{target}"; + } + + private static string FormatRelicDetails(Dictionary relic, string nameSuffix = "") + { + string rarity = relic.TryGetValue("rarity", out var r) && r != null ? $" {r}" : ""; + string keywords = FormatKeywordNames(relic); + string description = relic.TryGetValue("description", out var d) && d != null ? $" - {d}" : ""; + return $"**{relic.GetValueOrDefault("name")}**{nameSuffix}{rarity}{keywords}{description}"; + } + private static string FormatKeywordNames(Dictionary item) { if (!item.TryGetValue("keywords", out var kw) || kw is not List> keywords || keywords.Count == 0) @@ -767,12 +782,7 @@ private static void FormatCardRewardMarkdown(StringBuilder sb, Dictionary> cards) { foreach (var card in cards) - { - string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; - string keywords = FormatKeywordNames(card); - string upgraded = FormatUpgradeMarker(card); - sb.AppendLine($"- [{card["index"]}] **{card["name"]}**{upgraded} ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} - {card["description"]}"); - } + sb.AppendLine($"- [{card["index"]}] {FormatCardDetails(card)}"); sb.AppendLine(); } @@ -791,7 +801,7 @@ private static void FormatRelicSelectMarkdown(StringBuilder sb, Dictionary> relics) { foreach (var relic in relics) - sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}** - {relic["description"]}"); + sb.AppendLine($"- [{relic["index"]}] {FormatRelicDetails(relic)}"); sb.AppendLine(); } @@ -817,10 +827,7 @@ private static void FormatHandSelectMarkdown(StringBuilder sb, Dictionary> cards) { foreach (var card in cards) - { - string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; - sb.AppendLine($" **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]} - {card["description"]}"); - } + sb.AppendLine($" {FormatCardDetails(card)}"); } } sb.AppendLine(); @@ -915,10 +916,7 @@ private static void FormatBundleSelectMarkdown(StringBuilder sb, Dictionary None: print(f"actions: {len(sp_actions)} singleplayer, {len(mp_actions)} multiplayer actions covered") +def audit_static_formatters(repo: Path) -> None: + formatting = (repo / "McpMod.Formatting.cs").read_text(encoding="utf-8") + forbidden_patterns = { + r'card\["name"\].*card\["cost"\]': "card markdown should use FormatCardDetails so upgrades/keywords stay visible", + r'relic\["name"\].*relic\["description"\]': "relic markdown should use FormatRelicDetails so rarity/keywords stay visible", + r"List\s+\w+List": "keyword markdown should read keyword objects, not legacy string lists", + } + for pattern, message in forbidden_patterns.items(): + if re.search(pattern, formatting): + fail(message) + print("formatters: card/relic metadata helpers enforced") + + def audit_live(base_url: str) -> None: root_status, root = load_json_url(base_url.rstrip("/") + "/") if root_status != 200 or not isinstance(root, dict): @@ -198,6 +211,7 @@ def main() -> None: repo = Path(__file__).resolve().parents[1] audit_docs(repo) audit_action_surface(repo) + audit_static_formatters(repo) if not args.skip_live: audit_live(args.base_url) From 9b32200679567c00aad4756464a2285bbabeb766 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:05:25 -0400 Subject: [PATCH 073/190] Audit multiplayer POST guard --- scripts/audit_endpoints.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4e1a9106..ee8670bb 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -192,6 +192,12 @@ def audit_live(base_url: str) -> None: if status != expected_status: fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + for body in (b"{", b"{}"): + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/multiplayer", "POST", body) + assert_error_body("/api/v1/multiplayer", status, data) + if status not in {400, 409}: + fail(f"/api/v1/multiplayer expected HTTP 400 in MP or 409 outside MP, got {status}: {data}") + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/settings", "POST", b"{}") assert_error_body("/api/v1/settings", status, data) if status != 405: From cd9f2bdc34040ad1d30c71f2e2f1c0ef7c8549a2 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:08:44 -0400 Subject: [PATCH 074/190] Normalize state endpoint errors --- McpMod.cs | 16 ++-------------- scripts/audit_endpoints.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 09f6a605..495afad6 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -454,13 +454,7 @@ private static void HandleGetMultiplayerState(HttpListenerRequest request, HttpL GD.PrintErr($"[STS2 MCP] HandleGetMultiplayerState: {ex}"); try { - response.StatusCode = 500; - SendJson(response, new Dictionary - { - ["error"] = $"Failed to read multiplayer game state: {ex.Message}", - ["exception_type"] = ex.GetType().FullName, - ["stack_trace"] = ex.StackTrace - }); + SendError(response, 500, $"Failed to read multiplayer game state: {ex.Message}"); } catch { /* response may be unusable */ } } @@ -555,13 +549,7 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp GD.PrintErr($"[STS2 MCP] HandleGetState: {ex}"); try { - response.StatusCode = 500; - SendJson(response, new Dictionary - { - ["error"] = $"Failed to read game state: {ex.Message}", - ["exception_type"] = ex.GetType().FullName, - ["stack_trace"] = ex.StackTrace - }); + SendError(response, 500, $"Failed to read game state: {ex.Message}"); } catch { /* response may be unusable */ } } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ee8670bb..f2ae1bdf 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -141,6 +141,18 @@ def audit_static_formatters(repo: Path) -> None: print("formatters: card/relic metadata helpers enforced") +def audit_static_error_shapes(repo: Path) -> None: + raw_error_writes: list[str] = [] + for path in repo.glob("McpMod*.cs"): + text = path.read_text(encoding="utf-8") + if re.search(r'response\.StatusCode\s*=\s*500\s*;\s*SendJson\(\s*response\s*,\s*new Dictionary', text, re.S): + raw_error_writes.append(path.name) + + if raw_error_writes: + fail(f"500 handlers should use SendError for structured error bodies: {sorted(raw_error_writes)}") + print("errors: structured 500 response helpers enforced") + + def audit_live(base_url: str) -> None: root_status, root = load_json_url(base_url.rstrip("/") + "/") if root_status != 200 or not isinstance(root, dict): @@ -218,6 +230,7 @@ def main() -> None: audit_docs(repo) audit_action_surface(repo) audit_static_formatters(repo) + audit_static_error_shapes(repo) if not args.skip_live: audit_live(args.base_url) From fa0915d49eea44e74e45458ff22687a4378d89a0 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:11:01 -0400 Subject: [PATCH 075/190] Restrict root endpoint methods --- McpMod.cs | 19 +++++++++++++------ scripts/audit_endpoints.py | 5 +++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 495afad6..42bd4cf2 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -271,13 +271,20 @@ private static void HandleRequest(HttpListenerContext context) if (path == "/") { - SendJson(response, new + if (request.HttpMethod == "GET") + { + SendJson(response, new + { + message = $"Hello from STS2 MCP v{Version}", + status = "ok", + bound_prefixes = _boundPrefixes, + endpoints = BuildEndpointIndex() + }); + } + else { - message = $"Hello from STS2 MCP v{Version}", - status = "ok", - bound_prefixes = _boundPrefixes, - endpoints = BuildEndpointIndex() - }); + SendError(response, 405, "Method not allowed"); + } } else if (path == "/api/v1/singleplayer") { diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f2ae1bdf..6f51b8e7 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -215,6 +215,11 @@ def audit_live(base_url: str) -> None: if status != 405: fail(f"/api/v1/settings expected HTTP 405 for POST, got {status}: {data}") + status, data = load_json_url(base_url.rstrip("/") + "/", "POST", b"{}") + assert_error_body("/", status, data) + if status != 405: + fail(f"/ expected HTTP 405 for POST, got {status}: {data}") + print("live: GET endpoint smoke checks passed") print("live: state format checks passed") print("live: safe POST validation checks passed") From 3204d7ff504bfbdba14c6e5b0b054cd6a188458e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:17:45 -0400 Subject: [PATCH 076/190] Scope glossary responses to active run --- McpMod.Compendium.cs | 25 ++++++++---- McpMod.ForkEndpoints.cs | 79 +++++++++++++++++++++++++++++++++++--- docs/raw-full.md | 26 ++++++++++++- docs/raw-simplified.md | 2 +- mcp/README.md | 10 ++--- scripts/audit_endpoints.py | 11 ++++++ 6 files changed, 133 insertions(+), 20 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 74da695f..ccc758ad 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -364,19 +364,27 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) } private static Dictionary? BuildCurrentRunContext(CompendiumSnapshot snapshot) + => BuildCurrentRunContext(snapshot.IsRunInProgress, snapshot.ProfileId, snapshot.SaveScope, snapshot.ProgressPath, snapshot.ProfileRoot); + + private static Dictionary? BuildCurrentRunContext( + bool isRunInProgress, + int profileId, + string saveScope, + string? progressPath, + string profileRoot) { - if (!snapshot.IsRunInProgress) + if (!isRunInProgress) return null; var result = new Dictionary { ["is_in_progress"] = true, - ["profile_id"] = snapshot.ProfileId, - ["save_scope"] = snapshot.SaveScope, + ["profile_id"] = profileId, + ["save_scope"] = saveScope, ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" }; - var currentRunPath = ResolveCurrentRunPath(snapshot); + var currentRunPath = ResolveCurrentRunPath(progressPath, profileRoot); if (currentRunPath == null || !File.Exists(currentRunPath)) { result["limitation"] = "Run is in progress, but current_run.save was not found yet."; @@ -406,7 +414,7 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) result["seed"] = GetJsonValue(seed); if (result.TryGetValue("start_time", out var startTime) && startTime != null) - result["run_id"] = $"{snapshot.SaveScope}:profile{snapshot.ProfileId}:{startTime}"; + result["run_id"] = $"{saveScope}:profile{profileId}:{startTime}"; } catch (Exception ex) { @@ -595,8 +603,11 @@ private static IEnumerable EnumerateSteamDataRoots() } private static string? ResolveCurrentRunPath(CompendiumSnapshot snapshot) + => ResolveCurrentRunPath(snapshot.ProgressPath, snapshot.ProfileRoot); + + private static string? ResolveCurrentRunPath(string? progressPath, string profileRoot) { - var saveDirectory = GetSaveDirectoryFromProgressPath(snapshot.ProgressPath); + var saveDirectory = GetSaveDirectoryFromProgressPath(progressPath); if (saveDirectory != null) { var currentRunPath = Path.Combine(saveDirectory, "current_run.save"); @@ -606,7 +617,7 @@ private static IEnumerable EnumerateSteamDataRoots() foreach (var saveRoot in EnumerateSaveRoots()) { - var absolutePath = Path.Combine(saveRoot, snapshot.ProfileRoot, "saves", "current_run.save"); + var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "current_run.save"); if (File.Exists(absolutePath)) return absolutePath; } diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 565c7004..e5cb9b91 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -91,7 +91,7 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(BuildGlossaryCards); + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("cards", BuildGlossaryCards)); SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) @@ -104,7 +104,7 @@ private static void HandleGetGlossaryKeywords(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(BuildGlossaryKeywords); + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("keywords", BuildGlossaryKeywords)); SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) @@ -117,7 +117,7 @@ private static void HandleGetGlossaryPotions(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(BuildGlossaryPotions); + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("potions", BuildGlossaryPotions)); SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) @@ -130,7 +130,7 @@ private static void HandleGetGlossaryRelics(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(BuildGlossaryRelics); + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("relics", BuildGlossaryRelics)); SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) @@ -139,8 +139,51 @@ private static void HandleGetGlossaryRelics(HttpListenerResponse response) } } - private static void SendGlossaryJson(HttpListenerResponse response, object data) + private sealed class GlossaryPayload { + public string Kind { get; init; } = ""; + public object Data { get; init; } = new(); + public int ProfileId { get; init; } + public string? ProgressPath { get; init; } + public string ProfileRoot { get; init; } = ""; + public string SaveScope { get; init; } = "vanilla"; + public string NetType { get; init; } = ""; + public List> Players { get; init; } = new(); + } + + private static GlossaryPayload BuildGlossaryPayload(string kind, Func buildData) + { + var data = buildData(); + if (data is Dictionary error && error.TryGetValue("error_code", out _)) + return new GlossaryPayload { Kind = kind, Data = error }; + + var profileId = SaveManager.Instance?.CurrentProfileId ?? 0; + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + var runState = RunManager.Instance.DebugOnlyGetState(); + var players = runState?.Players.Select((player, index) => new Dictionary + { + ["index"] = index, + ["character"] = player.Character?.Id.Entry, + ["character_name"] = SafeGetText(() => player.Character?.Title) + }).ToList() ?? new List>(); + + return new GlossaryPayload + { + Kind = kind, + Data = data, + ProfileId = profileId, + ProgressPath = progressPath, + ProfileRoot = profileRoot, + SaveScope = GetSaveScope(profileRoot), + NetType = RunManager.Instance.NetService.Type.ToString(), + Players = players + }; + } + + private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayload payload) + { + var data = payload.Data; if (data is Dictionary error && error.TryGetValue("error_code", out var errorCode)) { @@ -150,9 +193,33 @@ private static void SendGlossaryJson(HttpListenerResponse response, object data) RunStateUnavailableErrorCode => 503, _ => 500 }; + + SendJson(response, data); + return; } - SendJson(response, data); + if (data is not List> items) + { + SendJson(response, data); + return; + } + + SendJson(response, new Dictionary + { + ["status"] = "ok", + ["kind"] = payload.Kind, + ["scope"] = "active_run", + ["count"] = items.Count, + ["current_run"] = BuildCurrentRunContext( + isRunInProgress: true, + profileId: payload.ProfileId, + saveScope: payload.SaveScope, + progressPath: payload.ProgressPath, + profileRoot: payload.ProfileRoot), + ["net_type"] = payload.NetType, + ["players"] = payload.Players, + ["items"] = items + }); } private static Dictionary GlossaryError(string message, string errorCode) diff --git a/docs/raw-full.md b/docs/raw-full.md index 821da829..8e8c5a8e 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -989,7 +989,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and ### `GET /api/v1/glossary/*` -The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. - `GET /api/v1/glossary/cards`: active-run card pool metadata. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. @@ -998,6 +998,30 @@ The glossary endpoints expose active-run pool metadata. They require a run in pr If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"`. +Example success shape: + +```json +{ + "status": "ok", + "kind": "cards", + "scope": "active_run", + "count": 87, + "current_run": { + "run_id": "modded:profile1:1778295706", + "seed": "VQY2JBY38L" + }, + "items": [ + { + "id": "STRIKE_RED", + "name": "Strike", + "type": "Attack", + "rarity": "Basic", + "keywords": [] + } + ] +} +``` + ### `GET /api/v1/profiles` Lists the three profile slots and identifies the active slot. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index e05004ba..98b61a8f 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -75,7 +75,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index aa90a67c..f34c46d3 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -11,10 +11,10 @@ | `get_profile()` | Profiles | Get active profile progress | | `get_compendium()` | Profiles | Get Compendium-shaped profile progress | | `get_bestiary()` | Profiles | Get monster and encounter metadata | -| `get_glossary_cards()` | Active Pool | Get active-run card pool metadata | -| `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata | -| `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata | -| `get_glossary_keywords()` | Active Pool | Get active-run keyword metadata | +| `get_glossary_cards()` | Active Pool | Get active-run card pool metadata with run_id/seed scope | +| `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata with run_id/seed scope | +| `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata with run_id/seed scope | +| `get_glossary_keywords()` | Active Pool | Get active-run keyword metadata with run_id/seed scope | | `list_profiles()` | Profiles | List profile slots and active slot | | `switch_profile(profile_id)` | Profiles | Switch to a profile slot through the game UI | | `delete_profile(profile_id)` | Profiles | Delete an inactive profile slot | @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Glossary tools require an active run. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 6f51b8e7..ea579a29 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -183,6 +183,17 @@ def audit_live(base_url: str) -> None: if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") + if path.startswith("/api/v1/glossary/") and status == 200: + expected_kind = path.rsplit("/", 1)[-1] + if not isinstance(data, dict): + fail(f"{path} expected structured glossary object, got {type(data).__name__}") + if data.get("status") != "ok" or data.get("kind") != expected_kind: + fail(f"{path} expected status ok and kind {expected_kind}, got {data}") + if not isinstance(data.get("items"), list) or data.get("count") != len(data["items"]): + fail(f"{path} expected items list with matching count, got {data}") + current_run = data.get("current_run") + if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): + fail(f"{path} expected current_run run_id and seed, got {current_run}") status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") if status != 200 or not isinstance(data, dict) or "state_type" not in data: From 95744960c9a16f43130c5ed019cd12f251205b31 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:21:33 -0400 Subject: [PATCH 077/190] Treat malformed action payloads as bad requests --- McpMod.Profile.cs | 16 ++++++++++++++-- McpMod.cs | 26 ++++++++++++++++++++++---- scripts/audit_endpoints.py | 4 ++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 055eedb1..d5c9a4dc 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -63,9 +63,21 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener SendError(response, 400, "Missing 'action' field. Use: switch, delete"); return; } + if (actionElem.ValueKind != JsonValueKind.String) + { + SendError(response, 400, "'action' field must be a string"); + return; + } string action = actionElem.GetString() ?? ""; - int profileId = parsed.TryGetValue("profile_id", out var idElem) && idElem.ValueKind == JsonValueKind.Number + JsonElement idElem; + if (parsed.TryGetValue("profile_id", out idElem) && idElem.ValueKind != JsonValueKind.Number) + { + SendError(response, 400, "'profile_id' field must be a number"); + return; + } + + int profileId = parsed.TryGetValue("profile_id", out idElem) && idElem.ValueKind == JsonValueKind.Number ? idElem.GetInt32() : 0; @@ -76,7 +88,7 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener } catch (Exception ex) { - SendError(response, 500, $"Profile action failed: {ex.Message}"); + SendActionError(response, "Profile action failed", ex); } } diff --git a/McpMod.cs b/McpMod.cs index 42bd4cf2..93444584 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -489,6 +489,11 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt SendError(response, 400, "Missing 'action' field"); return; } + if (actionElem.ValueKind != JsonValueKind.String) + { + SendError(response, 400, "'action' field must be a string"); + return; + } string action = actionElem.GetString() ?? ""; @@ -508,7 +513,7 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt } catch (Exception ex) { - SendError(response, 500, $"Menu action failed: {ex.Message}"); + SendActionError(response, "Menu action failed", ex); } return; } @@ -521,7 +526,7 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt } catch (Exception ex) { - SendError(response, 500, $"Multiplayer action failed: {ex.Message}"); + SendActionError(response, "Multiplayer action failed", ex); } } @@ -584,6 +589,11 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe SendError(response, 400, "Missing 'action' field"); return; } + if (actionElem.ValueKind != JsonValueKind.String) + { + SendError(response, 400, "'action' field must be a string"); + return; + } string action = actionElem.GetString() ?? ""; @@ -600,7 +610,7 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe } catch (Exception ex) { - SendError(response, 500, $"Menu action failed: {ex.Message}"); + SendActionError(response, "Menu action failed", ex); } return; } @@ -613,7 +623,15 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe } catch (Exception ex) { - SendError(response, 500, $"Action failed: {ex.Message}"); + SendActionError(response, "Action failed", ex); } } + + private static void SendActionError(HttpListenerResponse response, string prefix, Exception ex) + { + var statusCode = ex is InvalidOperationException or FormatException or OverflowException + ? 400 + : 500; + SendError(response, statusCode, $"{prefix}: {ex.Message}"); + } } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ea579a29..404130c6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -206,8 +206,12 @@ def audit_live(base_url: str) -> None: post_validation_checks = [ ("/api/v1/singleplayer", b"{", 400), ("/api/v1/singleplayer", b"{}", 400), + ("/api/v1/singleplayer", b'{"action": 1}', 400), + ("/api/v1/singleplayer", b'{"action": "menu_select", "option": 1}', 400), ("/api/v1/profiles", b"{", 400), ("/api/v1/profiles", b"{}", 400), + ("/api/v1/profiles", b'{"action": 1}', 400), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400), ] for path, body, expected_status in post_validation_checks: status, data = load_json_url(base_url.rstrip("/") + path, "POST", body) From 933968fd491766ee0e960ab7a07088ffd51a837c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:23:22 -0400 Subject: [PATCH 078/190] Audit GET-only route methods --- scripts/audit_endpoints.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 404130c6..1aa3237a 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -225,15 +225,27 @@ def audit_live(base_url: str) -> None: if status not in {400, 409}: fail(f"/api/v1/multiplayer expected HTTP 400 in MP or 409 outside MP, got {status}: {data}") - status, data = load_json_url(base_url.rstrip("/") + "/api/v1/settings", "POST", b"{}") - assert_error_body("/api/v1/settings", status, data) - if status != 405: - fail(f"/api/v1/settings expected HTTP 405 for POST, got {status}: {data}") - - status, data = load_json_url(base_url.rstrip("/") + "/", "POST", b"{}") - assert_error_body("/", status, data) - if status != 405: - fail(f"/ expected HTTP 405 for POST, got {status}: {data}") + get_only_paths = [ + "/", + "/api/v1/settings", + "/api/v1/profile", + "/api/v1/compendium", + "/api/v1/bestiary", + "/api/v1/glossary/cards", + "/api/v1/glossary/relics", + "/api/v1/glossary/potions", + "/api/v1/glossary/keywords", + ] + for path in get_only_paths: + status, data = load_json_url(base_url.rstrip("/") + path, "POST", b"{}") + assert_error_body(path, status, data) + if status != 405: + fail(f"{path} expected HTTP 405 for POST, got {status}: {data}") + + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/does-not-exist") + assert_error_body("/api/v1/does-not-exist", status, data) + if status != 404: + fail(f"/api/v1/does-not-exist expected HTTP 404, got {status}: {data}") print("live: GET endpoint smoke checks passed") print("live: state format checks passed") From ed96abe51e2d770d77f0bdb8e2636dc1f98945cd Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:24:23 -0400 Subject: [PATCH 079/190] Audit state surface coverage --- scripts/audit_endpoints.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 1aa3237a..246879b6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -153,6 +153,34 @@ def audit_static_error_shapes(repo: Path) -> None: print("errors: structured 500 response helpers enforced") +def audit_state_surface(repo: Path) -> None: + state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") + formatting = (repo / "McpMod.Formatting.cs").read_text(encoding="utf-8") + docs = "\n".join( + [ + (repo / "docs" / "raw-simplified.md").read_text(encoding="utf-8"), + (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"), + (repo / "mcp" / "README.md").read_text(encoding="utf-8"), + ] + ) + + state_types = set(re.findall(r'\["state_type"\]\s*=\s*"([^"]+)"', state_builder)) + missing_docs = sorted(state_type for state_type in state_types if state_type not in docs) + if missing_docs: + fail(f"state types missing docs: {missing_docs}") + + formatter_refs = set() + for match in re.findall(r'TryGetValue\("([a-z_]+)"|stateType\s*==\s*"([a-z_]+)"', formatting): + formatter_refs.update(value for value in match if value) + covered_by_battle = {"monster", "elite", "boss"} + intentionally_unformatted = {"unknown"} + missing_formatters = sorted(state_types - formatter_refs - covered_by_battle - intentionally_unformatted) + if missing_formatters: + fail(f"state types missing markdown formatter coverage: {missing_formatters}") + + print(f"states: {len(state_types)} documented, markdown coverage enforced") + + def audit_live(base_url: str) -> None: root_status, root = load_json_url(base_url.rstrip("/") + "/") if root_status != 200 or not isinstance(root, dict): @@ -263,6 +291,7 @@ def main() -> None: audit_action_surface(repo) audit_static_formatters(repo) audit_static_error_shapes(repo) + audit_state_surface(repo) if not args.skip_live: audit_live(args.base_url) From 8ec1f4e7f4aafa578ca7eae874a77b7b0537d72f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:25:49 -0400 Subject: [PATCH 080/190] Audit MCP tool documentation --- scripts/audit_endpoints.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 246879b6..fbea3548 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import ast import json import re import sys @@ -128,6 +129,34 @@ def audit_action_surface(repo: Path) -> None: print(f"actions: {len(sp_actions)} singleplayer, {len(mp_actions)} multiplayer actions covered") +def audit_mcp_tool_docs(repo: Path) -> None: + server_path = repo / "mcp" / "server.py" + server = server_path.read_text(encoding="utf-8") + readme = (repo / "mcp" / "README.md").read_text(encoding="utf-8") + module = ast.parse(server, filename=str(server_path)) + + tools: list[str] = [] + for node in module.body: + if not isinstance(node, ast.AsyncFunctionDef): + continue + if any( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and decorator.func.attr == "tool" + for decorator in node.decorator_list + ): + tools.append(node.name) + + missing_readme = [ + tool for tool in tools + if f"`{tool}(" not in readme and f"`{tool}()`" not in readme + ] + if missing_readme: + fail(f"MCP tools missing mcp/README.md entries: {missing_readme}") + + print(f"mcp: {len(tools)} tools documented") + + def audit_static_formatters(repo: Path) -> None: formatting = (repo / "McpMod.Formatting.cs").read_text(encoding="utf-8") forbidden_patterns = { @@ -289,6 +318,7 @@ def main() -> None: repo = Path(__file__).resolve().parents[1] audit_docs(repo) audit_action_surface(repo) + audit_mcp_tool_docs(repo) audit_static_formatters(repo) audit_static_error_shapes(repo) audit_state_surface(repo) From df1212d860a5f21d60f044dc233fcd2ff25d9070 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:26:56 -0400 Subject: [PATCH 081/190] Clarify proceed action coverage --- docs/raw-full.md | 2 +- mcp/README.md | 2 +- mcp/server.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index 8e8c5a8e..504611fa 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1216,7 +1216,7 @@ Skip the card reward (if a skip/bowl option is available). ### `proceed` -Leave the current screen and open the map. Works from: rewards, rest site, shop, treasure room. +Leave the current screen and open the map. Works from: rewards, rest site, shop, fake merchant, and treasure room. ```json { "action": "proceed" } diff --git a/mcp/README.md b/mcp/README.md index f34c46d3..b034a7b3 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -60,7 +60,7 @@ All multiplayer tools are prefixed with `mp_`. They route through `/api/v1/multi | `mp_combat_undo_end_turn()` | Combat | Retract end-turn vote | | `mp_use_potion(slot, target?)` | General | Use a potion from the local player's slots | | `mp_discard_potion(slot)` | General | Discard a potion from the local player's slots | -| `mp_proceed_to_map()` | General | Proceed from current screen to the map | +| `mp_proceed_to_map()` | General | Proceed from rewards/rest site/shop/fake merchant/treasure to the map | | `mp_map_vote(node_index)` | Map | Vote for a map node (travel when all agree) | | `mp_event_choose_option(option_index)` | Event | Vote for / choose an event option | | `mp_event_advance_dialogue()` | Event | Advance ancient event dialogue | diff --git a/mcp/server.py b/mcp/server.py index f7b80bbd..0131e634 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -484,7 +484,7 @@ async def discard_potion(slot: int) -> str: async def proceed_to_map() -> str: """Proceed from the current screen to the map. - Works from: rewards screen, rest site, shop, fake merchant. + Works from: rewards screen, rest site, shop, fake merchant, and treasure. Does NOT work for events — use event_choose_option() with the Proceed option's index. """ try: @@ -1084,7 +1084,7 @@ async def mp_rewards_skip_card() -> str: async def mp_proceed_to_map() -> str: """[Multiplayer] Proceed from the current screen to the map. - Works from: rewards screen, rest site, shop. + Works from: rewards screen, rest site, shop, fake merchant, and treasure. """ try: return await _mp_post({"action": "proceed"}) From 437c457f2b82459256fde602bf79f73e148238d1 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:30:20 -0400 Subject: [PATCH 082/190] Validate profile id integer payloads --- McpMod.Profile.cs | 10 ++++++---- scripts/audit_endpoints.py | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index d5c9a4dc..56d35e14 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -76,10 +76,12 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener SendError(response, 400, "'profile_id' field must be a number"); return; } - - int profileId = parsed.TryGetValue("profile_id", out idElem) && idElem.ValueKind == JsonValueKind.Number - ? idElem.GetInt32() - : 0; + int profileId = 0; + if (idElem.ValueKind == JsonValueKind.Number && !idElem.TryGetInt32(out profileId)) + { + SendError(response, 400, "'profile_id' field must be a 32-bit integer"); + return; + } try { diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index fbea3548..9831192b 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -269,6 +269,8 @@ def audit_live(base_url: str) -> None: ("/api/v1/profiles", b"{}", 400), ("/api/v1/profiles", b'{"action": 1}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 1.5}', 400), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 999999999999}', 400), ] for path, body, expected_status in post_validation_checks: status, data = load_json_url(base_url.rstrip("/") + path, "POST", body) From b158ec18d2a998408247fa4452102fc8bac700ec Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:32:06 -0400 Subject: [PATCH 083/190] Document scoped glossary tools --- mcp/server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp/server.py b/mcp/server.py index 0131e634..b477f536 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -333,6 +333,7 @@ async def get_glossary_cards() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered cards; use get_compendium() for profile progress. + The response includes current_run.run_id, current_run.seed, count, and items. """ try: return await _glossary_get("cards") @@ -346,6 +347,7 @@ async def get_glossary_relics() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered relics; use get_compendium() for profile progress. + The response includes current_run.run_id, current_run.seed, count, and items. """ try: return await _glossary_get("relics") @@ -359,6 +361,7 @@ async def get_glossary_potions() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered potions; use get_compendium() for profile progress. + The response includes current_run.run_id, current_run.seed, count, and items. """ try: return await _glossary_get("potions") @@ -371,7 +374,8 @@ async def get_glossary_keywords() -> str: """Get active-run keyword metadata collected from active pools. Requires a run in progress. Keywords come from cards, relics, and potions - reachable in the current run context. + reachable in the current run context. The response includes current_run.run_id, + current_run.seed, count, and items. """ try: return await _glossary_get("keywords") From 25ffd33087372311f5e1c6a518e8a6f0437f0d85 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:35:33 -0400 Subject: [PATCH 084/190] Exclude item self-tooltips from keywords --- McpMod.ForkEndpoints.cs | 4 ++-- scripts/audit_endpoints.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index e5cb9b91..ebd3648b 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -415,7 +415,7 @@ internal static object BuildGlossaryKeywords() if (relicPool != null) { foreach (var relic in relicPool.AllRelics) - foreach (var tip in relic.HoverTips) + foreach (var tip in relic.HoverTipsExcludingRelic) if (tip is HoverTip ht) { var title = SafeGetText(() => ht.Title); @@ -428,7 +428,7 @@ internal static object BuildGlossaryKeywords() if (potionPool != null) { foreach (var potion in potionPool.AllPotions) - foreach (var tip in potion.HoverTips) + foreach (var tip in potion.ExtraHoverTips) if (tip is HoverTip ht) { var title = SafeGetText(() => ht.Title); diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 9831192b..0ac5a126 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -251,6 +251,12 @@ def audit_live(base_url: str) -> None: current_run = data.get("current_run") if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): fail(f"{path} expected current_run run_id and seed, got {current_run}") + if expected_kind == "keywords": + keyword_names = {str(item.get("name")) for item in data["items"] if isinstance(item, dict)} + item_names = {"Blood Potion", "Brimstone", "Burning Blood"} + leaked = sorted(keyword_names & item_names) + if leaked: + fail(f"{path} leaked item self-tooltips into keyword glossary: {leaked}") status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") if status != 200 or not isinstance(data, dict) or "state_type" not in data: From be68ce611bdc534b33d387ec7dee6f2c144aca1a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:37:26 -0400 Subject: [PATCH 085/190] Audit keyword glossary item leaks dynamically --- scripts/audit_endpoints.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 0ac5a126..22a05e5f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -229,6 +229,7 @@ def audit_live(base_url: str) -> None: fail(f"root endpoint index mismatch. missing={sorted(expected - live_index)} extra={sorted(live_index - expected)}") print(f"root: {len(live_index)} endpoints advertised") + glossary_payloads: dict[str, dict] = {} for method, path in EXPECTED_ENDPOINTS: if method != "GET": continue @@ -251,12 +252,23 @@ def audit_live(base_url: str) -> None: current_run = data.get("current_run") if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): fail(f"{path} expected current_run run_id and seed, got {current_run}") - if expected_kind == "keywords": - keyword_names = {str(item.get("name")) for item in data["items"] if isinstance(item, dict)} - item_names = {"Blood Potion", "Brimstone", "Burning Blood"} - leaked = sorted(keyword_names & item_names) - if leaked: - fail(f"{path} leaked item self-tooltips into keyword glossary: {leaked}") + glossary_payloads[expected_kind] = data + + if {"keywords", "relics", "potions"}.issubset(glossary_payloads): + keyword_names = { + str(item.get("name")) + for item in glossary_payloads["keywords"]["items"] + if isinstance(item, dict) + } + item_names = { + str(item.get("name")) + for kind in ("relics", "potions") + for item in glossary_payloads[kind]["items"] + if isinstance(item, dict) and item.get("name") + } + leaked = sorted(keyword_names & item_names) + if leaked: + fail(f"/api/v1/glossary/keywords leaked item self-tooltips: {leaked}") status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") if status != 200 or not isinstance(data, dict) or "state_type" not in data: From 98d7503e8030aa876e80d2b26fe63cc0f5405e79 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 06:48:58 -0400 Subject: [PATCH 086/190] Keep scoped endpoint surfaces aligned --- McpMod.ForkEndpoints.cs | 25 ------------------------- McpMod.MultiplayerState.cs | 10 ++++++++++ scripts/audit_endpoints.py | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index ebd3648b..bccb285d 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -7,7 +7,6 @@ using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Models.Events; using MegaCrit.Sts2.Core.Models.Monsters; -using MegaCrit.Sts2.Core.Models.RelicPools; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; @@ -316,30 +315,6 @@ internal static object BuildGlossaryRelics() } } - foreach (var type in typeof(RelicModel).Assembly.GetTypes()) - { - if (type.IsAbstract || !type.IsSubclassOf(typeof(RelicModel))) continue; - try - { - var instance = (RelicModel)Activator.CreateInstance(type)!; - if (instance.CanonicalInstance is not { } canonical) continue; - var id = canonical.Id.Entry; - if (seen.Contains(id)) continue; - seen.Add(id); - - result.Add(new Dictionary - { - ["id"] = id, - ["name"] = SafeGetText(() => canonical.Title), - ["description"] = SafeGetText(() => canonical.DynamicDescription), - ["rarity"] = canonical.Rarity.ToString(), - ["pool"] = canonical.Pool?.Id.Category ?? "Shared", - ["keywords"] = BuildHoverTips(canonical.HoverTipsExcludingRelic) - }); - } - catch { } - } - return result; } diff --git a/McpMod.MultiplayerState.cs b/McpMod.MultiplayerState.cs index 49439791..46cee259 100644 --- a/McpMod.MultiplayerState.cs +++ b/McpMod.MultiplayerState.cs @@ -14,6 +14,7 @@ using MegaCrit.Sts2.Core.Nodes.Rooms; using MegaCrit.Sts2.Core.Nodes.Screens; using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; using MegaCrit.Sts2.Core.Nodes.Screens.Map; using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; @@ -118,6 +119,15 @@ public static partial class McpMod result["state_type"] = "rewards"; result["rewards"] = BuildRewardsState(rewardsScreen, runState); } + else if (topOverlay is NGameOverScreen) + { + result["state_type"] = "game_over"; + result["game_over"] = new Dictionary + { + ["message"] = "Run ended.", + ["options"] = new List { "main_menu" } + }; + } else if (topOverlay is IOverlayScreen && topOverlay is not NRewardsScreen && topOverlay is not NCardRewardSelectionScreen) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 22a05e5f..f06172c4 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -182,8 +182,23 @@ def audit_static_error_shapes(repo: Path) -> None: print("errors: structured 500 response helpers enforced") +def audit_static_glossary_scope(repo: Path) -> None: + fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + relic_match = re.search( + r"internal static object BuildGlossaryRelics\(\).*?\n internal static object BuildGlossaryPotions\(\)", + fork_endpoints, + re.S, + ) + if not relic_match: + fail("could not locate BuildGlossaryRelics for scope audit") + if "Assembly.GetTypes()" in relic_match.group(0) or "typeof(RelicModel)" in relic_match.group(0): + fail("relic glossary must stay scoped to active run relic pools, not reflected assembly-wide relics") + print("glossary: active-run relic scope enforced") + + def audit_state_surface(repo: Path) -> None: state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") + multiplayer_state = (repo / "McpMod.MultiplayerState.cs").read_text(encoding="utf-8") formatting = (repo / "McpMod.Formatting.cs").read_text(encoding="utf-8") docs = "\n".join( [ @@ -194,6 +209,12 @@ def audit_state_surface(repo: Path) -> None: ) state_types = set(re.findall(r'\["state_type"\]\s*=\s*"([^"]+)"', state_builder)) + multiplayer_state_types = set(re.findall(r'\["state_type"\]\s*=\s*"([^"]+)"', multiplayer_state)) + missing_mp_parity = sorted(state_types - multiplayer_state_types) + if missing_mp_parity: + fail(f"multiplayer state surface missing singleplayer states: {missing_mp_parity}") + + state_types |= multiplayer_state_types missing_docs = sorted(state_type for state_type in state_types if state_type not in docs) if missing_docs: fail(f"state types missing docs: {missing_docs}") @@ -341,6 +362,7 @@ def main() -> None: audit_mcp_tool_docs(repo) audit_static_formatters(repo) audit_static_error_shapes(repo) + audit_static_glossary_scope(repo) audit_state_surface(repo) if not args.skip_live: audit_live(args.base_url) From f801150fe69e24a01109f186f6f6d86fed9d201b Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:04:15 -0400 Subject: [PATCH 087/190] Include shared pools in active glossary --- McpMod.ForkEndpoints.cs | 200 ++++++++++++++++++++++--------------- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 17 +++- 5 files changed, 138 insertions(+), 85 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index bccb285d..6aba49f6 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -5,8 +5,11 @@ using System.Runtime.CompilerServices; using MegaCrit.Sts2.Core.HoverTips; using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Models.CardPools; using MegaCrit.Sts2.Core.Models.Events; using MegaCrit.Sts2.Core.Models.Monsters; +using MegaCrit.Sts2.Core.Models.PotionPools; +using MegaCrit.Sts2.Core.Models.RelicPools; using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; @@ -243,39 +246,50 @@ internal static object BuildGlossaryCards() var result = new List>(); var seen = new HashSet(); + var colorlessPool = ModelDb.CardPool(); + AddCardsFromPool(colorlessPool, SafeGetText(() => colorlessPool.Title) ?? colorlessPool.Id.Entry, result, seen); + foreach (var player in runState.Players) { var pool = player.Character?.CardPool; if (pool == null) continue; var poolName = SafeGetText(() => pool.Title) ?? "Unknown"; + AddCardsFromPool(pool, poolName, result, seen); + } - foreach (var card in pool.AllCards) - { - var id = card.Id.Entry; - if (seen.Contains(id)) continue; - seen.Add(id); + return result; + } - string costDisplay; - if (card.EnergyCost.CostsX) - costDisplay = "X"; - else - costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); + private static void AddCardsFromPool( + CardPoolModel pool, + string poolName, + List> result, + HashSet seen) + { + foreach (var card in pool.AllCards) + { + var id = card.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); - result.Add(new Dictionary - { - ["id"] = id, - ["name"] = SafeGetText(() => card.Title), - ["type"] = card.Type.ToString(), - ["cost"] = costDisplay, - ["description"] = SafeGetCardDescription(card), - ["rarity"] = card.Rarity.ToString(), - ["pool"] = poolName, - ["keywords"] = BuildHoverTips(card.HoverTips) - }); - } - } + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); - return result; + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["description"] = SafeGetCardDescription(card), + ["rarity"] = card.Rarity.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + } } internal static object BuildGlossaryRelics() @@ -290,34 +304,45 @@ internal static object BuildGlossaryRelics() var result = new List>(); var seen = new HashSet(); + var sharedPool = ModelDb.RelicPool(); + AddRelicsFromPool(sharedPool, "Shared", result, seen); + foreach (var player in runState.Players) { var character = player.Character; if (character == null || character.RelicPool == null) continue; var pool = character.RelicPool; var poolName = SafeGetText(() => character.Title) ?? "Unknown"; - - foreach (var relic in pool.AllRelics) - { - var id = relic.Id.Entry; - if (seen.Contains(id)) continue; - seen.Add(id); - - result.Add(new Dictionary - { - ["id"] = id, - ["name"] = SafeGetText(() => relic.Title), - ["description"] = SafeGetText(() => relic.DynamicDescription), - ["rarity"] = relic.Rarity.ToString(), - ["pool"] = poolName, - ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) - }); - } + AddRelicsFromPool(pool, poolName, result, seen); } return result; } + private static void AddRelicsFromPool( + RelicPoolModel pool, + string poolName, + List> result, + HashSet seen) + { + foreach (var relic in pool.AllRelics) + { + var id = relic.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + } + internal static object BuildGlossaryPotions() { if (!RunManager.Instance.IsInProgress) @@ -330,36 +355,47 @@ internal static object BuildGlossaryPotions() var result = new List>(); var seen = new HashSet(); + var sharedPool = ModelDb.PotionPool(); + AddPotionsFromPool(sharedPool, "Shared", result, seen); + foreach (var player in runState.Players) { var character = player.Character; if (character == null || character.PotionPool == null) continue; var pool = character.PotionPool; var poolName = SafeGetText(() => character.Title) ?? "Unknown"; - - foreach (var potion in pool.AllPotions) - { - var id = potion.Id.Entry; - if (seen.Contains(id)) continue; - seen.Add(id); - - result.Add(new Dictionary - { - ["id"] = id, - ["name"] = SafeGetText(() => potion.Title), - ["description"] = SafeGetText(() => potion.DynamicDescription), - ["rarity"] = potion.Rarity.ToString(), - ["target_type"] = potion.TargetType.ToString(), - ["usage"] = potion.Usage.ToString(), - ["pool"] = poolName, - ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) - }); - } + AddPotionsFromPool(pool, poolName, result, seen); } return result; } + private static void AddPotionsFromPool( + PotionPoolModel pool, + string poolName, + List> result, + HashSet seen) + { + foreach (var potion in pool.AllPotions) + { + var id = potion.Id.Entry; + if (seen.Contains(id)) continue; + seen.Add(id); + + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["rarity"] = potion.Rarity.ToString(), + ["target_type"] = potion.TargetType.ToString(), + ["usage"] = potion.Usage.ToString(), + ["pool"] = poolName, + ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) + }); + } + } + internal static object BuildGlossaryKeywords() { if (!RunManager.Instance.IsInProgress) @@ -371,45 +407,34 @@ internal static object BuildGlossaryKeywords() var keywords = new Dictionary(); + foreach (var card in ModelDb.CardPool().AllCards) + AddKeywordTips(card.HoverTips, keywords); + foreach (var relic in ModelDb.RelicPool().AllRelics) + AddKeywordTips(relic.HoverTipsExcludingRelic, keywords); + foreach (var potion in ModelDb.PotionPool().AllPotions) + AddKeywordTips(potion.ExtraHoverTips, keywords); + foreach (var player in runState.Players) { var cardPool = player.Character?.CardPool; if (cardPool != null) { foreach (var card in cardPool.AllCards) - foreach (var tip in card.HoverTips) - if (tip is HoverTip ht) - { - var title = SafeGetText(() => ht.Title); - if (!string.IsNullOrEmpty(title)) - keywords[title!] = SafeGetText(() => ht.Description) ?? ""; - } + AddKeywordTips(card.HoverTips, keywords); } var relicPool = player.Character?.RelicPool; if (relicPool != null) { foreach (var relic in relicPool.AllRelics) - foreach (var tip in relic.HoverTipsExcludingRelic) - if (tip is HoverTip ht) - { - var title = SafeGetText(() => ht.Title); - if (!string.IsNullOrEmpty(title)) - keywords[title!] = SafeGetText(() => ht.Description) ?? ""; - } + AddKeywordTips(relic.HoverTipsExcludingRelic, keywords); } var potionPool = player.Character?.PotionPool; if (potionPool != null) { foreach (var potion in potionPool.AllPotions) - foreach (var tip in potion.ExtraHoverTips) - if (tip is HoverTip ht) - { - var title = SafeGetText(() => ht.Title); - if (!string.IsNullOrEmpty(title)) - keywords[title!] = SafeGetText(() => ht.Description) ?? ""; - } + AddKeywordTips(potion.ExtraHoverTips, keywords); } } @@ -426,6 +451,19 @@ internal static object BuildGlossaryKeywords() return result; } + private static void AddKeywordTips(IEnumerable tips, Dictionary keywords) + { + foreach (var tip in tips) + { + if (tip is not HoverTip ht) + continue; + + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + } + } + internal static object BuildBestiary() { var result = new Dictionary(); diff --git a/docs/raw-full.md b/docs/raw-full.md index 504611fa..71a86d66 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -989,7 +989,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and ### `GET /api/v1/glossary/*` -The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. - `GET /api/v1/glossary/cards`: active-run card pool metadata. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 98b61a8f..4719f846 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -75,7 +75,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context, not profile-wide discovered content. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index b034a7b3..90cce422 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Glossary tools require an active run. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f06172c4..3364618c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -193,7 +193,22 @@ def audit_static_glossary_scope(repo: Path) -> None: fail("could not locate BuildGlossaryRelics for scope audit") if "Assembly.GetTypes()" in relic_match.group(0) or "typeof(RelicModel)" in relic_match.group(0): fail("relic glossary must stay scoped to active run relic pools, not reflected assembly-wide relics") - print("glossary: active-run relic scope enforced") + required_shared_pools = { + "BuildGlossaryCards": "ModelDb.CardPool", + "BuildGlossaryRelics": "ModelDb.RelicPool", + "BuildGlossaryPotions": "ModelDb.PotionPool", + "BuildGlossaryKeywords": "ModelDb.RelicPool", + } + for method, required in required_shared_pools.items(): + match = re.search( + rf"internal static object {method}\(\).*?(?=\n internal static object|\n private static|\n internal static|\n\}})", + fork_endpoints, + re.S, + ) + if not match or required not in match.group(0): + fail(f"{method} must include active-run shared pool {required}") + + print("glossary: active-run shared/scoped pools enforced") def audit_state_surface(repo: Path) -> None: From d9a07f55f9781e0d59948a3fc000a360a8b58f92 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:10:43 -0400 Subject: [PATCH 088/190] Search all save account roots --- McpMod.Compendium.cs | 18 ++++++++---------- scripts/audit_endpoints.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index ccc758ad..757d6d5e 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -521,16 +521,14 @@ private static IEnumerable EnumerateSaveRoots() } } - if (yielded.Count > 0) - yield break; - - var accountRoots = steamRoots - .SelectMany(Directory.GetDirectories) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (accountRoots.Count == 1 && yielded.Add(accountRoots[0])) - yield return accountRoots[0]; + foreach (var steamRoot in steamRoots) + { + foreach (var accountRoot in Directory.GetDirectories(steamRoot).OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + if (yielded.Add(accountRoot)) + yield return accountRoot; + } + } } private static IEnumerable EnumerateSteamDataRoots() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3364618c..e5650fbd 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -211,6 +211,23 @@ def audit_static_glossary_scope(repo: Path) -> None: print("glossary: active-run shared/scoped pools enforced") +def audit_static_save_roots(repo: Path) -> None: + compendium = (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8") + match = re.search( + r"private static IEnumerable EnumerateSaveRoots\(\).*?\n private static IEnumerable EnumerateSteamDataRoots\(\)", + compendium, + re.S, + ) + if not match: + fail("could not locate EnumerateSaveRoots for save-root audit") + body = match.group(0) + if "accountRoots.Count == 1" in body or "yielded.Count > 0" in body: + fail("save-root fallback must search all Steam account roots after the active account") + if "Directory.GetDirectories(steamRoot)" not in body: + fail("save-root fallback must enumerate Steam account directories") + print("saves: multi-account fallback lookup enforced") + + def audit_state_surface(repo: Path) -> None: state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") multiplayer_state = (repo / "McpMod.MultiplayerState.cs").read_text(encoding="utf-8") @@ -378,6 +395,7 @@ def main() -> None: audit_static_formatters(repo) audit_static_error_shapes(repo) audit_static_glossary_scope(repo) + audit_static_save_roots(repo) audit_state_surface(repo) if not args.skip_live: audit_live(args.base_url) From 5d38dd2306235879e69b38a769e45c5170420118 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:14:06 -0400 Subject: [PATCH 089/190] Include all shared card pools in glossary --- McpMod.ForkEndpoints.cs | 9 +++++---- scripts/audit_endpoints.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 6aba49f6..cbb7de1a 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -246,8 +246,8 @@ internal static object BuildGlossaryCards() var result = new List>(); var seen = new HashSet(); - var colorlessPool = ModelDb.CardPool(); - AddCardsFromPool(colorlessPool, SafeGetText(() => colorlessPool.Title) ?? colorlessPool.Id.Entry, result, seen); + foreach (var pool in ModelDb.AllSharedCardPools) + AddCardsFromPool(pool, SafeGetText(() => pool.Title) ?? pool.Id.Entry, result, seen); foreach (var player in runState.Players) { @@ -407,8 +407,9 @@ internal static object BuildGlossaryKeywords() var keywords = new Dictionary(); - foreach (var card in ModelDb.CardPool().AllCards) - AddKeywordTips(card.HoverTips, keywords); + foreach (var pool in ModelDb.AllSharedCardPools) + foreach (var card in pool.AllCards) + AddKeywordTips(card.HoverTips, keywords); foreach (var relic in ModelDb.RelicPool().AllRelics) AddKeywordTips(relic.HoverTipsExcludingRelic, keywords); foreach (var potion in ModelDb.PotionPool().AllPotions) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index e5650fbd..9a07c993 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -194,10 +194,10 @@ def audit_static_glossary_scope(repo: Path) -> None: if "Assembly.GetTypes()" in relic_match.group(0) or "typeof(RelicModel)" in relic_match.group(0): fail("relic glossary must stay scoped to active run relic pools, not reflected assembly-wide relics") required_shared_pools = { - "BuildGlossaryCards": "ModelDb.CardPool", + "BuildGlossaryCards": "ModelDb.AllSharedCardPools", "BuildGlossaryRelics": "ModelDb.RelicPool", "BuildGlossaryPotions": "ModelDb.PotionPool", - "BuildGlossaryKeywords": "ModelDb.RelicPool", + "BuildGlossaryKeywords": "ModelDb.AllSharedCardPools", } for method, required in required_shared_pools.items(): match = re.search( From 94bed2dba5b2fa37e11ec4633ef8a9c32b8ca906 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:18:50 -0400 Subject: [PATCH 090/190] Expose card upgrade previews in glossary --- McpMod.ForkEndpoints.cs | 7 +++++++ docs/raw-full.md | 7 ++++++- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 25 +++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index cbb7de1a..6a5056b4 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -287,6 +287,13 @@ private static void AddCardsFromPool( ["description"] = SafeGetCardDescription(card), ["rarity"] = card.Rarity.ToString(), ["pool"] = poolName, + ["is_upgradable"] = card.IsUpgradable, + ["current_upgrade_level"] = card.CurrentUpgradeLevel, + ["max_upgrade_level"] = card.MaxUpgradeLevel, + ["upgrade_preview_type"] = card.UpgradePreviewType.ToString(), + ["upgrade_preview_description"] = card.IsUpgradable + ? SafeGetText(() => card.GetDescriptionForUpgradePreview()) + : null, ["keywords"] = BuildHoverTips(card.HoverTips) }); } diff --git a/docs/raw-full.md b/docs/raw-full.md index 71a86d66..5e3b56cc 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -991,7 +991,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. -- `GET /api/v1/glossary/cards`: active-run card pool metadata. +- `GET /api/v1/glossary/cards`: active-run card pool metadata, including upgrade availability and game-provided upgrade preview text. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. @@ -1016,6 +1016,11 @@ Example success shape: "name": "Strike", "type": "Attack", "rarity": "Basic", + "is_upgradable": true, + "current_upgrade_level": 0, + "max_upgrade_level": 1, + "upgrade_preview_type": "Upgrade", + "upgrade_preview_description": "Deal 9 damage.", "keywords": [] } ] diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 4719f846..b1d3b089 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -75,7 +75,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability and game-provided upgrade preview text. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index 90cce422..ae18af76 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include upgrade availability and game-provided upgrade preview text. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 9a07c993..02dd3991 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -211,6 +211,30 @@ def audit_static_glossary_scope(repo: Path) -> None: print("glossary: active-run shared/scoped pools enforced") +def audit_static_card_glossary_metadata(repo: Path) -> None: + fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + match = re.search( + r"private static void AddCardsFromPool\(.*?\n internal static object BuildGlossaryRelics\(\)", + fork_endpoints, + re.S, + ) + if not match: + fail("could not locate AddCardsFromPool for card glossary audit") + body = match.group(0) + required = [ + "is_upgradable", + "current_upgrade_level", + "max_upgrade_level", + "upgrade_preview_type", + "upgrade_preview_description", + "GetDescriptionForUpgradePreview", + ] + missing = [field for field in required if field not in body] + if missing: + fail(f"card glossary missing upgrade metadata: {missing}") + print("glossary: card upgrade metadata enforced") + + def audit_static_save_roots(repo: Path) -> None: compendium = (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8") match = re.search( @@ -395,6 +419,7 @@ def main() -> None: audit_static_formatters(repo) audit_static_error_shapes(repo) audit_static_glossary_scope(repo) + audit_static_card_glossary_metadata(repo) audit_static_save_roots(repo) audit_state_surface(repo) if not args.skip_live: From 54bf98b3407d01a93b5dc4b388cec6883a96ebc5 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:34:52 -0400 Subject: [PATCH 091/190] Expose upgraded card previews in state payloads --- McpMod.ForkEndpoints.cs | 7 ++++--- McpMod.Helpers.cs | 30 ++++++++++++++++++++++++++++++ McpMod.StateBuilder.cs | 8 ++++++++ docs/raw-full.md | 20 ++++++++++++++++++-- docs/raw-simplified.md | 4 +++- mcp/README.md | 2 +- scripts/audit_endpoints.py | 33 +++++++++++++++++++++++++++++++-- 7 files changed, 95 insertions(+), 9 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 6a5056b4..6d1a9b6e 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -278,6 +278,7 @@ private static void AddCardsFromPool( else costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); + var upgradedPreview = SafeBuildUpgradedCardPreview(card); result.Add(new Dictionary { ["id"] = id, @@ -291,9 +292,9 @@ private static void AddCardsFromPool( ["current_upgrade_level"] = card.CurrentUpgradeLevel, ["max_upgrade_level"] = card.MaxUpgradeLevel, ["upgrade_preview_type"] = card.UpgradePreviewType.ToString(), - ["upgrade_preview_description"] = card.IsUpgradable - ? SafeGetText(() => card.GetDescriptionForUpgradePreview()) - : null, + ["upgrade_preview_cost"] = upgradedPreview != null ? GetCostDisplay(upgradedPreview) : null, + ["upgrade_preview_star_cost"] = upgradedPreview != null ? GetStarCostDisplay(upgradedPreview) : null, + ["upgrade_preview_description"] = SafeGetCardUpgradePreviewDescription(card), ["keywords"] = BuildHoverTips(card.HoverTips) }); } diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index ced7df8b..3a1cf76d 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -21,6 +21,36 @@ public static partial class McpMod catch { return SafeGetText(() => card.Description)?.Replace("\n", " "); } } + private static CardModel? SafeBuildUpgradedCardPreview(CardModel card) + { + if (!card.IsUpgradable) + return null; + + try + { + var preview = card.IsMutable + ? (CardModel)card.MutableClone() + : card.ToMutable(); + preview.UpgradeInternal(); + return preview; + } + catch + { + return null; + } + } + + private static string? SafeGetCardUpgradePreviewDescription(CardModel card, PileType pile = PileType.Hand) + { + var preview = SafeBuildUpgradedCardPreview(card); + if (preview != null) + return SafeGetCardDescription(preview, pile); + + return card.IsUpgradable + ? SafeGetText(() => card.GetDescriptionForUpgradePreview())?.Replace("\n", " ") + : null; + } + internal static string? SafeGetText(Func getter) { try diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 775052ca..caf2b42f 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1241,6 +1241,7 @@ private static string GetCostDisplay(CardModel card) /// private static Dictionary BuildCardInfo(CardModel card, PileType pile = PileType.None) { + var upgradedPreview = SafeBuildUpgradedCardPreview(card); return new Dictionary { ["id"] = card.Id.Entry, @@ -1251,6 +1252,13 @@ private static string GetCostDisplay(CardModel card) ["description"] = SafeGetCardDescription(card, pile), ["rarity"] = card.Rarity.ToString(), ["is_upgraded"] = card.IsUpgraded, + ["is_upgradable"] = card.IsUpgradable, + ["current_upgrade_level"] = card.CurrentUpgradeLevel, + ["max_upgrade_level"] = card.MaxUpgradeLevel, + ["upgrade_preview_type"] = card.UpgradePreviewType.ToString(), + ["upgrade_preview_cost"] = upgradedPreview != null ? GetCostDisplay(upgradedPreview) : null, + ["upgrade_preview_star_cost"] = upgradedPreview != null ? GetStarCostDisplay(upgradedPreview) : null, + ["upgrade_preview_description"] = SafeGetCardUpgradePreviewDescription(card, pile), ["keywords"] = BuildHoverTips(card.HoverTips) }; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 5e3b56cc..d8d176b0 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -133,6 +133,13 @@ Always present at the top level (except `menu`). Contains everything about the l "can_play": true, "unplayable_reason": null, // e.g. "NotEnoughEnergy", "Unplayable", null if playable "is_upgraded": false, + "is_upgradable": true, + "current_upgrade_level": 0, + "max_upgrade_level": 1, + "upgrade_preview_type": "None", + "upgrade_preview_cost": "1", + "upgrade_preview_star_cost": null, + "upgrade_preview_description": "Deal 9 damage.", "keywords": [ /* Keyword Objects */ ] } ``` @@ -150,6 +157,13 @@ Always present at the top level (except `menu`). Contains everything about the l "description": "Deal 6 damage.", "rarity": "Basic", "is_upgraded": false, + "is_upgradable": true, + "current_upgrade_level": 0, + "max_upgrade_level": 1, + "upgrade_preview_type": "None", + "upgrade_preview_cost": "1", + "upgrade_preview_star_cost": null, + "upgrade_preview_description": "Deal 9 damage.", "keywords": [ /* Keyword Objects */ ] } ``` @@ -991,7 +1005,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. -- `GET /api/v1/glossary/cards`: active-run card pool metadata, including upgrade availability and game-provided upgrade preview text. +- `GET /api/v1/glossary/cards`: active-run card pool metadata, including upgrade availability plus upgraded-preview cost and description. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. @@ -1019,7 +1033,9 @@ Example success shape: "is_upgradable": true, "current_upgrade_level": 0, "max_upgrade_level": 1, - "upgrade_preview_type": "Upgrade", + "upgrade_preview_type": "None", + "upgrade_preview_cost": "1", + "upgrade_preview_star_cost": null, "upgrade_preview_description": "Deal 9 damage.", "keywords": [] } diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index b1d3b089..c44f7130 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -34,6 +34,8 @@ Every JSON response includes: - `run` — `{ act, floor, ascension }` (absent for `menu`) - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) +Serialized card objects in hand, deck, piles, rewards, card selections, and bundles include upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. + | `state_type` | Screen | Available Actions | |---|---|---| | `menu` | Main menu, menu submenu (incl. multiplayer host/join/load lobby), character select, or a blocking FTUE/tutorial/popup that can also appear mid-run | `menu_select` | @@ -75,7 +77,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability and game-provided upgrade preview text. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index ae18af76..da4b5a8e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include upgrade availability and game-provided upgrade preview text. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 02dd3991..19d567e0 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -213,6 +213,7 @@ def audit_static_glossary_scope(repo: Path) -> None: def audit_static_card_glossary_metadata(repo: Path) -> None: fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") match = re.search( r"private static void AddCardsFromPool\(.*?\n internal static object BuildGlossaryRelics\(\)", fork_endpoints, @@ -226,13 +227,41 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: "current_upgrade_level", "max_upgrade_level", "upgrade_preview_type", + "upgrade_preview_cost", + "upgrade_preview_star_cost", "upgrade_preview_description", - "GetDescriptionForUpgradePreview", + "SafeGetCardUpgradePreviewDescription", ] missing = [field for field in required if field not in body] if missing: fail(f"card glossary missing upgrade metadata: {missing}") - print("glossary: card upgrade metadata enforced") + + state_match = re.search( + r"private static Dictionary BuildCardInfo\(.*?\n private static Dictionary BuildCardState\(", + state_builder, + re.S, + ) + if not state_match: + fail("could not locate BuildCardInfo for card metadata audit") + state_body = state_match.group(0) + missing_state = [field for field in required if field not in state_body] + if missing_state: + fail(f"state card serialization missing upgrade metadata: {missing_state}") + + helpers = (repo / "McpMod.Helpers.cs").read_text(encoding="utf-8") + helper_match = re.search( + r"private static CardModel\? SafeBuildUpgradedCardPreview\(.*?\n private static string\? SafeGetCardUpgradePreviewDescription\(", + helpers, + re.S, + ) + if not helper_match: + fail("could not locate upgraded card preview helper") + helper_body = helper_match.group(0) + helper_required = ["ToMutable", "MutableClone", "UpgradeInternal"] + missing_helper = [field for field in helper_required if field not in helper_body] + if missing_helper: + fail(f"upgraded card preview helper missing clone/upgrade path: {missing_helper}") + print("cards: upgrade metadata enforced for glossary and state payloads") def audit_static_save_roots(repo: Path) -> None: From 7ffc394f18d7e728a0d03850c3f0c75d8f267294 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:42:34 -0400 Subject: [PATCH 092/190] Add upgrade previews to shop cards --- McpMod.Formatting.cs | 15 ++++++++++++++- McpMod.StateBuilder.cs | 7 +++++++ docs/raw-full.md | 7 +++++++ docs/raw-simplified.md | 2 +- scripts/audit_endpoints.py | 21 +++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/McpMod.Formatting.cs b/McpMod.Formatting.cs index 2fb5942a..0836646a 100644 --- a/McpMod.Formatting.cs +++ b/McpMod.Formatting.cs @@ -601,10 +601,23 @@ private static void FormatShopMarkdown(StringBuilder sb, Dictionary $"**{item.GetValueOrDefault("card_name")}**{(item.GetValueOrDefault("card_is_upgraded") is true ? "+" : "")}{cardEnergy} [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")}{itemKeywords} - {item.GetValueOrDefault("card_description")}", + "card" => $"**{item.GetValueOrDefault("card_name")}**{(item.GetValueOrDefault("card_is_upgraded") is true ? "+" : "")}{cardEnergy} [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")}{itemKeywords} - {item.GetValueOrDefault("card_description")}{cardUpgrade}", "relic" => $"**{item.GetValueOrDefault("relic_name")}** {item.GetValueOrDefault("relic_rarity")}{itemKeywords} - {item.GetValueOrDefault("relic_description")}", "potion" => $"**{item.GetValueOrDefault("potion_name")}** {item.GetValueOrDefault("potion_rarity")} ({item.GetValueOrDefault("potion_usage")}, target: {item.GetValueOrDefault("potion_target_type")}){itemKeywords} - {item.GetValueOrDefault("potion_description")}", "card_removal" => "**Remove a card** from your deck", diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index caf2b42f..b2c73b2c 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1633,6 +1633,13 @@ private static IEnumerable GetPlayerDeckCards(Player player) item["card_rarity"] = cardInfo["rarity"]; item["card_description"] = cardInfo["description"]; item["card_is_upgraded"] = cardInfo["is_upgraded"]; + item["card_is_upgradable"] = cardInfo["is_upgradable"]; + item["card_current_upgrade_level"] = cardInfo["current_upgrade_level"]; + item["card_max_upgrade_level"] = cardInfo["max_upgrade_level"]; + item["card_upgrade_preview_type"] = cardInfo["upgrade_preview_type"]; + item["card_upgrade_preview_cost"] = cardInfo["upgrade_preview_cost"]; + item["card_upgrade_preview_star_cost"] = cardInfo["upgrade_preview_star_cost"]; + item["card_upgrade_preview_description"] = cardInfo["upgrade_preview_description"]; item["keywords"] = cardInfo["keywords"]; } items.Add(item); diff --git a/docs/raw-full.md b/docs/raw-full.md index d8d176b0..2b2c9e09 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -642,6 +642,13 @@ Shop inventory is auto-opened when state is queried. "card_rarity": "Rare", "card_description": "Lose 6 HP. Gain 2 Energy. Draw 3 cards.", "card_is_upgraded": false, + "card_is_upgradable": true, + "card_current_upgrade_level": 0, + "card_max_upgrade_level": 1, + "card_upgrade_preview_type": "None", + "card_upgrade_preview_cost": "0", + "card_upgrade_preview_star_cost": null, + "card_upgrade_preview_description": "Lose 6 HP. Gain 2 Energy. Draw 4 cards.", "keywords": [ /* Keyword Objects */ ] }, // Relic entry diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c44f7130..c4b7a834 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -34,7 +34,7 @@ Every JSON response includes: - `run` — `{ act, floor, ascension }` (absent for `menu`) - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) -Serialized card objects in hand, deck, piles, rewards, card selections, and bundles include upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. +Serialized card objects in hand, deck, piles, rewards, card selections, and bundles include upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. | `state_type` | Screen | Available Actions | |---|---|---| diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 19d567e0..1d49b95d 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -261,6 +261,27 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: missing_helper = [field for field in helper_required if field not in helper_body] if missing_helper: fail(f"upgraded card preview helper missing clone/upgrade path: {missing_helper}") + + shop_match = re.search( + r"private static Dictionary BuildShopState\(.*?\n private static Dictionary BuildMapState\(", + state_builder, + re.S, + ) + if not shop_match: + fail("could not locate BuildShopState for shop card metadata audit") + shop_body = shop_match.group(0) + shop_required = [ + "card_is_upgradable", + "card_current_upgrade_level", + "card_max_upgrade_level", + "card_upgrade_preview_type", + "card_upgrade_preview_cost", + "card_upgrade_preview_star_cost", + "card_upgrade_preview_description", + ] + missing_shop = [field for field in shop_required if field not in shop_body] + if missing_shop: + fail(f"shop card serialization missing upgrade metadata: {missing_shop}") print("cards: upgrade metadata enforced for glossary and state payloads") From 3a3ee6f2fa81244b24fb63eead8018dce3df77e3 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:49:05 -0400 Subject: [PATCH 093/190] Align shop proceed state with action behavior --- McpMod.StateBuilder.cs | 21 ++++++++++++++++++--- docs/raw-full.md | 6 +++++- docs/raw-simplified.md | 4 ++-- scripts/audit_endpoints.py | 4 ++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index b2c73b2c..0c13fdcf 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1514,11 +1514,19 @@ private static IEnumerable GetPlayerDeckCards(Player player) // Proceed button if (fakeMerchantNode != null) { + var inventoryUI = FindFirst(fakeMerchantNode); + var backButton = FindFirst(fakeMerchantNode); var proceedButton = FindFirst(fakeMerchantNode); - shopState["can_proceed"] = proceedButton?.IsEnabled ?? false; + var inventoryOpen = inventoryUI?.IsOpen == true; + var canCloseInventory = inventoryOpen && backButton?.IsEnabled == true; + shopState["inventory_open"] = inventoryOpen; + shopState["can_close_inventory"] = canCloseInventory; + shopState["can_proceed"] = proceedButton?.IsEnabled == true || canCloseInventory; } else { + shopState["inventory_open"] = false; + shopState["can_close_inventory"] = false; shopState["can_proceed"] = false; } @@ -1709,8 +1717,15 @@ private static IEnumerable GetPlayerDeckCards(Player player) state["items"] = items; - var proceedButton = NMerchantRoom.Instance?.ProceedButton; - state["can_proceed"] = proceedButton?.IsEnabled ?? false; + var merchantRoomNode = NMerchantRoom.Instance; + var inventoryOpen = merchantRoomNode?.Inventory?.IsOpen == true; + var backButton = merchantRoomNode != null ? FindFirst(merchantRoomNode) : null; + var canCloseInventory = inventoryOpen && backButton?.IsEnabled == true; + state["inventory_open"] = inventoryOpen; + state["can_close_inventory"] = canCloseInventory; + + var proceedButton = merchantRoomNode?.ProceedButton; + state["can_proceed"] = proceedButton?.IsEnabled == true || canCloseInventory; return state; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 2b2c9e09..ea493c43 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -619,7 +619,7 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire ### `shop` — Shop -Shop inventory is auto-opened when state is queried. +Shop inventory is auto-opened when state is queried. `can_proceed` mirrors the `proceed` action: it is true if the proceed button is already enabled, or if the inventory can be closed and then proceeded through in one action. ```jsonc { @@ -688,6 +688,8 @@ Shop inventory is auto-opened when state is queried. "can_afford": true } ], + "inventory_open": true, + "can_close_inventory": true, "can_proceed": true, "error": "..." // Only present if inventory isn't ready; retry in a moment }, @@ -722,6 +724,8 @@ A relic-only shop disguised as an event. Uses `shop_purchase` and `proceed` acti "keywords": [ /* Keyword Objects */ ] } ], + "inventory_open": true, + "can_close_inventory": true, "can_proceed": true }, // After fight: diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c4b7a834..c2c7553e 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -47,8 +47,8 @@ Serialized card objects in hand, deck, piles, rewards, card selections, and bund | `map` | Map navigation | `choose_map_node` | | `event` | Event or Ancient encounter | `choose_event_option`, `advance_dialogue` | | `rest_site` | Rest site | `choose_rest_option`, `proceed` | -| `shop` | Shop (auto-opens inventory) | `shop_purchase`, `proceed` | -| `fake_merchant` | Fake Merchant event (relic-only shop) | `shop_purchase`, `proceed` | +| `shop` | Shop (auto-opens inventory; `can_proceed` is true when `proceed` can close inventory and leave) | `shop_purchase`, `proceed` | +| `fake_merchant` | Fake Merchant event (relic-only shop; `can_proceed` follows the same close-inventory behavior) | `shop_purchase`, `proceed` | | `treasure` | Treasure room (auto-opens chest) | `claim_treasure_relic`, `proceed` | | `card_select` | Deck card selection overlay (transform, upgrade, remove, choose-a-card) | `select_card`, `confirm_selection`, `cancel_selection` | | `bundle_select` | Card bundle choice overlay | `select_bundle`, `confirm_bundle_selection`, `cancel_bundle_selection` | diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 1d49b95d..b667b69f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -271,6 +271,8 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: fail("could not locate BuildShopState for shop card metadata audit") shop_body = shop_match.group(0) shop_required = [ + "inventory_open", + "can_close_inventory", "card_is_upgradable", "card_current_upgrade_level", "card_max_upgrade_level", @@ -282,6 +284,8 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: missing_shop = [field for field in shop_required if field not in shop_body] if missing_shop: fail(f"shop card serialization missing upgrade metadata: {missing_shop}") + if "proceedButton?.IsEnabled == true || canCloseInventory" not in shop_body: + fail("shop can_proceed must mirror ExecuteProceed close-inventory behavior") print("cards: upgrade metadata enforced for glossary and state payloads") From 838f83164c6bafbad8f4287438d1b8811150b434 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 07:56:08 -0400 Subject: [PATCH 094/190] Align hand can_play with action guards --- McpMod.StateBuilder.cs | 18 ++++++++++++++++-- docs/raw-full.md | 2 +- scripts/audit_endpoints.py | 12 ++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 0c13fdcf..3ad26a3e 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1266,13 +1266,27 @@ private static string GetCostDisplay(CardModel card) private static Dictionary BuildCardState(CardModel card, int index) { card.CanPlay(out var unplayableReason, out _); + string? blockedReason = unplayableReason != UnplayableReason.None + ? unplayableReason.ToString() + : null; + + var combatReady = CombatManager.Instance.IsInProgress + && CombatManager.Instance.IsPlayPhase + && !CombatManager.Instance.PlayerActionsDisabled; + if (blockedReason == null && CombatManager.Instance.IsInProgress) + { + if (!CombatManager.Instance.IsPlayPhase) + blockedReason = "NotInPlayPhase"; + else if (CombatManager.Instance.PlayerActionsDisabled) + blockedReason = "PlayerActionsDisabled"; + } var state = BuildCardInfo(card); state["index"] = index; state["description"] = SafeGetCardDescription(card); // hand cards use default pile state["target_type"] = card.TargetType.ToString(); - state["can_play"] = unplayableReason == UnplayableReason.None; - state["unplayable_reason"] = unplayableReason != UnplayableReason.None ? unplayableReason.ToString() : null; + state["can_play"] = combatReady && blockedReason == null; + state["unplayable_reason"] = blockedReason; return state; } diff --git a/docs/raw-full.md b/docs/raw-full.md index ea493c43..ff1450f4 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -131,7 +131,7 @@ Always present at the top level (except `menu`). Contains everything about the l "description": "Deal 6 damage.", "target_type": "AnyEnemy", // None, Self, AnyEnemy, AllEnemies, etc. "can_play": true, - "unplayable_reason": null, // e.g. "NotEnoughEnergy", "Unplayable", null if playable + "unplayable_reason": null, // e.g. "NotEnoughEnergy", "NotInPlayPhase", "PlayerActionsDisabled", null if playable "is_upgraded": false, "is_upgradable": true, "current_upgrade_level": 0, diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index b667b69f..47be59e4 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -248,6 +248,18 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: if missing_state: fail(f"state card serialization missing upgrade metadata: {missing_state}") + card_state_match = re.search( + r"private static Dictionary BuildCardState\(.*?\n private static List> BuildPileCardList\(", + state_builder, + re.S, + ) + if not card_state_match: + fail("could not locate BuildCardState for can_play audit") + card_state_body = card_state_match.group(0) + for required_guard in ["IsPlayPhase", "PlayerActionsDisabled", "NotInPlayPhase"]: + if required_guard not in card_state_body: + fail(f"hand card can_play missing action guard: {required_guard}") + helpers = (repo / "McpMod.Helpers.cs").read_text(encoding="utf-8") helper_match = re.search( r"private static CardModel\? SafeBuildUpgradedCardPreview\(.*?\n private static string\? SafeGetCardUpgradePreviewDescription\(", From f9b17c4649bb57ef4b4600e7616ef350432fd07c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:04:17 -0400 Subject: [PATCH 095/190] Expose card selection progress state --- McpMod.Actions.cs | 7 ++++++ McpMod.StateBuilder.cs | 45 ++++++++++++++++++++++++++++++++++++++ docs/raw-full.md | 8 +++++++ docs/raw-simplified.md | 2 +- mcp/server.py | 6 ++++- scripts/audit_endpoints.py | 34 ++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 7a537665..fd5e83ee 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -618,6 +618,13 @@ public static partial class McpMod if (overlay is NCardGridSelectionScreen gridScreen) { + foreach (var containerName in new[] { "%UpgradeSinglePreviewContainer", "%UpgradeMultiPreviewContainer", "%PreviewContainer" }) + { + var container = gridScreen.GetNodeOrNull(containerName); + if (container?.Visible == true) + return Error("A card selection preview is already open - confirm or cancel it first"); + } + var grid = FindFirst(gridScreen); if (grid == null) return Error("Card grid not found in selection screen"); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 3ad26a3e..a07c0294 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1360,6 +1360,23 @@ private static IEnumerable GetPlayerDeckCards(Player player) return null; } + private static bool? GetBoolMemberValue(object source, string memberName) + { + var value = GetMemberValue(source, memberName); + return value is bool boolValue ? boolValue : null; + } + + private static void AddCardHolderState(Dictionary cardInfo, Control holder) + { + var isSelected = GetBoolMemberValue(holder, "IsSelected") + ?? GetBoolMemberValue(holder, "_isSelected"); + var isEnabled = GetBoolMemberValue(holder, "IsEnabled"); + + cardInfo["is_selected"] = isSelected ?? false; + cardInfo["is_visible"] = holder.Visible && holder.IsVisibleInTree(); + cardInfo["can_select"] = (isEnabled ?? true) && holder.Visible && holder.IsVisibleInTree(); + } + private static Dictionary BuildEnemyState(Creature creature, Dictionary entityCounts) { var monster = creature.Monster; @@ -1986,6 +2003,7 @@ private static IEnumerable GetPlayerDeckCards(Player player) // Cards in the grid (sorted by visual position - MoveToFront can reorder children) var cardHolders = FindAllSortedByPosition(screen); var cards = new List>(); + var selectedCards = new List>(); int index = 0; foreach (var holder in cardHolders) { @@ -1994,10 +2012,17 @@ private static IEnumerable GetPlayerDeckCards(Player player) var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; + AddCardHolderState(cardInfo, holder); cards.Add(cardInfo); + if (cardInfo["is_selected"] is true) + selectedCards.Add(new Dictionary(cardInfo)); index++; } state["cards"] = cards; + state["selected_cards"] = selectedCards; + state["selected_count"] = selectedCards.Count; + state["min_select"] = GetMemberValue(screen, "MinSelect"); + state["max_select"] = GetMemberValue(screen, "MaxSelect"); // Preview container showing? (selection complete, awaiting confirm) // Upgrade screens use UpgradeSinglePreviewContainer / UpgradeMultiPreviewContainer @@ -2009,6 +2034,24 @@ private static IEnumerable GetPlayerDeckCards(Player player) || (previewGeneric?.Visible ?? false); state["preview_showing"] = previewShowing; + var previewCards = new List>(); + if (previewShowing) + { + int previewIndex = 0; + foreach (var holder in FindAllSortedByPosition(screen) + .Where(holder => holder.Visible && holder.IsVisibleInTree())) + { + var card = holder.CardModel; + if (card == null) continue; + + var cardInfo = BuildCardInfo(card); + cardInfo["index"] = previewIndex; + previewCards.Add(cardInfo); + previewIndex++; + } + } + state["preview_cards"] = previewCards; + // Button states - when a preview is open, cancel goes through the // preview container's Cancel / PreviewCancel button (same path as // the action handler), not the top-level %Close button. @@ -2077,6 +2120,7 @@ private static IEnumerable GetPlayerDeckCards(Player player) var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; + AddCardHolderState(cardInfo, holder); cards.Add(cardInfo); index++; } @@ -2186,6 +2230,7 @@ private static IEnumerable GetPlayerDeckCards(Player player) var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; cardInfo["description"] = SafeGetCardDescription(card); // hand cards use default pile + AddCardHolderState(cardInfo, holder); selectableCards.Add(cardInfo); index++; } diff --git a/docs/raw-full.md b/docs/raw-full.md index ff1450f4..3b41ddc8 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -788,10 +788,18 @@ Covers deck transforms, upgrades, removals, and choose-a-card effects. Appears o "description": "Deal 6 damage.", "rarity": "Common", "is_upgraded": false, + "is_selected": false, + "is_visible": true, + "can_select": true, "keywords": [ /* Keyword Objects */ ] } ], + "selected_cards": [], + "selected_count": 0, + "min_select": 1, + "max_select": 1, "preview_showing": false, // true when selection is complete and preview is displayed + "preview_cards": [], // cards shown by an upgrade/transform/select preview "can_confirm": false, // true when confirm button is available "can_cancel": true // true when close/cancel button is available diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c2c7553e..2121e843 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -180,7 +180,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `select_card` | `index`: int | Grid screens: toggle card selection. Choose-a-card: pick immediately. | +| `select_card` | `index`: int | Grid screens: toggle card selection unless a preview is open. Choose-a-card: pick immediately. | | `confirm_selection` | _(none)_ | Confirm (for grid screens with preview). Not needed for choose-a-card. | | `cancel_selection` | _(none)_ | Cancel preview, skip (choose-a-card), or close screen. | diff --git a/mcp/server.py b/mcp/server.py index b477f536..af5fc93d 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -713,7 +713,8 @@ async def deck_select_card(card_index: int) -> str: Used when the game asks you to choose cards from your deck (transform, upgrade, remove, discard) or pick a card from offered choices (potions, effects). - For deck selections: toggles card selection. For choose-a-card: picks immediately. + For deck selections: toggles card selection unless a preview is open. For + choose-a-card: picks immediately. Args: card_index: 0-based index of the card (as shown in game state). @@ -1100,6 +1101,9 @@ async def mp_proceed_to_map() -> str: async def mp_deck_select_card(card_index: int) -> str: """[Multiplayer Card Selection] Select or deselect a card in the card selection screen. + For deck selections: toggles card selection unless a preview is open. For + choose-a-card: picks immediately. + Args: card_index: 0-based index of the card. """ diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 47be59e4..4597bce0 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -322,6 +322,7 @@ def audit_state_surface(repo: Path) -> None: state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") multiplayer_state = (repo / "McpMod.MultiplayerState.cs").read_text(encoding="utf-8") formatting = (repo / "McpMod.Formatting.cs").read_text(encoding="utf-8") + actions = (repo / "McpMod.Actions.cs").read_text(encoding="utf-8") docs = "\n".join( [ (repo / "docs" / "raw-simplified.md").read_text(encoding="utf-8"), @@ -350,6 +351,39 @@ def audit_state_surface(repo: Path) -> None: if missing_formatters: fail(f"state types missing markdown formatter coverage: {missing_formatters}") + card_select_match = re.search( + r"private static Dictionary BuildCardSelectState\(.*?\n private static Dictionary BuildChooseCardState\(", + state_builder, + re.S, + ) + if not card_select_match: + fail("could not locate BuildCardSelectState for selection audit") + card_select_body = card_select_match.group(0) + for required_field in [ + "is_selected", + "can_select", + "selected_cards", + "selected_count", + "min_select", + "max_select", + "preview_cards", + ]: + if required_field not in card_select_body and required_field not in state_builder: + fail(f"card_select state missing selection metadata: {required_field}") + if required_field not in docs: + fail(f"docs missing card_select selection metadata: {required_field}") + + select_action_match = re.search( + r"private static Dictionary ExecuteSelectCard\(.*?\n private static Dictionary ExecuteConfirmSelection\(", + actions, + re.S, + ) + if not select_action_match: + fail("could not locate ExecuteSelectCard for preview guard audit") + select_action_body = select_action_match.group(0) + if "preview is already open" not in select_action_body or "%PreviewContainer" not in select_action_body: + fail("select_card must reject grid selection while a preview is open") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From e8e811c7dcab4bb1a42e2e488157b39743380b3f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:07:06 -0400 Subject: [PATCH 096/190] Guard card reward selection availability --- McpMod.Actions.cs | 8 ++++++-- McpMod.StateBuilder.cs | 9 +++++++-- docs/raw-full.md | 5 ++++- scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index fd5e83ee..d3a09e74 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -502,7 +502,9 @@ public static partial class McpMod int cardIndex = indexElem.GetInt32(); - var cardHolders = FindAllSortedByPosition(cardScreen); + var cardHolders = FindAllSortedByPosition(cardScreen) + .Where(holder => holder.CardModel != null && holder.Visible && holder.IsVisibleInTree()) + .ToList(); if (cardIndex < 0 || cardIndex >= cardHolders.Count) return Error($"Card index {cardIndex} out of range (screen has {cardHolders.Count} cards)"); @@ -523,7 +525,9 @@ public static partial class McpMod if (overlay is not NCardRewardSelectionScreen cardScreen) return Error("Card reward selection screen is not open"); - var altButtons = FindAll(cardScreen); + var altButtons = FindAll(cardScreen) + .Where(button => button.IsEnabled && button.Visible && button.IsVisibleInTree()) + .ToList(); if (altButtons.Count == 0) return Error("No skip option available on this card reward"); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index a07c0294..796b1d59 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1955,7 +1955,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { var state = new Dictionary(); - var cardHolders = FindAllSortedByPosition(cardScreen); + var cardHolders = FindAllSortedByPosition(cardScreen) + .Where(holder => holder.CardModel != null && holder.Visible && holder.IsVisibleInTree()) + .ToList(); var cards = new List>(); int index = 0; foreach (var holder in cardHolders) @@ -1965,12 +1967,15 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; + AddCardHolderState(cardInfo, holder); cards.Add(cardInfo); index++; } state["cards"] = cards; - var altButtons = FindAll(cardScreen); + var altButtons = FindAll(cardScreen) + .Where(button => button.IsEnabled && button.Visible && button.IsVisibleInTree()) + .ToList(); state["can_skip"] = altButtons.Count > 0; return state; diff --git a/docs/raw-full.md b/docs/raw-full.md index 3b41ddc8..f96c7160 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -513,10 +513,13 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire "description": "Deal 13 damage. Apply 1 Weak. Apply 1 Vulnerable.", "rarity": "Uncommon", "is_upgraded": false, + "is_selected": false, + "is_visible": true, + "can_select": true, "keywords": [ /* Keyword Objects */ ] } ], - "can_skip": true + "can_skip": true // true only when the skip/alternate button is enabled and visible }, "run": { ... }, "player": { ... } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4597bce0..4a08f71c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -384,6 +384,29 @@ def audit_state_surface(repo: Path) -> None: if "preview is already open" not in select_action_body or "%PreviewContainer" not in select_action_body: fail("select_card must reject grid selection while a preview is open") + card_reward_match = re.search( + r"private static Dictionary BuildCardRewardState\(.*?\n private static Dictionary BuildCardSelectState\(", + state_builder, + re.S, + ) + if not card_reward_match: + fail("could not locate BuildCardRewardState for reward-card audit") + card_reward_body = card_reward_match.group(0) + reward_action_match = re.search( + r"private static Dictionary ExecuteSelectCardReward\(.*?\n private static Dictionary ExecuteProceed\(", + actions, + re.S, + ) + if not reward_action_match: + fail("could not locate card reward actions for reward-card audit") + reward_action_body = reward_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "IsEnabled", "AddCardHolderState"]: + if required_fragment not in card_reward_body: + fail(f"card_reward state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in reward_action_body: + fail(f"card_reward action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 472d7c635a674f4e28ca6b6079856c8313713203 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:09:48 -0400 Subject: [PATCH 097/190] Align event option availability state --- McpMod.Actions.cs | 6 +++++- McpMod.StateBuilder.cs | 7 ++++++- docs/raw-full.md | 3 +++ docs/raw-simplified.md | 2 +- mcp/server.py | 2 ++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index d3a09e74..753c9830 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -278,7 +278,9 @@ public static partial class McpMod int index = indexElem.GetInt32(); - var buttons = FindAll(uiRoom); + var buttons = FindAll(uiRoom) + .Where(button => button.Visible && button.IsVisibleInTree()) + .ToList(); if (buttons.Count == 0) return Error("No event options available"); @@ -288,6 +290,8 @@ public static partial class McpMod var button = buttons[index]; if (button.Option.IsLocked) return Error($"Event option {index} is locked"); + if (!button.IsEnabled) + return Error($"Event option {index} is disabled"); string title = SafeGetText(() => button.Option.Title) ?? "option"; button.ForceClick(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 796b1d59..071239e8 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1464,7 +1464,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var options = new List>(); if (uiRoom != null) { - var buttons = FindAll(uiRoom); + var buttons = FindAll(uiRoom) + .Where(button => button.Visible && button.IsVisibleInTree()) + .ToList(); int index = 0; foreach (var button in buttons) { @@ -1475,6 +1477,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["title"] = SafeGetText(() => opt.Title), ["description"] = SafeGetText(() => opt.Description), ["is_locked"] = opt.IsLocked, + ["is_enabled"] = button.IsEnabled, + ["is_visible"] = true, + ["can_choose"] = button.IsEnabled && !opt.IsLocked, ["is_proceed"] = opt.IsProceed, ["was_chosen"] = opt.WasChosen }; diff --git a/docs/raw-full.md b/docs/raw-full.md index f96c7160..aa332931 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -582,6 +582,9 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire "title": "Draft", "description": "Choose 10 card rewards to replace your starting deck.", "is_locked": false, + "is_enabled": true, + "is_visible": true, + "can_choose": true, "is_proceed": false, "was_chosen": false, "relic_id": "RELIC_ID", // Only if option has a relic diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 2121e843..4c338700 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -152,7 +152,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `choose_event_option` | `index`: int | Choose an event option by index from state. Locked options return an error. Also used for "Proceed" options. | +| `choose_event_option` | `index`: int | Choose a visible event option by index from state. Locked or disabled options return an error. Also used for "Proceed" options. | | `advance_dialogue` | _(none)_ | Click through Ancient dialogue until `in_dialogue` is false. | ### Rest Site (`rest_site`) diff --git a/mcp/server.py b/mcp/server.py index af5fc93d..667ed493 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -679,6 +679,7 @@ async def event_choose_option(option_index: int) -> str: Works for both regular events and ancients (after dialogue ends). Also used to click the Proceed option after an event resolves. + Locked or disabled options are reported as errors. Args: option_index: 0-based index of the option from the event state. @@ -1001,6 +1002,7 @@ async def mp_event_choose_option(option_index: int) -> str: For shared events: this is a vote (resolves when all players vote). For individual events: immediate choice, same as singleplayer. + Locked or disabled options are reported as errors. Args: option_index: 0-based index of the option from the event state. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4a08f71c..da51d923 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -407,6 +407,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in reward_action_body: fail(f"card_reward action missing visibility/enabled guard: {required_fragment}") + event_state_match = re.search( + r"private static Dictionary BuildEventState\(.*?\n private static Dictionary BuildFakeMerchantState\(", + state_builder, + re.S, + ) + if not event_state_match: + fail("could not locate BuildEventState for event-option audit") + event_state_body = event_state_match.group(0) + event_action_match = re.search( + r"private static Dictionary ExecuteChooseEventOption\(.*?\n private static Dictionary ExecuteAdvanceDialogue\(", + actions, + re.S, + ) + if not event_action_match: + fail("could not locate ExecuteChooseEventOption for event-option audit") + event_action_body = event_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_enabled", "can_choose"]: + if required_fragment not in event_state_body: + fail(f"event option state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in event_action_body: + fail(f"event option action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 4fe67e6e1f016d367ca094afa4e12a35994c205b Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:13:21 -0400 Subject: [PATCH 098/190] Align rest option availability state --- McpMod.Actions.cs | 6 +++++- McpMod.StateBuilder.cs | 14 ++++++++++++-- docs/raw-full.md | 4 +++- docs/raw-simplified.md | 2 +- mcp/server.py | 4 ++++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 753c9830..4c8f4b6b 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -336,7 +336,9 @@ public static partial class McpMod if (restRoom == null) return Error("Rest site room is not open"); - var buttons = FindAll(restRoom); + var buttons = FindAll(restRoom) + .Where(button => button.Visible && button.IsVisibleInTree()) + .ToList(); if (buttons.Count == 0) return Error("No rest site options available"); @@ -346,6 +348,8 @@ public static partial class McpMod var button = buttons[index]; if (!button.Option.IsEnabled) return Error($"Rest option {index} ({button.Option.OptionId}) is disabled"); + if (!button.IsEnabled) + return Error($"Rest option {index} ({button.Option.OptionId}) is disabled"); string optionName = SafeGetText(() => button.Option.Title) ?? button.Option.OptionId; button.ForceClick(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 071239e8..917d0aec 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -31,6 +31,7 @@ using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; using MegaCrit.Sts2.Core.Nodes.Screens.Map; using MegaCrit.Sts2.Core.Nodes.Relics; +using MegaCrit.Sts2.Core.Nodes.RestSite; using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; using MegaCrit.Sts2.Core.Nodes.Screens.Shops; using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; @@ -1615,17 +1616,26 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { var state = new Dictionary(); + var optionButtons = NRestSiteRoom.Instance != null + ? FindAll(NRestSiteRoom.Instance) + .Where(button => button.Visible && button.IsVisibleInTree()) + .ToList() + : new List(); var options = new List>(); + int index = 0; - foreach (var opt in restSiteRoom.Options) + foreach (var button in optionButtons) { + var opt = button.Option; options.Add(new Dictionary { ["index"] = index, ["id"] = opt.OptionId, ["name"] = SafeGetText(() => opt.Title), ["description"] = SafeGetText(() => opt.Description), - ["is_enabled"] = opt.IsEnabled + ["is_enabled"] = opt.IsEnabled && button.IsEnabled, + ["is_visible"] = true, + ["can_choose"] = opt.IsEnabled && button.IsEnabled }); index++; } diff --git a/docs/raw-full.md b/docs/raw-full.md index aa332931..28db9351 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -613,7 +613,9 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire "id": "rest", "name": "Rest", "description": "Heal 30% of max HP.", - "is_enabled": true + "is_enabled": true, + "is_visible": true, + "can_choose": true } ], "can_proceed": false diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 4c338700..88297723 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -159,7 +159,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `choose_rest_option` | `index`: int | Choose rest, smith, or other option. | +| `choose_rest_option` | `index`: int | Choose a visible enabled rest, smith, or other option. | | `proceed` | _(none)_ | Leave the rest site. | ### Shop (`shop`) diff --git a/mcp/server.py b/mcp/server.py index 667ed493..48743544 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -638,6 +638,8 @@ async def map_choose_node(node_index: int) -> str: async def rest_choose_option(option_index: int) -> str: """[Rest Site] Choose a rest site option (rest, smith, etc.). + Only visible enabled options are actionable. + Args: option_index: 0-based index of the option from the rest site state. """ @@ -1026,6 +1028,8 @@ async def mp_event_advance_dialogue() -> str: async def mp_rest_choose_option(option_index: int) -> str: """[Multiplayer Rest Site] Choose a rest site option (rest, smith, etc.). + Only visible enabled options are actionable. + Per-player choice — no voting needed. Args: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index da51d923..a69dd474 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -430,6 +430,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in event_action_body: fail(f"event option action missing visibility/enabled guard: {required_fragment}") + rest_state_match = re.search( + r"private static Dictionary BuildRestSiteState\(.*?\n private static Dictionary BuildShopState\(", + state_builder, + re.S, + ) + if not rest_state_match: + fail("could not locate BuildRestSiteState for rest-option audit") + rest_state_body = rest_state_match.group(0) + rest_action_match = re.search( + r"private static Dictionary ExecuteChooseRestOption\(.*?\n private static Dictionary ExecuteShopPurchase\(", + actions, + re.S, + ) + if not rest_action_match: + fail("could not locate ExecuteChooseRestOption for rest-option audit") + rest_action_body = rest_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_choose"]: + if required_fragment not in rest_state_body: + fail(f"rest option state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in rest_action_body: + fail(f"rest option action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From c8c95301198b9212e0ed58738c24b6adc619f144 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:16:04 -0400 Subject: [PATCH 099/190] Align relic selection availability state --- McpMod.Actions.cs | 6 ++++-- McpMod.StateBuilder.cs | 8 ++++++-- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 4 ++-- mcp/server.py | 7 +++++-- scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 4c8f4b6b..8834ecb0 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -914,7 +914,9 @@ public static partial class McpMod int index = indexElem.GetInt32(); - var holders = FindAll(screen); + var holders = FindAll(screen) + .Where(holder => holder.Relic?.Model != null && holder.IsEnabled && holder.Visible && holder.IsVisibleInTree()) + .ToList(); if (index < 0 || index >= holders.Count) return Error($"Relic index {index} out of range ({holders.Count} relics available)"); @@ -936,7 +938,7 @@ public static partial class McpMod return Error("No relic selection screen is open"); var skipButton = screen.GetNodeOrNull("SkipButton"); - if (skipButton is not { IsEnabled: true }) + if (skipButton is not { IsEnabled: true, Visible: true } || !skipButton.IsVisibleInTree()) return Error("No skip option available"); skipButton.ForceClick(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 917d0aec..d3e9a4d6 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2290,7 +2290,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con state["prompt"] = "Choose a relic."; - var relicHolders = FindAll(screen); + var relicHolders = FindAll(screen) + .Where(holder => holder.Relic?.Model != null && holder.IsEnabled && holder.Visible && holder.IsVisibleInTree()) + .ToList(); var relics = new List>(); int index = 0; foreach (var holder in relicHolders) @@ -2305,6 +2307,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["name"] = SafeGetText(() => relic.Title), ["description"] = SafeGetText(() => relic.DynamicDescription), ["rarity"] = relic.Rarity.ToString(), + ["is_visible"] = true, + ["can_select"] = holder.IsEnabled, ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) }); index++; @@ -2312,7 +2316,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con state["relics"] = relics; var skipButton = screen.GetNodeOrNull("SkipButton"); - state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible && skipButton.IsVisibleInTree(); return state; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 28db9351..72cf67d3 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -875,6 +875,8 @@ Boss relic selection. Pick is immediate. "name": "Black Star", "description": "Elites drop an additional relic.", "rarity": "Rare", + "is_visible": true, + "can_select": true, "keywords": [ /* Keyword Objects */ ] } ], diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 88297723..8565a165 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -196,8 +196,8 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `select_relic` | `index`: int | Pick a relic (immediate). | -| `skip_relic_selection` | _(none)_ | Skip the relic choice. | +| `select_relic` | `index`: int | Pick a visible enabled relic (immediate). | +| `skip_relic_selection` | _(none)_ | Skip the relic choice when the skip button is visible and enabled. | ### Crystal Sphere (`crystal_sphere`) diff --git a/mcp/server.py b/mcp/server.py index 48743544..87b87042 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -802,6 +802,7 @@ async def relic_select(relic_index: int) -> str: """[Relic Selection] Select a relic from the relic selection screen. Used when the game offers a choice of relics (e.g., boss relic rewards). + Only visible enabled relic choices are actionable. Args: relic_index: 0-based index of the relic (as shown in game state). @@ -814,7 +815,7 @@ async def relic_select(relic_index: int) -> str: @mcp.tool() async def relic_skip() -> str: - """[Relic Selection] Skip the relic selection without choosing a relic.""" + """[Relic Selection] Skip when the skip button is visible and enabled.""" try: return await _post({"action": "skip_relic_selection"}) except Exception as e: @@ -1194,6 +1195,8 @@ async def mp_combat_confirm_selection() -> str: async def mp_relic_select(relic_index: int) -> str: """[Multiplayer Relic Selection] Select a relic (boss relic rewards). + Only visible enabled relic choices are actionable. + Args: relic_index: 0-based index of the relic. """ @@ -1205,7 +1208,7 @@ async def mp_relic_select(relic_index: int) -> str: @mcp.tool() async def mp_relic_skip() -> str: - """[Multiplayer Relic Selection] Skip the relic selection.""" + """[Multiplayer Relic Selection] Skip when the skip button is visible and enabled.""" try: return await _mp_post({"action": "skip_relic_selection"}) except Exception as e: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index a69dd474..8c9b6a1a 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -453,6 +453,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in rest_action_body: fail(f"rest option action missing visibility/enabled guard: {required_fragment}") + relic_state_match = re.search( + r"private static Dictionary BuildRelicSelectState\(.*?\n private static Dictionary BuildCrystalSphereState\(", + state_builder, + re.S, + ) + if not relic_state_match: + fail("could not locate BuildRelicSelectState for relic-select audit") + relic_state_body = relic_state_match.group(0) + relic_action_match = re.search( + r"private static Dictionary ExecuteSelectRelic\(.*?\n private static Dictionary ExecuteClaimTreasureRelic\(", + actions, + re.S, + ) + if not relic_action_match: + fail("could not locate relic selection actions for relic-select audit") + relic_action_body = relic_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_select"]: + if required_fragment not in relic_state_body: + fail(f"relic_select state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in relic_action_body: + fail(f"relic_select action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 156b8adb04acfc52fe87c009b6cfa3d3c3cb28ac Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:18:41 -0400 Subject: [PATCH 100/190] Align bundle selection availability state --- McpMod.Actions.cs | 4 +++- McpMod.StateBuilder.cs | 7 +++++-- docs/raw-full.md | 4 +++- docs/raw-simplified.md | 2 +- mcp/server.py | 4 ++++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 8834ecb0..ea07c51a 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -809,7 +809,9 @@ public static partial class McpMod if (previewContainer?.Visible == true) return Error("A bundle preview is already open - confirm or cancel it first"); - var bundles = FindAll(screen); + var bundles = FindAll(screen) + .Where(bundle => bundle.Visible && bundle.IsVisibleInTree() && bundle.Hitbox.IsEnabled) + .ToList(); if (index < 0 || index >= bundles.Count) return Error($"Bundle index {index} out of range ({bundles.Count} bundles available)"); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index d3e9a4d6..8b40d9b6 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2164,7 +2164,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var bundles = new List>(); int index = 0; - foreach (var bundle in FindAll(screen)) + foreach (var bundle in FindAll(screen) + .Where(bundle => bundle.Visible && bundle.IsVisibleInTree())) { var cards = new List>(); int cardIndex = 0; @@ -2180,7 +2181,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { ["index"] = index, ["card_count"] = cards.Count, - ["cards"] = cards + ["cards"] = cards, + ["is_visible"] = true, + ["can_select"] = bundle.Hitbox.IsEnabled }); index++; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 72cf67d3..43c54799 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -390,7 +390,9 @@ Run state or room type not recognized. "title": "Attack", // Hover tip title "description": "Deals 11 damage." // Hover tip description } - ] + ], + "is_visible": true, + "can_select": true } ] }, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 8565a165..0df29a1f 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -188,7 +188,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `select_bundle` | `index`: int | Open a bundle preview. | +| `select_bundle` | `index`: int | Open a visible enabled bundle preview. | | `confirm_bundle_selection` | _(none)_ | Confirm the previewed bundle. | | `cancel_bundle_selection` | _(none)_ | Cancel the bundle preview. | diff --git a/mcp/server.py b/mcp/server.py index 87b87042..ded5193f 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -765,6 +765,8 @@ async def deck_cancel_selection() -> str: async def bundle_select(bundle_index: int) -> str: """[Bundle Selection] Open a bundle preview. + Only visible enabled bundles are actionable. + Args: bundle_index: 0-based index of the bundle. """ @@ -1142,6 +1144,8 @@ async def mp_deck_cancel_selection() -> str: async def mp_bundle_select(bundle_index: int) -> str: """[Multiplayer Bundle Selection] Open a bundle preview. + Only visible enabled bundles are actionable. + Args: bundle_index: 0-based index of the bundle. """ diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8c9b6a1a..7105600d 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -476,6 +476,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in relic_action_body: fail(f"relic_select action missing visibility/enabled guard: {required_fragment}") + bundle_state_match = re.search( + r"private static Dictionary BuildBundleSelectState\(.*?\n private static Dictionary BuildHandSelectState\(", + state_builder, + re.S, + ) + if not bundle_state_match: + fail("could not locate BuildBundleSelectState for bundle-select audit") + bundle_state_body = bundle_state_match.group(0) + bundle_action_match = re.search( + r"private static Dictionary ExecuteSelectBundle\(.*?\n private static Dictionary ExecuteConfirmBundleSelection\(", + actions, + re.S, + ) + if not bundle_action_match: + fail("could not locate ExecuteSelectBundle for bundle-select audit") + bundle_action_body = bundle_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_select"]: + if required_fragment not in bundle_state_body: + fail(f"bundle_select state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "Hitbox.IsEnabled"]: + if required_fragment not in bundle_action_body: + fail(f"bundle_select action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From b75366f4e207282222ae26b07e7d95cd538e1e9e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:21:06 -0400 Subject: [PATCH 101/190] Expose shop purchase readiness --- McpMod.StateBuilder.cs | 13 +++++++++---- docs/raw-full.md | 7 ++++++- scripts/audit_endpoints.py | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 8b40d9b6..2c897bf9 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1594,7 +1594,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["category"] = "relic", ["price"] = entry.Cost, ["is_stocked"] = entry.IsStocked, - ["can_afford"] = entry.EnoughGold + ["can_afford"] = entry.EnoughGold, + ["can_purchase"] = entry.IsStocked && entry.EnoughGold }; if (entry.Model is { } relic) { @@ -1674,6 +1675,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["price"] = entry.Cost, ["is_stocked"] = entry.IsStocked, ["can_afford"] = entry.EnoughGold, + ["can_purchase"] = entry.IsStocked && entry.EnoughGold, ["on_sale"] = entry.IsOnSale }; if (entry.CreationResult?.Card is { } card) @@ -1709,7 +1711,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["category"] = "relic", ["price"] = entry.Cost, ["is_stocked"] = entry.IsStocked, - ["can_afford"] = entry.EnoughGold + ["can_afford"] = entry.EnoughGold, + ["can_purchase"] = entry.IsStocked && entry.EnoughGold }; if (entry.Model is { } relic) { @@ -1732,7 +1735,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["category"] = "potion", ["price"] = entry.Cost, ["is_stocked"] = entry.IsStocked, - ["can_afford"] = entry.EnoughGold + ["can_afford"] = entry.EnoughGold, + ["can_purchase"] = entry.IsStocked && entry.EnoughGold }; if (entry.Model is { } potion) { @@ -1757,7 +1761,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["category"] = "card_removal", ["price"] = removal.Cost, ["is_stocked"] = removal.IsStocked, - ["can_afford"] = removal.EnoughGold + ["can_afford"] = removal.EnoughGold, + ["can_purchase"] = removal.IsStocked && removal.EnoughGold }); } diff --git a/docs/raw-full.md b/docs/raw-full.md index 43c54799..594920ca 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -643,6 +643,7 @@ Shop inventory is auto-opened when state is queried. `can_proceed` mirrors the ` "price": 75, // Gold price in the shop "is_stocked": true, "can_afford": true, + "can_purchase": true, "on_sale": false, "card_id": "OFFERING", "card_name": "Offering", @@ -668,6 +669,7 @@ Shop inventory is auto-opened when state is queried. `can_proceed` mirrors the ` "price": 150, "is_stocked": true, "can_afford": false, + "can_purchase": false, "relic_id": "VAJRA", "relic_name": "Vajra", "relic_description": "At the start of each combat, gain 1 Strength.", @@ -681,6 +683,7 @@ Shop inventory is auto-opened when state is queried. `can_proceed` mirrors the ` "price": 50, "is_stocked": true, "can_afford": true, + "can_purchase": true, "potion_id": "FIRE_POTION", "potion_name": "Fire Potion", "potion_description": "Deal 20 damage to target enemy.", @@ -695,7 +698,8 @@ Shop inventory is auto-opened when state is queried. `can_proceed` mirrors the ` "category": "card_removal", "price": 75, "is_stocked": true, - "can_afford": true + "can_afford": true, + "can_purchase": true } ], "inventory_open": true, @@ -727,6 +731,7 @@ A relic-only shop disguised as an event. Uses `shop_purchase` and `proceed` acti "cost": 150, "is_stocked": true, "can_afford": true, + "can_purchase": true, "relic_id": "VAJRA", "relic_name": "Vajra", "relic_description": "At the start of each combat, gain 1 Strength.", diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 7105600d..df2d33cf 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -285,6 +285,7 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: shop_required = [ "inventory_open", "can_close_inventory", + "can_purchase", "card_is_upgradable", "card_current_upgrade_level", "card_max_upgrade_level", From f4707cf5b7ebd540a6ee0252a5cd0b8f79c9d8cd Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:23:40 -0400 Subject: [PATCH 102/190] Expose treasure relic claim readiness --- McpMod.Actions.cs | 2 +- McpMod.StateBuilder.cs | 4 +++- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 +- mcp/server.py | 2 ++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index ea07c51a..55c574f0 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -969,7 +969,7 @@ public static partial class McpMod int index = indexElem.GetInt32(); var holders = FindAll(relicCollection) - .Where(h => h.IsEnabled && h.Visible) + .Where(h => h.IsEnabled && h.Visible && h.IsVisibleInTree()) .ToList(); if (index < 0 || index >= holders.Count) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 2c897bf9..d64a8631 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2456,7 +2456,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con if (relicCollection?.Visible == true) { var holders = FindAll(relicCollection) - .Where(h => h.IsEnabled && h.Visible) + .Where(h => h.IsEnabled && h.Visible && h.IsVisibleInTree()) .ToList(); var relics = new List>(); @@ -2472,6 +2472,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["name"] = SafeGetText(() => relic.Title), ["description"] = SafeGetText(() => relic.DynamicDescription), ["rarity"] = relic.Rarity.ToString(), + ["is_visible"] = true, + ["can_claim"] = holder.IsEnabled, ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) }); index++; diff --git a/docs/raw-full.md b/docs/raw-full.md index 594920ca..afb7f0ab 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -772,6 +772,8 @@ Chest is auto-opened on first state query. "name": "Lantern", "description": "Gain 1 Energy on the first turn of each combat.", "rarity": "Uncommon", + "is_visible": true, + "can_claim": true, "keywords": [ /* Keyword Objects */ ] } ], diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 0df29a1f..4bcb4101 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -173,7 +173,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `claim_treasure_relic` | `index`: int | Claim a relic from the opened chest. | +| `claim_treasure_relic` | `index`: int | Claim a visible enabled relic from the opened chest. | | `proceed` | _(none)_ | Leave the treasure room. | ### Card Selection Overlay (`card_select`) diff --git a/mcp/server.py b/mcp/server.py index ded5193f..9267dd56 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -835,6 +835,7 @@ async def treasure_claim_relic(relic_index: int) -> str: The chest is auto-opened when entering the treasure room. After claiming, use proceed_to_map() to continue. + Only visible enabled relics are actionable. Args: relic_index: 0-based index of the relic (as shown in game state). @@ -1225,6 +1226,7 @@ async def mp_treasure_claim_relic(relic_index: int) -> str: In multiplayer, this is a bid — if multiple players pick the same relic, a "relic fight" determines the winner. Others get consolation prizes. + Only visible enabled relics are actionable. Args: relic_index: 0-based index of the relic. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index df2d33cf..190be94f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -500,6 +500,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in bundle_action_body: fail(f"bundle_select action missing visibility/enabled guard: {required_fragment}") + treasure_state_match = re.search( + r"private static Dictionary BuildTreasureState\(.*?\n private static string GetRewardTypeName\(", + state_builder, + re.S, + ) + if not treasure_state_match: + fail("could not locate BuildTreasureState for treasure audit") + treasure_state_body = treasure_state_match.group(0) + treasure_action_match = re.search( + r"private static Dictionary ExecuteClaimTreasureRelic\(.*?\n private static Dictionary ExecuteCrystalSphereSetTool\(", + actions, + re.S, + ) + if not treasure_action_match: + fail("could not locate ExecuteClaimTreasureRelic for treasure audit") + treasure_action_body = treasure_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_claim"]: + if required_fragment not in treasure_state_body: + fail(f"treasure state missing visibility/enabled metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in treasure_action_body: + fail(f"treasure action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From c338ecbe6cabefd5891c9e6b2e67deb0b11f54ab Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:25:34 -0400 Subject: [PATCH 103/190] Tighten crystal sphere visibility guards --- McpMod.Actions.cs | 6 +++--- McpMod.StateBuilder.cs | 11 ++++++----- scripts/audit_endpoints.py | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 55c574f0..5dbefd4e 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -1005,7 +1005,7 @@ public static partial class McpMod if (button == null) return Error($"Unknown Crystal Sphere tool: {tool}"); - if (!button.Visible || !button.IsEnabled) + if (!button.Visible || !button.IsVisibleInTree() || !button.IsEnabled) return Error($"Crystal Sphere tool '{tool}' is not available"); button.ForceClick(); @@ -1034,7 +1034,7 @@ public static partial class McpMod .FirstOrDefault(c => c.Entity.X == x && c.Entity.Y == y); if (cell == null) return Error($"Crystal Sphere cell ({x}, {y}) was not found"); - if (!cell.Entity.IsHidden || !cell.Visible) + if (!cell.Entity.IsHidden || !cell.Visible || !cell.IsVisibleInTree()) return Error($"Crystal Sphere cell ({x}, {y}) is not clickable"); cell.EmitSignal(NClickableControl.SignalName.Released, cell); @@ -1052,7 +1052,7 @@ public static partial class McpMod return Error("Crystal Sphere screen is not open"); var proceedButton = screen.GetNodeOrNull("%ProceedButton"); - if (proceedButton is not { IsEnabled: true }) + if (proceedButton is not { IsEnabled: true, Visible: true } || !proceedButton.IsVisibleInTree()) return Error("Crystal Sphere proceed button is not enabled"); proceedButton.ForceClick(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index d64a8631..fe7868e7 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -2357,12 +2357,13 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var clickableCells = new List>(); foreach (var cell in cells.OrderBy(c => c.Entity.Y).ThenBy(c => c.Entity.X)) { + var cellVisible = cell.Visible && cell.IsVisibleInTree(); var cellState = new Dictionary { ["x"] = cell.Entity.X, ["y"] = cell.Entity.Y, ["is_hidden"] = cell.Entity.IsHidden, - ["is_clickable"] = cell.Entity.IsHidden && cell.Visible, + ["is_clickable"] = cell.Entity.IsHidden && cellVisible, ["is_highlighted"] = cell.Entity.IsHighlighted, ["is_hovered"] = cell.Entity.IsHovered }; @@ -2374,7 +2375,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con } cellStates.Add(cellState); - if (cell.Entity.IsHidden && cell.Visible) + if (cell.Entity.IsHidden && cellVisible) { clickableCells.Add(new Dictionary { @@ -2406,8 +2407,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var bigButton = screen.GetNodeOrNull("%BigDivinationButton"); var smallButton = screen.GetNodeOrNull("%SmallDivinationButton"); - bool bigUsable = bigButton?.Visible == true && bigButton.IsEnabled; - bool smallUsable = smallButton?.Visible == true && smallButton.IsEnabled; + bool bigUsable = bigButton?.Visible == true && bigButton.IsVisibleInTree() && bigButton.IsEnabled; + bool smallUsable = smallButton?.Visible == true && smallButton.IsVisibleInTree() && smallButton.IsEnabled; bool bigActive = bigButton?.GetNodeOrNull("%Outline")?.Visible == true; bool smallActive = smallButton?.GetNodeOrNull("%Outline")?.Visible == true; @@ -2424,7 +2425,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con } var proceedButton = screen.GetNodeOrNull("%ProceedButton"); - state["can_proceed"] = proceedButton?.IsEnabled == true; + state["can_proceed"] = proceedButton?.IsEnabled == true && proceedButton.Visible && proceedButton.IsVisibleInTree(); return state; } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 190be94f..e4f97ae9 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -523,6 +523,27 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in treasure_action_body: fail(f"treasure action missing visibility/enabled guard: {required_fragment}") + crystal_state_match = re.search( + r"private static Dictionary BuildCrystalSphereState\(.*?\n private static Dictionary BuildTreasureState\(", + state_builder, + re.S, + ) + if not crystal_state_match: + fail("could not locate BuildCrystalSphereState for crystal-sphere audit") + crystal_state_body = crystal_state_match.group(0) + crystal_action_match = re.search( + r"private static Dictionary ExecuteCrystalSphereSetTool\(.*?\n private static Creature\? ResolveTarget\(", + actions, + re.S, + ) + if not crystal_action_match: + fail("could not locate Crystal Sphere actions for crystal-sphere audit") + crystal_action_body = crystal_action_match.group(0) + if "IsVisibleInTree" not in crystal_state_body: + fail("crystal_sphere state missing visible-in-tree guards") + if "IsVisibleInTree" not in crystal_action_body: + fail("crystal_sphere actions missing visible-in-tree guards") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 23208789bfa621c0a10b4a960c1c0531f267b8c9 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:29:56 -0400 Subject: [PATCH 104/190] Expose potion use readiness --- McpMod.Actions.cs | 2 ++ McpMod.StateBuilder.cs | 74 ++++++++++++++++++++++++++++++++++++++ docs/raw-full.md | 6 +++- docs/raw-simplified.md | 2 +- mcp/server.py | 3 ++ scripts/audit_endpoints.py | 23 ++++++++++++ 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 5dbefd4e..43b8a56f 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -209,6 +209,8 @@ public static partial class McpMod switch (potion.TargetType) { case TargetType.AnyEnemy: + if (!inCombat) + return Error("Enemy-targeted potions can only be used in combat"); if (!data.TryGetValue("target", out var targetElem)) return Error("Potion requires a target enemy. Provide 'target' with an entity_id."); string targetId = targetElem.GetString() ?? ""; diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index fe7868e7..1396324c 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1205,6 +1205,8 @@ private static void AddMultiplayerLoadLobbyMenuState( { if (potion != null) { + var validTargets = BuildPotionTargetRefs(potion, player); + var blockedReason = GetPotionUseBlockedReason(potion, validTargets); potions.Add(new Dictionary { ["id"] = potion.Id.Entry, @@ -1213,6 +1215,10 @@ private static void AddMultiplayerLoadLobbyMenuState( ["rarity"] = potion.Rarity.ToString(), ["slot"] = slotIndex, ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, + ["can_use"] = blockedReason == null, + ["use_blocked_reason"] = blockedReason, + ["requires_target"] = potion.TargetType == TargetType.AnyEnemy, + ["valid_targets"] = validTargets, ["target_type"] = potion.TargetType.ToString(), ["usage"] = potion.Usage.ToString(), ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) @@ -1226,6 +1232,74 @@ private static void AddMultiplayerLoadLobbyMenuState( return state; } + private static string? GetPotionUseBlockedReason( + PotionModel potion, + List> validTargets) + { + if (potion.IsQueued) + return "AlreadyQueued"; + if (potion.Owner.Creature.IsDead) + return "PlayerDead"; + if (!potion.PassesCustomUsabilityCheck) + return "CustomUsabilityCheckFailed"; + + var inCombat = CombatManager.Instance.IsInProgress; + if (potion.Usage == PotionUsage.CombatOnly) + { + if (!inCombat) + return "CombatOnly"; + if (!CombatManager.Instance.IsPlayPhase) + return "NotInPlayPhase"; + } + else if (potion.Usage == PotionUsage.Automatic) + { + return "Automatic"; + } + + if (inCombat && CombatManager.Instance.PlayerActionsDisabled) + return "PlayerActionsDisabled"; + + if (potion.TargetType == TargetType.AnyEnemy && validTargets.Count == 0) + return "NoValidTargets"; + + return null; + } + + private static List> BuildPotionTargetRefs(PotionModel potion, Player player) + { + var targets = new List>(); + if (potion.TargetType != TargetType.AnyEnemy) + return targets; + + if (!CombatManager.Instance.IsInProgress) + return targets; + + var combatState = player.Creature.CombatState; + if (combatState == null) + return targets; + + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (!creature.IsAlive) + continue; + + var baseId = creature.Monster?.Id.Entry ?? "unknown"; + if (!entityCounts.TryGetValue(baseId, out var count)) + count = 0; + entityCounts[baseId] = count + 1; + + targets.Add(new Dictionary + { + ["entity_id"] = $"{baseId}_{count}", + ["combat_id"] = creature.CombatId, + ["name"] = SafeGetText(() => creature.Monster?.Title) + }); + } + + return targets; + } + private static string GetCostDisplay(CardModel card) => card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(); diff --git a/docs/raw-full.md b/docs/raw-full.md index afb7f0ab..94a0bbbd 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -109,6 +109,10 @@ Always present at the top level (except `menu`). Contains everything about the l "rarity": "Common", "slot": 0, // Potion slot index (use this for use_potion action) "can_use_in_combat": true, + "can_use": true, // true when use_potion can run now, assuming any required target is supplied + "use_blocked_reason": null, // e.g. "CombatOnly", "Automatic", "AlreadyQueued", "PlayerDead", "CustomUsabilityCheckFailed", "NotInPlayPhase", "PlayerActionsDisabled", "NoValidTargets" + "requires_target": false, + "valid_targets": [], // For AnyEnemy potions: [{ "entity_id": "JAW_WORM_0", "combat_id": 12, "name": "Jaw Worm" }] "target_type": "None", // None, Self, AnyEnemy, AnyAlly, AnyPlayer, etc. "usage": "Manual", "keywords": [ /* Keyword Objects */ ] @@ -1189,7 +1193,7 @@ Use a potion from the potion belt. | `slot` | int | Yes | Potion slot index | | `target` | string | For `AnyEnemy` potions | `entity_id` of the target enemy | -**Errors:** Empty slot, combat-only potion used outside combat, automatic potion, already queued, player dead. +**Errors:** Empty slot, combat-only potion used outside combat, automatic potion, already queued, player dead, custom usability blocked, missing/invalid target, enemy-targeted potion outside combat. ### `discard_potion` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 4bcb4101..e65aba4d 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -117,7 +117,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| | `play_card` | `card_index`: int, `target`?: string | Play a card from hand. `target` is an `entity_id` (e.g. `"JAW_WORM_0"`), required for single-target cards. | -| `use_potion` | `slot`: int, `target`?: string | Use a potion. `target` required for enemy-targeting potions. Works outside combat for non-combat-only potions. | +| `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use one of `valid_targets`. Works outside combat for non-combat-only, non-enemy-targeting potions. | | `discard_potion` | `slot`: int | Discard a potion to free up the slot. Use when slots are full and you need room for incoming potions. | | `end_turn` | _(none)_ | End the player's turn. | diff --git a/mcp/server.py b/mcp/server.py index 9267dd56..666bcbe1 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -454,6 +454,7 @@ async def use_potion(slot: int, target: str | None = None) -> str: """Use a potion from the player's potion slots. Works both during and outside of combat. Combat-only potions require an active battle. + Enemy-targeted potions require combat and a target from state `valid_targets`. Args: slot: Potion slot index (as shown in game state). @@ -960,6 +961,8 @@ async def mp_combat_undo_end_turn() -> str: async def mp_use_potion(slot: int, target: str | None = None) -> str: """[Multiplayer] Use a potion from the local player's potion slots. + Enemy-targeted potions require combat and a target from state `valid_targets`. + Args: slot: Potion slot index (as shown in game state). target: Entity ID of the target enemy. Required for enemy-targeted potions. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index e4f97ae9..c298f122 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -544,6 +544,29 @@ def audit_state_surface(repo: Path) -> None: if "IsVisibleInTree" not in crystal_action_body: fail("crystal_sphere actions missing visible-in-tree guards") + player_state_match = re.search( + r"private static Dictionary BuildPlayerState\(.*?\n private static string GetCostDisplay\(", + state_builder, + re.S, + ) + if not player_state_match: + fail("could not locate BuildPlayerState for potion audit") + player_state_body = player_state_match.group(0) + potion_action_match = re.search( + r"private static Dictionary ExecuteUsePotion\(.*?\n private static Dictionary ExecuteDiscardPotion\(", + actions, + re.S, + ) + if not potion_action_match: + fail("could not locate ExecuteUsePotion for potion audit") + potion_action_body = potion_action_match.group(0) + for required_fragment in ["can_use", "use_blocked_reason", "requires_target", "valid_targets", "GetPotionUseBlockedReason"]: + if required_fragment not in player_state_body: + fail(f"potion state missing use readiness metadata: {required_fragment}") + for required_fragment in ["IsQueued", "PassesCustomUsabilityCheck", "PlayerActionsDisabled", "Enemy-targeted potions can only be used in combat"]: + if required_fragment not in potion_action_body: + fail(f"use_potion action missing readiness guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From a708c7e616ed520d0d131843d923d0368a7fddf4 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:32:29 -0400 Subject: [PATCH 105/190] Expose combat end turn readiness --- McpMod.StateBuilder.cs | 22 ++++++++++++++++++++++ docs/raw-full.md | 5 ++++- docs/raw-simplified.md | 2 +- mcp/server.py | 2 +- scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 1396324c..ae9f2627 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1067,6 +1067,10 @@ private static void AddMultiplayerLoadLobbyMenuState( battle["round"] = combatState.RoundNumber; battle["turn"] = combatState.CurrentSide.ToString().ToLower(); battle["is_play_phase"] = CombatManager.Instance.IsPlayPhase; + battle["player_actions_disabled"] = CombatManager.Instance.PlayerActionsDisabled; + var endTurnBlockedReason = GetEndTurnBlockedReason(); + battle["can_end_turn"] = endTurnBlockedReason == null; + battle["end_turn_blocked_reason"] = endTurnBlockedReason; // Enemies var enemies = new List>(); @@ -1083,6 +1087,24 @@ private static void AddMultiplayerLoadLobbyMenuState( return battle; } + private static string? GetEndTurnBlockedReason() + { + if (!CombatManager.Instance.IsInProgress) + return "NotInCombat"; + if (!CombatManager.Instance.IsPlayPhase) + return "NotInPlayPhase"; + if (CombatManager.Instance.PlayerActionsDisabled) + return "PlayerActionsDisabled"; + + var hand = NCombatRoom.Instance?.Ui?.Hand; + if (hand != null && hand.InCardPlay) + return "CardInPlay"; + if (hand != null && hand.CurrentMode != NPlayerHand.Mode.Play) + return "HandSelectionMode"; + + return null; + } + private static Dictionary BuildPlayerState(Player player) { var state = new Dictionary(); diff --git a/docs/raw-full.md b/docs/raw-full.md index 94a0bbbd..3dce6ab1 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -378,6 +378,9 @@ Run state or room type not recognized. "round": 1, "turn": "player", // "player" or "enemy" "is_play_phase": true, + "player_actions_disabled": false, + "can_end_turn": true, + "end_turn_blocked_reason": null, // e.g. "NotInPlayPhase", "PlayerActionsDisabled", "CardInPlay", "HandSelectionMode" "enemies": [ { "entity_id": "JAW_WORM_0", // Synthesized ID for targeting @@ -1217,7 +1220,7 @@ End the player's combat turn. { "action": "end_turn" } ``` -**Errors:** Not in combat, not play phase, card mid-play, hand in selection mode. +**Errors:** Not in combat, not play phase, actions disabled, card mid-play, hand in selection mode. ### `combat_select_card` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index e65aba4d..01c7c0dd 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -119,7 +119,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | `play_card` | `card_index`: int, `target`?: string | Play a card from hand. `target` is an `entity_id` (e.g. `"JAW_WORM_0"`), required for single-target cards. | | `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use one of `valid_targets`. Works outside combat for non-combat-only, non-enemy-targeting potions. | | `discard_potion` | `slot`: int | Discard a potion to free up the slot. Use when slots are full and you need room for incoming potions. | -| `end_turn` | _(none)_ | End the player's turn. | +| `end_turn` | _(none)_ | End the player's turn when battle state says `can_end_turn`. | ### In-Combat Hand Selection (`hand_select`) diff --git a/mcp/server.py b/mcp/server.py index 666bcbe1..92c35af6 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -525,7 +525,7 @@ async def combat_play_card(card_index: int, target: str | None = None) -> str: @mcp.tool() async def combat_end_turn() -> str: - """[Combat] End the player's current turn.""" + """[Combat] End the player's current turn when battle state says can_end_turn.""" try: return await _post({"action": "end_turn"}) except Exception as e: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index c298f122..78294771 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -567,6 +567,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in potion_action_body: fail(f"use_potion action missing readiness guard: {required_fragment}") + battle_state_match = re.search( + r"private static Dictionary BuildBattleState\(.*?\n private static Dictionary BuildPlayerState\(", + state_builder, + re.S, + ) + if not battle_state_match: + fail("could not locate BuildBattleState for end-turn audit") + battle_state_body = battle_state_match.group(0) + end_turn_action_match = re.search( + r"private static Dictionary ExecuteEndTurn\(.*?\n private static Dictionary ExecuteUsePotion\(", + actions, + re.S, + ) + if not end_turn_action_match: + fail("could not locate ExecuteEndTurn for end-turn audit") + end_turn_action_body = end_turn_action_match.group(0) + for required_fragment in ["can_end_turn", "end_turn_blocked_reason", "GetEndTurnBlockedReason"]: + if required_fragment not in battle_state_body: + fail(f"battle state missing end-turn readiness metadata: {required_fragment}") + for required_fragment in ["PlayerActionsDisabled", "InCardPlay", "CurrentMode != NPlayerHand.Mode.Play"]: + if required_fragment not in end_turn_action_body: + fail(f"end_turn action missing readiness guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From dc17fcdb0bd03f93d3e9f8ed867feec595b9a0b8 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:35:37 -0400 Subject: [PATCH 106/190] Expose multiplayer end turn readiness --- McpMod.MultiplayerState.cs | 58 ++++++++++++++++++++++++++++++++++++++ docs/raw-full.md | 11 ++++++-- docs/raw-simplified.md | 4 +-- mcp/server.py | 4 ++- scripts/audit_endpoints.py | 24 ++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/McpMod.MultiplayerState.cs b/McpMod.MultiplayerState.cs index 46cee259..5f78f74a 100644 --- a/McpMod.MultiplayerState.cs +++ b/McpMod.MultiplayerState.cs @@ -280,8 +280,24 @@ public static partial class McpMod battle["round"] = combatState.RoundNumber; battle["turn"] = combatState.CurrentSide.ToString().ToLower(); battle["is_play_phase"] = CombatManager.Instance.IsPlayPhase; + battle["player_actions_disabled"] = CombatManager.Instance.PlayerActionsDisabled; battle["all_players_ready"] = CombatManager.Instance.AllPlayersReadyToEndTurn(); + var localPlayer = LocalContext.GetMe(runState); + if (localPlayer != null) + { + var isReady = CombatManager.Instance.IsPlayerReadyToEndTurn(localPlayer); + battle["local_player_ready_to_end_turn"] = isReady; + + var endTurnBlockedReason = GetMultiplayerEndTurnBlockedReason(localPlayer); + battle["can_end_turn"] = endTurnBlockedReason == null; + battle["end_turn_blocked_reason"] = endTurnBlockedReason; + + var undoBlockedReason = GetMultiplayerUndoEndTurnBlockedReason(localPlayer); + battle["can_undo_end_turn"] = undoBlockedReason == null; + battle["undo_end_turn_blocked_reason"] = undoBlockedReason; + } + // Enemies var enemies = new List>(); var entityCounts = new Dictionary(); @@ -295,6 +311,48 @@ public static partial class McpMod return battle; } + private static string? GetMultiplayerEndTurnBlockedReason(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return "NotInCombat"; + if (!CombatManager.Instance.IsPlayPhase) + return "NotInPlayPhase"; + if (CombatManager.Instance.PlayerActionsDisabled) + return "PlayerActionsDisabled"; + if (!player.Creature.IsAlive) + return "PlayerDead"; + if (CombatManager.Instance.IsPlayerReadyToEndTurn(player)) + return "AlreadyReady"; + + var hand = NCombatRoom.Instance?.Ui?.Hand; + if (hand != null && hand.InCardPlay) + return "CardInPlay"; + if (hand != null && hand.CurrentMode != NPlayerHand.Mode.Play) + return "HandSelectionMode"; + if (player.Creature.CombatState == null) + return "NoCombatState"; + + return null; + } + + private static string? GetMultiplayerUndoEndTurnBlockedReason(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return "NotInCombat"; + if (!CombatManager.Instance.IsPlayPhase) + return "NotInPlayPhase"; + if (CombatManager.Instance.PlayerActionsDisabled) + return "PlayerActionsDisabled"; + if (!player.Creature.IsAlive) + return "PlayerDead"; + if (!CombatManager.Instance.IsPlayerReadyToEndTurn(player)) + return "NotReady"; + if (player.Creature.CombatState == null) + return "NoCombatState"; + + return null; + } + private static Dictionary BuildMultiplayerMapState(RunState runState) { // Start with the standard map state diff --git a/docs/raw-full.md b/docs/raw-full.md index 3dce6ab1..d9c88b62 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1513,7 +1513,12 @@ Finish the Crystal Sphere minigame. ```jsonc { "battle": { - "all_players_ready": false + "all_players_ready": false, + "local_player_ready_to_end_turn": false, + "can_end_turn": true, + "end_turn_blocked_reason": null, + "can_undo_end_turn": false, + "undo_end_turn_blocked_reason": "NotReady" // Same shape as singleplayer (round, turn, is_play_phase, enemies). // Player state lives in top-level "player" (local) and "players" (all). }, @@ -1610,12 +1615,12 @@ Finish the Crystal Sphere minigame. ```json { "action": "end_turn" } ``` -In multiplayer, this is a vote. The turn ends only when all players submit. +In multiplayer, this is a vote. The turn ends only when all players submit. Use `battle.can_end_turn` / `battle.end_turn_blocked_reason` to check local readiness. **Undo end turn:** ```json { "action": "undo_end_turn" } ``` -Retract the end-turn vote before all players have committed. +Retract the end-turn vote before all players have committed. Use `battle.can_undo_end_turn` / `battle.undo_end_turn_blocked_reason` to check local readiness. All other actions work identically to singleplayer. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 01c7c0dd..3057e57f 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -213,5 +213,5 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `end_turn` | _(none)_ | Vote to end turn. Turn ends when all players vote. | -| `undo_end_turn` | _(none)_ | Retract end-turn vote (before all players committed). | +| `end_turn` | _(none)_ | Vote to end turn when battle state says `can_end_turn`. Turn ends when all players vote. | +| `undo_end_turn` | _(none)_ | Retract end-turn vote when battle state says `can_undo_end_turn` (before all players committed). | diff --git a/mcp/server.py b/mcp/server.py index 92c35af6..c896cf48 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -936,7 +936,8 @@ async def mp_combat_end_turn() -> str: """[Multiplayer Combat] Submit end-turn vote. In multiplayer, ending the turn is a VOTE — the turn only ends when ALL - players have submitted. Use mp_combat_undo_end_turn() to retract. + players have submitted. Use battle can_end_turn/end_turn_blocked_reason + to check local readiness. Use mp_combat_undo_end_turn() to retract. """ try: return await _mp_post({"action": "end_turn"}) @@ -949,6 +950,7 @@ async def mp_combat_undo_end_turn() -> str: """[Multiplayer Combat] Retract end-turn vote. If you submitted end turn but want to play more cards, use this to undo. + Use battle can_undo_end_turn/undo_end_turn_blocked_reason to check readiness. Only works if other players haven't all committed yet. """ try: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 78294771..6aefa145 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -590,6 +590,30 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in end_turn_action_body: fail(f"end_turn action missing readiness guard: {required_fragment}") + mp_battle_match = re.search( + r"private static Dictionary BuildMultiplayerBattleState\(.*?\n private static Dictionary BuildMultiplayerMapState\(", + multiplayer_state, + re.S, + ) + if not mp_battle_match: + fail("could not locate BuildMultiplayerBattleState for multiplayer end-turn audit") + mp_battle_body = mp_battle_match.group(0) + mp_actions = (repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8") + for required_fragment in [ + "local_player_ready_to_end_turn", + "can_end_turn", + "end_turn_blocked_reason", + "can_undo_end_turn", + "undo_end_turn_blocked_reason", + "GetMultiplayerEndTurnBlockedReason", + "GetMultiplayerUndoEndTurnBlockedReason", + ]: + if required_fragment not in mp_battle_body: + fail(f"multiplayer battle state missing end-turn readiness metadata: {required_fragment}") + for required_fragment in ["Already submitted end turn", "Not ready to end turn"]: + if required_fragment not in mp_actions: + fail(f"multiplayer end-turn action missing readiness guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 07bc7bddb7709e2be217cb88675e39cf02afba06 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:39:05 -0400 Subject: [PATCH 107/190] Expose reward claim readiness --- McpMod.Actions.cs | 2 +- McpMod.StateBuilder.cs | 11 +++++++---- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 +- mcp/server.py | 3 +++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 43b8a56f..c9f69f73 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -476,7 +476,7 @@ public static partial class McpMod int index = indexElem.GetInt32(); var enabledButtons = FindAll(rewardsScreen) - .Where(b => b.IsEnabled && b.Reward != null) + .Where(b => b.IsEnabled && b.Visible && b.IsVisibleInTree() && b.Reward != null) .ToList(); if (index < 0 || index >= enabledButtons.Count) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index ae9f2627..d88cbb0b 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1991,19 +1991,22 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var state = new Dictionary(); // Reward items - var rewardButtons = FindAll(rewardsScreen); + var rewardButtons = FindAll(rewardsScreen) + .Where(button => button.Reward != null && button.IsEnabled && button.Visible && button.IsVisibleInTree()) + .ToList(); var items = new List>(); int index = 0; foreach (var button in rewardButtons) { - if (button.Reward == null || !button.IsEnabled) continue; - var reward = button.Reward; + var reward = button.Reward!; var item = new Dictionary { ["index"] = index, ["type"] = GetRewardTypeName(reward), - ["description"] = SafeGetText(() => reward.Description) + ["description"] = SafeGetText(() => reward.Description), + ["is_visible"] = true, + ["can_claim"] = true }; // Type-specific details diff --git a/docs/raw-full.md b/docs/raw-full.md index d9c88b62..52d0f625 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -465,6 +465,8 @@ Appears after combat ends or when triggered by events (e.g. TheFutureOfPotions, "index": 0, "type": "gold", // gold, potion, relic, card, special_card, card_removal "description": "Obtain 25 gold.", + "is_visible": true, + "can_claim": true, "gold_amount": 25 // Only for gold rewards }, { diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 3057e57f..a3aa2c88 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -132,7 +132,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `claim_reward` | `index`: int | Claim a reward. Card rewards open the `card_reward` screen. | +| `claim_reward` | `index`: int | Claim a visible enabled reward. Card rewards open the `card_reward` screen. | | `proceed` | _(none)_ | Leave the rewards screen. | ### Card Reward (`card_reward`) diff --git a/mcp/server.py b/mcp/server.py index c896cf48..032635b3 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -577,6 +577,7 @@ async def rewards_claim(reward_index: int) -> str: Gold, potion, and relic rewards are claimed immediately. Card rewards open the card selection screen (state changes to card_reward). + Only visible enabled rewards are actionable. Args: reward_index: 0-based index of the reward on the rewards screen. @@ -1069,6 +1070,8 @@ async def mp_shop_purchase(item_index: int) -> str: async def mp_rewards_claim(reward_index: int) -> str: """[Multiplayer Rewards] Claim a reward from the post-combat rewards screen. + Only visible enabled rewards are actionable. + Args: reward_index: 0-based index of the reward. """ diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 6aefa145..5b0fc12a 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -614,6 +614,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in mp_actions: fail(f"multiplayer end-turn action missing readiness guard: {required_fragment}") + rewards_state_match = re.search( + r"private static Dictionary BuildRewardsState\(.*?\n private static RelicModel\? GetRelicRewardModel\(", + state_builder, + re.S, + ) + if not rewards_state_match: + fail("could not locate BuildRewardsState for rewards audit") + rewards_state_body = rewards_state_match.group(0) + rewards_action_match = re.search( + r"private static Dictionary ExecuteClaimReward\(.*?\n private static Dictionary ExecuteSelectCardReward\(", + actions, + re.S, + ) + if not rewards_action_match: + fail("could not locate ExecuteClaimReward for rewards audit") + rewards_action_body = rewards_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_claim"]: + if required_fragment not in rewards_state_body: + fail(f"rewards state missing visibility/claim metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "IsEnabled"]: + if required_fragment not in rewards_action_body: + fail(f"claim_reward action missing visibility/enabled guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 9ca2b82618452ff162c0889ec3e49e1bbd01d35c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:41:48 -0400 Subject: [PATCH 108/190] Expose map travel readiness --- McpMod.Actions.cs | 2 +- McpMod.StateBuilder.cs | 6 ++++-- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 +- mcp/server.py | 3 +++ scripts/audit_endpoints.py | 23 +++++++++++++++++++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index c9f69f73..6df4904d 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -444,7 +444,7 @@ public static partial class McpMod int index = indexElem.GetInt32(); var travelable = FindAll(mapScreen) - .Where(mp => mp.State == MapPointState.Travelable && mp.Point != null) + .Where(mp => mp.State == MapPointState.Travelable && mp.Point != null && mp.Visible && mp.IsVisibleInTree()) .OrderBy(mp => mp.Point!.coord.col) .ToList(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index d88cbb0b..63e44c73 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1913,7 +1913,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con if (mapScreen != null) { var travelable = FindAll(mapScreen) - .Where(mp => mp.State == MapPointState.Travelable && mp.Point != null) + .Where(mp => mp.State == MapPointState.Travelable && mp.Point != null && mp.Visible && mp.IsVisibleInTree()) .OrderBy(mp => mp.Point!.coord.col) .ToList(); @@ -1926,7 +1926,9 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["index"] = index, ["col"] = pt.coord.col, ["row"] = pt.coord.row, - ["type"] = pt.PointType.ToString() + ["type"] = pt.PointType.ToString(), + ["is_visible"] = true, + ["can_travel"] = true }; // 1-level lookahead diff --git a/docs/raw-full.md b/docs/raw-full.md index 52d0f625..e273b715 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -556,6 +556,8 @@ Pick one card to add to your deck. Appears after claiming a card reward, or dire "index": 0, "col": 2, "row": 3, "type": "RestSite", + "is_visible": true, + "can_travel": true, "leads_to": [ // 1-level lookahead (children) { "col": 1, "row": 4, "type": "Elite" }, { "col": 3, "row": 4, "type": "Shop" } diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index a3aa2c88..747af75c 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -146,7 +146,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `choose_map_node` | `index`: int | Travel to a node from `next_options`. | +| `choose_map_node` | `index`: int | Travel to a visible travelable node from `next_options`. | ### Event (`event`) diff --git a/mcp/server.py b/mcp/server.py index 032635b3..ac2172e7 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -622,6 +622,8 @@ async def rewards_skip_card() -> str: async def map_choose_node(node_index: int) -> str: """[Map] Choose a map node to travel to. + Only visible travelable nodes from next_options are actionable. + Args: node_index: 0-based index of the node from the next_options list. """ @@ -998,6 +1000,7 @@ async def mp_map_vote(node_index: int) -> str: In multiplayer, map selection is a vote — travel happens when all players agree. Re-voting for the same node sends a ping to other players. + Only visible travelable nodes from next_options are actionable. Args: node_index: 0-based index of the node from the next_options list. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 5b0fc12a..f80eb8a4 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -637,6 +637,29 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in rewards_action_body: fail(f"claim_reward action missing visibility/enabled guard: {required_fragment}") + map_state_match = re.search( + r"private static Dictionary BuildMapState\(.*?\n private static Dictionary BuildMapNode\(", + state_builder, + re.S, + ) + if not map_state_match: + fail("could not locate BuildMapState for map audit") + map_state_body = map_state_match.group(0) + map_action_match = re.search( + r"private static Dictionary ExecuteChooseMapNode\(.*?\n private static Dictionary ExecuteClaimReward\(", + actions, + re.S, + ) + if not map_action_match: + fail("could not locate ExecuteChooseMapNode for map audit") + map_action_body = map_action_match.group(0) + for required_fragment in ["IsVisibleInTree", "is_visible", "can_travel"]: + if required_fragment not in map_state_body: + fail(f"map next_options missing visibility/travel metadata: {required_fragment}") + for required_fragment in ["IsVisibleInTree", "MapPointState.Travelable"]: + if required_fragment not in map_action_body: + fail(f"choose_map_node action missing visibility/travel guard: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From e6eab0cb1ff8998e42591a672ce7b49be9b4cb84 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:44:21 -0400 Subject: [PATCH 109/190] Expose potion discard readiness --- McpMod.Actions.cs | 2 ++ McpMod.StateBuilder.cs | 2 ++ docs/raw-full.md | 4 +++- docs/raw-simplified.md | 2 +- mcp/server.py | 4 +++- scripts/audit_endpoints.py | 19 ++++++++++++++++++- 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 6df4904d..2524e951 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -258,6 +258,8 @@ public static partial class McpMod var potion = player.GetPotionAtSlotIndex(slot); if (potion == null) return Error($"No potion in slot {slot}"); + if (potion.IsQueued) + return Error($"Potion '{SafeGetText(() => potion.Title)}' is already queued and cannot be discarded"); string potionName = SafeGetText(() => potion.Title) ?? "unknown"; _ = PotionCmd.Discard(potion); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 63e44c73..9c15e1a2 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1239,6 +1239,8 @@ private static void AddMultiplayerLoadLobbyMenuState( ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, ["can_use"] = blockedReason == null, ["use_blocked_reason"] = blockedReason, + ["can_discard"] = !potion.IsQueued, + ["discard_blocked_reason"] = potion.IsQueued ? "AlreadyQueued" : null, ["requires_target"] = potion.TargetType == TargetType.AnyEnemy, ["valid_targets"] = validTargets, ["target_type"] = potion.TargetType.ToString(), diff --git a/docs/raw-full.md b/docs/raw-full.md index e273b715..5e533790 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -111,6 +111,8 @@ Always present at the top level (except `menu`). Contains everything about the l "can_use_in_combat": true, "can_use": true, // true when use_potion can run now, assuming any required target is supplied "use_blocked_reason": null, // e.g. "CombatOnly", "Automatic", "AlreadyQueued", "PlayerDead", "CustomUsabilityCheckFailed", "NotInPlayPhase", "PlayerActionsDisabled", "NoValidTargets" + "can_discard": true, + "discard_blocked_reason": null, // e.g. "AlreadyQueued" "requires_target": false, "valid_targets": [], // For AnyEnemy potions: [{ "entity_id": "JAW_WORM_0", "combat_id": 12, "name": "Jaw Worm" }] "target_type": "None", // None, Self, AnyEnemy, AnyAlly, AnyPlayer, etc. @@ -1214,7 +1216,7 @@ Discard a potion to free up the slot. Use when potion slots are full and you nee |---|---|---|---| | `slot` | int | Yes | Potion slot index | -**Errors:** Empty slot, out-of-range slot. +**Errors:** Empty slot, out-of-range slot, potion already queued. ### `end_turn` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 747af75c..8b5d0d8a 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -118,7 +118,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope |---|---|---| | `play_card` | `card_index`: int, `target`?: string | Play a card from hand. `target` is an `entity_id` (e.g. `"JAW_WORM_0"`), required for single-target cards. | | `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use one of `valid_targets`. Works outside combat for non-combat-only, non-enemy-targeting potions. | -| `discard_potion` | `slot`: int | Discard a potion to free up the slot. Use when slots are full and you need room for incoming potions. | +| `discard_potion` | `slot`: int | Discard a potion when its state says `can_discard`. Use when slots are full and you need room for incoming potions. | | `end_turn` | _(none)_ | End the player's turn when battle state says `can_end_turn`. | ### In-Combat Hand Selection (`hand_select`) diff --git a/mcp/server.py b/mcp/server.py index ac2172e7..d49bb5bd 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -474,7 +474,7 @@ async def discard_potion(slot: int) -> str: """Discard a potion from the player's potion slots to free up space. Use this when all potion slots are full and you need room for incoming potions - (e.g. before collecting a potion reward). + (e.g. before collecting a potion reward). Queued potions cannot be discarded. Args: slot: Potion slot index to discard (as shown in game state). @@ -985,6 +985,8 @@ async def mp_use_potion(slot: int, target: str | None = None) -> str: async def mp_discard_potion(slot: int) -> str: """[Multiplayer] Discard a potion from the local player's potion slots to free up space. + Queued potions cannot be discarded. + Args: slot: Potion slot index to discard (as shown in game state). """ diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f80eb8a4..4258e163 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -560,12 +560,29 @@ def audit_state_surface(repo: Path) -> None: if not potion_action_match: fail("could not locate ExecuteUsePotion for potion audit") potion_action_body = potion_action_match.group(0) - for required_fragment in ["can_use", "use_blocked_reason", "requires_target", "valid_targets", "GetPotionUseBlockedReason"]: + for required_fragment in [ + "can_use", + "use_blocked_reason", + "can_discard", + "discard_blocked_reason", + "requires_target", + "valid_targets", + "GetPotionUseBlockedReason", + ]: if required_fragment not in player_state_body: fail(f"potion state missing use readiness metadata: {required_fragment}") for required_fragment in ["IsQueued", "PassesCustomUsabilityCheck", "PlayerActionsDisabled", "Enemy-targeted potions can only be used in combat"]: if required_fragment not in potion_action_body: fail(f"use_potion action missing readiness guard: {required_fragment}") + discard_action_match = re.search( + r"private static Dictionary ExecuteDiscardPotion\(.*?\n private static Dictionary ExecuteChooseMapNode\(", + actions, + re.S, + ) + if not discard_action_match: + fail("could not locate ExecuteDiscardPotion for potion audit") + if "IsQueued" not in discard_action_match.group(0): + fail("discard_potion action missing queued guard") battle_state_match = re.search( r"private static Dictionary BuildBattleState\(.*?\n private static Dictionary BuildPlayerState\(", From f80ec8ca642d8984b8dd4c1a9c6caf46380b540a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:46:18 -0400 Subject: [PATCH 110/190] Expose enemy targetability state --- McpMod.StateBuilder.cs | 4 ++++ docs/raw-full.md | 2 ++ scripts/audit_endpoints.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 9c15e1a2..13c238d2 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1495,6 +1495,10 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["hp"] = creature.CurrentHp, ["max_hp"] = creature.MaxHp, ["block"] = creature.Block, + ["is_alive"] = creature.IsAlive, + ["is_visible"] = creature.IsAlive, + ["can_target"] = creature.IsAlive, + ["can_select"] = creature.IsAlive, ["status"] = BuildPowersState(creature) }; diff --git a/docs/raw-full.md b/docs/raw-full.md index 5e533790..f179ad76 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -391,6 +391,7 @@ Run state or room type not recognized. "hp": 44, "max_hp": 44, "block": 0, + "is_alive": true, "status": [ /* Power Objects */ ], "intents": [ { @@ -401,6 +402,7 @@ Run state or room type not recognized. } ], "is_visible": true, + "can_target": true, "can_select": true } ] diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4258e163..7f84dc0f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -677,6 +677,21 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in map_action_body: fail(f"choose_map_node action missing visibility/travel guard: {required_fragment}") + enemy_state_match = re.search( + r"private static Dictionary BuildEnemyState\(.*?\n private static Dictionary BuildEventState\(", + state_builder, + re.S, + ) + if not enemy_state_match: + fail("could not locate BuildEnemyState for enemy target audit") + enemy_state_body = enemy_state_match.group(0) + docs_raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + for required_fragment in ["is_alive", "is_visible", "can_target", "can_select"]: + if required_fragment not in enemy_state_body: + fail(f"enemy state missing targetability metadata: {required_fragment}") + if required_fragment not in docs_raw_full: + fail(f"docs missing enemy targetability metadata: {required_fragment}") + print(f"states: {len(state_types)} documented, markdown coverage enforced") From 2ba30d1d50183fae0032f1dd691d444556a125be Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:50:36 -0400 Subject: [PATCH 111/190] Refine enemy-targeted potion readiness --- McpMod.StateBuilder.cs | 3 +++ docs/raw-full.md | 2 +- scripts/audit_endpoints.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 13c238d2..16011e3a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1283,6 +1283,9 @@ private static void AddMultiplayerLoadLobbyMenuState( if (inCombat && CombatManager.Instance.PlayerActionsDisabled) return "PlayerActionsDisabled"; + if (potion.TargetType == TargetType.AnyEnemy && !inCombat) + return "EnemyTargetRequiresCombat"; + if (potion.TargetType == TargetType.AnyEnemy && validTargets.Count == 0) return "NoValidTargets"; diff --git a/docs/raw-full.md b/docs/raw-full.md index f179ad76..e589b377 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -110,7 +110,7 @@ Always present at the top level (except `menu`). Contains everything about the l "slot": 0, // Potion slot index (use this for use_potion action) "can_use_in_combat": true, "can_use": true, // true when use_potion can run now, assuming any required target is supplied - "use_blocked_reason": null, // e.g. "CombatOnly", "Automatic", "AlreadyQueued", "PlayerDead", "CustomUsabilityCheckFailed", "NotInPlayPhase", "PlayerActionsDisabled", "NoValidTargets" + "use_blocked_reason": null, // e.g. "CombatOnly", "EnemyTargetRequiresCombat", "Automatic", "AlreadyQueued", "PlayerDead", "CustomUsabilityCheckFailed", "NotInPlayPhase", "PlayerActionsDisabled", "NoValidTargets" "can_discard": true, "discard_blocked_reason": null, // e.g. "AlreadyQueued" "requires_target": false, diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 7f84dc0f..2e0efc40 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -568,6 +568,7 @@ def audit_state_surface(repo: Path) -> None: "requires_target", "valid_targets", "GetPotionUseBlockedReason", + "EnemyTargetRequiresCombat", ]: if required_fragment not in player_state_body: fail(f"potion state missing use readiness metadata: {required_fragment}") From edcfe995ea9ece2bc2d5ecea26b55803409ec5f1 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 08:59:54 -0400 Subject: [PATCH 112/190] Harden selection action readiness guards --- McpMod.Actions.cs | 50 +++++++++++++++++++------- McpMod.StateBuilder.cs | 34 ++++++++++-------- docs/raw-full.md | 20 +++++------ docs/raw-simplified.md | 14 ++++---- scripts/audit_endpoints.py | 73 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 44 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 2524e951..4c438676 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -645,11 +645,18 @@ public static partial class McpMod if (grid == null) return Error("Card grid not found in selection screen"); - var holders = FindAllSortedByPosition(gridScreen); + var holders = FindAllSortedByPosition(gridScreen) + .Where(holder => holder.CardModel != null) + .ToList(); if (index < 0 || index >= holders.Count) return Error($"Card index {index} out of range ({holders.Count} cards available)"); var holder = holders[index]; + if (holder.CardModel == null) + return Error($"Card index {index} does not contain a card"); + if (!IsCardHolderSelectable(holder)) + return Error($"Card index {index} is not selectable"); + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; grid.EmitSignal(NCardGrid.SignalName.HolderPressed, holder); @@ -661,11 +668,18 @@ public static partial class McpMod } else if (overlay is NChooseACardSelectionScreen chooseScreen) { - var holders = FindAllSortedByPosition(chooseScreen); + var holders = FindAllSortedByPosition(chooseScreen) + .Where(holder => holder.CardModel != null) + .ToList(); if (index < 0 || index >= holders.Count) return Error($"Card index {index} out of range ({holders.Count} cards available)"); var holder = holders[index]; + if (holder.CardModel == null) + return Error($"Card index {index} does not contain a card"); + if (!IsCardHolderSelectable(holder)) + return Error($"Card index {index} is not selectable"); + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); @@ -696,7 +710,7 @@ public static partial class McpMod { var confirm = container.GetNodeOrNull("Confirm") ?? container.GetNodeOrNull("%PreviewConfirm"); - if (confirm is { IsEnabled: true }) + if (IsControlVisibleOrActionable(confirm)) { confirm.ForceClick(); return new Dictionary @@ -711,7 +725,7 @@ public static partial class McpMod // Try main confirm button var mainConfirm = screen.GetNodeOrNull("Confirm") ?? screen.GetNodeOrNull("%Confirm"); - if (mainConfirm is { IsEnabled: true }) + if (IsControlVisibleOrActionable(mainConfirm)) { mainConfirm.ForceClick(); return new Dictionary @@ -727,7 +741,7 @@ public static partial class McpMod var allConfirmButtons = FindAll(screen); foreach (var btn in allConfirmButtons) { - if (btn.IsEnabled && btn.IsVisibleInTree()) + if (IsControlVisibleOrActionable(btn)) { btn.ForceClick(); return new Dictionary @@ -749,7 +763,7 @@ public static partial class McpMod if (overlay is NChooseACardSelectionScreen chooseScreen) { var skipButton = chooseScreen.GetNodeOrNull("SkipButton"); - if (skipButton is { IsEnabled: true }) + if (IsControlVisibleOrActionable(skipButton)) { skipButton.ForceClick(); return new Dictionary @@ -772,7 +786,7 @@ public static partial class McpMod { var cancelBtn = container.GetNodeOrNull("Cancel") ?? container.GetNodeOrNull("%PreviewCancel"); - if (cancelBtn is { IsEnabled: true }) + if (IsControlVisibleOrActionable(cancelBtn)) { cancelBtn.ForceClick(); return new Dictionary @@ -786,7 +800,7 @@ public static partial class McpMod // Close the screen entirely var closeButton = screen.GetNodeOrNull("%Close"); - if (closeButton is { IsEnabled: true }) + if (IsControlVisibleOrActionable(closeButton)) { closeButton.ForceClick(); return new Dictionary @@ -814,7 +828,10 @@ public static partial class McpMod return Error("A bundle preview is already open - confirm or cancel it first"); var bundles = FindAll(screen) - .Where(bundle => bundle.Visible && bundle.IsVisibleInTree() && bundle.Hitbox.IsEnabled) + .Where(bundle => bundle.Visible + && bundle.IsVisibleInTree() + && bundle.Hitbox.IsEnabled + && IsNodeVisible(bundle.Hitbox)) .ToList(); if (index < 0 || index >= bundles.Count) return Error($"Bundle index {index} out of range ({bundles.Count} bundles available)"); @@ -834,7 +851,7 @@ public static partial class McpMod return Error("No bundle selection screen is open"); var confirmButton = screen.GetNodeOrNull("%Confirm"); - if (confirmButton is not { IsEnabled: true }) + if (!IsControlVisibleOrActionable(confirmButton)) return Error("Bundle confirm button is not enabled"); confirmButton.ForceClick(); @@ -852,7 +869,7 @@ public static partial class McpMod return Error("No bundle selection screen is open"); var cancelButton = screen.GetNodeOrNull("%Cancel"); - if (cancelButton is not { IsEnabled: true }) + if (!IsControlVisibleOrActionable(cancelButton)) return Error("Bundle cancel button is not enabled"); cancelButton.ForceClick(); @@ -873,11 +890,18 @@ public static partial class McpMod return Error("Missing 'card_index' (index of the card in hand)"); int index = indexElem.GetInt32(); - var holders = hand.ActiveHolders; + var holders = hand.ActiveHolders + .Where(holder => holder.CardModel != null) + .ToList(); if (index < 0 || index >= holders.Count) return Error($"Card index {index} out of range ({holders.Count} selectable cards)"); var holder = holders[index]; + if (holder.CardModel == null) + return Error($"Card index {index} does not contain a card"); + if (!IsCardHolderSelectable(holder)) + return Error($"Card index {index} is not selectable"); + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; // Emit the Pressed signal - same path the game UI uses @@ -897,7 +921,7 @@ public static partial class McpMod return Error("No in-combat card selection is active"); var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); - if (confirmBtn == null || !confirmBtn.IsEnabled) + if (!IsControlVisibleOrActionable(confirmBtn)) return Error("Confirm button is not enabled - select more cards first"); confirmBtn.ForceClick(); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 16011e3a..9e57b284 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1472,11 +1472,16 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { var isSelected = GetBoolMemberValue(holder, "IsSelected") ?? GetBoolMemberValue(holder, "_isSelected"); - var isEnabled = GetBoolMemberValue(holder, "IsEnabled"); cardInfo["is_selected"] = isSelected ?? false; - cardInfo["is_visible"] = holder.Visible && holder.IsVisibleInTree(); - cardInfo["can_select"] = (isEnabled ?? true) && holder.Visible && holder.IsVisibleInTree(); + cardInfo["is_visible"] = IsNodeVisible(holder); + cardInfo["can_select"] = IsCardHolderSelectable(holder); + } + + private static bool IsCardHolderSelectable(Control holder) + { + var isEnabled = GetBoolMemberValue(holder, "IsEnabled"); + return (isEnabled ?? true) && IsNodeVisible(holder); } private static Dictionary BuildEnemyState(Creature creature, Dictionary entityCounts) @@ -2199,14 +2204,14 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { var cancelBtn = container.GetNodeOrNull("Cancel") ?? container.GetNodeOrNull("%PreviewCancel"); - if (cancelBtn?.IsEnabled == true) { canCancel = true; break; } + if (IsControlVisibleOrActionable(cancelBtn)) { canCancel = true; break; } } } } if (!canCancel) { var closeButton = screen.GetNodeOrNull("%Close"); - canCancel = closeButton?.IsEnabled ?? false; + canCancel = IsControlVisibleOrActionable(closeButton); } state["can_cancel"] = canCancel; @@ -2218,20 +2223,20 @@ private static void AddCardHolderState(Dictionary cardInfo, Con { var confirm = container.GetNodeOrNull("Confirm") ?? container.GetNodeOrNull("%PreviewConfirm"); - if (confirm?.IsEnabled == true) { canConfirm = true; break; } + if (IsControlVisibleOrActionable(confirm)) { canConfirm = true; break; } } } if (!canConfirm) { var mainConfirm = screen.GetNodeOrNull("Confirm") ?? screen.GetNodeOrNull("%Confirm"); - if (mainConfirm?.IsEnabled == true) canConfirm = true; + if (IsControlVisibleOrActionable(mainConfirm)) canConfirm = true; } // Fallback: search entire screen tree for any enabled confirm button // (covers subclasses like NDeckEnchantSelectScreen) if (!canConfirm) { - canConfirm = FindAll(screen).Any(b => b.IsEnabled && b.IsVisibleInTree()); + canConfirm = FindAll(screen).Any(IsControlVisibleOrActionable); } state["can_confirm"] = canConfirm; @@ -2262,7 +2267,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con state["cards"] = cards; var skipButton = screen.GetNodeOrNull("SkipButton"); - state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + state["can_skip"] = IsControlVisibleOrActionable(skipButton); state["preview_showing"] = false; state["can_confirm"] = false; state["can_cancel"] = state["can_skip"]; @@ -2298,7 +2303,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con ["card_count"] = cards.Count, ["cards"] = cards, ["is_visible"] = true, - ["can_select"] = bundle.Hitbox.IsEnabled + ["can_select"] = bundle.Hitbox.IsEnabled && IsNodeVisible(bundle.Hitbox) }); index++; } @@ -2313,7 +2318,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con if (previewCardsContainer != null) { int previewIndex = 0; - foreach (var holder in FindAll(previewCardsContainer)) + foreach (var holder in FindAll(previewCardsContainer) + .Where(holder => IsNodeVisible(holder))) { var card = holder.CardModel; if (card == null) continue; @@ -2328,8 +2334,8 @@ private static void AddCardHolderState(Dictionary cardInfo, Con var cancelButton = screen.GetNodeOrNull("%Cancel"); var confirmButton = screen.GetNodeOrNull("%Confirm"); - state["can_cancel"] = cancelButton?.IsEnabled == true; - state["can_confirm"] = confirmButton?.IsEnabled == true; + state["can_cancel"] = IsControlVisibleOrActionable(cancelButton); + state["can_confirm"] = IsControlVisibleOrActionable(confirmButton); return state; } @@ -2397,7 +2403,7 @@ private static void AddCardHolderState(Dictionary cardInfo, Con // Confirm button state var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); - state["can_confirm"] = confirmBtn?.IsEnabled ?? false; + state["can_confirm"] = IsControlVisibleOrActionable(confirmBtn); return state; } diff --git a/docs/raw-full.md b/docs/raw-full.md index e589b377..035adafa 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1240,11 +1240,11 @@ Select a card during in-combat hand selection (exhaust, discard, upgrade prompts | Parameter | Type | Required | Description | |---|---|---|---| -| `card_index` | int | Yes | Index in the selectable hand cards | +| `card_index` | int | Yes | Index in the hand-selection cards; the card must be visible and report `can_select: true` | ### `combat_confirm_selection` -Confirm the in-combat hand card selection. +Confirm the in-combat hand card selection when the visible confirm button is enabled. ```json { "action": "combat_confirm_selection" } @@ -1370,7 +1370,7 @@ Select a card in a card selection overlay. | Parameter | Type | Required | Description | |---|---|---|---| -| `index` | int | Yes | 0-based card index in the grid | +| `index` | int | Yes | 0-based card index in the grid; the card must be visible and report `can_select: true` | **Behavior varies by screen type:** - Grid screens (transform, upgrade, select): toggles selection. When enough cards are selected, a preview may appear. @@ -1384,7 +1384,7 @@ Confirm card selection (grid screens only). { "action": "confirm_selection" } ``` -Checks preview containers first, then main confirm button. Not needed for choose-a-card screens. +Checks visible enabled preview containers first, then the visible enabled main confirm button. Not needed for choose-a-card screens. ### `cancel_selection` @@ -1395,9 +1395,9 @@ Cancel or close the card selection overlay. ``` **Behavior:** -- If a preview is showing: cancels back to the selection grid. -- For choose-a-card screens: clicks the skip button (if available). -- Otherwise: closes the selection screen (if cancellation is allowed). +- If a preview is showing: cancels back to the selection grid when the visible cancel button is enabled. +- For choose-a-card screens: clicks the visible skip button when it is enabled. +- Otherwise: closes the selection screen when the visible close button is enabled. ### `select_bundle` @@ -1409,11 +1409,11 @@ Open a bundle preview in the bundle selection screen. | Parameter | Type | Required | Description | |---|---|---|---| -| `index` | int | Yes | 0-based bundle index | +| `index` | int | Yes | 0-based bundle index; the bundle must be visible and report `can_select: true` | ### `confirm_bundle_selection` -Confirm the currently previewed bundle. +Confirm the currently previewed bundle when the visible confirm button is enabled. ```json { "action": "confirm_bundle_selection" } @@ -1421,7 +1421,7 @@ Confirm the currently previewed bundle. ### `cancel_bundle_selection` -Cancel the bundle preview. +Cancel the bundle preview when the visible cancel button is enabled. ```json { "action": "cancel_bundle_selection" } diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 8b5d0d8a..c920d9f2 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -125,8 +125,8 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `combat_select_card` | `card_index`: int | Select/deselect a card during "choose a card to exhaust/discard" prompts. | -| `combat_confirm_selection` | _(none)_ | Confirm the hand card selection. | +| `combat_select_card` | `card_index`: int | Select/deselect a visible hand card whose `can_select` is true during selection prompts. | +| `combat_confirm_selection` | _(none)_ | Confirm the hand card selection when the visible confirm button is enabled. | ### Rewards (`rewards`) @@ -180,17 +180,17 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `select_card` | `index`: int | Grid screens: toggle card selection unless a preview is open. Choose-a-card: pick immediately. | -| `confirm_selection` | _(none)_ | Confirm (for grid screens with preview). Not needed for choose-a-card. | -| `cancel_selection` | _(none)_ | Cancel preview, skip (choose-a-card), or close screen. | +| `select_card` | `index`: int | Grid screens: toggle a visible card whose `can_select` is true unless a preview is open. Choose-a-card: pick immediately when `can_select` is true. | +| `confirm_selection` | _(none)_ | Confirm only when the visible confirm button is enabled. Not needed for choose-a-card. | +| `cancel_selection` | _(none)_ | Cancel preview, skip (choose-a-card), or close screen when the visible cancel/skip button is enabled. | ### Bundle Selection Overlay (`bundle_select`) | Action | Parameters | When to Use | |---|---|---| | `select_bundle` | `index`: int | Open a visible enabled bundle preview. | -| `confirm_bundle_selection` | _(none)_ | Confirm the previewed bundle. | -| `cancel_bundle_selection` | _(none)_ | Cancel the bundle preview. | +| `confirm_bundle_selection` | _(none)_ | Confirm the previewed bundle when the visible confirm button is enabled. | +| `cancel_bundle_selection` | _(none)_ | Cancel the bundle preview when the visible cancel button is enabled. | ### Relic Selection Overlay (`relic_select`) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 2e0efc40..ebd88585 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -384,6 +384,29 @@ def audit_state_surface(repo: Path) -> None: select_action_body = select_action_match.group(0) if "preview is already open" not in select_action_body or "%PreviewContainer" not in select_action_body: fail("select_card must reject grid selection while a preview is open") + for required_fragment in ["IsCardHolderSelectable", "does not contain a card", "is not selectable"]: + if required_fragment not in select_action_body: + fail(f"select_card action missing selectable-card guard: {required_fragment}") + confirm_action_match = re.search( + r"private static Dictionary ExecuteConfirmSelection\(.*?\n private static Dictionary ExecuteCancelSelection\(", + actions, + re.S, + ) + if not confirm_action_match: + fail("could not locate ExecuteConfirmSelection for visible button audit") + confirm_action_body = confirm_action_match.group(0) + if "IsControlVisibleOrActionable" not in confirm_action_body: + fail("confirm_selection action must require visible enabled buttons") + cancel_action_match = re.search( + r"private static Dictionary ExecuteCancelSelection\(.*?\n private static Dictionary ExecuteSelectBundle\(", + actions, + re.S, + ) + if not cancel_action_match: + fail("could not locate ExecuteCancelSelection for visible button audit") + cancel_action_body = cancel_action_match.group(0) + if "IsControlVisibleOrActionable" not in cancel_action_body: + fail("cancel_selection action must require visible enabled buttons") card_reward_match = re.search( r"private static Dictionary BuildCardRewardState\(.*?\n private static Dictionary BuildCardSelectState\(", @@ -499,6 +522,56 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["IsVisibleInTree", "Hitbox.IsEnabled"]: if required_fragment not in bundle_action_body: fail(f"bundle_select action missing visibility/enabled guard: {required_fragment}") + bundle_confirm_match = re.search( + r"private static Dictionary ExecuteConfirmBundleSelection\(.*?\n private static Dictionary ExecuteCancelBundleSelection\(", + actions, + re.S, + ) + if not bundle_confirm_match: + fail("could not locate ExecuteConfirmBundleSelection for visible button audit") + if "IsControlVisibleOrActionable" not in bundle_confirm_match.group(0): + fail("confirm_bundle_selection action must require a visible enabled button") + bundle_cancel_match = re.search( + r"private static Dictionary ExecuteCancelBundleSelection\(.*?\n private static Dictionary ExecuteCombatSelectCard\(", + actions, + re.S, + ) + if not bundle_cancel_match: + fail("could not locate ExecuteCancelBundleSelection for visible button audit") + if "IsControlVisibleOrActionable" not in bundle_cancel_match.group(0): + fail("cancel_bundle_selection action must require a visible enabled button") + + hand_state_match = re.search( + r"private static Dictionary BuildHandSelectState\(.*?\n private static Dictionary BuildRelicSelectState\(", + state_builder, + re.S, + ) + if not hand_state_match: + fail("could not locate BuildHandSelectState for hand-select audit") + hand_state_body = hand_state_match.group(0) + for required_fragment in ["AddCardHolderState", "IsControlVisibleOrActionable"]: + if required_fragment not in hand_state_body: + fail(f"hand_select state missing selectable/confirm metadata: {required_fragment}") + hand_action_match = re.search( + r"private static Dictionary ExecuteCombatSelectCard\(.*?\n private static Dictionary ExecuteCombatConfirmSelection\(", + actions, + re.S, + ) + if not hand_action_match: + fail("could not locate ExecuteCombatSelectCard for hand-select audit") + hand_action_body = hand_action_match.group(0) + for required_fragment in ["IsCardHolderSelectable", "does not contain a card", "is not selectable"]: + if required_fragment not in hand_action_body: + fail(f"combat_select_card action missing selectable-card guard: {required_fragment}") + hand_confirm_match = re.search( + r"private static Dictionary ExecuteCombatConfirmSelection\(.*?\n private static Dictionary ExecuteSelectRelic\(", + actions, + re.S, + ) + if not hand_confirm_match: + fail("could not locate ExecuteCombatConfirmSelection for hand-select audit") + if "IsControlVisibleOrActionable" not in hand_confirm_match.group(0): + fail("combat_confirm_selection action must require a visible enabled button") treasure_state_match = re.search( r"private static Dictionary BuildTreasureState\(.*?\n private static string GetRewardTypeName\(", From 39e4bcd34eb557770bd83005521b809569880b25 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:05:07 -0400 Subject: [PATCH 113/190] Gate proceed actions on visible controls --- McpMod.Actions.cs | 14 +++++++------- McpMod.StateBuilder.cs | 16 ++++++++-------- docs/raw-full.md | 2 +- docs/raw-simplified.md | 6 +++--- scripts/audit_endpoints.py | 34 ++++++++++++++++++++++++++++++++-- 5 files changed, 51 insertions(+), 21 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 4c438676..ccedcdf1 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -559,7 +559,7 @@ public static partial class McpMod if (overlay is NRewardsScreen rewardsScreen) { var btn = FindFirst(rewardsScreen); - if (btn is { IsEnabled: true }) + if (btn != null && IsControlVisibleOrActionable(btn)) { btn.ForceClick(); return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rewards" }; @@ -567,7 +567,7 @@ public static partial class McpMod } // Try rest site - if (NRestSiteRoom.Instance is { } restRoom && restRoom.ProceedButton.IsEnabled) + if (NRestSiteRoom.Instance is { } restRoom && IsControlVisibleOrActionable(restRoom.ProceedButton)) { restRoom.ProceedButton.ForceClick(); return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rest site" }; @@ -579,10 +579,10 @@ public static partial class McpMod if (merchRoom.Inventory?.IsOpen == true) { var backBtn = FindFirst(merchRoom); - if (backBtn is { IsEnabled: true }) + if (backBtn != null && IsControlVisibleOrActionable(backBtn)) backBtn.ForceClick(); } - if (merchRoom.ProceedButton.IsEnabled) + if (IsControlVisibleOrActionable(merchRoom.ProceedButton)) { merchRoom.ProceedButton.ForceClick(); return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from shop" }; @@ -599,11 +599,11 @@ public static partial class McpMod if (fmInventory is { IsOpen: true }) { var backBtn = FindFirst(fmNode); - if (backBtn is { IsEnabled: true }) + if (backBtn != null && IsControlVisibleOrActionable(backBtn)) backBtn.ForceClick(); } var proceedBtn = FindFirst(fmNode); - if (proceedBtn is { IsEnabled: true }) + if (proceedBtn != null && IsControlVisibleOrActionable(proceedBtn)) { proceedBtn.ForceClick(); return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from fake merchant" }; @@ -614,7 +614,7 @@ public static partial class McpMod // Try treasure room var treasureUI = FindFirst( ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); - if (treasureUI != null && treasureUI.ProceedButton.IsEnabled) + if (treasureUI != null && IsControlVisibleOrActionable(treasureUI.ProceedButton)) { treasureUI.ProceedButton.ForceClick(); return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from treasure room" }; diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 9e57b284..17af25fe 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1665,10 +1665,10 @@ private static bool IsCardHolderSelectable(Control holder) var backButton = FindFirst(fakeMerchantNode); var proceedButton = FindFirst(fakeMerchantNode); var inventoryOpen = inventoryUI?.IsOpen == true; - var canCloseInventory = inventoryOpen && backButton?.IsEnabled == true; + var canCloseInventory = inventoryOpen && IsControlVisibleOrActionable(backButton); shopState["inventory_open"] = inventoryOpen; shopState["can_close_inventory"] = canCloseInventory; - shopState["can_proceed"] = proceedButton?.IsEnabled == true || canCloseInventory; + shopState["can_proceed"] = IsControlVisibleOrActionable(proceedButton) || canCloseInventory; } else { @@ -1753,7 +1753,7 @@ private static bool IsCardHolderSelectable(Control holder) state["options"] = options; var proceedButton = NRestSiteRoom.Instance?.ProceedButton; - state["can_proceed"] = proceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton); return state; } @@ -1766,7 +1766,7 @@ private static bool IsCardHolderSelectable(Control holder) if (inventory == null) { state["items"] = new List>(); - state["can_proceed"] = NMerchantRoom.Instance?.ProceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(NMerchantRoom.Instance?.ProceedButton); state["error"] = "Shop inventory is not ready yet (null). Often happens right after entering the merchant from the map; retry in a moment."; return state; @@ -1881,12 +1881,12 @@ private static bool IsCardHolderSelectable(Control holder) var merchantRoomNode = NMerchantRoom.Instance; var inventoryOpen = merchantRoomNode?.Inventory?.IsOpen == true; var backButton = merchantRoomNode != null ? FindFirst(merchantRoomNode) : null; - var canCloseInventory = inventoryOpen && backButton?.IsEnabled == true; + var canCloseInventory = inventoryOpen && IsControlVisibleOrActionable(backButton); state["inventory_open"] = inventoryOpen; state["can_close_inventory"] = canCloseInventory; var proceedButton = merchantRoomNode?.ProceedButton; - state["can_proceed"] = proceedButton?.IsEnabled == true || canCloseInventory; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton) || canCloseInventory; return state; } @@ -2054,7 +2054,7 @@ private static bool IsCardHolderSelectable(Control holder) // Proceed button var proceedButton = FindFirst(rewardsScreen); - state["can_proceed"] = proceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton); return state; } @@ -2598,7 +2598,7 @@ private static bool IsCardHolderSelectable(Control holder) state["relics"] = relics; } - state["can_proceed"] = treasureUI.ProceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(treasureUI.ProceedButton); return state; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 035adafa..fe2b8e36 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1290,7 +1290,7 @@ Skip the card reward (if a skip/bowl option is available). ### `proceed` -Leave the current screen and open the map. Works from: rewards, rest site, shop, fake merchant, and treasure room. +Leave the current screen and open the map when a visible proceed button is enabled. Works from: rewards, rest site, shop, fake merchant, and treasure room; shop/fake merchant can close an open inventory first when the visible back button is enabled. ```json { "action": "proceed" } diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c920d9f2..85256180 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -160,21 +160,21 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| | `choose_rest_option` | `index`: int | Choose a visible enabled rest, smith, or other option. | -| `proceed` | _(none)_ | Leave the rest site. | +| `proceed` | _(none)_ | Leave the rest site when the visible proceed button is enabled. | ### Shop (`shop`) | Action | Parameters | When to Use | |---|---|---| | `shop_purchase` | `index`: int | Buy an item by its index. Must be stocked and affordable. | -| `proceed` | _(none)_ | Leave the shop. | +| `proceed` | _(none)_ | Leave the shop when the visible proceed button is enabled, or close an open inventory first when its visible back button is enabled. | ### Treasure (`treasure`) | Action | Parameters | When to Use | |---|---|---| | `claim_treasure_relic` | `index`: int | Claim a visible enabled relic from the opened chest. | -| `proceed` | _(none)_ | Leave the treasure room. | +| `proceed` | _(none)_ | Leave the treasure room when the visible proceed button is enabled. | ### Card Selection Overlay (`card_select`) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ebd88585..adebc5c9 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -297,8 +297,9 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: missing_shop = [field for field in shop_required if field not in shop_body] if missing_shop: fail(f"shop card serialization missing upgrade metadata: {missing_shop}") - if "proceedButton?.IsEnabled == true || canCloseInventory" not in shop_body: - fail("shop can_proceed must mirror ExecuteProceed close-inventory behavior") + for required_fragment in ["can_proceed", "canCloseInventory", "IsControlVisibleOrActionable"]: + if required_fragment not in shop_body: + fail(f"shop can_proceed must mirror ExecuteProceed visible close/proceed behavior: {required_fragment}") print("cards: upgrade metadata enforced for glossary and state payloads") @@ -454,6 +455,18 @@ def audit_state_surface(repo: Path) -> None: if required_fragment not in event_action_body: fail(f"event option action missing visibility/enabled guard: {required_fragment}") + fake_merchant_match = re.search( + r"private static Dictionary BuildFakeMerchantState\(.*?\n private static Dictionary BuildFakeMerchantShopItems\(", + state_builder, + re.S, + ) + if not fake_merchant_match: + fail("could not locate BuildFakeMerchantState for fake-merchant proceed audit") + fake_merchant_body = fake_merchant_match.group(0) + for required_fragment in ["can_proceed", "can_close_inventory", "IsControlVisibleOrActionable"]: + if required_fragment not in fake_merchant_body: + fail(f"fake_merchant state must gate proceed/close on visible enabled buttons: {required_fragment}") + rest_state_match = re.search( r"private static Dictionary BuildRestSiteState\(.*?\n private static Dictionary BuildShopState\(", state_builder, @@ -473,6 +486,8 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["IsVisibleInTree", "is_visible", "can_choose"]: if required_fragment not in rest_state_body: fail(f"rest option state missing visibility/enabled metadata: {required_fragment}") + if "can_proceed" not in rest_state_body or "IsControlVisibleOrActionable" not in rest_state_body: + fail("rest_site state must gate can_proceed on a visible enabled proceed button") for required_fragment in ["IsVisibleInTree", "IsEnabled"]: if required_fragment not in rest_action_body: fail(f"rest option action missing visibility/enabled guard: {required_fragment}") @@ -573,6 +588,17 @@ def audit_state_surface(repo: Path) -> None: if "IsControlVisibleOrActionable" not in hand_confirm_match.group(0): fail("combat_confirm_selection action must require a visible enabled button") + proceed_action_match = re.search( + r"private static Dictionary ExecuteProceed\(.*?\n private static Dictionary ExecuteSelectCard\(", + actions, + re.S, + ) + if not proceed_action_match: + fail("could not locate ExecuteProceed for visible proceed audit") + proceed_action_body = proceed_action_match.group(0) + if "IsControlVisibleOrActionable" not in proceed_action_body: + fail("proceed action must require visible enabled proceed/close buttons") + treasure_state_match = re.search( r"private static Dictionary BuildTreasureState\(.*?\n private static string GetRewardTypeName\(", state_builder, @@ -592,6 +618,8 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["IsVisibleInTree", "is_visible", "can_claim"]: if required_fragment not in treasure_state_body: fail(f"treasure state missing visibility/enabled metadata: {required_fragment}") + if "can_proceed" not in treasure_state_body or "IsControlVisibleOrActionable" not in treasure_state_body: + fail("treasure state must gate can_proceed on a visible enabled proceed button") for required_fragment in ["IsVisibleInTree", "IsEnabled"]: if required_fragment not in treasure_action_body: fail(f"treasure action missing visibility/enabled guard: {required_fragment}") @@ -724,6 +752,8 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["IsVisibleInTree", "is_visible", "can_claim"]: if required_fragment not in rewards_state_body: fail(f"rewards state missing visibility/claim metadata: {required_fragment}") + if "can_proceed" not in rewards_state_body or "IsControlVisibleOrActionable" not in rewards_state_body: + fail("rewards state must gate can_proceed on a visible enabled proceed button") for required_fragment in ["IsVisibleInTree", "IsEnabled"]: if required_fragment not in rewards_action_body: fail(f"claim_reward action missing visibility/enabled guard: {required_fragment}") From c477a1784678724264d51df82d5ff8744aec1130 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:09:43 -0400 Subject: [PATCH 114/190] Expose glossary card star costs --- McpMod.ForkEndpoints.cs | 9 ++------- docs/raw-full.md | 4 +++- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 3 +++ 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 6d1a9b6e..6c35cbc7 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -272,19 +272,14 @@ private static void AddCardsFromPool( if (seen.Contains(id)) continue; seen.Add(id); - string costDisplay; - if (card.EnergyCost.CostsX) - costDisplay = "X"; - else - costDisplay = card.EnergyCost.GetAmountToSpend().ToString(); - var upgradedPreview = SafeBuildUpgradedCardPreview(card); result.Add(new Dictionary { ["id"] = id, ["name"] = SafeGetText(() => card.Title), ["type"] = card.Type.ToString(), - ["cost"] = costDisplay, + ["cost"] = GetCostDisplay(card), + ["star_cost"] = GetStarCostDisplay(card), ["description"] = SafeGetCardDescription(card), ["rarity"] = card.Rarity.ToString(), ["pool"] = poolName, diff --git a/docs/raw-full.md b/docs/raw-full.md index fe2b8e36..55e3fab8 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1058,7 +1058,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. -- `GET /api/v1/glossary/cards`: active-run card pool metadata, including upgrade availability plus upgraded-preview cost and description. +- `GET /api/v1/glossary/cards`: active-run card pool metadata, including energy/star costs, upgrade availability, plus upgraded-preview cost and description. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. @@ -1082,6 +1082,8 @@ Example success shape: "id": "STRIKE_RED", "name": "Strike", "type": "Attack", + "cost": "1", + "star_cost": null, "rarity": "Basic", "is_upgradable": true, "current_upgrade_level": 0, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 85256180..2b30601e 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -34,7 +34,7 @@ Every JSON response includes: - `run` — `{ act, floor, ascension }` (absent for `menu`) - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) -Serialized card objects in hand, deck, piles, rewards, card selections, and bundles include upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. +Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. | `state_type` | Screen | Available Actions | |---|---|---| diff --git a/mcp/README.md b/mcp/README.md index da4b5a8e..efee6178 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index adebc5c9..cdf23c6c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -223,6 +223,9 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: fail("could not locate AddCardsFromPool for card glossary audit") body = match.group(0) required = [ + "GetCostDisplay", + "star_cost", + "GetStarCostDisplay", "is_upgradable", "current_upgrade_level", "max_upgrade_level", From 5c048b07df1e226e8fd5d7f9b5c0b5e92e46047e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:12:12 -0400 Subject: [PATCH 115/190] Align glossary card upgrade flags --- McpMod.ForkEndpoints.cs | 1 + docs/raw-full.md | 1 + scripts/audit_endpoints.py | 1 + 3 files changed, 3 insertions(+) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 6c35cbc7..a48213e3 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -283,6 +283,7 @@ private static void AddCardsFromPool( ["description"] = SafeGetCardDescription(card), ["rarity"] = card.Rarity.ToString(), ["pool"] = poolName, + ["is_upgraded"] = card.IsUpgraded, ["is_upgradable"] = card.IsUpgradable, ["current_upgrade_level"] = card.CurrentUpgradeLevel, ["max_upgrade_level"] = card.MaxUpgradeLevel, diff --git a/docs/raw-full.md b/docs/raw-full.md index 55e3fab8..f63ea2f4 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1085,6 +1085,7 @@ Example success shape: "cost": "1", "star_cost": null, "rarity": "Basic", + "is_upgraded": false, "is_upgradable": true, "current_upgrade_level": 0, "max_upgrade_level": 1, diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index cdf23c6c..ca247d8b 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -226,6 +226,7 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: "GetCostDisplay", "star_cost", "GetStarCostDisplay", + "is_upgraded", "is_upgradable", "current_upgrade_level", "max_upgrade_level", From be26e59d6c67442353edb96841afd7a82cb8eb60 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:17:22 -0400 Subject: [PATCH 116/190] Expose hand card target references --- McpMod.StateBuilder.cs | 14 ++++++++++++-- docs/raw-full.md | 6 +++++- docs/raw-simplified.md | 4 ++-- mcp/server.py | 4 ++-- scripts/audit_endpoints.py | 3 +++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 17af25fe..9e4ac778 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1135,7 +1135,7 @@ private static void AddMultiplayerLoadLobbyMenuState( int cardIndex = 0; foreach (var card in combatState.Hand.Cards) { - hand.Add(BuildCardState(card, cardIndex)); + hand.Add(BuildCardState(card, cardIndex, player)); cardIndex++; } state["hand"] = hand; @@ -1302,6 +1302,12 @@ private static void AddMultiplayerLoadLobbyMenuState( return targets; var combatState = player.Creature.CombatState; + return BuildEnemyTargetRefs(combatState); + } + + private static List> BuildEnemyTargetRefs(CombatState? combatState) + { + var targets = new List>(); if (combatState == null) return targets; @@ -1365,7 +1371,7 @@ private static string GetCostDisplay(CardModel card) }; } - private static Dictionary BuildCardState(CardModel card, int index) + private static Dictionary BuildCardState(CardModel card, int index, Player player) { card.CanPlay(out var unplayableReason, out _); string? blockedReason = unplayableReason != UnplayableReason.None @@ -1387,6 +1393,10 @@ private static string GetCostDisplay(CardModel card) state["index"] = index; state["description"] = SafeGetCardDescription(card); // hand cards use default pile state["target_type"] = card.TargetType.ToString(); + state["requires_target"] = card.TargetType == TargetType.AnyEnemy; + state["valid_targets"] = card.TargetType == TargetType.AnyEnemy + ? BuildEnemyTargetRefs(player.Creature.CombatState) + : new List>(); state["can_play"] = combatReady && blockedReason == null; state["unplayable_reason"] = blockedReason; return state; diff --git a/docs/raw-full.md b/docs/raw-full.md index f63ea2f4..578d9043 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -136,6 +136,10 @@ Always present at the top level (except `menu`). Contains everything about the l "star_cost": null, // Regent star cost as string, null if N/A "description": "Deal 6 damage.", "target_type": "AnyEnemy", // None, Self, AnyEnemy, AllEnemies, etc. + "requires_target": true, + "valid_targets": [ + { "entity_id": "JAW_WORM_0", "combat_id": 12, "name": "Jaw Worm" } + ], "can_play": true, "unplayable_reason": null, // e.g. "NotEnoughEnergy", "NotInPlayPhase", "PlayerActionsDisabled", null if playable "is_upgraded": false, @@ -1188,7 +1192,7 @@ Play a card from hand during combat. | Parameter | Type | Required | Description | |---|---|---|---| | `card_index` | int | Yes | 0-based index in hand | -| `target` | string | For `AnyEnemy` cards | `entity_id` of the target enemy | +| `target` | string | When hand card `requires_target` is true | `entity_id` from the card's `valid_targets` list | **Errors:** Not in combat, not play phase, card unplayable, invalid index, missing target. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 2b30601e..57646acf 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -34,7 +34,7 @@ Every JSON response includes: - `run` — `{ act, floor, ascension }` (absent for `menu`) - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) -Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. +Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Hand cards also include `requires_target` and `valid_targets` for enemy-targeted cards. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. | `state_type` | Screen | Available Actions | |---|---|---| @@ -116,7 +116,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | Action | Parameters | When to Use | |---|---|---| -| `play_card` | `card_index`: int, `target`?: string | Play a card from hand. `target` is an `entity_id` (e.g. `"JAW_WORM_0"`), required for single-target cards. | +| `play_card` | `card_index`: int, `target`?: string | Play a card from hand when `can_play` is true. `target` is required when the card has `requires_target`; use one of `valid_targets`. | | `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use one of `valid_targets`. Works outside combat for non-combat-only, non-enemy-targeting potions. | | `discard_potion` | `slot`: int | Discard a potion when its state says `can_discard`. Use when slots are full and you need room for incoming potions. | | `end_turn` | _(none)_ | End the player's turn when battle state says `can_end_turn`. | diff --git a/mcp/server.py b/mcp/server.py index d49bb5bd..656ffab9 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -509,7 +509,7 @@ async def combat_play_card(card_index: int, target: str | None = None) -> str: Args: card_index: Index of the card in hand (0-based, as shown in game state). - target: Entity ID of the target enemy (e.g. "JAW_WORM_0"). Required for single-target cards. + target: Entity ID from the hand card's `valid_targets` list. Required when `requires_target` is true. Note that the index can change as cards are played - playing a card will shift the indices of remaining cards in hand. Refer to the latest game state for accurate indices. New cards are drawn to the right, so playing cards from right to left can help maintain more stable indices for remaining cards. @@ -923,7 +923,7 @@ async def mp_combat_play_card(card_index: int, target: str | None = None) -> str Args: card_index: Index of the card in hand (0-based). - target: Entity ID of the target enemy (e.g. "JAW_WORM_0"). Required for single-target cards. + target: Entity ID from the hand card's `valid_targets` list. Required when `requires_target` is true. """ body: dict = {"action": "play_card", "card_index": card_index} if target is not None: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ca247d8b..0792e0dd 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -260,6 +260,9 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: if not card_state_match: fail("could not locate BuildCardState for can_play audit") card_state_body = card_state_match.group(0) + for required_fragment in ["requires_target", "valid_targets", "BuildEnemyTargetRefs"]: + if required_fragment not in card_state_body: + fail(f"hand card state missing target metadata: {required_fragment}") for required_guard in ["IsPlayPhase", "PlayerActionsDisabled", "NotInPlayPhase"]: if required_guard not in card_state_body: fail(f"hand card can_play missing action guard: {required_guard}") From 66dc3e51d9b0f11e4a46023a0245b53789cbdcaa Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:20:23 -0400 Subject: [PATCH 117/190] Validate missing profile id --- McpMod.Profile.cs | 8 ++++++-- scripts/audit_endpoints.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 56d35e14..51ab4a39 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -70,8 +70,12 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener } string action = actionElem.GetString() ?? ""; - JsonElement idElem; - if (parsed.TryGetValue("profile_id", out idElem) && idElem.ValueKind != JsonValueKind.Number) + if (!parsed.TryGetValue("profile_id", out var idElem)) + { + SendError(response, 400, "Missing 'profile_id' field. Use a profile slot 1-3."); + return; + } + if (idElem.ValueKind != JsonValueKind.Number) { SendError(response, 400, "'profile_id' field must be a number"); return; diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 0792e0dd..b2e5a9fc 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -882,6 +882,7 @@ def audit_live(base_url: str) -> None: ("/api/v1/profiles", b"{", 400), ("/api/v1/profiles", b"{}", 400), ("/api/v1/profiles", b'{"action": 1}', 400), + ("/api/v1/profiles", b'{"action": "switch"}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": 1.5}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": 999999999999}', 400), From a6b0feb48d0dafaccafc47917f9dd768cff4ec0e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:24:41 -0400 Subject: [PATCH 118/190] Expose current run context in state --- McpMod.Compendium.cs | 17 +++++++++++++++++ McpMod.MultiplayerState.cs | 1 + McpMod.StateBuilder.cs | 1 + docs/raw-full.md | 6 ++++++ docs/raw-simplified.md | 1 + scripts/audit_endpoints.py | 6 ++++++ 6 files changed, 32 insertions(+) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 757d6d5e..105c258c 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -366,6 +366,23 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) private static Dictionary? BuildCurrentRunContext(CompendiumSnapshot snapshot) => BuildCurrentRunContext(snapshot.IsRunInProgress, snapshot.ProfileId, snapshot.SaveScope, snapshot.ProgressPath, snapshot.ProfileRoot); + private static Dictionary? BuildActiveRunContext() + { + var saveManager = SaveManager.Instance; + if (saveManager == null) + return null; + + var profileId = saveManager.CurrentProfileId; + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + return BuildCurrentRunContext( + RunManager.Instance?.IsInProgress == true, + profileId, + GetSaveScope(profileRoot), + progressPath, + profileRoot); + } + private static Dictionary? BuildCurrentRunContext( bool isRunInProgress, int profileId, diff --git a/McpMod.MultiplayerState.cs b/McpMod.MultiplayerState.cs index 5f78f74a..78bba8ff 100644 --- a/McpMod.MultiplayerState.cs +++ b/McpMod.MultiplayerState.cs @@ -252,6 +252,7 @@ public static partial class McpMod ["floor"] = runState.TotalFloor, ["ascension"] = runState.AscensionLevel }; + result["current_run"] = BuildActiveRunContext(); // All players summary (always included for multiplayer) result["players"] = BuildAllPlayersState(runState); diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 9e4ac778..2497ad1a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -601,6 +601,7 @@ public static partial class McpMod ["floor"] = runState.TotalFloor, ["ascension"] = runState.AscensionLevel }; + result["current_run"] = BuildActiveRunContext(); // Always include full player data (relics, potions, deck, etc.) on every screen var _player = LocalContext.GetMe(runState); diff --git a/docs/raw-full.md b/docs/raw-full.md index 578d9043..a68f52ad 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -44,6 +44,12 @@ Every response (except `menu`) includes these top-level fields alongside the sta "floor": 3, // Total floors visited "ascension": 0 // Ascension level }, + "current_run": { + "profile_id": 1, + "save_scope": "modded", + "run_id": "modded:profile1:1778295706", + "seed": "2450ZAR9EF" + }, "player": { ... }, // Full player state (see Player Object below) // ... state-specific fields } diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 57646acf..057cc05d 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -32,6 +32,7 @@ Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if misma Every JSON response includes: - `state_type` — which screen the game is on (see below) - `run` — `{ act, floor, ascension }` (absent for `menu`) +- `current_run` — run identity and save context when a run is active, including `profile_id`, `save_scope`, `run_id`, and `seed` when the save file exposes it - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Hand cards also include `requires_target` and `valid_targets` for enemy-targeted cards. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index b2e5a9fc..ab8f4380 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -346,6 +346,12 @@ def audit_state_surface(repo: Path) -> None: if missing_mp_parity: fail(f"multiplayer state surface missing singleplayer states: {missing_mp_parity}") + for source_name, source in [("singleplayer", state_builder), ("multiplayer", multiplayer_state)]: + if '["current_run"] = BuildActiveRunContext()' not in source: + fail(f"{source_name} state missing current_run identity context") + if "run_id" not in docs or "save_scope" not in docs: + fail("docs missing current_run run_id/save_scope context") + state_types |= multiplayer_state_types missing_docs = sorted(state_type for state_type in state_types if state_type not in docs) if missing_docs: From 450232526bd0b8b004f1df1e66c870c37ace94ea Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:31:18 -0400 Subject: [PATCH 119/190] Expose profile run context --- McpMod.Profile.cs | 13 +++++++++++-- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 ++ scripts/audit_endpoints.py | 14 +++++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 51ab4a39..ca1d42fc 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -232,11 +232,20 @@ private static bool TrySwitchProfileViaOpenScreen(Node root, int profileId) internal static object BuildProfile() { - var progress = SaveManager.Instance?.Progress; - if (progress == null) + var saveManager = SaveManager.Instance; + var progress = saveManager?.Progress; + if (saveManager == null || progress == null) return Error("No profile data available."); var result = new Dictionary(); + var profileId = saveManager.CurrentProfileId; + var progressPath = GetProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + result["profile_id"] = profileId; + result["progress_path"] = progressPath; + result["profile_root"] = profileRoot; + result["save_scope"] = GetSaveScope(profileRoot); + result["current_run"] = BuildActiveRunContext(); var characters = new List>(); foreach (var kv in progress.CharacterStats) diff --git a/docs/raw-full.md b/docs/raw-full.md index a68f52ad..188280ae 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1010,7 +1010,7 @@ Returns current settings and preferences, including display, audio, gameplay, la ### `GET /api/v1/profile` -Returns the active profile's persistent progress summary, including character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. +Returns the active profile's persistent progress summary, including `profile_id`, save scope/path context, `current_run` when a run is active, character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. ### `GET /api/v1/compendium` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 057cc05d..8cd6f289 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -95,6 +95,8 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope `GET /api/v1/profiles` returns the three profile slots: +`GET /api/v1/profile` returns the active profile's progress with `profile_id`, save scope/path context, and `current_run` when a run is active. + ```json { "current_profile_id": 1, diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ab8f4380..fed39984 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -312,6 +312,7 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: def audit_static_save_roots(repo: Path) -> None: compendium = (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8") + profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") match = re.search( r"private static IEnumerable EnumerateSaveRoots\(\).*?\n private static IEnumerable EnumerateSteamDataRoots\(\)", compendium, @@ -324,7 +325,18 @@ def audit_static_save_roots(repo: Path) -> None: fail("save-root fallback must search all Steam account roots after the active account") if "Directory.GetDirectories(steamRoot)" not in body: fail("save-root fallback must enumerate Steam account directories") - print("saves: multi-account fallback lookup enforced") + profile_match = re.search( + r"internal static object BuildProfile\(\).*?\n \}", + profile, + re.S, + ) + if not profile_match: + fail("could not locate BuildProfile for profile context audit") + profile_body = profile_match.group(0) + for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: + if required_fragment not in profile_body: + fail(f"profile endpoint missing identity/run context: {required_fragment}") + print("saves: multi-account fallback and profile context enforced") def audit_state_surface(repo: Path) -> None: From 8817e98607a0e4b668436a8292382858599e4b30 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:35:43 -0400 Subject: [PATCH 120/190] Expose compendium save context --- McpMod.Compendium.cs | 3 +++ docs/raw-full.md | 4 ++++ docs/raw-simplified.md | 2 ++ scripts/audit_endpoints.py | 21 ++++++++++++++++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 105c258c..22104c50 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -112,6 +112,9 @@ private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) return new Dictionary { ["profile_id"] = snapshot.ProfileId, + ["progress_path"] = snapshot.ProgressPath, + ["profile_root"] = snapshot.ProfileRoot, + ["save_scope"] = snapshot.SaveScope, ["current_run"] = BuildCurrentRunContext(snapshot), ["source"] = "SaveManager.Progress plus model metadata endpoints", ["sections"] = new Dictionary diff --git a/docs/raw-full.md b/docs/raw-full.md index 188280ae..71633001 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1016,6 +1016,7 @@ Returns the active profile's persistent progress summary, including `profile_id` Returns the active profile's progress grouped by the in-game Compendium cards: +- `profile_id`, `progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. - `current_run`: present while a run is active. Includes a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. - `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. @@ -1027,6 +1028,9 @@ Returns the active profile's progress grouped by the in-game Compendium cards: ```jsonc { "profile_id": 1, + "progress_path": "modded/profile1\\saves\\progress.save", + "profile_root": "modded/profile1", + "save_scope": "modded", "current_run": { "is_in_progress": true, "profile_id": 1, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 8cd6f289..ad5ff6b1 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -82,6 +82,8 @@ The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: +The top level includes `profile_id`, `progress_path`, `profile_root`, and `save_scope`, matching `/api/v1/profile`. + When a run is active, the response includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content. | Section | Status | diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index fed39984..5872f658 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -325,6 +325,19 @@ def audit_static_save_roots(repo: Path) -> None: fail("save-root fallback must search all Steam account roots after the active account") if "Directory.GetDirectories(steamRoot)" not in body: fail("save-root fallback must enumerate Steam account directories") + + compendium_response_match = re.search( + r"private static object BuildCompendiumResponse\(.*?\n private sealed class CompendiumSnapshot", + compendium, + re.S, + ) + if not compendium_response_match: + fail("could not locate BuildCompendiumResponse for profile context audit") + compendium_response = compendium_response_match.group(0) + for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + if required_fragment not in compendium_response: + fail(f"compendium endpoint missing profile/save context: {required_fragment}") + profile_match = re.search( r"internal static object BuildProfile\(\).*?\n \}", profile, @@ -336,7 +349,7 @@ def audit_static_save_roots(repo: Path) -> None: for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: if required_fragment not in profile_body: fail(f"profile endpoint missing identity/run context: {required_fragment}") - print("saves: multi-account fallback and profile context enforced") + print("saves: multi-account fallback and profile/compendium context enforced") def audit_state_surface(repo: Path) -> None: @@ -852,6 +865,12 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/settings", "/api/v1/profile", "/api/v1/compendium", "/api/v1/bestiary", "/api/v1/profiles"} and status != 200: fail(f"{path} expected HTTP 200, got {status}: {data}") + if path in {"/api/v1/profile", "/api/v1/compendium"}: + if not isinstance(data, dict): + fail(f"{path} expected structured profile context object, got {type(data).__name__}") + for required_field in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + if required_field not in data: + fail(f"{path} missing profile/save context field: {required_field}") if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") From 718da1dd553b70e1d37c453f76dd47fe580c8835 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:39:18 -0400 Subject: [PATCH 121/190] Use mutable event state text --- McpMod.StateBuilder.cs | 2 +- scripts/audit_endpoints.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 2497ad1a..ddcbd3f4 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -1559,7 +1559,7 @@ private static bool IsCardHolderSelectable(Control holder) { var state = new Dictionary(); - var eventModel = eventRoom.CanonicalEvent; + var eventModel = eventRoom.LocalMutableEvent ?? eventRoom.CanonicalEvent; bool isAncient = eventModel is AncientEventModel; state["event_id"] = eventModel.Id.Entry; state["event_name"] = SafeGetText(() => eventModel.Title); diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 5872f658..08f1d215 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -489,6 +489,8 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["IsVisibleInTree", "is_enabled", "can_choose"]: if required_fragment not in event_state_body: fail(f"event option state missing visibility/enabled metadata: {required_fragment}") + if "eventRoom.LocalMutableEvent ?? eventRoom.CanonicalEvent" not in event_state_body: + fail("event state must prefer LocalMutableEvent so multi-step event text does not go stale") for required_fragment in ["IsVisibleInTree", "IsEnabled"]: if required_fragment not in event_action_body: fail(f"event option action missing visibility/enabled guard: {required_fragment}") From b886d57bba402186805343d9f816cb26345dbb67 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:44:45 -0400 Subject: [PATCH 122/190] Advertise profile save context --- McpMod.cs | 4 ++-- mcp/README.md | 6 ++++-- mcp/server.py | 7 +++++-- scripts/audit_endpoints.py | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 93444584..3f1cc16a 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -412,8 +412,8 @@ private static void HandleRequest(HttpListenerContext context) new() { ["method"] = "GET", ["path"] = "/api/v1/multiplayer", ["description"] = "Read multiplayer state" }, new() { ["method"] = "POST", ["path"] = "/api/v1/multiplayer", ["description"] = "Perform multiplayer action" }, new() { ["method"] = "GET", ["path"] = "/api/v1/settings", ["description"] = "Read settings and preferences" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/profile", ["description"] = "Read active profile progress" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/compendium", ["description"] = "Read Compendium-shaped profile progress" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/profile", ["description"] = "Read active profile progress plus save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/compendium", ["description"] = "Read Compendium-shaped profile progress plus save/run context" }, new() { ["method"] = "GET", ["path"] = "/api/v1/bestiary", ["description"] = "Read monster and encounter metadata" }, new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/cards", ["description"] = "Read active-run card pool metadata" }, new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/relics", ["description"] = "Read active-run relic pool metadata" }, diff --git a/mcp/README.md b/mcp/README.md index efee6178..48b93d1e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -8,8 +8,8 @@ | `get_game_state(format?)` | General | Get current game state (`markdown` or `json`) | | `menu_select(option, seed?)` | General | Select a visible menu/game-over option, including `abandon_run` when advertised; automatically retries through the multiplayer route during MP runs | | `get_settings()` | General | Get current settings and preferences | -| `get_profile()` | Profiles | Get active profile progress | -| `get_compendium()` | Profiles | Get Compendium-shaped profile progress | +| `get_profile()` | Profiles | Get active profile progress plus save/run context | +| `get_compendium()` | Profiles | Get Compendium-shaped profile progress plus save/run context | | `get_bestiary()` | Profiles | Get monster and encounter metadata | | `get_glossary_cards()` | Active Pool | Get active-run card pool metadata with run_id/seed scope | | `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata with run_id/seed scope | @@ -46,6 +46,8 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | +Profile and Compendium tools include `profile_id`, `progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, and current run attempt. + Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/mcp/server.py b/mcp/server.py index 656ffab9..77df205a 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -278,7 +278,8 @@ async def get_profile() -> str: """Get the current profile's persistent progress summary. Includes character stats, discovered content, achievements, epochs, and global - run totals for the active profile. + run totals for the active profile, plus profile_id, progress_path, + profile_root, save_scope, and current_run when a run is active. """ try: return await _profile_get() @@ -293,7 +294,9 @@ async def get_compendium() -> str: Mirrors the visible Compendium cards: Card Library, Relic Collection, Potion Lab, Bestiary, Character Stats, and Run History. Some sections point to detail endpoints such as glossary or bestiary when model-level metadata is - separate from profile discovery/progress data. + separate from profile discovery/progress data. The top level includes the + same profile_id/progress_path/profile_root/save_scope/current_run context as + get_profile(). """ try: return await _compendium_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 08f1d215..f03050c6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -313,6 +313,8 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: def audit_static_save_roots(repo: Path) -> None: compendium = (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8") profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") + mcp_server = (repo / "mcp" / "server.py").read_text(encoding="utf-8") + mcp_readme = (repo / "mcp" / "README.md").read_text(encoding="utf-8") match = re.search( r"private static IEnumerable EnumerateSaveRoots\(\).*?\n private static IEnumerable EnumerateSteamDataRoots\(\)", compendium, @@ -349,6 +351,11 @@ def audit_static_save_roots(repo: Path) -> None: for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: if required_fragment not in profile_body: fail(f"profile endpoint missing identity/run context: {required_fragment}") + + for doc_name, doc_text in [("mcp/server.py", mcp_server), ("mcp/README.md", mcp_readme)]: + for required_fragment in ["progress_path", "profile_root", "save_scope", "current_run"]: + if required_fragment not in doc_text: + fail(f"{doc_name} missing profile/compendium context documentation: {required_fragment}") print("saves: multi-account fallback and profile/compendium context enforced") @@ -856,6 +863,15 @@ def audit_live(base_url: str) -> None: expected = set(EXPECTED_ENDPOINTS) if live_index != expected: fail(f"root endpoint index mismatch. missing={sorted(expected - live_index)} extra={sorted(live_index - expected)}") + endpoint_descriptions = { + str(row.get("path")): str(row.get("description")) + for row in endpoint_rows + if isinstance(row, dict) + } + for path in ["/api/v1/profile", "/api/v1/compendium"]: + description = endpoint_descriptions.get(path, "") + if "save/run context" not in description: + fail(f"root endpoint index should advertise save/run context for {path}: {description}") print(f"root: {len(live_index)} endpoints advertised") glossary_payloads: dict[str, dict] = {} From 5612fab09d8719e4b3045e2ba8e44428a82983df Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:50:13 -0400 Subject: [PATCH 123/190] Expose glossary save context --- McpMod.ForkEndpoints.cs | 4 ++++ docs/raw-full.md | 6 +++++- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- mcp/server.py | 13 ++++++++----- scripts/audit_endpoints.py | 27 ++++++++++++++++++++++++++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index a48213e3..1fda0509 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -212,6 +212,10 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl ["kind"] = payload.Kind, ["scope"] = "active_run", ["count"] = items.Count, + ["profile_id"] = payload.ProfileId, + ["progress_path"] = payload.ProgressPath, + ["profile_root"] = payload.ProfileRoot, + ["save_scope"] = payload.SaveScope, ["current_run"] = BuildCurrentRunContext( isRunInProgress: true, profileId: payload.ProfileId, diff --git a/docs/raw-full.md b/docs/raw-full.md index 71633001..b44f1c51 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1070,7 +1070,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and ### `GET /api/v1/glossary/*` -The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, `current_run`, `players`, and `items`. +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, profile/save context (`profile_id`, `progress_path`, `profile_root`, `save_scope`), `current_run`, `players`, and `items`. - `GET /api/v1/glossary/cards`: active-run card pool metadata, including energy/star costs, upgrade availability, plus upgraded-preview cost and description. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. @@ -1087,6 +1087,10 @@ Example success shape: "kind": "cards", "scope": "active_run", "count": 87, + "profile_id": 1, + "progress_path": "modded/profile1\\saves\\progress.save", + "profile_root": "modded/profile1", + "save_scope": "modded", "current_run": { "run_id": "modded:profile1:1778295706", "seed": "VQY2JBY38L" diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index ad5ff6b1..6cd4ce47 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -78,7 +78,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index 48b93d1e..3da3031f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -48,7 +48,7 @@ Profile and Compendium tools include `profile_id`, `progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, and current run attempt. -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/mcp/server.py b/mcp/server.py index 77df205a..9f6e1fbc 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -336,7 +336,8 @@ async def get_glossary_cards() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered cards; use get_compendium() for profile progress. - The response includes current_run.run_id, current_run.seed, count, and items. + The response includes profile/save context, current_run.run_id, + current_run.seed, count, and items. """ try: return await _glossary_get("cards") @@ -350,7 +351,8 @@ async def get_glossary_relics() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered relics; use get_compendium() for profile progress. - The response includes current_run.run_id, current_run.seed, count, and items. + The response includes profile/save context, current_run.run_id, + current_run.seed, count, and items. """ try: return await _glossary_get("relics") @@ -364,7 +366,8 @@ async def get_glossary_potions() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered potions; use get_compendium() for profile progress. - The response includes current_run.run_id, current_run.seed, count, and items. + The response includes profile/save context, current_run.run_id, + current_run.seed, count, and items. """ try: return await _glossary_get("potions") @@ -377,8 +380,8 @@ async def get_glossary_keywords() -> str: """Get active-run keyword metadata collected from active pools. Requires a run in progress. Keywords come from cards, relics, and potions - reachable in the current run context. The response includes current_run.run_id, - current_run.seed, count, and items. + reachable in the current run context. The response includes profile/save + context, current_run.run_id, current_run.seed, count, and items. """ try: return await _glossary_get("keywords") diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f03050c6..7d7f0822 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -184,6 +184,14 @@ def audit_static_error_shapes(repo: Path) -> None: def audit_static_glossary_scope(repo: Path) -> None: fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + docs = "\n".join( + [ + (repo / "docs" / "raw-simplified.md").read_text(encoding="utf-8"), + (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"), + (repo / "mcp" / "README.md").read_text(encoding="utf-8"), + (repo / "mcp" / "server.py").read_text(encoding="utf-8"), + ] + ) relic_match = re.search( r"internal static object BuildGlossaryRelics\(\).*?\n internal static object BuildGlossaryPotions\(\)", fork_endpoints, @@ -208,7 +216,21 @@ def audit_static_glossary_scope(repo: Path) -> None: if not match or required not in match.group(0): fail(f"{method} must include active-run shared pool {required}") - print("glossary: active-run shared/scoped pools enforced") + send_match = re.search( + r"private static void SendGlossaryJson\(.*?\n private static Dictionary GlossaryError\(", + fork_endpoints, + re.S, + ) + if not send_match: + fail("could not locate SendGlossaryJson for glossary context audit") + send_body = send_match.group(0) + for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + if required_fragment not in send_body: + fail(f"glossary success payload missing profile/save context: {required_fragment}") + if required_fragment not in docs: + fail(f"docs missing glossary profile/save context: {required_fragment}") + + print("glossary: active-run shared/scoped pools and profile context enforced") def audit_static_card_glossary_metadata(repo: Path) -> None: @@ -900,6 +922,9 @@ def audit_live(base_url: str) -> None: fail(f"{path} expected status ok and kind {expected_kind}, got {data}") if not isinstance(data.get("items"), list) or data.get("count") != len(data["items"]): fail(f"{path} expected items list with matching count, got {data}") + for required_field in ["profile_id", "progress_path", "profile_root", "save_scope"]: + if required_field not in data: + fail(f"{path} missing profile/save context field: {required_field}") current_run = data.get("current_run") if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): fail(f"{path} expected current_run run_id and seed, got {current_run}") From d5024e5acaff7a8abdfef92df5409de6869856b3 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:56:05 -0400 Subject: [PATCH 124/190] Expose resolved progress paths --- McpMod.Compendium.cs | 4 ++++ McpMod.ForkEndpoints.cs | 4 ++++ McpMod.Profile.cs | 2 ++ docs/raw-full.md | 8 +++++--- docs/raw-simplified.md | 6 +++--- mcp/README.md | 4 ++-- mcp/server.py | 7 ++++--- scripts/audit_endpoints.py | 12 ++++++------ 8 files changed, 30 insertions(+), 17 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 22104c50..cd180b27 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -47,6 +47,7 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() var profileId = saveManager.CurrentProfileId; var progressPath = GetProfileProgressPath(profileId); + var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); var cardStats = progress.CardStats.Select(kv => new Dictionary @@ -75,6 +76,7 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() { ProfileId = profileId, ProgressPath = progressPath, + ResolvedProgressPath = resolvedProgressPath, ProfileRoot = profileRoot, SaveScope = GetSaveScope(profileRoot), IsRunInProgress = RunManager.Instance?.IsInProgress == true, @@ -113,6 +115,7 @@ private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) { ["profile_id"] = snapshot.ProfileId, ["progress_path"] = snapshot.ProgressPath, + ["resolved_progress_path"] = snapshot.ResolvedProgressPath, ["profile_root"] = snapshot.ProfileRoot, ["save_scope"] = snapshot.SaveScope, ["current_run"] = BuildCurrentRunContext(snapshot), @@ -177,6 +180,7 @@ private sealed class CompendiumSnapshot public string? Error { get; init; } public int ProfileId { get; init; } public string? ProgressPath { get; init; } + public string? ResolvedProgressPath { get; init; } public string ProfileRoot { get; init; } = ""; public string SaveScope { get; init; } = "vanilla"; public bool IsRunInProgress { get; init; } diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 1fda0509..4d6d081e 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -147,6 +147,7 @@ private sealed class GlossaryPayload public object Data { get; init; } = new(); public int ProfileId { get; init; } public string? ProgressPath { get; init; } + public string? ResolvedProgressPath { get; init; } public string ProfileRoot { get; init; } = ""; public string SaveScope { get; init; } = "vanilla"; public string NetType { get; init; } = ""; @@ -161,6 +162,7 @@ private static GlossaryPayload BuildGlossaryPayload(string kind, Func bu var profileId = SaveManager.Instance?.CurrentProfileId ?? 0; var progressPath = GetProfileProgressPath(profileId); + var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); var runState = RunManager.Instance.DebugOnlyGetState(); var players = runState?.Players.Select((player, index) => new Dictionary @@ -176,6 +178,7 @@ private static GlossaryPayload BuildGlossaryPayload(string kind, Func bu Data = data, ProfileId = profileId, ProgressPath = progressPath, + ResolvedProgressPath = resolvedProgressPath, ProfileRoot = profileRoot, SaveScope = GetSaveScope(profileRoot), NetType = RunManager.Instance.NetService.Type.ToString(), @@ -214,6 +217,7 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl ["count"] = items.Count, ["profile_id"] = payload.ProfileId, ["progress_path"] = payload.ProgressPath, + ["resolved_progress_path"] = payload.ResolvedProgressPath, ["profile_root"] = payload.ProfileRoot, ["save_scope"] = payload.SaveScope, ["current_run"] = BuildCurrentRunContext( diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index ca1d42fc..cb74e184 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -240,9 +240,11 @@ internal static object BuildProfile() var result = new Dictionary(); var profileId = saveManager.CurrentProfileId; var progressPath = GetProfileProgressPath(profileId); + var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); result["profile_id"] = profileId; result["progress_path"] = progressPath; + result["resolved_progress_path"] = resolvedProgressPath; result["profile_root"] = profileRoot; result["save_scope"] = GetSaveScope(profileRoot); result["current_run"] = BuildActiveRunContext(); diff --git a/docs/raw-full.md b/docs/raw-full.md index b44f1c51..bbff4095 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1010,13 +1010,13 @@ Returns current settings and preferences, including display, audio, gameplay, la ### `GET /api/v1/profile` -Returns the active profile's persistent progress summary, including `profile_id`, save scope/path context, `current_run` when a run is active, character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. +Returns the active profile's persistent progress summary, including `profile_id`, save scope/path context, `resolved_progress_path`, `current_run` when a run is active, character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. ### `GET /api/v1/compendium` Returns the active profile's progress grouped by the in-game Compendium cards: -- `profile_id`, `progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. +- `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. - `current_run`: present while a run is active. Includes a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. - `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. @@ -1029,6 +1029,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: { "profile_id": 1, "progress_path": "modded/profile1\\saves\\progress.save", + "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", "profile_root": "modded/profile1", "save_scope": "modded", "current_run": { @@ -1070,7 +1071,7 @@ Returns reflected monster and encounter metadata. Profile-specific encounter and ### `GET /api/v1/glossary/*` -The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, profile/save context (`profile_id`, `progress_path`, `profile_root`, `save_scope`), `current_run`, `players`, and `items`. +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), `current_run`, `players`, and `items`. - `GET /api/v1/glossary/cards`: active-run card pool metadata, including energy/star costs, upgrade availability, plus upgraded-preview cost and description. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. @@ -1089,6 +1090,7 @@ Example success shape: "count": 87, "profile_id": 1, "progress_path": "modded/profile1\\saves\\progress.save", + "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", "profile_root": "modded/profile1", "save_scope": "modded", "current_run": { diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 6cd4ce47..6ce34aab 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -78,11 +78,11 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: -The top level includes `profile_id`, `progress_path`, `profile_root`, and `save_scope`, matching `/api/v1/profile`. +The top level includes `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`, matching `/api/v1/profile`. When a run is active, the response includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content. @@ -97,7 +97,7 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope `GET /api/v1/profiles` returns the three profile slots: -`GET /api/v1/profile` returns the active profile's progress with `profile_id`, save scope/path context, and `current_run` when a run is active. +`GET /api/v1/profile` returns the active profile's progress with `profile_id`, save scope/path context, `resolved_progress_path`, and `current_run` when a run is active. ```json { diff --git a/mcp/README.md b/mcp/README.md index 3da3031f..93ae4e53 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,9 +46,9 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Profile and Compendium tools include `profile_id`, `progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, and current run attempt. +Profile and Compendium tools include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. ## Multiplayer diff --git a/mcp/server.py b/mcp/server.py index 9f6e1fbc..a378287c 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -279,7 +279,8 @@ async def get_profile() -> str: Includes character stats, discovered content, achievements, epochs, and global run totals for the active profile, plus profile_id, progress_path, - profile_root, save_scope, and current_run when a run is active. + resolved_progress_path, profile_root, save_scope, and current_run when a run + is active. """ try: return await _profile_get() @@ -295,8 +296,8 @@ async def get_compendium() -> str: Potion Lab, Bestiary, Character Stats, and Run History. Some sections point to detail endpoints such as glossary or bestiary when model-level metadata is separate from profile discovery/progress data. The top level includes the - same profile_id/progress_path/profile_root/save_scope/current_run context as - get_profile(). + same profile_id/progress_path/resolved_progress_path/profile_root/save_scope/ + current_run context as get_profile(). """ try: return await _compendium_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 7d7f0822..8cbbf04e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -224,7 +224,7 @@ def audit_static_glossary_scope(repo: Path) -> None: if not send_match: fail("could not locate SendGlossaryJson for glossary context audit") send_body = send_match.group(0) - for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + for required_fragment in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_fragment not in send_body: fail(f"glossary success payload missing profile/save context: {required_fragment}") if required_fragment not in docs: @@ -358,7 +358,7 @@ def audit_static_save_roots(repo: Path) -> None: if not compendium_response_match: fail("could not locate BuildCompendiumResponse for profile context audit") compendium_response = compendium_response_match.group(0) - for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + for required_fragment in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_fragment not in compendium_response: fail(f"compendium endpoint missing profile/save context: {required_fragment}") @@ -370,12 +370,12 @@ def audit_static_save_roots(repo: Path) -> None: if not profile_match: fail("could not locate BuildProfile for profile context audit") profile_body = profile_match.group(0) - for required_fragment in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: + for required_fragment in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: if required_fragment not in profile_body: fail(f"profile endpoint missing identity/run context: {required_fragment}") for doc_name, doc_text in [("mcp/server.py", mcp_server), ("mcp/README.md", mcp_readme)]: - for required_fragment in ["progress_path", "profile_root", "save_scope", "current_run"]: + for required_fragment in ["progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_fragment not in doc_text: fail(f"{doc_name} missing profile/compendium context documentation: {required_fragment}") print("saves: multi-account fallback and profile/compendium context enforced") @@ -908,7 +908,7 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") - for required_field in ["profile_id", "progress_path", "profile_root", "save_scope", "current_run"]: + for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") @@ -922,7 +922,7 @@ def audit_live(base_url: str) -> None: fail(f"{path} expected status ok and kind {expected_kind}, got {data}") if not isinstance(data.get("items"), list) or data.get("count") != len(data["items"]): fail(f"{path} expected items list with matching count, got {data}") - for required_field in ["profile_id", "progress_path", "profile_root", "save_scope"]: + for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") current_run = data.get("current_run") From c5ae36ccced512b4fcb6e08f7bd1774edf49bcc0 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 09:59:59 -0400 Subject: [PATCH 125/190] Enrich current run context --- McpMod.Compendium.cs | 3 +++ docs/raw-full.md | 5 ++++- docs/raw-simplified.md | 2 +- scripts/audit_endpoints.py | 8 ++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index cd180b27..fd8a610c 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -404,6 +404,9 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) { ["is_in_progress"] = true, ["profile_id"] = profileId, + ["progress_path"] = progressPath, + ["resolved_progress_path"] = ResolveProfileProgressPath(profileId), + ["profile_root"] = profileRoot, ["save_scope"] = saveScope, ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" }; diff --git a/docs/raw-full.md b/docs/raw-full.md index bbff4095..074eebfb 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1017,7 +1017,7 @@ Returns the active profile's persistent progress summary, including `profile_id` Returns the active profile's progress grouped by the in-game Compendium cards: - `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. -- `current_run`: present while a run is active. Includes a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. +- `current_run`: present while a run is active. Includes profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. - `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. - `potion_lab`: discovered potion IDs. Detailed potion metadata lives at `/api/v1/glossary/potions`, which currently requires a run context. @@ -1035,6 +1035,9 @@ Returns the active profile's progress grouped by the in-game Compendium cards: "current_run": { "is_in_progress": true, "profile_id": 1, + "progress_path": "modded/profile1\\saves\\progress.save", + "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", + "profile_root": "modded/profile1", "save_scope": "modded", "run_id": "modded:profile1:1778295706", "start_time": 1778295706, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 6ce34aab..c3ea7bbe 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -32,7 +32,7 @@ Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if misma Every JSON response includes: - `state_type` — which screen the game is on (see below) - `run` — `{ act, floor, ascension }` (absent for `menu`) -- `current_run` — run identity and save context when a run is active, including `profile_id`, `save_scope`, `run_id`, and `seed` when the save file exposes it +- `current_run` — run identity and save context when a run is active, including `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `run_id`, and `seed` when the save file exposes it - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Hand cards also include `requires_target` and `valid_targets` for enemy-targeted cards. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8cbbf04e..f3891258 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -403,8 +403,9 @@ def audit_state_surface(repo: Path) -> None: for source_name, source in [("singleplayer", state_builder), ("multiplayer", multiplayer_state)]: if '["current_run"] = BuildActiveRunContext()' not in source: fail(f"{source_name} state missing current_run identity context") - if "run_id" not in docs or "save_scope" not in docs: - fail("docs missing current_run run_id/save_scope context") + for required_fragment in ["run_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if required_fragment not in docs: + fail(f"docs missing current_run context: {required_fragment}") state_types |= multiplayer_state_types missing_docs = sorted(state_type for state_type in state_types if state_type not in docs) @@ -928,6 +929,9 @@ def audit_live(base_url: str) -> None: current_run = data.get("current_run") if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): fail(f"{path} expected current_run run_id and seed, got {current_run}") + for required_field in ["progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if required_field not in current_run: + fail(f"{path} current_run missing profile/save context field: {required_field}") glossary_payloads[expected_kind] = data if {"keywords", "relics", "potions"}.issubset(glossary_payloads): From 39ff8a678352c8dfde2a139ffda032ea134583a6 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:06:19 -0400 Subject: [PATCH 126/190] Preserve glossary error context --- McpMod.ForkEndpoints.cs | 43 +++++++++++++++++++++++++++++++++----- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 6 ++++++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 4d6d081e..3b3c3782 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -156,14 +156,27 @@ private sealed class GlossaryPayload private static GlossaryPayload BuildGlossaryPayload(string kind, Func buildData) { - var data = buildData(); - if (data is Dictionary error && error.TryGetValue("error_code", out _)) - return new GlossaryPayload { Kind = kind, Data = error }; - var profileId = SaveManager.Instance?.CurrentProfileId ?? 0; var progressPath = GetProfileProgressPath(profileId); var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + + var data = buildData(); + if (data is Dictionary error && error.TryGetValue("error_code", out _)) + { + return new GlossaryPayload + { + Kind = kind, + Data = error, + ProfileId = profileId, + ProgressPath = progressPath, + ResolvedProgressPath = resolvedProgressPath, + ProfileRoot = profileRoot, + SaveScope = GetSaveScope(profileRoot), + NetType = SafeGetNetTypeName() + }; + } + var runState = RunManager.Instance.DebugOnlyGetState(); var players = runState?.Players.Select((player, index) => new Dictionary { @@ -181,11 +194,23 @@ private static GlossaryPayload BuildGlossaryPayload(string kind, Func bu ResolvedProgressPath = resolvedProgressPath, ProfileRoot = profileRoot, SaveScope = GetSaveScope(profileRoot), - NetType = RunManager.Instance.NetService.Type.ToString(), + NetType = SafeGetNetTypeName(), Players = players }; } + private static string SafeGetNetTypeName() + { + try + { + return RunManager.Instance?.NetService?.Type.ToString() ?? ""; + } + catch + { + return ""; + } + } + private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayload payload) { var data = payload.Data; @@ -199,6 +224,14 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl _ => 500 }; + error["kind"] = payload.Kind; + error["scope"] = "active_run"; + error["profile_id"] = payload.ProfileId; + error["progress_path"] = payload.ProgressPath; + error["resolved_progress_path"] = payload.ResolvedProgressPath; + error["profile_root"] = payload.ProfileRoot; + error["save_scope"] = payload.SaveScope; + error["net_type"] = payload.NetType; SendJson(response, data); return; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 074eebfb..5618b3c1 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1081,7 +1081,7 @@ The glossary endpoints expose active-run pool metadata. They require a run in pr - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. -If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"`. +If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"` plus the same profile/save context fields so callers can identify the active profile even without a current run. Example success shape: diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c3ea7bbe..be2a85e7 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -78,7 +78,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"`. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index 93ae4e53..7dff3d41 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -48,7 +48,7 @@ Profile and Compendium tools include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index f3891258..8ccc845c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -915,6 +915,12 @@ def audit_live(base_url: str) -> None: if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") + if path.startswith("/api/v1/glossary/") and status == 409: + if not isinstance(data, dict) or data.get("error_code") != "run_not_in_progress": + fail(f"{path} expected run_not_in_progress glossary error, got {data}") + for required_field in ["kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if required_field not in data: + fail(f"{path} glossary error missing profile/save context field: {required_field}") if path.startswith("/api/v1/glossary/") and status == 200: expected_kind = path.rsplit("/", 1)[-1] if not isinstance(data, dict): From 4f985ac093c722a20b7d106fa9fdf497e06c4b11 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:10:50 -0400 Subject: [PATCH 127/190] Shape bestiary metadata response --- McpMod.ForkEndpoints.cs | 21 +++++++++++++++++---- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- mcp/server.py | 4 +++- scripts/audit_endpoints.py | 13 +++++++++++++ 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 3b3c3782..701b26df 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -512,7 +512,6 @@ private static void AddKeywordTips(IEnumerable tips, Dictionary(); var bindFlags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; @@ -555,7 +554,6 @@ internal static object BuildBestiary() monsters.Add(entry); } - result["monsters"] = monsters; var encounters = new List>(); foreach (var type in typeof(EncounterModel).Assembly.GetTypes()) @@ -609,8 +607,23 @@ internal static object BuildBestiary() encounters.Add(entry); } - result["encounters"] = encounters; - return result; + var orderedMonsters = monsters + .OrderBy(entry => entry.TryGetValue("id", out var id) ? id?.ToString() : "", StringComparer.Ordinal) + .ToList(); + var orderedEncounters = encounters + .OrderBy(entry => entry.TryGetValue("id", out var id) ? id?.ToString() : "", StringComparer.Ordinal) + .ToList(); + + return new Dictionary + { + ["status"] = "ok", + ["kind"] = "bestiary", + ["source"] = "reflected MonsterModel and EncounterModel metadata", + ["monster_count"] = orderedMonsters.Count, + ["encounter_count"] = orderedEncounters.Count, + ["monsters"] = orderedMonsters, + ["encounters"] = orderedEncounters + }; } } diff --git a/docs/raw-full.md b/docs/raw-full.md index 5618b3c1..7eb6c3e1 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1070,7 +1070,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: ### `GET /api/v1/bestiary` -Returns reflected monster and encounter metadata. Profile-specific encounter and enemy fight stats are also summarized under `/api/v1/compendium`. +Returns reflected monster and encounter metadata as a deterministic structured object with `status`, `kind`, `monster_count`, `encounter_count`, `monsters`, and `encounters`. Profile-specific encounter and enemy fight stats are also summarized under `/api/v1/compendium`. ### `GET /api/v1/glossary/*` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index be2a85e7..2e10a3be 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -76,7 +76,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/settings` returns display, audio, gameplay, language, and mod-loading preferences. -`GET /api/v1/bestiary` returns reflected monster and encounter metadata. Profile-specific fight stats are also summarized under `/api/v1/compendium`. +`GET /api/v1/bestiary` returns deterministic reflected monster and encounter metadata with `status`, `kind`, `monster_count`, `encounter_count`, `monsters`, and `encounters`. Profile-specific fight stats are also summarized under `/api/v1/compendium`. The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. diff --git a/mcp/README.md b/mcp/README.md index 7dff3d41..78a637ec 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -10,7 +10,7 @@ | `get_settings()` | General | Get current settings and preferences | | `get_profile()` | Profiles | Get active profile progress plus save/run context | | `get_compendium()` | Profiles | Get Compendium-shaped profile progress plus save/run context | -| `get_bestiary()` | Profiles | Get monster and encounter metadata | +| `get_bestiary()` | Profiles | Get deterministic monster and encounter metadata with counts | | `get_glossary_cards()` | Active Pool | Get active-run card pool metadata with run_id/seed scope | | `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata with run_id/seed scope | | `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata with run_id/seed scope | diff --git a/mcp/server.py b/mcp/server.py index a378287c..ffb8ee14 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -323,7 +323,9 @@ async def get_bestiary() -> str: """Get reflected monster and encounter metadata. This is model/reference metadata and is separate from Compendium profile - encounter stats. The in-game Bestiary card is currently marked locked/future. + encounter stats. The response includes status/kind, monster and encounter + counts, and deterministic monster/encounter arrays. The in-game Bestiary + card is currently marked locked/future. """ try: return await _bestiary_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8ccc845c..1446432e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -912,6 +912,19 @@ def audit_live(base_url: str) -> None: for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") + if path == "/api/v1/bestiary": + if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "bestiary": + fail(f"{path} expected structured bestiary status/kind, got {data}") + monsters = data.get("monsters") + encounters = data.get("encounters") + if not isinstance(monsters, list) or data.get("monster_count") != len(monsters): + fail(f"{path} expected monsters list with matching monster_count, got {data}") + if not isinstance(encounters, list) or data.get("encounter_count") != len(encounters): + fail(f"{path} expected encounters list with matching encounter_count, got {data}") + monster_ids = [str(item.get("id")) for item in monsters if isinstance(item, dict)] + encounter_ids = [str(item.get("id")) for item in encounters if isinstance(item, dict)] + if monster_ids != sorted(monster_ids) or encounter_ids != sorted(encounter_ids): + fail(f"{path} expected deterministic id ordering") if path.startswith("/api/v1/glossary/") and status not in {200, 409}: fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") From c5726bc8267326ff0ed967383addd4a7a1c742c1 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:16:03 -0400 Subject: [PATCH 128/190] Harden settings endpoint shape --- McpMod.ForkEndpoints.cs | 95 +++++++++++++++++++++----------------- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/server.py | 4 +- scripts/audit_endpoints.py | 18 ++++++++ 5 files changed, 74 insertions(+), 47 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 701b26df..023aacc8 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -24,49 +24,7 @@ private static void HandleGetSettings(HttpListenerResponse response) { try { - var dataTask = RunOnMainThread(() => - { - var sm = SaveManager.Instance; - var settings = sm.SettingsSave; - var prefs = sm.PrefsSave; - - return new Dictionary - { - ["display"] = new Dictionary - { - ["fullscreen"] = settings.Fullscreen, - ["resolution"] = $"{settings.WindowSize.X}x{settings.WindowSize.Y}", - ["fps_limit"] = settings.FpsLimit, - ["vsync"] = settings.VSync.ToString(), - ["msaa"] = settings.Msaa, - ["aspect_ratio"] = settings.AspectRatioSetting.ToString(), - ["target_display"] = settings.TargetDisplay, - ["limit_fps_background"] = settings.LimitFpsInBackground, - }, - ["audio"] = new Dictionary - { - ["master"] = settings.VolumeMaster, - ["bgm"] = settings.VolumeBgm, - ["sfx"] = settings.VolumeSfx, - ["ambience"] = settings.VolumeAmbience, - }, - ["gameplay"] = new Dictionary - { - ["fast_mode"] = prefs.FastMode.ToString(), - ["screen_shake"] = prefs.ScreenShakeOptionIndex, - ["show_run_timer"] = prefs.ShowRunTimer, - ["show_card_indices"] = prefs.ShowCardIndices, - ["text_effects"] = prefs.TextEffectsEnabled, - ["long_press"] = prefs.IsLongPressEnabled, - }, - ["mods"] = new Dictionary - { - ["enabled"] = settings.ModSettings?.PlayerAgreedToModLoading ?? false, - }, - ["language"] = settings.Language, - ["skip_intro"] = settings.SkipIntroLogo, - }; - }); + var dataTask = RunOnMainThread(BuildSettings); SendJson(response, dataTask.GetAwaiter().GetResult()); } @@ -76,6 +34,57 @@ private static void HandleGetSettings(HttpListenerResponse response) } } + internal static object BuildSettings() + { + var sm = SaveManager.Instance; + if (sm == null) + return Error("Save manager is not available"); + + var settings = sm.SettingsSave; + var prefs = sm.PrefsSave; + if (settings == null || prefs == null) + return Error("Settings data is not available"); + + return new Dictionary + { + ["status"] = "ok", + ["kind"] = "settings", + ["display"] = new Dictionary + { + ["fullscreen"] = settings.Fullscreen, + ["resolution"] = $"{settings.WindowSize.X}x{settings.WindowSize.Y}", + ["fps_limit"] = settings.FpsLimit, + ["vsync"] = settings.VSync.ToString(), + ["msaa"] = settings.Msaa, + ["aspect_ratio"] = settings.AspectRatioSetting.ToString(), + ["target_display"] = settings.TargetDisplay, + ["limit_fps_background"] = settings.LimitFpsInBackground, + }, + ["audio"] = new Dictionary + { + ["master"] = settings.VolumeMaster, + ["bgm"] = settings.VolumeBgm, + ["sfx"] = settings.VolumeSfx, + ["ambience"] = settings.VolumeAmbience, + }, + ["gameplay"] = new Dictionary + { + ["fast_mode"] = prefs.FastMode.ToString(), + ["screen_shake"] = prefs.ScreenShakeOptionIndex, + ["show_run_timer"] = prefs.ShowRunTimer, + ["show_card_indices"] = prefs.ShowCardIndices, + ["text_effects"] = prefs.TextEffectsEnabled, + ["long_press"] = prefs.IsLongPressEnabled, + }, + ["mods"] = new Dictionary + { + ["enabled"] = settings.ModSettings?.PlayerAgreedToModLoading ?? false, + }, + ["language"] = settings.Language, + ["skip_intro"] = settings.SkipIntroLogo, + }; + } + private static void HandleGetBestiary(HttpListenerResponse response) { try diff --git a/docs/raw-full.md b/docs/raw-full.md index 7eb6c3e1..9269fb76 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1006,7 +1006,7 @@ Profile endpoints are independent of the singleplayer and multiplayer run endpoi ### `GET /api/v1/settings` -Returns current settings and preferences, including display, audio, gameplay, language, skip-intro, and mod-loading status. +Returns current settings and preferences as a structured object with `status: "ok"`, `kind: "settings"`, display, audio, gameplay, language, skip-intro, and mod-loading status. ### `GET /api/v1/profile` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 2e10a3be..6e0e809f 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -74,7 +74,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/profile` returns persistent progress for the active profile, including character stats, discoveries, achievements, epochs, and global run totals. -`GET /api/v1/settings` returns display, audio, gameplay, language, and mod-loading preferences. +`GET /api/v1/settings` returns `status: "ok"`, `kind: "settings"`, display, audio, gameplay, language, and mod-loading preferences. `GET /api/v1/bestiary` returns deterministic reflected monster and encounter metadata with `status`, `kind`, `monster_count`, `encounter_count`, `monsters`, and `encounters`. Profile-specific fight stats are also summarized under `/api/v1/compendium`. diff --git a/mcp/server.py b/mcp/server.py index ffb8ee14..5ded9133 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -309,8 +309,8 @@ async def get_compendium() -> str: async def get_settings() -> str: """Get current game settings and preferences. - Includes display, audio, gameplay preferences, language, and mod-loading - status as exposed by the STS2_MCP HTTP settings endpoint. + Includes status/kind, display, audio, gameplay preferences, language, and + mod-loading status as exposed by the STS2_MCP HTTP settings endpoint. """ try: return await _settings_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 1446432e..2eab797b 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -179,6 +179,18 @@ def audit_static_error_shapes(repo: Path) -> None: if raw_error_writes: fail(f"500 handlers should use SendError for structured error bodies: {sorted(raw_error_writes)}") + fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + settings_match = re.search( + r"internal static object BuildSettings\(\).*?\n private static void HandleGetBestiary", + fork_endpoints, + re.S, + ) + if not settings_match: + fail("could not locate BuildSettings for settings audit") + settings_body = settings_match.group(0) + for required_fragment in ["SaveManager.Instance", "Save manager is not available", "Settings data is not available", "status", "kind"]: + if required_fragment not in settings_body: + fail(f"settings endpoint missing startup-safe structured field: {required_fragment}") print("errors: structured 500 response helpers enforced") @@ -906,6 +918,12 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/settings", "/api/v1/profile", "/api/v1/compendium", "/api/v1/bestiary", "/api/v1/profiles"} and status != 200: fail(f"{path} expected HTTP 200, got {status}: {data}") + if path == "/api/v1/settings": + if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "settings": + fail(f"{path} expected structured settings status/kind, got {data}") + for required_field in ["display", "audio", "gameplay", "mods", "language", "skip_intro"]: + if required_field not in data: + fail(f"{path} missing settings field: {required_field}") if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") From 0cabf0ef1db02ed29f454e2ca26ac4790753b636 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:25:31 -0400 Subject: [PATCH 129/190] Shape profiles endpoint response --- McpMod.Profile.cs | 23 +++++++++++++++++------ docs/raw-full.md | 36 +++++++++++++++++++++++++++++++++--- docs/raw-simplified.md | 11 +++++++---- mcp/README.md | 2 +- mcp/server.py | 5 ++++- scripts/audit_endpoints.py | 24 ++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 15 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index cb74e184..7d382fbb 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -110,28 +110,39 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener var profileData = new Dictionary { ["id"] = i, + ["profile_id"] = i, ["is_current"] = i == sm.CurrentProfileId, }; + string? path = null; + string? resolvedPath = null; + var profileRoot = $"profile{i}"; try { - var path = ProgressSaveManager.GetProgressPathForProfile(i); - var resolvedPath = ResolveProfileProgressPath(i); - profileData["has_data"] = resolvedPath != null && File.Exists(resolvedPath); - profileData["path"] = path; - profileData["resolved_path"] = resolvedPath; + path = GetProfileProgressPath(i); + resolvedPath = ResolveProfileProgressPath(i); + profileRoot = GetProfileRootFromProgressPath(path, i); } catch { - profileData["has_data"] = false; } + profileData["has_data"] = resolvedPath != null && File.Exists(resolvedPath); + profileData["path"] = path; + profileData["resolved_path"] = resolvedPath; + profileData["progress_path"] = path; + profileData["resolved_progress_path"] = resolvedPath; + profileData["profile_root"] = profileRoot; + profileData["save_scope"] = GetSaveScope(profileRoot); profiles.Add(profileData); } return new Dictionary { + ["status"] = "ok", + ["kind"] = "profiles", ["current_profile_id"] = sm.CurrentProfileId, + ["count"] = profiles.Count, ["profiles"] = profiles }; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 9269fb76..1f7e4954 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1128,11 +1128,41 @@ Lists the three profile slots and identifies the active slot. ```json { + "status": "ok", + "kind": "profiles", "current_profile_id": 1, + "count": 3, "profiles": [ - { "id": 1, "is_current": true, "has_data": true }, - { "id": 2, "is_current": false, "has_data": false }, - { "id": 3, "is_current": false, "has_data": true } + { + "id": 1, + "profile_id": 1, + "is_current": true, + "has_data": true, + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/.../SlayTheSpire2/steam//modded/profile1/saves/progress.save", + "profile_root": "modded/profile1", + "save_scope": "modded" + }, + { + "id": 2, + "profile_id": 2, + "is_current": false, + "has_data": false, + "progress_path": "modded/profile2/saves/progress.save", + "resolved_progress_path": "modded/profile2/saves/progress.save", + "profile_root": "modded/profile2", + "save_scope": "modded" + }, + { + "id": 3, + "profile_id": 3, + "is_current": false, + "has_data": true, + "progress_path": "modded/profile3/saves/progress.save", + "resolved_progress_path": "C:/.../SlayTheSpire2/steam//modded/profile3/saves/progress.save", + "profile_root": "modded/profile3", + "save_scope": "modded" + } ] } ``` diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 6e0e809f..93b1ef04 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -95,17 +95,20 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | `character_stats` | Per-character and global totals. | | `run_history` | Summaries of the active profile's saved `saves/history/*.run` files, capped to the 20 most recent entries. | -`GET /api/v1/profiles` returns the three profile slots: +`GET /api/v1/profiles` returns the three profile slots with `status`, `kind`, `count`, and per-slot save context fields (`progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`): `GET /api/v1/profile` returns the active profile's progress with `profile_id`, save scope/path context, `resolved_progress_path`, and `current_run` when a run is active. ```json { + "status": "ok", + "kind": "profiles", "current_profile_id": 1, + "count": 3, "profiles": [ - { "id": 1, "is_current": true, "has_data": true }, - { "id": 2, "is_current": false, "has_data": false }, - { "id": 3, "is_current": false, "has_data": true } + { "id": 1, "profile_id": 1, "is_current": true, "has_data": true, "progress_path": "modded/profile1/saves/progress.save", "resolved_progress_path": "C:/.../progress.save", "profile_root": "modded/profile1", "save_scope": "modded" }, + { "id": 2, "profile_id": 2, "is_current": false, "has_data": false, "progress_path": "modded/profile2/saves/progress.save", "resolved_progress_path": "modded/profile2/saves/progress.save", "profile_root": "modded/profile2", "save_scope": "modded" }, + { "id": 3, "profile_id": 3, "is_current": false, "has_data": true, "progress_path": "modded/profile3/saves/progress.save", "resolved_progress_path": "C:/.../progress.save", "profile_root": "modded/profile3", "save_scope": "modded" } ] } ``` diff --git a/mcp/README.md b/mcp/README.md index 78a637ec..cdcc5d34 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Profile and Compendium tools include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, and `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. +Profile, profile-list, and Compendium tools include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/mcp/server.py b/mcp/server.py index 5ded9133..72d4c750 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -396,7 +396,10 @@ async def get_glossary_keywords() -> str: async def list_profiles() -> str: """List the three profile slots and identify the active slot. - The has_data field indicates whether a slot currently has progress data. + The response includes status/kind/count plus per-slot profile_id, + progress_path, resolved_progress_path, profile_root, save_scope, and + has_data. The has_data field indicates whether a slot currently has + progress data. """ try: return await _profiles_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 2eab797b..027b43c2 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -386,6 +386,18 @@ def audit_static_save_roots(repo: Path) -> None: if required_fragment not in profile_body: fail(f"profile endpoint missing identity/run context: {required_fragment}") + profiles_match = re.search( + r"private static Dictionary BuildProfilesSummary\(\).*?\n private static Dictionary ExecuteProfileAction", + profile, + re.S, + ) + if not profiles_match: + fail("could not locate BuildProfilesSummary for profile slots audit") + profiles_body = profiles_match.group(0) + for required_fragment in ["status", "kind", "count", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if required_fragment not in profiles_body: + fail(f"profiles endpoint missing structured slot context: {required_fragment}") + for doc_name, doc_text in [("mcp/server.py", mcp_server), ("mcp/README.md", mcp_readme)]: for required_fragment in ["progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_fragment not in doc_text: @@ -930,6 +942,18 @@ def audit_live(base_url: str) -> None: for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") + if path == "/api/v1/profiles": + if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "profiles": + fail(f"{path} expected structured profiles status/kind, got {data}") + profiles = data.get("profiles") + if not isinstance(profiles, list) or data.get("count") != len(profiles): + fail(f"{path} expected profiles list with matching count, got {data}") + for profile_slot in profiles: + if not isinstance(profile_slot, dict): + fail(f"{path} expected profile slot objects, got {profile_slot}") + for required_field in ["id", "profile_id", "is_current", "has_data", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if required_field not in profile_slot: + fail(f"{path} profile slot missing field {required_field}: {profile_slot}") if path == "/api/v1/bestiary": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "bestiary": fail(f"{path} expected structured bestiary status/kind, got {data}") From 79d0551bf7989bf70ea63075e877033519d9dec3 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:28:52 -0400 Subject: [PATCH 130/190] Add profile endpoint envelopes --- McpMod.Compendium.cs | 2 ++ McpMod.Profile.cs | 2 ++ docs/raw-full.md | 5 ++++- docs/raw-simplified.md | 4 +++- mcp/README.md | 2 +- mcp/server.py | 6 +++--- scripts/audit_endpoints.py | 7 +++++-- 7 files changed, 20 insertions(+), 8 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index fd8a610c..edcc033c 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -113,6 +113,8 @@ private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) return new Dictionary { + ["status"] = "ok", + ["kind"] = "compendium", ["profile_id"] = snapshot.ProfileId, ["progress_path"] = snapshot.ProgressPath, ["resolved_progress_path"] = snapshot.ResolvedProgressPath, diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 7d382fbb..7bf1a5c0 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -253,6 +253,8 @@ internal static object BuildProfile() var progressPath = GetProfileProgressPath(profileId); var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + result["status"] = "ok"; + result["kind"] = "profile"; result["profile_id"] = profileId; result["progress_path"] = progressPath; result["resolved_progress_path"] = resolvedProgressPath; diff --git a/docs/raw-full.md b/docs/raw-full.md index 1f7e4954..18f723b8 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1010,12 +1010,13 @@ Returns current settings and preferences as a structured object with `status: "o ### `GET /api/v1/profile` -Returns the active profile's persistent progress summary, including `profile_id`, save scope/path context, `resolved_progress_path`, `current_run` when a run is active, character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. +Returns the active profile's persistent progress summary, including `status: "ok"`, `kind: "profile"`, `profile_id`, save scope/path context, `resolved_progress_path`, `current_run` when a run is active, character stats, card stats, encounter stats, discovered content, achievements, epochs, and global totals. ### `GET /api/v1/compendium` Returns the active profile's progress grouped by the in-game Compendium cards: +- `status`, `kind`: response envelope fields. `kind` is `compendium`. - `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. - `current_run`: present while a run is active. Includes profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. @@ -1027,6 +1028,8 @@ Returns the active profile's progress grouped by the in-game Compendium cards: ```jsonc { + "status": "ok", + "kind": "compendium", "profile_id": 1, "progress_path": "modded/profile1\\saves\\progress.save", "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 93b1ef04..77a15c90 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -97,7 +97,9 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope `GET /api/v1/profiles` returns the three profile slots with `status`, `kind`, `count`, and per-slot save context fields (`progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`): -`GET /api/v1/profile` returns the active profile's progress with `profile_id`, save scope/path context, `resolved_progress_path`, and `current_run` when a run is active. +`GET /api/v1/profile` returns the active profile's progress with `status`, `kind: "profile"`, `profile_id`, save scope/path context, `resolved_progress_path`, and `current_run` when a run is active. + +`GET /api/v1/compendium` returns the active profile's Compendium-shaped progress with `status`, `kind: "compendium"`, the same profile/save context, and grouped Compendium sections. ```json { diff --git a/mcp/README.md b/mcp/README.md index cdcc5d34..d2f483eb 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -Profile, profile-list, and Compendium tools include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. +Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/mcp/server.py b/mcp/server.py index 72d4c750..1b32b87c 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -278,7 +278,7 @@ async def get_profile() -> str: """Get the current profile's persistent progress summary. Includes character stats, discovered content, achievements, epochs, and global - run totals for the active profile, plus profile_id, progress_path, + run totals for the active profile, plus status/kind, profile_id, progress_path, resolved_progress_path, profile_root, save_scope, and current_run when a run is active. """ @@ -296,8 +296,8 @@ async def get_compendium() -> str: Potion Lab, Bestiary, Character Stats, and Run History. Some sections point to detail endpoints such as glossary or bestiary when model-level metadata is separate from profile discovery/progress data. The top level includes the - same profile_id/progress_path/resolved_progress_path/profile_root/save_scope/ - current_run context as get_profile(). + same status/kind/profile_id/progress_path/resolved_progress_path/profile_root/ + save_scope/current_run context as get_profile(). """ try: return await _compendium_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 027b43c2..e557289a 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -370,7 +370,7 @@ def audit_static_save_roots(repo: Path) -> None: if not compendium_response_match: fail("could not locate BuildCompendiumResponse for profile context audit") compendium_response = compendium_response_match.group(0) - for required_fragment in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: + for required_fragment in ["status", "kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_fragment not in compendium_response: fail(f"compendium endpoint missing profile/save context: {required_fragment}") @@ -382,7 +382,7 @@ def audit_static_save_roots(repo: Path) -> None: if not profile_match: fail("could not locate BuildProfile for profile context audit") profile_body = profile_match.group(0) - for required_fragment in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: + for required_fragment in ["status", "kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run", "BuildActiveRunContext"]: if required_fragment not in profile_body: fail(f"profile endpoint missing identity/run context: {required_fragment}") @@ -939,6 +939,9 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") + expected_kind = path.rsplit("/", 1)[-1] + if data.get("status") != "ok" or data.get("kind") != expected_kind: + fail(f"{path} expected status ok and kind {expected_kind}, got {data}") for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") From c95f601245c32ca4d750c8e894435b451cc720bd Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:34:39 -0400 Subject: [PATCH 131/190] Stabilize profile metadata ordering --- McpMod.Compendium.cs | 69 +++++++++++++++++++++++--------------- McpMod.Profile.cs | 42 ++++++++++++----------- scripts/audit_endpoints.py | 46 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 47 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index edcc033c..ea41ebf4 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -50,27 +50,31 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() var resolvedProgressPath = ResolveProfileProgressPath(profileId); var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); - var cardStats = progress.CardStats.Select(kv => new Dictionary - { - ["id"] = kv.Key.Entry, - ["times_picked"] = kv.Value.TimesPicked, - ["times_skipped"] = kv.Value.TimesSkipped, - ["times_won"] = kv.Value.TimesWon, - ["times_lost"] = kv.Value.TimesLost - }).ToList(); - - var characterStats = progress.CharacterStats.Select(kv => new Dictionary - { - ["id"] = kv.Key.Entry, - ["max_ascension"] = kv.Value.MaxAscension, - ["preferred_ascension"] = kv.Value.PreferredAscension, - ["total_wins"] = kv.Value.TotalWins, - ["total_losses"] = kv.Value.TotalLosses, - ["fastest_win_time"] = kv.Value.FastestWinTime, - ["best_win_streak"] = kv.Value.BestWinStreak, - ["current_win_streak"] = kv.Value.CurrentWinStreak, - ["playtime"] = kv.Value.Playtime - }).ToList(); + var cardStats = progress.CardStats + .OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal) + .Select(kv => new Dictionary + { + ["id"] = kv.Key.Entry, + ["times_picked"] = kv.Value.TimesPicked, + ["times_skipped"] = kv.Value.TimesSkipped, + ["times_won"] = kv.Value.TimesWon, + ["times_lost"] = kv.Value.TimesLost + }).ToList(); + + var characterStats = progress.CharacterStats + .OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal) + .Select(kv => new Dictionary + { + ["id"] = kv.Key.Entry, + ["max_ascension"] = kv.Value.MaxAscension, + ["preferred_ascension"] = kv.Value.PreferredAscension, + ["total_wins"] = kv.Value.TotalWins, + ["total_losses"] = kv.Value.TotalLosses, + ["fastest_win_time"] = kv.Value.FastestWinTime, + ["best_win_streak"] = kv.Value.BestWinStreak, + ["current_win_streak"] = kv.Value.CurrentWinStreak, + ["playtime"] = kv.Value.Playtime + }).ToList(); return new CompendiumSnapshot { @@ -80,9 +84,9 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() ProfileRoot = profileRoot, SaveScope = GetSaveScope(profileRoot), IsRunInProgress = RunManager.Instance?.IsInProgress == true, - DiscoveredCards = progress.DiscoveredCards.Select(id => id.Entry).ToList(), - DiscoveredRelics = progress.DiscoveredRelics.Select(id => id.Entry).ToList(), - DiscoveredPotions = progress.DiscoveredPotions.Select(id => id.Entry).ToList(), + DiscoveredCards = progress.DiscoveredCards.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(), + DiscoveredRelics = progress.DiscoveredRelics.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(), + DiscoveredPotions = progress.DiscoveredPotions.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(), CardStats = cardStats, CharacterStats = characterStats, EncounterStats = BuildEncounterStats(progress), @@ -202,7 +206,7 @@ private sealed class CompendiumSnapshot var result = new List>(); foreach (var kv in progress.EncounterStats) result.Add(BuildFightStatsEntry(kv.Key.Entry, kv.Value)); - return result; + return SortDictionaryListByStringField(result, "id"); } private static List> BuildEnemyStats(dynamic progress) @@ -210,7 +214,7 @@ private sealed class CompendiumSnapshot var result = new List>(); foreach (var kv in progress.EnemyStats) result.Add(BuildFightStatsEntry(kv.Key.Entry, kv.Value)); - return result; + return SortDictionaryListByStringField(result, "id"); } private static Dictionary BuildFightStatsEntry(string id, dynamic stats) @@ -233,11 +237,22 @@ private sealed class CompendiumSnapshot }); } if (byCharacter.Count > 0) - entry["by_character"] = byCharacter; + entry["by_character"] = byCharacter + .OrderBy(item => item.TryGetValue("character", out var value) ? value?.ToString() : "", StringComparer.Ordinal) + .ToList(); return entry; } + private static List> SortDictionaryListByStringField( + List> items, + string field) + { + return items + .OrderBy(item => item.TryGetValue(field, out var value) ? value?.ToString() : "", StringComparer.Ordinal) + .ToList(); + } + private static Dictionary BuildRunHistoryProgressMembers(object progress) { var values = new Dictionary(); diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 7bf1a5c0..a699b184 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -263,7 +263,7 @@ internal static object BuildProfile() result["current_run"] = BuildActiveRunContext(); var characters = new List>(); - foreach (var kv in progress.CharacterStats) + foreach (var kv in progress.CharacterStats.OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal)) { var stats = kv.Value; characters.Add(new Dictionary @@ -282,7 +282,7 @@ internal static object BuildProfile() result["characters"] = characters; var cards = new List>(); - foreach (var kv in progress.CardStats) + foreach (var kv in progress.CardStats.OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal)) { var stats = kv.Value; cards.Add(new Dictionary @@ -297,7 +297,7 @@ internal static object BuildProfile() result["card_stats"] = cards; var encounters = new List>(); - foreach (var kv in progress.EncounterStats) + foreach (var kv in progress.EncounterStats.OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal)) { var enc = new Dictionary { @@ -316,13 +316,13 @@ internal static object BuildProfile() }); } if (fightStats.Count > 0) - enc["by_character"] = fightStats; + enc["by_character"] = SortDictionaryListByStringField(fightStats, "character"); encounters.Add(enc); } result["encounter_stats"] = encounters; var enemies = new List>(); - foreach (var kv in progress.EnemyStats) + foreach (var kv in progress.EnemyStats.OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal)) { var enemy = new Dictionary { @@ -341,13 +341,13 @@ internal static object BuildProfile() }); } if (fightStats.Count > 0) - enemy["by_character"] = fightStats; + enemy["by_character"] = SortDictionaryListByStringField(fightStats, "character"); enemies.Add(enemy); } result["enemy_stats"] = enemies; var ancients = new List>(); - foreach (var kv in progress.AncientStats) + foreach (var kv in progress.AncientStats.OrderBy(kv => kv.Key.Entry, StringComparer.Ordinal)) { var anc = new Dictionary { @@ -367,16 +367,16 @@ internal static object BuildProfile() }); } if (charStats.Count > 0) - anc["by_character"] = charStats; + anc["by_character"] = SortDictionaryListByStringField(charStats, "character"); ancients.Add(anc); } result["ancient_stats"] = ancients; - result["discovered_cards"] = progress.DiscoveredCards.Select(id => id.Entry).ToList(); - result["discovered_relics"] = progress.DiscoveredRelics.Select(id => id.Entry).ToList(); - result["discovered_potions"] = progress.DiscoveredPotions.Select(id => id.Entry).ToList(); - result["discovered_events"] = progress.DiscoveredEvents.Select(id => id.Entry).ToList(); - result["discovered_acts"] = progress.DiscoveredActs.Select(id => id.Entry).ToList(); + result["discovered_cards"] = progress.DiscoveredCards.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(); + result["discovered_relics"] = progress.DiscoveredRelics.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(); + result["discovered_potions"] = progress.DiscoveredPotions.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(); + result["discovered_events"] = progress.DiscoveredEvents.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(); + result["discovered_acts"] = progress.DiscoveredActs.Select(id => id.Entry).OrderBy(id => id, StringComparer.Ordinal).ToList(); var achievements = new List>(); foreach (var kv in progress.UnlockedAchievements) @@ -387,14 +387,16 @@ internal static object BuildProfile() ["unlocked_at"] = kv.Value }); } - result["achievements"] = achievements; + result["achievements"] = SortDictionaryListByStringField(achievements, "id"); - result["epochs"] = progress.Epochs.Select(e => new Dictionary - { - ["id"] = e.Id, - ["state"] = e.State.ToString(), - ["obtained"] = e.ObtainDate - }).ToList(); + result["epochs"] = progress.Epochs + .OrderBy(e => e.Id?.ToString(), StringComparer.Ordinal) + .Select(e => new Dictionary + { + ["id"] = e.Id, + ["state"] = e.State.ToString(), + ["obtained"] = e.ObtainDate + }).ToList(); result["total_playtime"] = progress.TotalPlaytime; result["total_unlocks"] = progress.TotalUnlocks; diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index e557289a..bc8e34a6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -73,6 +73,29 @@ def assert_error_body(path: str, status: int, data: object) -> None: fail(f"{path} returned HTTP {status} without status/error fields: {data}") +def assert_sorted_strings(path: str, field: str, values: object) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + strings = [str(value) for value in values] + if strings != sorted(strings): + fail(f"{path} expected {field} to be sorted") + + +def assert_sorted_objects(path: str, field: str, values: object, key: str) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + keys = [] + for item in values: + if not isinstance(item, dict): + fail(f"{path} expected {field} entries to be objects, got {item}") + keys.append(str(item.get(key))) + nested = item.get("by_character") + if nested is not None: + assert_sorted_objects(path, f"{field}.by_character", nested, "character") + if keys != sorted(keys): + fail(f"{path} expected {field} to be sorted by {key}") + + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) @@ -945,6 +968,29 @@ def audit_live(base_url: str) -> None: for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") + if path == "/api/v1/profile": + for field in ["characters", "card_stats", "encounter_stats", "enemy_stats", "ancient_stats", "achievements", "epochs"]: + assert_sorted_objects(path, field, data.get(field), "id") + for field in ["discovered_cards", "discovered_relics", "discovered_potions", "discovered_events", "discovered_acts"]: + assert_sorted_strings(path, field, data.get(field)) + if path == "/api/v1/compendium": + sections = data.get("sections") + if not isinstance(sections, dict): + fail(f"{path} expected sections object, got {sections}") + card_library = sections.get("card_library") + relic_collection = sections.get("relic_collection") + potion_lab = sections.get("potion_lab") + bestiary = sections.get("bestiary") + character_stats = sections.get("character_stats") + if not all(isinstance(section, dict) for section in [card_library, relic_collection, potion_lab, bestiary, character_stats]): + fail(f"{path} expected structured compendium sections, got {sections}") + assert_sorted_strings(path, "card_library.discovered_ids", card_library.get("discovered_ids")) + assert_sorted_objects(path, "card_library.stats", card_library.get("stats"), "id") + assert_sorted_strings(path, "relic_collection.discovered_ids", relic_collection.get("discovered_ids")) + assert_sorted_strings(path, "potion_lab.discovered_ids", potion_lab.get("discovered_ids")) + assert_sorted_objects(path, "bestiary.encounter_stats", bestiary.get("encounter_stats"), "id") + assert_sorted_objects(path, "bestiary.enemy_stats", bestiary.get("enemy_stats"), "id") + assert_sorted_objects(path, "character_stats.characters", character_stats.get("characters"), "id") if path == "/api/v1/profiles": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "profiles": fail(f"{path} expected structured profiles status/kind, got {data}") From 74ae42f027a22f6e2f509ed4e772f1794b5c3db8 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:39:26 -0400 Subject: [PATCH 132/190] Return HTTP errors for profile actions --- McpMod.Helpers.cs | 7 +++++-- McpMod.Profile.cs | 29 +++++++++++++++++++++++------ docs/raw-full.md | 1 + docs/raw-simplified.md | 2 ++ mcp/README.md | 2 ++ mcp/server.py | 6 ++++-- scripts/audit_endpoints.py | 26 ++++++++++++++++++++++++++ 7 files changed, 63 insertions(+), 10 deletions(-) diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 3a1cf76d..3af14eec 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -132,9 +132,12 @@ internal static void SendError(HttpListenerResponse response, int statusCode, st SendJson(response, data); } - private static Dictionary Error(string message) + private static Dictionary Error(string message, string? errorCode = null) { - return new Dictionary { ["status"] = "error", ["error"] = message }; + var data = new Dictionary { ["status"] = "error", ["error"] = message }; + if (!string.IsNullOrWhiteSpace(errorCode)) + data["error_code"] = errorCode; + return data; } private static object? GetInstanceFieldValue(object source, string fieldName) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index a699b184..1b062e81 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -90,7 +90,7 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener try { var resultTask = RunOnMainThread(() => ExecuteProfileAction(action, profileId)); - SendJson(response, resultTask.GetAwaiter().GetResult()); + SendProfileActionJson(response, resultTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -98,6 +98,23 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener } } + private static void SendProfileActionJson(HttpListenerResponse response, Dictionary result) + { + if (result.TryGetValue("status", out var status) && status as string == "error") + { + response.StatusCode = result.TryGetValue("error_code", out var errorCode) + ? (errorCode as string) switch + { + "invalid_profile_id" or "unknown_profile_action" => 400, + "run_in_progress" or "active_profile_delete" => 409, + "save_manager_unavailable" => 503, + _ => 400 + } + : 400; + } + SendJson(response, result); + } + private static Dictionary BuildProfilesSummary() { var sm = SaveManager.Instance; @@ -151,16 +168,16 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener { var sm = SaveManager.Instance; if (sm == null) - return Error("Save manager is not available"); + return Error("Save manager is not available", "save_manager_unavailable"); if (profileId is < 1 or > 3) - return Error("profile_id must be 1-3"); + return Error("profile_id must be 1-3", "invalid_profile_id"); var normalizedAction = action.Trim().ToLowerInvariant(); if (normalizedAction == "switch") { if (RunManager.Instance?.IsInProgress == true) - return Error("Cannot switch profiles during a run"); + return Error("Cannot switch profiles during a run", "run_in_progress"); var tree = Engine.GetMainLoop() as SceneTree; if (tree?.Root != null) @@ -200,7 +217,7 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener if (normalizedAction == "delete") { if (profileId == sm.CurrentProfileId) - return Error("Cannot delete the active profile"); + return Error("Cannot delete the active profile", "active_profile_delete"); sm.DeleteProfile(profileId); return new Dictionary @@ -210,7 +227,7 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener }; } - return Error($"Unknown action: {action}. Use: switch, delete"); + return Error($"Unknown action: {action}. Use: switch, delete", "unknown_profile_action"); } private static bool TrySwitchProfileViaOpenScreen(Node root, int profileId) diff --git a/docs/raw-full.md b/docs/raw-full.md index 18f723b8..e815f082 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1190,6 +1190,7 @@ Delete an inactive profile slot: | `profile_id` | int | Yes | Profile slot, from 1 to 3 | Switching is rejected during a run. Deleting the active profile is rejected; switch away first if you need to remove a slot. +Profile action validation returns structured HTTP errors: invalid profile IDs and unknown actions return HTTP 400, deleting the active profile returns HTTP 409 with `error_code: "active_profile_delete"`, and switching profiles during a run returns HTTP 409 with `error_code: "run_in_progress"`. --- diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 77a15c90..f26a7ea8 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -122,6 +122,8 @@ When a run is active, the response includes `current_run.run_id` in `{save_scope | `switch` | `profile_id`: 1-3 | Switch through the game profile UI. Empty slots can be used for fresh-profile testing. Cannot be used during a run. | | `delete` | `profile_id`: 1-3 | Delete an inactive profile slot. The active profile is rejected. | +Profile action validation uses non-2xx HTTP status codes: invalid profile IDs and unknown actions return HTTP 400; deleting the active profile and switching during a run return HTTP 409 with an `error_code`. + ### Combat | Action | Parameters | When to Use | diff --git a/mcp/README.md b/mcp/README.md index d2f483eb..cb7e90b4 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -48,6 +48,8 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. +Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. + Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. ## Multiplayer diff --git a/mcp/server.py b/mcp/server.py index 1b32b87c..9c69ffdd 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -412,7 +412,8 @@ async def switch_profile(profile_id: int) -> str: """Switch to a profile slot through the game's profile UI. Use an empty slot to create or enter a fresh profile for testing. This cannot - be used while a run is in progress. + be used while a run is in progress. HTTP validation errors include + structured error_code values from the profile endpoint. Args: profile_id: Profile slot to switch to. Must be 1, 2, or 3. @@ -450,7 +451,8 @@ async def delete_profile(profile_id: int) -> str: """Delete an inactive profile slot. The active profile cannot be deleted through this tool. Switch away first if - you intend to remove a slot after backing up any data you need. + you intend to remove a slot after backing up any data you need. Attempting to + delete the active profile returns the endpoint's active_profile_delete error. Args: profile_id: Profile slot to delete. Must be 1, 2, or 3. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index bc8e34a6..1c736c2a 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -420,6 +420,16 @@ def audit_static_save_roots(repo: Path) -> None: for required_fragment in ["status", "kind", "count", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_fragment not in profiles_body: fail(f"profiles endpoint missing structured slot context: {required_fragment}") + for required_fragment in [ + "SendProfileActionJson", + "invalid_profile_id", + "unknown_profile_action", + "run_in_progress", + "active_profile_delete", + "save_manager_unavailable", + ]: + if required_fragment not in profile: + fail(f"profiles POST missing structured HTTP error handling: {required_fragment}") for doc_name, doc_text in [("mcp/server.py", mcp_server), ("mcp/README.md", mcp_readme)]: for required_fragment in ["progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: @@ -1080,6 +1090,8 @@ def audit_live(base_url: str) -> None: ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": 1.5}', 400), ("/api/v1/profiles", b'{"action": "switch", "profile_id": 999999999999}', 400), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 4}', 400), + ("/api/v1/profiles", b'{"action": "unknown", "profile_id": 1}', 400), ] for path, body, expected_status in post_validation_checks: status, data = load_json_url(base_url.rstrip("/") + path, "POST", body) @@ -1087,6 +1099,20 @@ def audit_live(base_url: str) -> None: if status != expected_status: fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + status, profiles_data = load_json_url(base_url.rstrip("/") + "/api/v1/profiles") + if status != 200 or not isinstance(profiles_data, dict): + fail(f"/api/v1/profiles expected profile data before active-delete validation, got HTTP {status}: {profiles_data}") + current_profile_id = profiles_data.get("current_profile_id") + if isinstance(current_profile_id, int): + status, data = load_json_url( + base_url.rstrip("/") + "/api/v1/profiles", + "POST", + json.dumps({"action": "delete", "profile_id": current_profile_id}).encode("utf-8"), + ) + assert_error_body("/api/v1/profiles", status, data) + if status != 409 or data.get("error_code") != "active_profile_delete": + fail(f"/api/v1/profiles expected HTTP 409 active_profile_delete, got HTTP {status}: {data}") + for body in (b"{", b"{}"): status, data = load_json_url(base_url.rstrip("/") + "/api/v1/multiplayer", "POST", body) assert_error_body("/api/v1/multiplayer", status, data) From e37826206cd3e2ee4e15ec777187b21ffecb3090 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:43:23 -0400 Subject: [PATCH 133/190] Validate state response formats --- McpMod.cs | 13 +++++++++++++ docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 ++ mcp/server.py | 2 ++ scripts/audit_endpoints.py | 9 +++++++++ 5 files changed, 28 insertions(+) diff --git a/McpMod.cs b/McpMod.cs index 3f1cc16a..b7690003 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -440,6 +440,8 @@ internal static bool IsMultiplayerRun() private static void HandleGetMultiplayerState(HttpListenerRequest request, HttpListenerResponse response) { string format = request.QueryString["format"] ?? "json"; + if (!TryValidateStateFormat(response, format)) + return; try { @@ -533,6 +535,8 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt private static void HandleGetState(HttpListenerRequest request, HttpListenerResponse response) { string format = request.QueryString["format"] ?? "json"; + if (!TryValidateStateFormat(response, format)) + return; try { @@ -567,6 +571,15 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp } } + private static bool TryValidateStateFormat(HttpListenerResponse response, string format) + { + if (format is "json" or "markdown") + return true; + + SendError(response, 400, "Invalid format. Use: json, markdown", "invalid_format"); + return false; + } + private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) { string body; diff --git a/docs/raw-full.md b/docs/raw-full.md index e815f082..76202604 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -32,6 +32,8 @@ The singleplayer and multiplayer endpoints are mutually exclusive: calling singl |-----------|--------------------|---------|-----------------| | `format` | `json`, `markdown` | `json` | Response format | +Unsupported `format` values return HTTP 400 with `error_code: "invalid_format"` instead of falling back to JSON. + ### Common Top-Level Fields Every response (except `menu`) includes these top-level fields alongside the state-specific data: diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index f26a7ea8..73ff30e5 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -27,6 +27,8 @@ Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if misma |-----------|--------------------|---------|-----------------| | `format` | `json`, `markdown` | `json` | Response format | +Unsupported `format` values return HTTP 400 with `error_code: "invalid_format"`. + ## GET — State Types Every JSON response includes: diff --git a/mcp/server.py b/mcp/server.py index 9c69ffdd..7a981669 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -232,6 +232,7 @@ async def get_game_state(format: str = "markdown") -> str: Args: format: "markdown" for human-readable output, "json" for structured data. + Other values return the endpoint's invalid_format error. """ try: return await _get({"format": format}) @@ -921,6 +922,7 @@ async def mp_get_game_state(format: str = "markdown") -> str: Args: format: "markdown" for human-readable output, "json" for structured data. + Other values return the endpoint's invalid_format error. """ try: return await _mp_get({"format": format}) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 1c736c2a..4839b87d 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -214,6 +214,10 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SaveManager.Instance", "Save manager is not available", "Settings data is not available", "status", "kind"]: if required_fragment not in settings_body: fail(f"settings endpoint missing startup-safe structured field: {required_fragment}") + mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") + for required_fragment in ["TryValidateStateFormat", "invalid_format", 'format is "json" or "markdown"']: + if required_fragment not in mcp_mod: + fail(f"state endpoints missing format validation: {required_fragment}") print("errors: structured 500 response helpers enforced") @@ -1078,6 +1082,11 @@ def audit_live(base_url: str) -> None: if markdown_status != 200 or "# Game State:" not in markdown: fail(f"/api/v1/singleplayer?format=markdown expected markdown state, got HTTP {markdown_status}: {markdown[:120]}") + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=xml") + assert_error_body("/api/v1/singleplayer?format=xml", status, data) + if status != 400 or not isinstance(data, dict) or data.get("error_code") != "invalid_format": + fail(f"/api/v1/singleplayer?format=xml expected invalid_format HTTP 400, got HTTP {status}: {data}") + post_validation_checks = [ ("/api/v1/singleplayer", b"{", 400), ("/api/v1/singleplayer", b"{}", 400), From 19314207975d07f03b3f182c211cb39738b44b03 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:47:41 -0400 Subject: [PATCH 134/190] Return HTTP errors for action dispatch --- McpMod.Actions.cs | 6 +++--- McpMod.MultiplayerActions.cs | 8 ++++---- McpMod.cs | 22 ++++++++++++++++++++-- docs/raw-full.md | 1 + docs/raw-simplified.md | 2 +- mcp/README.md | 2 ++ scripts/audit_endpoints.py | 17 +++++++++++++++++ 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index ccedcdf1..6f980d02 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -47,12 +47,12 @@ public static partial class McpMod private static Dictionary ExecuteAction(string action, Dictionary data) { if (!RunManager.Instance.IsInProgress) - return Error("No run in progress"); + return Error("No run in progress", "run_not_in_progress"); var runState = RunManager.Instance.DebugOnlyGetState()!; var player = LocalContext.GetMe(runState); if (player == null) - return Error("Could not find local player"); + return Error("Could not find local player", "local_player_unavailable"); var tree = (Godot.Engine.GetMainLoop()) as SceneTree; if (tree?.Root != null && IsAnyFtueVisible(tree.Root)) @@ -87,7 +87,7 @@ public static partial class McpMod "crystal_sphere_set_tool" => ExecuteCrystalSphereSetTool(data), "crystal_sphere_click_cell" => ExecuteCrystalSphereClickCell(data), "crystal_sphere_proceed" => ExecuteCrystalSphereProceed(), - _ => Error($"Unknown action: {action}") + _ => Error($"Unknown action: {action}", "unknown_action") }; } diff --git a/McpMod.MultiplayerActions.cs b/McpMod.MultiplayerActions.cs index 7ee02cd0..5c95c98b 100644 --- a/McpMod.MultiplayerActions.cs +++ b/McpMod.MultiplayerActions.cs @@ -17,15 +17,15 @@ public static partial class McpMod private static Dictionary ExecuteMultiplayerAction(string action, Dictionary data) { if (!RunManager.Instance.IsInProgress) - return Error("No run in progress"); + return Error("No run in progress", "run_not_in_progress"); if (!RunManager.Instance.NetService.Type.IsMultiplayer()) - return Error("Not in a multiplayer run. Use /api/v1/singleplayer instead."); + return Error("Not in a multiplayer run. Use /api/v1/singleplayer instead.", "not_multiplayer_run"); var runState = RunManager.Instance.DebugOnlyGetState()!; var player = LocalContext.GetMe(runState); if (player == null) - return Error("Could not find local player"); + return Error("Could not find local player", "local_player_unavailable"); var tree = Engine.GetMainLoop() as SceneTree; if (tree?.Root != null && IsAnyFtueVisible(tree.Root)) @@ -65,7 +65,7 @@ public static partial class McpMod "end_turn" => ExecuteMultiplayerEndTurn(player), "undo_end_turn" => ExecuteUndoEndTurn(player), - _ => Error($"Unknown multiplayer action: {action}") + _ => Error($"Unknown multiplayer action: {action}", "unknown_multiplayer_action") }; } diff --git a/McpMod.cs b/McpMod.cs index b7690003..85f65daf 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -524,7 +524,7 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt { var resultTask = RunOnMainThread(() => ExecuteMultiplayerAction(action, parsed)); var result = resultTask.GetAwaiter().GetResult(); - SendJson(response, result); + SendActionResultJson(response, result); } catch (Exception ex) { @@ -632,7 +632,7 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe { var resultTask = RunOnMainThread(() => ExecuteAction(action, parsed)); var result = resultTask.GetAwaiter().GetResult(); - SendJson(response, result); + SendActionResultJson(response, result); } catch (Exception ex) { @@ -647,4 +647,22 @@ private static void SendActionError(HttpListenerResponse response, string prefix : 500; SendError(response, statusCode, $"{prefix}: {ex.Message}"); } + + private static void SendActionResultJson(HttpListenerResponse response, Dictionary result) + { + if (result.TryGetValue("status", out var status) && status as string == "error") + { + if (result.TryGetValue("error_code", out var errorCode)) + { + response.StatusCode = (errorCode as string) switch + { + "unknown_action" or "unknown_multiplayer_action" => 400, + "run_not_in_progress" or "not_multiplayer_run" => 409, + "local_player_unavailable" => 409, + _ => 400 + }; + } + } + SendJson(response, result); + } } diff --git a/docs/raw-full.md b/docs/raw-full.md index 76202604..3f398ff5 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1199,6 +1199,7 @@ Profile action validation returns structured HTTP errors: invalid profile IDs an ## POST — Perform Actions All POST requests use a JSON body with an `"action"` field and action-specific parameters. +Action dispatch failures include structured `error_code` values where available. Unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. ### Success Response diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 73ff30e5..c2a551f0 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -64,7 +64,7 @@ Serialized card objects in hand, deck, piles, rewards, card selections, bundles, ## POST — Actions -All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. ### Menu / Game Over diff --git a/mcp/README.md b/mcp/README.md index cb7e90b4..5e75f4cc 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,6 +50,8 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_i Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. +Non-menu action dispatch failures also use structured endpoint errors where available: unknown actions return HTTP 400, and gameplay actions sent without an active run return HTTP 409. + Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4839b87d..8a44a1d6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -218,6 +218,14 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["TryValidateStateFormat", "invalid_format", 'format is "json" or "markdown"']: if required_fragment not in mcp_mod: fail(f"state endpoints missing format validation: {required_fragment}") + actions = (repo / "McpMod.Actions.cs").read_text(encoding="utf-8") + multiplayer_actions = (repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8") + for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable"]: + if required_fragment not in mcp_mod + actions: + fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") + for required_fragment in ["unknown_multiplayer_action", "not_multiplayer_run", "run_not_in_progress", "local_player_unavailable"]: + if required_fragment not in mcp_mod + multiplayer_actions: + fail(f"multiplayer actions missing structured dispatch error handling: {required_fragment}") print("errors: structured 500 response helpers enforced") @@ -1108,6 +1116,15 @@ def audit_live(base_url: str) -> None: if status != expected_status: fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + status, data = load_json_url( + base_url.rstrip("/") + "/api/v1/singleplayer", + "POST", + b'{"action": "unknown_action"}', + ) + assert_error_body("/api/v1/singleplayer", status, data) + if status not in {400, 409} or data.get("error_code") not in {"unknown_action", "run_not_in_progress"}: + fail(f"/api/v1/singleplayer expected structured unknown/no-run action error, got HTTP {status}: {data}") + status, profiles_data = load_json_url(base_url.rstrip("/") + "/api/v1/profiles") if status != 200 or not isinstance(profiles_data, dict): fail(f"/api/v1/profiles expected profile data before active-delete validation, got HTTP {status}: {profiles_data}") From 9ab12b2c1ba80ffb20733ccecef6c99dd672455a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:51:36 -0400 Subject: [PATCH 135/190] Return HTTP errors for menu dispatch --- McpMod.Actions.cs | 6 +++--- McpMod.cs | 8 ++++---- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 5 +++++ 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 6f980d02..4907172c 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -1122,7 +1122,7 @@ public static partial class McpMod option = option.Trim(); if (string.IsNullOrEmpty(option)) - return Error("Missing menu option"); + return Error("Missing menu option", "missing_menu_option"); var tree = (Engine.GetMainLoop()) as SceneTree; if (tree?.Root == null) @@ -1431,12 +1431,12 @@ public static partial class McpMod _ => null }; if (menuFieldName == null) - return Error($"Unknown menu option: {option}"); + return Error($"Unknown menu option: {option}", "unknown_menu_option"); return ClickMenuButtonField(mainMenu, menuFieldName, $"Selected {option}", $"Option '{option}' is not available"); } - return Error("Not on a menu screen"); + return Error("Not on a menu screen", "not_on_menu"); } private static Dictionary TimelineUnlocksNeedManualReveal(List unrevealedEpochs) diff --git a/McpMod.cs b/McpMod.cs index 85f65daf..d91dad93 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -511,7 +511,7 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt var seed = parsed.TryGetValue("seed", out var seedElem) ? seedElem.GetString() : null; var resultTask = RunOnMainThread(() => ExecuteMenuSelect(option, seed)); var result = resultTask.GetAwaiter().GetResult(); - SendJson(response, result); + SendActionResultJson(response, result); } catch (Exception ex) { @@ -619,7 +619,7 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe var seed = parsed.TryGetValue("seed", out var seedElem) ? seedElem.GetString() : null; var resultTask = RunOnMainThread(() => ExecuteMenuSelect(option, seed)); var result = resultTask.GetAwaiter().GetResult(); - SendJson(response, result); + SendActionResultJson(response, result); } catch (Exception ex) { @@ -656,8 +656,8 @@ private static void SendActionResultJson(HttpListenerResponse response, Dictiona { response.StatusCode = (errorCode as string) switch { - "unknown_action" or "unknown_multiplayer_action" => 400, - "run_not_in_progress" or "not_multiplayer_run" => 409, + "missing_menu_option" or "unknown_menu_option" or "unknown_action" or "unknown_multiplayer_action" => 400, + "not_on_menu" or "run_not_in_progress" or "not_multiplayer_run" => 409, "local_player_unavailable" => 409, _ => 400 }; diff --git a/docs/raw-full.md b/docs/raw-full.md index 3f398ff5..9280b0b6 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1199,7 +1199,7 @@ Profile action validation returns structured HTTP errors: invalid profile IDs an ## POST — Perform Actions All POST requests use a JSON body with an `"action"` field and action-specific parameters. -Action dispatch failures include structured `error_code` values where available. Unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. +Action dispatch failures include structured `error_code` values where available. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. ### Success Response diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c2a551f0..c77468ac 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -64,7 +64,7 @@ Serialized card objects in hand, deck, piles, rewards, card selections, bundles, ## POST — Actions -All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. ### Menu / Game Over diff --git a/mcp/README.md b/mcp/README.md index 5e75f4cc..a8b5ad34 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,7 +50,7 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_i Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. -Non-menu action dispatch failures also use structured endpoint errors where available: unknown actions return HTTP 400, and gameplay actions sent without an active run return HTTP 409. +Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, and gameplay actions sent without an active run return HTTP 409. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8a44a1d6..7382117e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -223,6 +223,9 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable"]: if required_fragment not in mcp_mod + actions: fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") + for required_fragment in ["missing_menu_option", "unknown_menu_option", "not_on_menu"]: + if required_fragment not in mcp_mod + actions: + fail(f"menu_select missing structured dispatch error handling: {required_fragment}") for required_fragment in ["unknown_multiplayer_action", "not_multiplayer_run", "run_not_in_progress", "local_player_unavailable"]: if required_fragment not in mcp_mod + multiplayer_actions: fail(f"multiplayer actions missing structured dispatch error handling: {required_fragment}") @@ -1099,7 +1102,9 @@ def audit_live(base_url: str) -> None: ("/api/v1/singleplayer", b"{", 400), ("/api/v1/singleplayer", b"{}", 400), ("/api/v1/singleplayer", b'{"action": 1}', 400), + ("/api/v1/singleplayer", b'{"action": "menu_select"}', 400), ("/api/v1/singleplayer", b'{"action": "menu_select", "option": 1}', 400), + ("/api/v1/singleplayer", b'{"action": "menu_select", "option": "definitely_not_real"}', 400), ("/api/v1/profiles", b"{", 400), ("/api/v1/profiles", b"{}", 400), ("/api/v1/profiles", b'{"action": 1}', 400), From f56c1a4412f512bc216036f1014b4a74419a68b4 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:56:37 -0400 Subject: [PATCH 136/190] Shape API index response --- McpMod.cs | 5 ++++- docs/raw-full.md | 2 ++ docs/raw-simplified.md | 2 ++ mcp/README.md | 2 ++ mcp/server.py | 5 +++-- scripts/audit_endpoints.py | 17 ++++++++++++++++- 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index d91dad93..9f0946b0 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -273,12 +273,15 @@ private static void HandleRequest(HttpListenerContext context) { if (request.HttpMethod == "GET") { + var endpoints = BuildEndpointIndex(); SendJson(response, new { message = $"Hello from STS2 MCP v{Version}", status = "ok", + kind = "api_index", bound_prefixes = _boundPrefixes, - endpoints = BuildEndpointIndex() + endpoint_count = endpoints.Count, + endpoints }); } else diff --git a/docs/raw-full.md b/docs/raw-full.md index 9280b0b6..b814ac4a 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -20,6 +20,8 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +`GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. + The singleplayer and multiplayer endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. --- diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c77468ac..3f846e9f 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -19,6 +19,8 @@ HTTP API on `localhost:15526`. No authentication. HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +`GET /` returns `status: "ok"`, `kind: "api_index"`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. + Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. ## GET — Query Parameters diff --git a/mcp/README.md b/mcp/README.md index a8b5ad34..f991f729 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,6 +46,8 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | +`get_api_index()` returns `status`, `kind: api_index`, `endpoint_count`, bound listener prefixes, and the advertised HTTP routes. + Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. diff --git a/mcp/server.py b/mcp/server.py index 7a981669..c53d4691 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -213,8 +213,9 @@ async def _menu_select_post(body: dict) -> str: async def get_api_index() -> str: """Get the STS2_MCP HTTP server status and endpoint index. - Returns the mod version greeting, bound listener prefixes, and the current - list of HTTP API routes exposed by the loaded mod. + Returns status/kind, the mod version greeting, bound listener prefixes, + endpoint_count, and the current list of HTTP API routes exposed by the + loaded mod. """ try: return await _root_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 7382117e..2dc617d5 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -949,6 +949,10 @@ def audit_live(base_url: str) -> None: endpoint_rows = root.get("endpoints") if not isinstance(endpoint_rows, list): fail("root response does not include endpoints list") + if root.get("status") != "ok" or root.get("kind") != "api_index": + fail(f"root response expected status ok and kind api_index, got {root}") + if root.get("endpoint_count") != len(endpoint_rows): + fail(f"root response endpoint_count mismatch, got {root.get('endpoint_count')} for {len(endpoint_rows)} endpoints") live_index = { (str(row.get("method")), str(row.get("path"))) @@ -1104,7 +1108,6 @@ def audit_live(base_url: str) -> None: ("/api/v1/singleplayer", b'{"action": 1}', 400), ("/api/v1/singleplayer", b'{"action": "menu_select"}', 400), ("/api/v1/singleplayer", b'{"action": "menu_select", "option": 1}', 400), - ("/api/v1/singleplayer", b'{"action": "menu_select", "option": "definitely_not_real"}', 400), ("/api/v1/profiles", b"{", 400), ("/api/v1/profiles", b"{}", 400), ("/api/v1/profiles", b'{"action": 1}', 400), @@ -1121,6 +1124,18 @@ def audit_live(base_url: str) -> None: if status != expected_status: fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + status, data = load_json_url( + base_url.rstrip("/") + "/api/v1/singleplayer", + "POST", + b'{"action": "menu_select", "option": "definitely_not_real"}', + ) + assert_error_body("/api/v1/singleplayer", status, data) + if (status, data.get("error_code")) not in { + (400, "unknown_menu_option"), + (409, "not_on_menu"), + }: + fail(f"/api/v1/singleplayer expected structured invalid menu option error, got HTTP {status}: {data}") + status, data = load_json_url( base_url.rstrip("/") + "/api/v1/singleplayer", "POST", From 1014fe7ff38da625dbad8a6b66ea5d2de764ec52 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 10:59:40 -0400 Subject: [PATCH 137/190] Expose API index version --- McpMod.cs | 1 + docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- mcp/server.py | 6 +++--- scripts/audit_endpoints.py | 2 ++ 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 9f0946b0..39b43c4a 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -279,6 +279,7 @@ private static void HandleRequest(HttpListenerContext context) message = $"Hello from STS2 MCP v{Version}", status = "ok", kind = "api_index", + version = Version, bound_prefixes = _boundPrefixes, endpoint_count = endpoints.Count, endpoints diff --git a/docs/raw-full.md b/docs/raw-full.md index b814ac4a..737b132b 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -20,7 +20,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. -`GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. +`GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. The singleplayer and multiplayer endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 3f846e9f..7ef91be7 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -19,7 +19,7 @@ HTTP API on `localhost:15526`. No authentication. HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. -`GET /` returns `status: "ok"`, `kind: "api_index"`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. +`GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. diff --git a/mcp/README.md b/mcp/README.md index f991f729..5b485e65 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -46,7 +46,7 @@ | `crystal_sphere_click_cell(x, y)` | Crystal Sphere | Click a hidden cell in the grid | | `crystal_sphere_proceed()` | Crystal Sphere | Continue after the minigame finishes | -`get_api_index()` returns `status`, `kind: api_index`, `endpoint_count`, bound listener prefixes, and the advertised HTTP routes. +`get_api_index()` returns `status`, `kind: api_index`, `version`, `endpoint_count`, bound listener prefixes, and the advertised HTTP routes. Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. diff --git a/mcp/server.py b/mcp/server.py index c53d4691..9ef04018 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -213,9 +213,9 @@ async def _menu_select_post(body: dict) -> str: async def get_api_index() -> str: """Get the STS2_MCP HTTP server status and endpoint index. - Returns status/kind, the mod version greeting, bound listener prefixes, - endpoint_count, and the current list of HTTP API routes exposed by the - loaded mod. + Returns status/kind, version, the mod version greeting, bound listener + prefixes, endpoint_count, and the current list of HTTP API routes exposed by + the loaded mod. """ try: return await _root_get() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 2dc617d5..02659ec6 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -951,6 +951,8 @@ def audit_live(base_url: str) -> None: fail("root response does not include endpoints list") if root.get("status") != "ok" or root.get("kind") != "api_index": fail(f"root response expected status ok and kind api_index, got {root}") + if not root.get("version"): + fail(f"root response expected explicit version field, got {root}") if root.get("endpoint_count") != len(endpoint_rows): fail(f"root response endpoint_count mismatch, got {root.get('endpoint_count')} for {len(endpoint_rows)} endpoints") From 5c6ce115116aeab21490100f140b4b396bfa3997 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:04:55 -0400 Subject: [PATCH 138/190] Return non-2xx for failed actions --- McpMod.cs | 4 ++++ docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 4 +++- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 39b43c4a..53f5381e 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -666,6 +666,10 @@ private static void SendActionResultJson(HttpListenerResponse response, Dictiona _ => 400 }; } + else + { + response.StatusCode = 400; + } } SendJson(response, result); } diff --git a/docs/raw-full.md b/docs/raw-full.md index 737b132b..d2b6178e 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1201,7 +1201,7 @@ Profile action validation returns structured HTTP errors: invalid profile IDs an ## POST — Perform Actions All POST requests use a JSON body with an `"action"` field and action-specific parameters. -Action dispatch failures include structured `error_code` values where available. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. +Action dispatch failures include structured `error_code` values where available. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. ### Success Response diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 7ef91be7..f01fb370 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -66,7 +66,7 @@ Serialized card objects in hand, deck, piles, rewards, card selections, bundles, ## POST — Actions -All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. ### Menu / Game Over diff --git a/mcp/README.md b/mcp/README.md index 5b485e65..b34aaaac 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -52,7 +52,7 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_i Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. -Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, and gameplay actions sent without an active run return HTTP 409. +Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and other failed actions return non-2xx structured error JSON instead of HTTP 200. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 02659ec6..82cff500 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -223,6 +223,8 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable"]: if required_fragment not in mcp_mod + actions: fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") + if "response.StatusCode = 400;" not in mcp_mod: + fail("action result sender must return non-2xx for uncoded action errors") for required_fragment in ["missing_menu_option", "unknown_menu_option", "not_on_menu"]: if required_fragment not in mcp_mod + actions: fail(f"menu_select missing structured dispatch error handling: {required_fragment}") @@ -1144,7 +1146,7 @@ def audit_live(base_url: str) -> None: b'{"action": "unknown_action"}', ) assert_error_body("/api/v1/singleplayer", status, data) - if status not in {400, 409} or data.get("error_code") not in {"unknown_action", "run_not_in_progress"}: + if status not in {400, 409} or data.get("error_code") not in {"unknown_action", "run_not_in_progress", "local_player_unavailable"}: fail(f"/api/v1/singleplayer expected structured unknown/no-run action error, got HTTP {status}: {data}") status, profiles_data = load_json_url(base_url.rstrip("/") + "/api/v1/profiles") From 4e51a5f0da41ca5496e4889315b9c14bdc80acec Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:12:03 -0400 Subject: [PATCH 139/190] Preserve MCP structured endpoint errors --- mcp/README.md | 2 +- mcp/server.py | 17 +++++++++++++++++ scripts/audit_endpoints.py | 7 +++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index b34aaaac..1cc925ae 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -52,7 +52,7 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_i Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. -Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and other failed actions return non-2xx structured error JSON instead of HTTP 200. +Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/mcp/server.py b/mcp/server.py index 9ef04018..93402bef 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -180,10 +180,27 @@ def _handle_error(e: Exception) -> str: if isinstance(e, httpx.ConnectError): return "Error: Cannot connect to STS2_MCP mod. Is the game running with the mod enabled?" if isinstance(e, httpx.HTTPStatusError): + structured = _format_structured_http_error(e.response) + if structured is not None: + return structured return f"Error: HTTP {e.response.status_code} — {e.response.text}" return f"Error: {e}" +def _format_structured_http_error(response: httpx.Response) -> str | None: + try: + data = response.json() + except ValueError: + return None + + if not isinstance(data, dict) or data.get("status") != "error": + return None + + payload = dict(data) + payload.setdefault("http_status", response.status_code) + return json.dumps(payload, indent=2) + + def _response_error_code(response: httpx.Response) -> str | None: try: data = response.json() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 82cff500..16f91072 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -231,6 +231,13 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["unknown_multiplayer_action", "not_multiplayer_run", "run_not_in_progress", "local_player_unavailable"]: if required_fragment not in mcp_mod + multiplayer_actions: fail(f"multiplayer actions missing structured dispatch error handling: {required_fragment}") + mcp_server = (repo / "mcp" / "server.py").read_text(encoding="utf-8") + for required_fragment in ["_format_structured_http_error", "http_status", 'data.get("status") != "error"']: + if required_fragment not in mcp_server: + fail(f"MCP server must preserve structured non-2xx endpoint errors: {required_fragment}") + mcp_readme = (repo / "mcp" / "README.md").read_text(encoding="utf-8") + if "MCP wrappers preserve those structured JSON error bodies" not in mcp_readme: + fail("mcp/README.md must document structured non-2xx error propagation") print("errors: structured 500 response helpers enforced") From 58edc32c5134bab9eee79477ecd008d42c75cedc Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:18:20 -0400 Subject: [PATCH 140/190] Add state endpoint response envelopes --- McpMod.Helpers.cs | 7 +++++++ McpMod.MultiplayerState.cs | 8 ++++++-- McpMod.StateBuilder.cs | 8 ++++++-- docs/raw-full.md | 4 +++- docs/raw-simplified.md | 1 + mcp/README.md | 2 ++ scripts/audit_endpoints.py | 9 +++++++++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 3af14eec..b3fa2ce5 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -140,6 +140,13 @@ internal static void SendError(HttpListenerResponse response, int statusCode, st return data; } + private static Dictionary WithStateEnvelope(Dictionary state, string kind) + { + state.TryAdd("status", "ok"); + state.TryAdd("kind", kind); + return state; + } + private static object? GetInstanceFieldValue(object source, string fieldName) { const System.Reflection.BindingFlags Flags = diff --git a/McpMod.MultiplayerState.cs b/McpMod.MultiplayerState.cs index 78bba8ff..b1f5f044 100644 --- a/McpMod.MultiplayerState.cs +++ b/McpMod.MultiplayerState.cs @@ -27,7 +27,11 @@ public static partial class McpMod { private static Dictionary BuildMultiplayerGameState() { - var result = new Dictionary(); + var result = new Dictionary + { + ["status"] = "ok", + ["kind"] = "multiplayer_state" + }; var tree = Engine.GetMainLoop() as SceneTree; // Surface blocking FTUE/tutorial/popup prompts before normal run state, so MP @@ -38,7 +42,7 @@ public static partial class McpMod { var ftueState = BuildVisibleFtueState(tree.Root); if (ftueState != null) - return ftueState; + return WithStateEnvelope(ftueState, "multiplayer_state"); } if (!RunManager.Instance.IsInProgress) diff --git a/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index ddcbd3f4..8df1575a 100644 --- a/McpMod.StateBuilder.cs +++ b/McpMod.StateBuilder.cs @@ -61,14 +61,18 @@ public static partial class McpMod { private static Dictionary BuildGameState() { - var result = new Dictionary(); + var result = new Dictionary + { + ["status"] = "ok", + ["kind"] = "singleplayer_state" + }; var tree = (Godot.Engine.GetMainLoop()) as SceneTree; if (tree?.Root != null) { var ftueState = BuildVisibleFtueState(tree.Root); if (ftueState != null) - return ftueState; + return WithStateEnvelope(ftueState, "singleplayer_state"); } if (!RunManager.Instance.IsInProgress) diff --git a/docs/raw-full.md b/docs/raw-full.md index d2b6178e..f579b95f 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -38,10 +38,12 @@ Unsupported `format` values return HTTP 400 with `error_code: "invalid_format"` ### Common Top-Level Fields -Every response (except `menu`) includes these top-level fields alongside the state-specific data: +Every state JSON response includes `status: "ok"`, `kind` (`singleplayer_state` or `multiplayer_state`), and `state_type` alongside the state-specific data. Active-run states also include run, current-run, and local-player context: ```jsonc { + "status": "ok", + "kind": "singleplayer_state", "state_type": "...", // Screen identifier (see sections below) "run": { "act": 1, // Current act (1-indexed) diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index f01fb370..aceaa0fe 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -34,6 +34,7 @@ Unsupported `format` values return HTTP 400 with `error_code: "invalid_format"`. ## GET — State Types Every JSON response includes: +- `status: "ok"` and `kind` — `singleplayer_state` or `multiplayer_state` - `state_type` — which screen the game is on (see below) - `run` — `{ act, floor, ascension }` (absent for `menu`) - `current_run` — run identity and save context when a run is active, including `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `run_id`, and `seed` when the save file exposes it diff --git a/mcp/README.md b/mcp/README.md index 1cc925ae..913cdcbc 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -48,6 +48,8 @@ `get_api_index()` returns `status`, `kind: api_index`, `version`, `endpoint_count`, bound listener prefixes, and the advertised HTTP routes. +`get_game_state()` and `mp_get_game_state()` JSON responses include `status: ok`, `kind` (`singleplayer_state` or `multiplayer_state`), `state_type`, and the active-run context fields when a run is in progress. + Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 16f91072..4e9522ef 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -484,6 +484,13 @@ def audit_state_surface(repo: Path) -> None: for source_name, source in [("singleplayer", state_builder), ("multiplayer", multiplayer_state)]: if '["current_run"] = BuildActiveRunContext()' not in source: fail(f"{source_name} state missing current_run identity context") + if '["kind"] = "singleplayer_state"' not in state_builder: + fail("singleplayer state missing status/kind response envelope") + if '["kind"] = "multiplayer_state"' not in multiplayer_state: + fail("multiplayer state missing status/kind response envelope") + for required_fragment in ['status: "ok"', "singleplayer_state", "multiplayer_state"]: + if required_fragment not in docs: + fail(f"docs missing state response envelope detail: {required_fragment}") for required_fragment in ["run_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_fragment not in docs: fail(f"docs missing current_run context: {required_fragment}") @@ -1103,6 +1110,8 @@ def audit_live(base_url: str) -> None: status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") if status != 200 or not isinstance(data, dict) or "state_type" not in data: fail(f"/api/v1/singleplayer?format=json expected JSON state, got HTTP {status}: {data}") + if data.get("status") != "ok" or data.get("kind") != "singleplayer_state": + fail(f"/api/v1/singleplayer?format=json expected status ok and kind singleplayer_state, got {data}") markdown_status, markdown = load_text_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=markdown") if markdown_status != 200 or "# Game State:" not in markdown: From aa3454ad21d3af80e11c06fb5ee156fc51d7897d Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:25:05 -0400 Subject: [PATCH 141/190] Normalize endpoint save paths --- McpMod.Compendium.cs | 14 +++++++------- McpMod.ForkEndpoints.cs | 12 ++++++------ McpMod.Helpers.cs | 3 +++ McpMod.Profile.cs | 16 ++++++++-------- docs/raw-full.md | 14 ++++++++------ docs/raw-simplified.md | 2 ++ mcp/README.md | 2 ++ scripts/audit_endpoints.py | 35 +++++++++++++++++++++++++++++++++++ 8 files changed, 71 insertions(+), 27 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index ea41ebf4..fcd5dc5e 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -120,9 +120,9 @@ private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) ["status"] = "ok", ["kind"] = "compendium", ["profile_id"] = snapshot.ProfileId, - ["progress_path"] = snapshot.ProgressPath, - ["resolved_progress_path"] = snapshot.ResolvedProgressPath, - ["profile_root"] = snapshot.ProfileRoot, + ["progress_path"] = NormalizePathForJson(snapshot.ProgressPath), + ["resolved_progress_path"] = NormalizePathForJson(snapshot.ResolvedProgressPath), + ["profile_root"] = NormalizePathForJson(snapshot.ProfileRoot), ["save_scope"] = snapshot.SaveScope, ["current_run"] = BuildCurrentRunContext(snapshot), ["source"] = "SaveManager.Progress plus model metadata endpoints", @@ -421,9 +421,9 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) { ["is_in_progress"] = true, ["profile_id"] = profileId, - ["progress_path"] = progressPath, - ["resolved_progress_path"] = ResolveProfileProgressPath(profileId), - ["profile_root"] = profileRoot, + ["progress_path"] = NormalizePathForJson(progressPath), + ["resolved_progress_path"] = NormalizePathForJson(ResolveProfileProgressPath(profileId)), + ["profile_root"] = NormalizePathForJson(profileRoot), ["save_scope"] = saveScope, ["id_format"] = "{save_scope}:profile{profile_id}:{start_time}" }; @@ -435,7 +435,7 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) return result; } - result["save_path"] = currentRunPath; + result["save_path"] = NormalizePathForJson(currentRunPath); try { diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 023aacc8..eac9096a 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -236,9 +236,9 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl error["kind"] = payload.Kind; error["scope"] = "active_run"; error["profile_id"] = payload.ProfileId; - error["progress_path"] = payload.ProgressPath; - error["resolved_progress_path"] = payload.ResolvedProgressPath; - error["profile_root"] = payload.ProfileRoot; + error["progress_path"] = NormalizePathForJson(payload.ProgressPath); + error["resolved_progress_path"] = NormalizePathForJson(payload.ResolvedProgressPath); + error["profile_root"] = NormalizePathForJson(payload.ProfileRoot); error["save_scope"] = payload.SaveScope; error["net_type"] = payload.NetType; SendJson(response, data); @@ -258,9 +258,9 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl ["scope"] = "active_run", ["count"] = items.Count, ["profile_id"] = payload.ProfileId, - ["progress_path"] = payload.ProgressPath, - ["resolved_progress_path"] = payload.ResolvedProgressPath, - ["profile_root"] = payload.ProfileRoot, + ["progress_path"] = NormalizePathForJson(payload.ProgressPath), + ["resolved_progress_path"] = NormalizePathForJson(payload.ResolvedProgressPath), + ["profile_root"] = NormalizePathForJson(payload.ProfileRoot), ["save_scope"] = payload.SaveScope, ["current_run"] = BuildCurrentRunContext( isRunInProgress: true, diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index b3fa2ce5..9fcc1809 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -147,6 +147,9 @@ internal static void SendError(HttpListenerResponse response, int statusCode, st return state; } + private static string? NormalizePathForJson(string? path) + => string.IsNullOrWhiteSpace(path) ? path : path.Replace('\\', '/'); + private static object? GetInstanceFieldValue(object source, string fieldName) { const System.Reflection.BindingFlags Flags = diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 1b062e81..35870b35 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -144,11 +144,11 @@ private static void SendProfileActionJson(HttpListenerResponse response, Diction { } profileData["has_data"] = resolvedPath != null && File.Exists(resolvedPath); - profileData["path"] = path; - profileData["resolved_path"] = resolvedPath; - profileData["progress_path"] = path; - profileData["resolved_progress_path"] = resolvedPath; - profileData["profile_root"] = profileRoot; + profileData["path"] = NormalizePathForJson(path); + profileData["resolved_path"] = NormalizePathForJson(resolvedPath); + profileData["progress_path"] = NormalizePathForJson(path); + profileData["resolved_progress_path"] = NormalizePathForJson(resolvedPath); + profileData["profile_root"] = NormalizePathForJson(profileRoot); profileData["save_scope"] = GetSaveScope(profileRoot); profiles.Add(profileData); @@ -273,9 +273,9 @@ internal static object BuildProfile() result["status"] = "ok"; result["kind"] = "profile"; result["profile_id"] = profileId; - result["progress_path"] = progressPath; - result["resolved_progress_path"] = resolvedProgressPath; - result["profile_root"] = profileRoot; + result["progress_path"] = NormalizePathForJson(progressPath); + result["resolved_progress_path"] = NormalizePathForJson(resolvedProgressPath); + result["profile_root"] = NormalizePathForJson(profileRoot); result["save_scope"] = GetSaveScope(profileRoot); result["current_run"] = BuildActiveRunContext(); diff --git a/docs/raw-full.md b/docs/raw-full.md index f579b95f..55aeef67 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -22,6 +22,8 @@ HTTP error responses include `status: "error"` and `error`. Some route-specific `GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. +Save/path context fields are normalized with forward slashes in JSON responses, including Windows absolute paths. + The singleplayer and multiplayer endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. --- @@ -1037,15 +1039,15 @@ Returns the active profile's progress grouped by the in-game Compendium cards: "status": "ok", "kind": "compendium", "profile_id": 1, - "progress_path": "modded/profile1\\saves\\progress.save", - "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam/76561197985806660/modded/profile1/saves/progress.save", "profile_root": "modded/profile1", "save_scope": "modded", "current_run": { "is_in_progress": true, "profile_id": 1, - "progress_path": "modded/profile1\\saves\\progress.save", - "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam/76561197985806660/modded/profile1/saves/progress.save", "profile_root": "modded/profile1", "save_scope": "modded", "run_id": "modded:profile1:1778295706", @@ -1101,8 +1103,8 @@ Example success shape: "scope": "active_run", "count": 87, "profile_id": 1, - "progress_path": "modded/profile1\\saves\\progress.save", - "resolved_progress_path": "C:\\Users\\timot\\AppData\\Roaming\\SlayTheSpire2\\steam\\76561197985806660\\modded/profile1\\saves\\progress.save", + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam/76561197985806660/modded/profile1/saves/progress.save", "profile_root": "modded/profile1", "save_scope": "modded", "current_run": { diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index aceaa0fe..7597ff36 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -21,6 +21,8 @@ HTTP error responses include `status: "error"` and `error`. Some route-specific `GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. +Save/path context fields are normalized with forward slashes in JSON responses, including Windows absolute paths. + Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). The mismatch response includes `error_code`: `multiplayer_run_active` when singleplayer is called during a multiplayer run, or `not_multiplayer_run` when multiplayer is called outside one. ## GET — Query Parameters diff --git a/mcp/README.md b/mcp/README.md index 913cdcbc..93902d48 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -52,6 +52,8 @@ Profile, profile-list, and Compendium tools include `status`, `kind`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`; profile and Compendium responses also include `current_run` when a run is active, so callers can distinguish the active profile, save scope, local save location, and current run attempt. +Save/path context fields are normalized with forward slashes, including Windows absolute paths. + Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4e9522ef..849be364 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -96,6 +96,20 @@ def assert_sorted_objects(path: str, field: str, values: object, key: str) -> No fail(f"{path} expected {field} to be sorted by {key}") +def assert_context_paths_normalized(path: str, data: object) -> None: + if not isinstance(data, dict): + return + + for field in ["progress_path", "resolved_progress_path", "profile_root", "save_path"]: + value = data.get(field) + if isinstance(value, str) and "\\" in value: + fail(f"{path} expected {field} to use forward slashes, got {value!r}") + + current_run = data.get("current_run") + if isinstance(current_run, dict): + assert_context_paths_normalized(f"{path}.current_run", current_run) + + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) @@ -394,8 +408,24 @@ def audit_static_card_glossary_metadata(repo: Path) -> None: def audit_static_save_roots(repo: Path) -> None: compendium = (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8") profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") + fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + helpers = (repo / "McpMod.Helpers.cs").read_text(encoding="utf-8") mcp_server = (repo / "mcp" / "server.py").read_text(encoding="utf-8") mcp_readme = (repo / "mcp" / "README.md").read_text(encoding="utf-8") + raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + + if "NormalizePathForJson" not in helpers: + fail("profile/save context paths must be normalized at the JSON boundary") + for source_name, source in [ + ("compendium", compendium), + ("profile", profile), + ("glossary", fork_endpoints), + ]: + if "NormalizePathForJson" not in source: + fail(f"{source_name} endpoint missing path normalization for profile/save context") + if "\\saves\\progress.save" in raw_full: + fail("docs/raw-full.md should show normalized profile/save paths with forward slashes") + match = re.search( r"private static IEnumerable EnumerateSaveRoots\(\).*?\n private static IEnumerable EnumerateSteamDataRoots\(\)", compendium, @@ -1009,6 +1039,7 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") + assert_context_paths_normalized(path, data) expected_kind = path.rsplit("/", 1)[-1] if data.get("status") != "ok" or data.get("kind") != expected_kind: fail(f"{path} expected status ok and kind {expected_kind}, got {data}") @@ -1047,6 +1078,7 @@ def audit_live(base_url: str) -> None: for profile_slot in profiles: if not isinstance(profile_slot, dict): fail(f"{path} expected profile slot objects, got {profile_slot}") + assert_context_paths_normalized(f"{path}.profiles[]", profile_slot) for required_field in ["id", "profile_id", "is_current", "has_data", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in profile_slot: fail(f"{path} profile slot missing field {required_field}: {profile_slot}") @@ -1069,6 +1101,7 @@ def audit_live(base_url: str) -> None: if path.startswith("/api/v1/glossary/") and status == 409: if not isinstance(data, dict) or data.get("error_code") != "run_not_in_progress": fail(f"{path} expected run_not_in_progress glossary error, got {data}") + assert_context_paths_normalized(path, data) for required_field in ["kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} glossary error missing profile/save context field: {required_field}") @@ -1076,6 +1109,7 @@ def audit_live(base_url: str) -> None: expected_kind = path.rsplit("/", 1)[-1] if not isinstance(data, dict): fail(f"{path} expected structured glossary object, got {type(data).__name__}") + assert_context_paths_normalized(path, data) if data.get("status") != "ok" or data.get("kind") != expected_kind: fail(f"{path} expected status ok and kind {expected_kind}, got {data}") if not isinstance(data.get("items"), list) or data.get("count") != len(data["items"]): @@ -1110,6 +1144,7 @@ def audit_live(base_url: str) -> None: status, data = load_json_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=json") if status != 200 or not isinstance(data, dict) or "state_type" not in data: fail(f"/api/v1/singleplayer?format=json expected JSON state, got HTTP {status}: {data}") + assert_context_paths_normalized("/api/v1/singleplayer?format=json", data) if data.get("status") != "ok" or data.get("kind") != "singleplayer_state": fail(f"/api/v1/singleplayer?format=json expected status ok and kind singleplayer_state, got {data}") From 1698771ae0074385d48149b8efc80147debf6c01 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:27:21 -0400 Subject: [PATCH 142/190] Normalize compendium history path --- McpMod.Compendium.cs | 2 +- docs/raw-full.md | 1 + scripts/audit_endpoints.py | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index fcd5dc5e..0fa3f8fc 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -307,7 +307,7 @@ private static MemberInfo[] GetRunHistoryMembers(Type progressType) ["ui_label"] = "Run History", ["status"] = files.Count > 0 ? "exposed" : "exposed_empty", ["source"] = "Profile save history files", - ["history_path"] = historyDirectory, + ["history_path"] = NormalizePathForJson(historyDirectory), ["entry_count"] = files.Count, ["entries"] = files.Take(20).Select(file => BuildRunHistoryEntry(file, snapshot.ProfileId, snapshot.SaveScope)).ToList(), ["progress_members"] = values.Count > 0 ? values : null, diff --git a/docs/raw-full.md b/docs/raw-full.md index 55aeef67..2f4d19b0 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1063,6 +1063,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: "character_stats": { "status": "exposed", "characters": [], "global": {} }, "run_history": { "status": "exposed", + "history_path": "C:/.../SlayTheSpire2/steam//modded/profile1/saves/history", "entry_count": 10, "entries": [ { diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 849be364..c64a7a44 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -100,7 +100,7 @@ def assert_context_paths_normalized(path: str, data: object) -> None: if not isinstance(data, dict): return - for field in ["progress_path", "resolved_progress_path", "profile_root", "save_path"]: + for field in ["progress_path", "resolved_progress_path", "profile_root", "save_path", "history_path"]: value = data.get(field) if isinstance(value, str) and "\\" in value: fail(f"{path} expected {field} to use forward slashes, got {value!r}") @@ -109,6 +109,12 @@ def assert_context_paths_normalized(path: str, data: object) -> None: if isinstance(current_run, dict): assert_context_paths_normalized(f"{path}.current_run", current_run) + sections = data.get("sections") + if isinstance(sections, dict): + run_history = sections.get("run_history") + if isinstance(run_history, dict): + assert_context_paths_normalized(f"{path}.sections.run_history", run_history) + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") From 6f80057ba24726c7cba821a97f570e72d4e3a376 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:30:01 -0400 Subject: [PATCH 143/190] Describe endpoint response contexts --- McpMod.cs | 18 +++++++++--------- scripts/audit_endpoints.py | 9 +++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index 53f5381e..f3b9f621 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -411,19 +411,19 @@ private static void HandleRequest(HttpListenerContext context) { return new List> { - new() { ["method"] = "GET", ["path"] = "/api/v1/singleplayer", ["description"] = "Read singleplayer state" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/singleplayer", ["description"] = "Read singleplayer state with status/kind envelope and active-run context" }, new() { ["method"] = "POST", ["path"] = "/api/v1/singleplayer", ["description"] = "Perform singleplayer action" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/multiplayer", ["description"] = "Read multiplayer state" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/multiplayer", ["description"] = "Read multiplayer state with status/kind envelope and active-run context" }, new() { ["method"] = "POST", ["path"] = "/api/v1/multiplayer", ["description"] = "Perform multiplayer action" }, new() { ["method"] = "GET", ["path"] = "/api/v1/settings", ["description"] = "Read settings and preferences" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/profile", ["description"] = "Read active profile progress plus save/run context" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/compendium", ["description"] = "Read Compendium-shaped profile progress plus save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/profile", ["description"] = "Read active profile progress plus normalized save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/compendium", ["description"] = "Read Compendium-shaped profile progress plus normalized save/run context" }, new() { ["method"] = "GET", ["path"] = "/api/v1/bestiary", ["description"] = "Read monster and encounter metadata" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/cards", ["description"] = "Read active-run card pool metadata" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/relics", ["description"] = "Read active-run relic pool metadata" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/potions", ["description"] = "Read active-run potion pool metadata" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/keywords", ["description"] = "Read active-run keyword metadata" }, - new() { ["method"] = "GET", ["path"] = "/api/v1/profiles", ["description"] = "List profile slots" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/cards", ["description"] = "Read active-run card pool metadata plus profile/save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/relics", ["description"] = "Read active-run relic pool metadata plus profile/save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/potions", ["description"] = "Read active-run potion pool metadata plus profile/save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/glossary/keywords", ["description"] = "Read active-run keyword metadata plus profile/save/run context" }, + new() { ["method"] = "GET", ["path"] = "/api/v1/profiles", ["description"] = "List profile slots plus normalized save context" }, new() { ["method"] = "POST", ["path"] = "/api/v1/profiles", ["description"] = "Switch or delete profile slots" } }; } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index c64a7a44..79142c34 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -118,6 +118,7 @@ def assert_context_paths_normalized(path: str, data: object) -> None: def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) expected = set(EXPECTED_ENDPOINTS) missing = sorted(expected - documented) @@ -126,6 +127,14 @@ def audit_docs(repo: Path) -> None: fail(f"docs/raw-full.md is missing endpoints: {missing}") if extra: fail(f"docs/raw-full.md documents unexpected endpoints: {extra}") + for required_fragment in [ + "status/kind envelope and active-run context", + "normalized save/run context", + "profile/save/run context", + "List profile slots plus normalized save context", + ]: + if required_fragment not in mcp_mod: + fail(f"root endpoint index missing response-context description: {required_fragment}") print(f"docs: {len(documented)} endpoints documented") From 520039892e1e96019c592e11d2c64aeac31d6455 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:33:03 -0400 Subject: [PATCH 144/190] Code blocking popup action errors --- McpMod.Actions.cs | 2 +- McpMod.MultiplayerActions.cs | 2 +- McpMod.cs | 2 +- docs/raw-full.md | 1 + docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 13 +++++++++++-- 7 files changed, 17 insertions(+), 7 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index 4907172c..c06cb88f 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -56,7 +56,7 @@ public static partial class McpMod var tree = (Godot.Engine.GetMainLoop()) as SceneTree; if (tree?.Root != null && IsAnyFtueVisible(tree.Root)) - return Error("Blocking popup active. Use menu_select with one of the advertised popup options before gameplay actions."); + return Error("Blocking popup active. Use menu_select with one of the advertised popup options before gameplay actions.", "blocking_popup_active"); return action switch { diff --git a/McpMod.MultiplayerActions.cs b/McpMod.MultiplayerActions.cs index 5c95c98b..a21c0d47 100644 --- a/McpMod.MultiplayerActions.cs +++ b/McpMod.MultiplayerActions.cs @@ -29,7 +29,7 @@ public static partial class McpMod var tree = Engine.GetMainLoop() as SceneTree; if (tree?.Root != null && IsAnyFtueVisible(tree.Root)) - return Error("Blocking popup active. Use menu_select with one of the advertised popup options before gameplay actions."); + return Error("Blocking popup active. Use menu_select with one of the advertised popup options before gameplay actions.", "blocking_popup_active"); return action switch { diff --git a/McpMod.cs b/McpMod.cs index f3b9f621..2817bfdb 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -661,7 +661,7 @@ private static void SendActionResultJson(HttpListenerResponse response, Dictiona response.StatusCode = (errorCode as string) switch { "missing_menu_option" or "unknown_menu_option" or "unknown_action" or "unknown_multiplayer_action" => 400, - "not_on_menu" or "run_not_in_progress" or "not_multiplayer_run" => 409, + "not_on_menu" or "run_not_in_progress" or "not_multiplayer_run" or "blocking_popup_active" => 409, "local_player_unavailable" => 409, _ => 400 }; diff --git a/docs/raw-full.md b/docs/raw-full.md index 2f4d19b0..5a60fbd5 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1207,6 +1207,7 @@ Profile action validation returns structured HTTP errors: invalid profile IDs an All POST requests use a JSON body with an `"action"` field and action-specific parameters. Action dispatch failures include structured `error_code` values where available. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. +If a tutorial or blocking popup is visible during a run, gameplay actions return HTTP 409 with `error_code: "blocking_popup_active"`; use the advertised `menu_select` option to dismiss or answer it first. ### Success Response diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 7597ff36..ff4f39bf 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -69,7 +69,7 @@ Serialized card objects in hand, deck, piles, rewards, card selections, bundles, ## POST — Actions -All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. If a tutorial or blocking popup is visible during a run, gameplay actions return HTTP 409 with `error_code: "blocking_popup_active"`; use the advertised `menu_select` option first. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. ### Menu / Game Over diff --git a/mcp/README.md b/mcp/README.md index 93902d48..3cbafd5d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -56,7 +56,7 @@ Save/path context fields are normalized with forward slashes, including Windows Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. -Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. +Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 79142c34..0bb74f80 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -249,7 +249,7 @@ def audit_static_error_shapes(repo: Path) -> None: fail(f"state endpoints missing format validation: {required_fragment}") actions = (repo / "McpMod.Actions.cs").read_text(encoding="utf-8") multiplayer_actions = (repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8") - for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable"]: + for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active"]: if required_fragment not in mcp_mod + actions: fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") if "response.StatusCode = 400;" not in mcp_mod: @@ -257,7 +257,7 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["missing_menu_option", "unknown_menu_option", "not_on_menu"]: if required_fragment not in mcp_mod + actions: fail(f"menu_select missing structured dispatch error handling: {required_fragment}") - for required_fragment in ["unknown_multiplayer_action", "not_multiplayer_run", "run_not_in_progress", "local_player_unavailable"]: + for required_fragment in ["unknown_multiplayer_action", "not_multiplayer_run", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active"]: if required_fragment not in mcp_mod + multiplayer_actions: fail(f"multiplayer actions missing structured dispatch error handling: {required_fragment}") mcp_server = (repo / "mcp" / "server.py").read_text(encoding="utf-8") @@ -267,6 +267,15 @@ def audit_static_error_shapes(repo: Path) -> None: mcp_readme = (repo / "mcp" / "README.md").read_text(encoding="utf-8") if "MCP wrappers preserve those structured JSON error bodies" not in mcp_readme: fail("mcp/README.md must document structured non-2xx error propagation") + docs = "\n".join( + [ + (repo / "docs" / "raw-simplified.md").read_text(encoding="utf-8"), + (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"), + mcp_readme, + ] + ) + if "blocking_popup_active" not in docs: + fail("docs must describe blocking popup action errors") print("errors: structured 500 response helpers enforced") From 71f9ddeca602ed7956e646163f4020896bbd6c10 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:36:26 -0400 Subject: [PATCH 145/190] Code POST validation errors --- McpMod.Profile.cs | 12 +++++----- McpMod.cs | 15 ++++++------ docs/raw-full.md | 1 + docs/raw-simplified.md | 1 + mcp/README.md | 2 ++ scripts/audit_endpoints.py | 47 ++++++++++++++++++++++++++------------ 6 files changed, 50 insertions(+), 28 deletions(-) diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 35870b35..03fa5129 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -54,36 +54,36 @@ private static void HandlePostProfiles(HttpListenerRequest request, HttpListener } catch { - SendError(response, 400, "Invalid JSON"); + SendError(response, 400, "Invalid JSON", "invalid_json"); return; } if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) { - SendError(response, 400, "Missing 'action' field. Use: switch, delete"); + SendError(response, 400, "Missing 'action' field. Use: switch, delete", "missing_action"); return; } if (actionElem.ValueKind != JsonValueKind.String) { - SendError(response, 400, "'action' field must be a string"); + SendError(response, 400, "'action' field must be a string", "invalid_action_type"); return; } string action = actionElem.GetString() ?? ""; if (!parsed.TryGetValue("profile_id", out var idElem)) { - SendError(response, 400, "Missing 'profile_id' field. Use a profile slot 1-3."); + SendError(response, 400, "Missing 'profile_id' field. Use a profile slot 1-3.", "missing_profile_id"); return; } if (idElem.ValueKind != JsonValueKind.Number) { - SendError(response, 400, "'profile_id' field must be a number"); + SendError(response, 400, "'profile_id' field must be a number", "invalid_profile_id_type"); return; } int profileId = 0; if (idElem.ValueKind == JsonValueKind.Number && !idElem.TryGetInt32(out profileId)) { - SendError(response, 400, "'profile_id' field must be a 32-bit integer"); + SendError(response, 400, "'profile_id' field must be a 32-bit integer", "invalid_profile_id_type"); return; } diff --git a/McpMod.cs b/McpMod.cs index 2817bfdb..d752b37e 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -486,18 +486,18 @@ private static void HandlePostMultiplayerAction(HttpListenerRequest request, Htt } catch { - SendError(response, 400, "Invalid JSON"); + SendError(response, 400, "Invalid JSON", "invalid_json"); return; } if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) { - SendError(response, 400, "Missing 'action' field"); + SendError(response, 400, "Missing 'action' field", "missing_action"); return; } if (actionElem.ValueKind != JsonValueKind.String) { - SendError(response, 400, "'action' field must be a string"); + SendError(response, 400, "'action' field must be a string", "invalid_action_type"); return; } @@ -597,18 +597,18 @@ private static void HandlePostAction(HttpListenerRequest request, HttpListenerRe } catch { - SendError(response, 400, "Invalid JSON"); + SendError(response, 400, "Invalid JSON", "invalid_json"); return; } if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) { - SendError(response, 400, "Missing 'action' field"); + SendError(response, 400, "Missing 'action' field", "missing_action"); return; } if (actionElem.ValueKind != JsonValueKind.String) { - SendError(response, 400, "'action' field must be a string"); + SendError(response, 400, "'action' field must be a string", "invalid_action_type"); return; } @@ -649,7 +649,8 @@ private static void SendActionError(HttpListenerResponse response, string prefix var statusCode = ex is InvalidOperationException or FormatException or OverflowException ? 400 : 500; - SendError(response, statusCode, $"{prefix}: {ex.Message}"); + var errorCode = statusCode == 400 ? "invalid_action_payload" : "action_failed"; + SendError(response, statusCode, $"{prefix}: {ex.Message}", errorCode); } private static void SendActionResultJson(HttpListenerResponse response, Dictionary result) diff --git a/docs/raw-full.md b/docs/raw-full.md index 5a60fbd5..fecea5e9 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -19,6 +19,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `POST /api/v1/profiles` — switch or delete profile slots HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. `GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index ff4f39bf..828fdb89 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -18,6 +18,7 @@ HTTP API on `localhost:15526`. No authentication. - `POST /api/v1/profiles` — switch or delete profile slots HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. `GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/mcp/README.md b/mcp/README.md index 3cbafd5d..d018520e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -56,6 +56,8 @@ Save/path context fields are normalized with forward slashes, including Windows Profile switch/delete failures are surfaced as structured endpoint errors with HTTP 400 for invalid input and HTTP 409 for state conflicts such as deleting the active profile or switching during a run. +POST validation failures preserve endpoint `error_code` values such as `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. + Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 0bb74f80..cff62416 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -247,6 +247,18 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["TryValidateStateFormat", "invalid_format", 'format is "json" or "markdown"']: if required_fragment not in mcp_mod: fail(f"state endpoints missing format validation: {required_fragment}") + profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") + validation_codes = [ + "invalid_json", + "missing_action", + "invalid_action_type", + "missing_profile_id", + "invalid_profile_id_type", + "invalid_action_payload", + ] + for required_fragment in validation_codes: + if required_fragment not in mcp_mod + profile: + fail(f"POST validation missing structured error code: {required_fragment}") actions = (repo / "McpMod.Actions.cs").read_text(encoding="utf-8") multiplayer_actions = (repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8") for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active"]: @@ -276,6 +288,9 @@ def audit_static_error_shapes(repo: Path) -> None: ) if "blocking_popup_active" not in docs: fail("docs must describe blocking popup action errors") + for required_fragment in validation_codes: + if required_fragment not in docs: + fail(f"docs must describe POST validation error code: {required_fragment}") print("errors: structured 500 response helpers enforced") @@ -1182,26 +1197,28 @@ def audit_live(base_url: str) -> None: fail(f"/api/v1/singleplayer?format=xml expected invalid_format HTTP 400, got HTTP {status}: {data}") post_validation_checks = [ - ("/api/v1/singleplayer", b"{", 400), - ("/api/v1/singleplayer", b"{}", 400), - ("/api/v1/singleplayer", b'{"action": 1}', 400), - ("/api/v1/singleplayer", b'{"action": "menu_select"}', 400), - ("/api/v1/singleplayer", b'{"action": "menu_select", "option": 1}', 400), - ("/api/v1/profiles", b"{", 400), - ("/api/v1/profiles", b"{}", 400), - ("/api/v1/profiles", b'{"action": 1}', 400), - ("/api/v1/profiles", b'{"action": "switch"}', 400), - ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400), - ("/api/v1/profiles", b'{"action": "switch", "profile_id": 1.5}', 400), - ("/api/v1/profiles", b'{"action": "switch", "profile_id": 999999999999}', 400), - ("/api/v1/profiles", b'{"action": "switch", "profile_id": 4}', 400), - ("/api/v1/profiles", b'{"action": "unknown", "profile_id": 1}', 400), + ("/api/v1/singleplayer", b"{", 400, "invalid_json"), + ("/api/v1/singleplayer", b"{}", 400, "missing_action"), + ("/api/v1/singleplayer", b'{"action": 1}', 400, "invalid_action_type"), + ("/api/v1/singleplayer", b'{"action": "menu_select"}', 400, "missing_menu_option"), + ("/api/v1/singleplayer", b'{"action": "menu_select", "option": 1}', 400, "invalid_action_payload"), + ("/api/v1/profiles", b"{", 400, "invalid_json"), + ("/api/v1/profiles", b"{}", 400, "missing_action"), + ("/api/v1/profiles", b'{"action": 1}', 400, "invalid_action_type"), + ("/api/v1/profiles", b'{"action": "switch"}', 400, "missing_profile_id"), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": "1"}', 400, "invalid_profile_id_type"), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 1.5}', 400, "invalid_profile_id_type"), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 999999999999}', 400, "invalid_profile_id_type"), + ("/api/v1/profiles", b'{"action": "switch", "profile_id": 4}', 400, "invalid_profile_id"), + ("/api/v1/profiles", b'{"action": "unknown", "profile_id": 1}', 400, "unknown_profile_action"), ] - for path, body, expected_status in post_validation_checks: + for path, body, expected_status, expected_code in post_validation_checks: status, data = load_json_url(base_url.rstrip("/") + path, "POST", body) assert_error_body(path, status, data) if status != expected_status: fail(f"{path} expected HTTP {expected_status} for validation check, got {status}: {data}") + if not isinstance(data, dict) or data.get("error_code") != expected_code: + fail(f"{path} expected error_code {expected_code} for validation check, got HTTP {status}: {data}") status, data = load_json_url( base_url.rstrip("/") + "/api/v1/singleplayer", From 2e87f8628f83889b63a6a107f85c83f49b7edded Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:40:17 -0400 Subject: [PATCH 146/190] Code route-level endpoint errors --- McpMod.cs | 34 ++++++++++++++++++++-------------- docs/raw-full.md | 1 + docs/raw-simplified.md | 1 + mcp/README.md | 2 ++ scripts/audit_endpoints.py | 10 ++++++++++ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/McpMod.cs b/McpMod.cs index d752b37e..9adc4b50 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -287,7 +287,7 @@ private static void HandleRequest(HttpListenerContext context) } else { - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } } else if (path == "/api/v1/singleplayer") @@ -307,7 +307,7 @@ private static void HandleRequest(HttpListenerContext context) else if (request.HttpMethod == "POST") HandlePostAction(request, response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/multiplayer") { @@ -325,14 +325,14 @@ private static void HandleRequest(HttpListenerContext context) else if (request.HttpMethod == "POST") HandlePostMultiplayerAction(request, response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/settings") { if (request.HttpMethod == "GET") HandleGetSettings(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/profiles") { @@ -341,67 +341,67 @@ private static void HandleRequest(HttpListenerContext context) else if (request.HttpMethod == "POST") HandlePostProfiles(request, response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/profile") { if (request.HttpMethod == "GET") HandleGetProfile(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/compendium") { if (request.HttpMethod == "GET") HandleGetCompendium(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/bestiary") { if (request.HttpMethod == "GET") HandleGetBestiary(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/glossary/cards") { if (request.HttpMethod == "GET") HandleGetGlossaryCards(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/glossary/relics") { if (request.HttpMethod == "GET") HandleGetGlossaryRelics(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/glossary/potions") { if (request.HttpMethod == "GET") HandleGetGlossaryPotions(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else if (path == "/api/v1/glossary/keywords") { if (request.HttpMethod == "GET") HandleGetGlossaryKeywords(response); else - SendError(response, 405, "Method not allowed"); + SendMethodNotAllowed(response); } else { - SendError(response, 404, "Not found"); + SendNotFound(response); } } catch (Exception ex) { try { - SendError(context.Response, 500, $"Internal error: {ex.Message}"); + SendError(context.Response, 500, $"Internal error: {ex.Message}", "internal_error"); } catch { /* response may already be closed */ } } @@ -441,6 +441,12 @@ internal static bool IsMultiplayerRun() catch { return false; } } + private static void SendMethodNotAllowed(HttpListenerResponse response) + => SendError(response, 405, "Method not allowed", "method_not_allowed"); + + private static void SendNotFound(HttpListenerResponse response) + => SendError(response, 404, "Not found", "not_found"); + private static void HandleGetMultiplayerState(HttpListenerRequest request, HttpListenerResponse response) { string format = request.QueryString["format"] ?? "json"; diff --git a/docs/raw-full.md b/docs/raw-full.md index fecea5e9..1a70269a 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -19,6 +19,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `POST /api/v1/profiles` — switch or delete profile slots HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. `GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 828fdb89..f2b746ad 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -18,6 +18,7 @@ HTTP API on `localhost:15526`. No authentication. - `POST /api/v1/profiles` — switch or delete profile slots HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. `GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/mcp/README.md b/mcp/README.md index d018520e..5cc53857 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -58,6 +58,8 @@ Profile switch/delete failures are surfaced as structured endpoint errors with H POST validation failures preserve endpoint `error_code` values such as `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. +Route-level failures preserve endpoint `error_code` values such as `method_not_allowed`, `not_found`, and `internal_error`. + Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index cff62416..3c996a7c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -247,6 +247,9 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["TryValidateStateFormat", "invalid_format", 'format is "json" or "markdown"']: if required_fragment not in mcp_mod: fail(f"state endpoints missing format validation: {required_fragment}") + for required_fragment in ["SendMethodNotAllowed", "method_not_allowed", "SendNotFound", "not_found", "internal_error"]: + if required_fragment not in mcp_mod: + fail(f"route errors missing structured error code: {required_fragment}") profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") validation_codes = [ "invalid_json", @@ -288,6 +291,9 @@ def audit_static_error_shapes(repo: Path) -> None: ) if "blocking_popup_active" not in docs: fail("docs must describe blocking popup action errors") + for required_fragment in ["method_not_allowed", "not_found", "internal_error"]: + if required_fragment not in docs: + fail(f"docs must describe route-level error code: {required_fragment}") for required_fragment in validation_codes: if required_fragment not in docs: fail(f"docs must describe POST validation error code: {required_fragment}") @@ -1277,11 +1283,15 @@ def audit_live(base_url: str) -> None: assert_error_body(path, status, data) if status != 405: fail(f"{path} expected HTTP 405 for POST, got {status}: {data}") + if not isinstance(data, dict) or data.get("error_code") != "method_not_allowed": + fail(f"{path} expected method_not_allowed error code for POST, got {data}") status, data = load_json_url(base_url.rstrip("/") + "/api/v1/does-not-exist") assert_error_body("/api/v1/does-not-exist", status, data) if status != 404: fail(f"/api/v1/does-not-exist expected HTTP 404, got {status}: {data}") + if not isinstance(data, dict) or data.get("error_code") != "not_found": + fail(f"/api/v1/does-not-exist expected not_found error code, got {data}") print("live: GET endpoint smoke checks passed") print("live: state format checks passed") From 715076b6fb62e0f87921bb8e785a93c9c94bde60 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:44:12 -0400 Subject: [PATCH 147/190] Return non-2xx read availability errors --- McpMod.Compendium.cs | 7 ++++--- McpMod.ForkEndpoints.cs | 7 +++---- McpMod.Helpers.cs | 18 ++++++++++++++++++ McpMod.Profile.cs | 8 ++++---- docs/raw-full.md | 1 + docs/raw-simplified.md | 1 + mcp/README.md | 2 ++ scripts/audit_endpoints.py | 7 +++++++ 8 files changed, 40 insertions(+), 11 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 0fa3f8fc..20d93794 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -25,7 +25,7 @@ private static void HandleGetCompendium(HttpListenerResponse response) { var snapshotTask = RunOnMainThread(BuildCompendiumSnapshot); var snapshot = snapshotTask.GetAwaiter().GetResult(); - SendJson(response, BuildCompendiumResponse(snapshot)); + SendReadResultJson(response, BuildCompendiumResponse(snapshot)); } catch (Exception ex) { @@ -43,7 +43,7 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() var progress = SaveManager.Instance?.Progress; var saveManager = SaveManager.Instance; if (progress == null || saveManager == null) - return new CompendiumSnapshot { Error = "No profile data available." }; + return new CompendiumSnapshot { Error = "No profile data available.", ErrorCode = "profile_data_unavailable" }; var profileId = saveManager.CurrentProfileId; var progressPath = GetProfileProgressPath(profileId); @@ -111,7 +111,7 @@ private static CompendiumSnapshot BuildCompendiumSnapshot() private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) { if (snapshot.Error != null) - return Error(snapshot.Error); + return Error(snapshot.Error, snapshot.ErrorCode); var runHistory = BuildRunHistorySection(snapshot); @@ -184,6 +184,7 @@ private static object BuildCompendiumResponse(CompendiumSnapshot snapshot) private sealed class CompendiumSnapshot { public string? Error { get; init; } + public string? ErrorCode { get; init; } public int ProfileId { get; init; } public string? ProgressPath { get; init; } public string? ResolvedProgressPath { get; init; } diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index eac9096a..b2be0196 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -25,8 +25,7 @@ private static void HandleGetSettings(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildSettings); - - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -38,12 +37,12 @@ internal static object BuildSettings() { var sm = SaveManager.Instance; if (sm == null) - return Error("Save manager is not available"); + return Error("Save manager is not available", "save_manager_unavailable"); var settings = sm.SettingsSave; var prefs = sm.PrefsSave; if (settings == null || prefs == null) - return Error("Settings data is not available"); + return Error("Settings data is not available", "settings_data_unavailable"); return new Dictionary { diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 9fcc1809..5d3b98ba 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -132,6 +132,24 @@ internal static void SendError(HttpListenerResponse response, int statusCode, st SendJson(response, data); } + internal static void SendReadResultJson(HttpListenerResponse response, object result) + { + if (result is Dictionary data + && data.TryGetValue("status", out var status) + && status as string == "error") + { + response.StatusCode = data.TryGetValue("error_code", out var errorCode) + ? (errorCode as string) switch + { + "save_manager_unavailable" or "settings_data_unavailable" or "profile_data_unavailable" => 503, + _ => 500 + } + : 500; + } + + SendJson(response, result); + } + private static Dictionary Error(string message, string? errorCode = null) { var data = new Dictionary { ["status"] = "error", ["error"] = message }; diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 03fa5129..141e5c5b 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -20,7 +20,7 @@ private static void HandleGetProfile(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildProfile); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -33,7 +33,7 @@ private static void HandleGetProfiles(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildProfilesSummary); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { @@ -119,7 +119,7 @@ private static void SendProfileActionJson(HttpListenerResponse response, Diction { var sm = SaveManager.Instance; if (sm == null) - return Error("Save manager is not available"); + return Error("Save manager is not available", "save_manager_unavailable"); var profiles = new List>(); for (int i = 1; i <= 3; i++) @@ -263,7 +263,7 @@ internal static object BuildProfile() var saveManager = SaveManager.Instance; var progress = saveManager?.Progress; if (saveManager == null || progress == null) - return Error("No profile data available."); + return Error("No profile data available.", "profile_data_unavailable"); var result = new Dictionary(); var profileId = saveManager.CurrentProfileId; diff --git a/docs/raw-full.md b/docs/raw-full.md index 1a70269a..b774e04e 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -21,6 +21,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. +Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. `GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index f2b746ad..079dfc59 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -20,6 +20,7 @@ HTTP API on `localhost:15526`. No authentication. HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. +Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. `GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/mcp/README.md b/mcp/README.md index 5cc53857..27df3cef 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -60,6 +60,8 @@ POST validation failures preserve endpoint `error_code` values such as `invalid_ Route-level failures preserve endpoint `error_code` values such as `method_not_allowed`, `not_found`, and `internal_error`. +Read endpoint startup/data-availability failures preserve endpoint `error_code` values such as `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. + Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3c996a7c..e46fde5e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -247,6 +247,10 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["TryValidateStateFormat", "invalid_format", 'format is "json" or "markdown"']: if required_fragment not in mcp_mod: fail(f"state endpoints missing format validation: {required_fragment}") + helpers = (repo / "McpMod.Helpers.cs").read_text(encoding="utf-8") + for required_fragment in ["SendReadResultJson", "save_manager_unavailable", "settings_data_unavailable", "profile_data_unavailable"]: + if required_fragment not in helpers + fork_endpoints + (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") + (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8"): + fail(f"read endpoints missing structured availability error handling: {required_fragment}") for required_fragment in ["SendMethodNotAllowed", "method_not_allowed", "SendNotFound", "not_found", "internal_error"]: if required_fragment not in mcp_mod: fail(f"route errors missing structured error code: {required_fragment}") @@ -294,6 +298,9 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["method_not_allowed", "not_found", "internal_error"]: if required_fragment not in docs: fail(f"docs must describe route-level error code: {required_fragment}") + for required_fragment in ["save_manager_unavailable", "settings_data_unavailable", "profile_data_unavailable"]: + if required_fragment not in docs: + fail(f"docs must describe read endpoint availability error code: {required_fragment}") for required_fragment in validation_codes: if required_fragment not in docs: fail(f"docs must describe POST validation error code: {required_fragment}") From 37c3735b70bacce9218f9566d92b415660244c11 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:48:59 -0400 Subject: [PATCH 148/190] Code read endpoint failure errors --- McpMod.Compendium.cs | 2 +- McpMod.ForkEndpoints.cs | 12 ++++++------ McpMod.Profile.cs | 4 ++-- McpMod.cs | 4 ++-- docs/raw-full.md | 1 + docs/raw-simplified.md | 1 + mcp/README.md | 2 ++ scripts/audit_endpoints.py | 33 +++++++++++++++++++++++++++++++++ 8 files changed, 48 insertions(+), 11 deletions(-) diff --git a/McpMod.Compendium.cs b/McpMod.Compendium.cs index 20d93794..f42645cb 100644 --- a/McpMod.Compendium.cs +++ b/McpMod.Compendium.cs @@ -29,7 +29,7 @@ private static void HandleGetCompendium(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build compendium: {ex.Message}"); + SendError(response, 500, $"Failed to build compendium: {ex.Message}", "compendium_build_failed"); } } diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index b2be0196..275f6a1c 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -29,7 +29,7 @@ private static void HandleGetSettings(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to read settings: {ex.Message}"); + SendError(response, 500, $"Failed to read settings: {ex.Message}", "settings_read_failed"); } } @@ -93,7 +93,7 @@ private static void HandleGetBestiary(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build bestiary: {ex.Message}"); + SendError(response, 500, $"Failed to build bestiary: {ex.Message}", "bestiary_build_failed"); } } @@ -106,7 +106,7 @@ private static void HandleGetGlossaryCards(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); } } @@ -119,7 +119,7 @@ private static void HandleGetGlossaryKeywords(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); } } @@ -132,7 +132,7 @@ private static void HandleGetGlossaryPotions(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); } } @@ -145,7 +145,7 @@ private static void HandleGetGlossaryRelics(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build glossary: {ex.Message}"); + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); } } diff --git a/McpMod.Profile.cs b/McpMod.Profile.cs index 141e5c5b..e95d63d5 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -24,7 +24,7 @@ private static void HandleGetProfile(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to build profile: {ex.Message}"); + SendError(response, 500, $"Failed to build profile: {ex.Message}", "profile_build_failed"); } } @@ -37,7 +37,7 @@ private static void HandleGetProfiles(HttpListenerResponse response) } catch (Exception ex) { - SendError(response, 500, $"Failed to get profiles: {ex.Message}"); + SendError(response, 500, $"Failed to get profiles: {ex.Message}", "profiles_read_failed"); } } diff --git a/McpMod.cs b/McpMod.cs index 9adc4b50..461b395d 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -473,7 +473,7 @@ private static void HandleGetMultiplayerState(HttpListenerRequest request, HttpL GD.PrintErr($"[STS2 MCP] HandleGetMultiplayerState: {ex}"); try { - SendError(response, 500, $"Failed to read multiplayer game state: {ex.Message}"); + SendError(response, 500, $"Failed to read multiplayer game state: {ex.Message}", "multiplayer_state_read_failed"); } catch { /* response may be unusable */ } } @@ -575,7 +575,7 @@ private static void HandleGetState(HttpListenerRequest request, HttpListenerResp GD.PrintErr($"[STS2 MCP] HandleGetState: {ex}"); try { - SendError(response, 500, $"Failed to read game state: {ex.Message}"); + SendError(response, 500, $"Failed to read game state: {ex.Message}", "singleplayer_state_read_failed"); } catch { /* response may be unusable */ } } diff --git a/docs/raw-full.md b/docs/raw-full.md index b774e04e..14a79ecf 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -22,6 +22,7 @@ HTTP error responses include `status: "error"` and `error`. Some route-specific Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. +Read endpoint exceptions include endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. `GET /` returns the API index with `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 079dfc59..1c7bcf9d 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -21,6 +21,7 @@ HTTP error responses include `status: "error"` and `error`. Some route-specific Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. +Read endpoint exceptions include endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. `GET /` returns `status: "ok"`, `kind: "api_index"`, `version`, `endpoint_count`, `bound_prefixes`, and the advertised endpoint list. diff --git a/mcp/README.md b/mcp/README.md index 27df3cef..ee443080 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -62,6 +62,8 @@ Route-level failures preserve endpoint `error_code` values such as `method_not_a Read endpoint startup/data-availability failures preserve endpoint `error_code` values such as `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. +Read endpoint exceptions preserve endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. + Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index e46fde5e..ddb6535e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -231,6 +231,15 @@ def audit_static_error_shapes(repo: Path) -> None: if raw_error_writes: fail(f"500 handlers should use SendError for structured error bodies: {sorted(raw_error_writes)}") + uncoded_500s: list[str] = [] + for path in repo.glob("McpMod*.cs"): + text = path.read_text(encoding="utf-8") + for match in re.finditer(r"SendError\([^;]*,\s*500\s*,[^;]*\);", text, re.S): + if match.group(0).count(",") < 3: + uncoded_500s.append(path.name) + break + if uncoded_500s: + fail(f"500 handlers should include explicit error_code values: {sorted(uncoded_500s)}") fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") settings_match = re.search( r"internal static object BuildSettings\(\).*?\n private static void HandleGetBestiary", @@ -254,6 +263,27 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SendMethodNotAllowed", "method_not_allowed", "SendNotFound", "not_found", "internal_error"]: if required_fragment not in mcp_mod: fail(f"route errors missing structured error code: {required_fragment}") + read_failure_codes = [ + "settings_read_failed", + "bestiary_build_failed", + "glossary_build_failed", + "profile_build_failed", + "profiles_read_failed", + "compendium_build_failed", + "singleplayer_state_read_failed", + "multiplayer_state_read_failed", + ] + read_failure_sources = "\n".join( + [ + mcp_mod, + fork_endpoints, + (repo / "McpMod.Profile.cs").read_text(encoding="utf-8"), + (repo / "McpMod.Compendium.cs").read_text(encoding="utf-8"), + ] + ) + for required_fragment in read_failure_codes: + if required_fragment not in read_failure_sources: + fail(f"read endpoints missing structured failure error code: {required_fragment}") profile = (repo / "McpMod.Profile.cs").read_text(encoding="utf-8") validation_codes = [ "invalid_json", @@ -301,6 +331,9 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["save_manager_unavailable", "settings_data_unavailable", "profile_data_unavailable"]: if required_fragment not in docs: fail(f"docs must describe read endpoint availability error code: {required_fragment}") + for required_fragment in read_failure_codes: + if required_fragment not in docs: + fail(f"docs must describe read endpoint failure error code: {required_fragment}") for required_fragment in validation_codes: if required_fragment not in docs: fail(f"docs must describe POST validation error code: {required_fragment}") From 5e87785dc2eebd2ba5329381c573d88983638be2 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:53:33 -0400 Subject: [PATCH 149/190] Add fallback action error codes --- McpMod.Helpers.cs | 12 +++++++++++- docs/raw-full.md | 1 + docs/raw-simplified.md | 1 + mcp/README.md | 2 +- scripts/audit_endpoints.py | 5 +++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/McpMod.Helpers.cs b/McpMod.Helpers.cs index 5d3b98ba..c71de295 100644 --- a/McpMod.Helpers.cs +++ b/McpMod.Helpers.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using Godot; @@ -150,9 +151,18 @@ internal static void SendReadResultJson(HttpListenerResponse response, object re SendJson(response, result); } - private static Dictionary Error(string message, string? errorCode = null) + private static Dictionary Error( + string message, + string? errorCode = null, + [CallerFilePath] string callerFilePath = "") { var data = new Dictionary { ["status"] = "error", ["error"] = message }; + if (string.IsNullOrWhiteSpace(errorCode) + && (callerFilePath.EndsWith("McpMod.Actions.cs", StringComparison.Ordinal) + || callerFilePath.EndsWith("McpMod.MultiplayerActions.cs", StringComparison.Ordinal))) + { + errorCode = "action_error"; + } if (!string.IsNullOrWhiteSpace(errorCode)) data["error_code"] = errorCode; return data; diff --git a/docs/raw-full.md b/docs/raw-full.md index 14a79ecf..79aa132a 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -21,6 +21,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. +Action failures include `error_code`; endpoint-specific conflicts keep their specific codes, while older generic action failures use the stable fallback `action_error`. Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. Read endpoint exceptions include endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 1c7bcf9d..c6f8dd64 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -20,6 +20,7 @@ HTTP API on `localhost:15526`. No authentication. HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. +Action failures include `error_code`; endpoint-specific conflicts keep their specific codes, while older generic action failures use the stable fallback `action_error`. Read endpoint startup/data-availability failures use non-2xx structured errors where possible, including `save_manager_unavailable`, `settings_data_unavailable`, and `profile_data_unavailable`. Read endpoint exceptions include endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. diff --git a/mcp/README.md b/mcp/README.md index ee443080..0c11175c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -64,7 +64,7 @@ Read endpoint startup/data-availability failures preserve endpoint `error_code` Read endpoint exceptions preserve endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. -Menu/action dispatch failures also use structured endpoint errors where available: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. +Menu/action dispatch failures also use structured endpoint errors: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Older generic action failures carry the stable fallback `error_code: action_error` instead of omitting an error code. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index ddb6535e..3309aed0 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -301,6 +301,9 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active"]: if required_fragment not in mcp_mod + actions: fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") + for required_fragment in ["CallerFilePath", "action_error", "McpMod.Actions.cs", "McpMod.MultiplayerActions.cs"]: + if required_fragment not in helpers: + fail(f"generic action failures must get fallback error_code values: {required_fragment}") if "response.StatusCode = 400;" not in mcp_mod: fail("action result sender must return non-2xx for uncoded action errors") for required_fragment in ["missing_menu_option", "unknown_menu_option", "not_on_menu"]: @@ -325,6 +328,8 @@ def audit_static_error_shapes(repo: Path) -> None: ) if "blocking_popup_active" not in docs: fail("docs must describe blocking popup action errors") + if "action_error" not in docs: + fail("docs must describe generic action error fallback code") for required_fragment in ["method_not_allowed", "not_found", "internal_error"]: if required_fragment not in docs: fail(f"docs must describe route-level error code: {required_fragment}") From 04f99331ab02514e2f202cd25b6d23be70fca009 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:55:13 -0400 Subject: [PATCH 150/190] Audit static endpoint manifest parity --- scripts/audit_endpoints.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3309aed0..3fdf58fb 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -119,14 +119,42 @@ def assert_context_paths_normalized(path: str, data: object) -> None: def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") - documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) expected = set(EXPECTED_ENDPOINTS) + + documented = set(re.findall(r"- `(GET|POST)\s+([^`]+)`", raw_full)) missing = sorted(expected - documented) extra = sorted(documented - expected) if missing: fail(f"docs/raw-full.md is missing endpoints: {missing}") if extra: fail(f"docs/raw-full.md documents unexpected endpoints: {extra}") + + indexed = set(re.findall(r'\["method"\]\s*=\s*"(GET|POST)"\s*,\s*\["path"\]\s*=\s*"([^"]+)"', mcp_mod)) + missing_index = sorted(expected - indexed) + extra_index = sorted(indexed - expected) + if missing_index: + fail(f"BuildEndpointIndex is missing endpoints: {missing_index}") + if extra_index: + fail(f"BuildEndpointIndex advertises unexpected endpoints: {extra_index}") + + routed_paths = set(re.findall(r'path\s*==\s*"(/api/v1/[^"]+)"', mcp_mod)) + expected_paths = {path for _, path in expected} + missing_routes = sorted(expected_paths - routed_paths) + extra_routes = sorted(routed_paths - expected_paths) + if missing_routes: + fail(f"HTTP route table is missing paths: {missing_routes}") + if extra_routes: + fail(f"HTTP route table includes unexpected paths: {extra_routes}") + + for method, path in EXPECTED_ENDPOINTS: + path_pos = mcp_mod.find(f'path == "{path}"') + if path_pos == -1: + fail(f"could not locate route block for {method} {path}") + next_pos = mcp_mod.find("else if (path ==", path_pos + 1) + block = mcp_mod[path_pos: next_pos if next_pos != -1 else mcp_mod.find("else\n", path_pos)] + if f'request.HttpMethod == "{method}"' not in block: + fail(f"HTTP route table missing {method} handling for {path}") + for required_fragment in [ "status/kind envelope and active-run context", "normalized save/run context", From a7a8d76c085e97399e5d691d0319efb3f6c004ce Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 11:58:53 -0400 Subject: [PATCH 151/190] Test MCP endpoint error handling --- README.md | 7 ++ scripts/test_mcp_server.py | 155 +++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/test_mcp_server.py diff --git a/README.md b/README.md index 4800f9bf..9d697a71 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,13 @@ To audit the documented and live HTTP endpoint surface: python3 scripts/audit_endpoints.py ``` +To run the focused MCP bridge tests for endpoint error propagation and +multiplayer menu retry behavior: + +```bash +uv run --project mcp python scripts/test_mcp_server.py +``` + ### 2. Give Your AI Instructions to Interact with the Game **Clone or download the repository**, then: diff --git a/scripts/test_mcp_server.py b/scripts/test_mcp_server.py new file mode 100644 index 00000000..e762494c --- /dev/null +++ b/scripts/test_mcp_server.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Focused tests for the Python MCP bridge. + +The bridge normally imports the MCP package only to register tool decorators. +These tests stub that package so they can exercise the endpoint error handling +without starting an MCP server. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +import sys +import types +from pathlib import Path + +import httpx + + +class _FakeFastMCP: + def __init__(self, name: str) -> None: + self.name = name + + def tool(self): + def decorator(func): + return func + + return decorator + + def run(self) -> None: + raise AssertionError("test import should not run the MCP server") + + +def _load_server_module(): + fake_mcp = types.ModuleType("mcp") + fake_mcp_server = types.ModuleType("mcp.server") + fake_fastmcp = types.ModuleType("mcp.server.fastmcp") + fake_fastmcp.FastMCP = _FakeFastMCP + sys.modules.setdefault("mcp", fake_mcp) + sys.modules.setdefault("mcp.server", fake_mcp_server) + sys.modules["mcp.server.fastmcp"] = fake_fastmcp + + server_path = Path(__file__).resolve().parents[1] / "mcp" / "server.py" + spec = importlib.util.spec_from_file_location("sts2_mcp_server_under_test", server_path) + if spec is None or spec.loader is None: + raise AssertionError(f"could not load {server_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _json_response(status_code: int, payload: object) -> httpx.Response: + request = httpx.Request("POST", "http://localhost:15526/api/v1/singleplayer") + return httpx.Response(status_code, json=payload, request=request) + + +def _http_status_error(status_code: int, payload: object) -> httpx.HTTPStatusError: + response = _json_response(status_code, payload) + return httpx.HTTPStatusError("endpoint error", request=response.request, response=response) + + +def test_structured_http_error_is_preserved(server) -> None: + error = _http_status_error( + 409, + { + "status": "error", + "error": "No run in progress", + "error_code": "run_not_in_progress", + }, + ) + + rendered = server._handle_error(error) + payload = json.loads(rendered) + assert payload["status"] == "error" + assert payload["error_code"] == "run_not_in_progress" + assert payload["http_status"] == 409 + + +def test_non_endpoint_http_error_stays_text(server) -> None: + error = _http_status_error(502, {"status": "ok", "message": "bad gateway"}) + rendered = server._handle_error(error) + assert rendered.startswith("Error: HTTP 502") + + +async def test_menu_select_retries_multiplayer_conflict(server) -> None: + calls: list[tuple[str, dict]] = [] + + async def fake_post(body: dict) -> str: + calls.append(("singleplayer", body)) + raise _http_status_error( + 409, + { + "status": "error", + "error": "Multiplayer run is active.", + "error_code": "multiplayer_run_active", + }, + ) + + async def fake_mp_post(body: dict) -> str: + calls.append(("multiplayer", body)) + return '{"status":"ok"}' + + server._post = fake_post + server._mp_post = fake_mp_post + result = await server._menu_select_post({"action": "menu_select", "option": "continue"}) + + assert result == '{"status":"ok"}' + assert calls == [ + ("singleplayer", {"action": "menu_select", "option": "continue"}), + ("multiplayer", {"action": "menu_select", "option": "continue"}), + ] + + +async def test_menu_select_does_not_retry_other_409(server) -> None: + calls: list[str] = [] + + async def fake_post(body: dict) -> str: + calls.append("singleplayer") + raise _http_status_error( + 409, + { + "status": "error", + "error": "No run in progress", + "error_code": "run_not_in_progress", + }, + ) + + async def fake_mp_post(body: dict) -> str: + calls.append("multiplayer") + return '{"status":"ok"}' + + server._post = fake_post + server._mp_post = fake_mp_post + try: + await server._menu_select_post({"action": "menu_select", "option": "continue"}) + except httpx.HTTPStatusError: + pass + else: + raise AssertionError("expected non-multiplayer 409 to be re-raised") + + assert calls == ["singleplayer"] + + +def main() -> None: + server = _load_server_module() + test_structured_http_error_is_preserved(server) + test_non_endpoint_http_error_stays_text(server) + asyncio.run(test_menu_select_retries_multiplayer_conflict(server)) + asyncio.run(test_menu_select_does_not_retry_other_409(server)) + print("mcp server tests passed") + + +if __name__ == "__main__": + main() From 88885f2f53f849aae0e2c57d6bd27640116b784f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:03:33 -0400 Subject: [PATCH 152/190] Clarify action target and error docs --- docs/raw-full.md | 12 ++++++++---- docs/raw-simplified.md | 4 ++-- mcp/server.py | 12 ++++++++---- scripts/audit_endpoints.py | 9 +++++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index 79aa132a..76860591 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1211,7 +1211,7 @@ Profile action validation returns structured HTTP errors: invalid profile IDs an ## POST — Perform Actions All POST requests use a JSON body with an `"action"` field and action-specific parameters. -Action dispatch failures include structured `error_code` values where available. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. +Action dispatch failures include structured `error_code` values. Missing or unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400; gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. Older generic action failures use the stable fallback `error_code: "action_error"`. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. If a tutorial or blocking popup is visible during a run, gameplay actions return HTTP 409 with `error_code: "blocking_popup_active"`; use the advertised `menu_select` option to dismiss or answer it first. ### Success Response @@ -1223,7 +1223,11 @@ If a tutorial or blocking popup is visible during a run, gameplay actions return ### Error Response ```jsonc -{ "status": "error", "error": "Card requires a target. Provide 'target' with an entity_id." } +{ + "status": "error", + "error": "Card requires a target. Provide 'target' with an entity_id.", + "error_code": "action_error" +} ``` --- @@ -1261,7 +1265,7 @@ Play a card from hand during combat. | Parameter | Type | Required | Description | |---|---|---|---| | `card_index` | int | Yes | 0-based index in hand | -| `target` | string | When hand card `requires_target` is true | `entity_id` from the card's `valid_targets` list | +| `target` | string | When hand card `requires_target` is true | `entity_id` from the card's `valid_targets` list, or the target combat_id as a string | **Errors:** Not in combat, not play phase, card unplayable, invalid index, missing target. @@ -1278,7 +1282,7 @@ Use a potion from the potion belt. | Parameter | Type | Required | Description | |---|---|---|---| | `slot` | int | Yes | Potion slot index | -| `target` | string | For `AnyEnemy` potions | `entity_id` of the target enemy | +| `target` | string | For `AnyEnemy` potions | `entity_id` of the target enemy, or the target combat_id as a string | **Errors:** Empty slot, combat-only potion used outside combat, automatic potion, already queued, player dead, custom usability blocked, missing/invalid target, enemy-targeted potion outside combat. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index c6f8dd64..6ba3a282 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -140,8 +140,8 @@ Profile action validation uses non-2xx HTTP status codes: invalid profile IDs an | Action | Parameters | When to Use | |---|---|---| -| `play_card` | `card_index`: int, `target`?: string | Play a card from hand when `can_play` is true. `target` is required when the card has `requires_target`; use one of `valid_targets`. | -| `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use one of `valid_targets`. Works outside combat for non-combat-only, non-enemy-targeting potions. | +| `play_card` | `card_index`: int, `target`?: string | Play a card from hand when `can_play` is true. `target` is required when the card has `requires_target`; use a `valid_targets` `entity_id`, or its combat_id as a string. | +| `use_potion` | `slot`: int, `target`?: string | Use a potion when its state says `can_use`. `target` required for enemy-targeting potions; use a `valid_targets` `entity_id`, or its combat_id as a string. Works outside combat for non-combat-only, non-enemy-targeting potions. | | `discard_potion` | `slot`: int | Discard a potion when its state says `can_discard`. Use when slots are full and you need room for incoming potions. | | `end_turn` | _(none)_ | End the player's turn when battle state says `can_end_turn`. | diff --git a/mcp/server.py b/mcp/server.py index 93402bef..7894e66b 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -491,7 +491,8 @@ async def use_potion(slot: int, target: str | None = None) -> str: Args: slot: Potion slot index (as shown in game state). - target: Entity ID of the target enemy (e.g. "JAW_WORM_0"). Required for enemy-targeted potions. + target: Entity ID of the target enemy (e.g. "JAW_WORM_0"), or the target combat_id as a string. + Required for enemy-targeted potions. """ body: dict = {"action": "use_potion", "slot": slot} if target is not None: @@ -542,7 +543,8 @@ async def combat_play_card(card_index: int, target: str | None = None) -> str: Args: card_index: Index of the card in hand (0-based, as shown in game state). - target: Entity ID from the hand card's `valid_targets` list. Required when `requires_target` is true. + target: Entity ID from the hand card's `valid_targets` list, or the target combat_id as a string. + Required when `requires_target` is true. Note that the index can change as cards are played - playing a card will shift the indices of remaining cards in hand. Refer to the latest game state for accurate indices. New cards are drawn to the right, so playing cards from right to left can help maintain more stable indices for remaining cards. @@ -957,7 +959,8 @@ async def mp_combat_play_card(card_index: int, target: str | None = None) -> str Args: card_index: Index of the card in hand (0-based). - target: Entity ID from the hand card's `valid_targets` list. Required when `requires_target` is true. + target: Entity ID from the hand card's `valid_targets` list, or the target combat_id as a string. + Required when `requires_target` is true. """ body: dict = {"action": "play_card", "card_index": card_index} if target is not None: @@ -1004,7 +1007,8 @@ async def mp_use_potion(slot: int, target: str | None = None) -> str: Args: slot: Potion slot index (as shown in game state). - target: Entity ID of the target enemy. Required for enemy-targeted potions. + target: Entity ID of the target enemy, or the target combat_id as a string. + Required for enemy-targeted potions. """ body: dict = {"action": "use_potion", "slot": slot} if target is not None: diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3fdf58fb..5046c3df 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -358,6 +358,8 @@ def audit_static_error_shapes(repo: Path) -> None: fail("docs must describe blocking popup action errors") if "action_error" not in docs: fail("docs must describe generic action error fallback code") + if '"error_code": "action_error"' not in (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"): + fail("docs/raw-full.md action error example must include action_error") for required_fragment in ["method_not_allowed", "not_found", "internal_error"]: if required_fragment not in docs: fail(f"docs must describe route-level error code: {required_fragment}") @@ -966,6 +968,13 @@ def audit_state_surface(repo: Path) -> None: ) if not potion_action_match: fail("could not locate ExecuteUsePotion for potion audit") + for doc_name, doc_text in [ + ("docs", docs), + ("mcp/server.py", (repo / "mcp" / "server.py").read_text(encoding="utf-8")), + ]: + for required_fragment in ["combat_id as a string", "valid_targets"]: + if required_fragment not in doc_text: + fail(f"{doc_name} must document target identifiers for card/potion actions: {required_fragment}") potion_action_body = potion_action_match.group(0) for required_fragment in [ "can_use", From 09dc7e3240607c7b77261d9f0990b52c92a38a10 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:06:18 -0400 Subject: [PATCH 153/190] Code timeline manual action errors --- McpMod.Actions.cs | 1 + McpMod.cs | 1 + docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 6 +++++- 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/McpMod.Actions.cs b/McpMod.Actions.cs index c06cb88f..8f34f6ee 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -1445,6 +1445,7 @@ public static partial class McpMod { ["status"] = "error", ["error"] = "Timeline has obtained epochs that still need to be revealed manually; not opening Timeline because this game state logs invalid unlock-state errors when entered through automation", + ["error_code"] = "timeline_manual_action_required", ["pending_epoch_ids"] = unrevealedEpochs, ["manual_action_required"] = true }; diff --git a/McpMod.cs b/McpMod.cs index 461b395d..6fb79da2 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -670,6 +670,7 @@ private static void SendActionResultJson(HttpListenerResponse response, Dictiona "missing_menu_option" or "unknown_menu_option" or "unknown_action" or "unknown_multiplayer_action" => 400, "not_on_menu" or "run_not_in_progress" or "not_multiplayer_run" or "blocking_popup_active" => 409, "local_player_unavailable" => 409, + "timeline_manual_action_required" => 409, _ => 400 }; } diff --git a/docs/raw-full.md b/docs/raw-full.md index 76860591..45fd214b 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1250,7 +1250,7 @@ Select an option from the main menu, a menu submenu, profile select, character s | `seed` | string | No | Only supported in menu contexts that expose a real seeded flow. Standard singleplayer character select currently returns an error without starting a run when `seed` is supplied. | `game_over` advertises only `main_menu`. `continue` is not actionable on that screen and returns an error. -If `timeline` is blocked by pending obtained epochs, `menu_select` returns an error with `manual_action_required: true` and `pending_epoch_ids` instead of opening Timeline. +If `timeline` is blocked by pending obtained epochs, `menu_select` returns HTTP 409 with `error_code: "timeline_manual_action_required"`, `manual_action_required: true`, and `pending_epoch_ids` instead of opening Timeline. --- diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 6ba3a282..1fac8727 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -80,7 +80,7 @@ All POST requests use JSON body with `"action"` field. Action responses include | Action | Parameters | When to Use | |---|---|---| -| `menu_select` | `option`: string, `seed`?: string | Choose an advertised menu option. Options are case-insensitive. Main menu may include `continue`, `abandon_run`, `singleplayer`, `multiplayer`, `compendium`, `timeline`, `settings`, and `quit`; `abandon_run` opens a confirmation popup. Submenus include `back` where visible, including `profile_select` options `profile_1`, `profile_2`, `profile_3`, and `back`. Blocking popups expose normalized button labels such as `ignore` or `back`. `game_over` supports `main_menu` only; `continue` returns an error. Supplying `seed` in unsupported contexts such as standard singleplayer character select returns an error and does not start a run. If Timeline has pending obtained epochs that require manual reveal, it may appear in `blocked_options`; selecting `timeline` returns `manual_action_required: true` with `pending_epoch_ids` instead of opening Timeline. Multiplayer flow: on `multiplayer_join` use `refresh` / `back` / `join_` / `join_`. On `multiplayer_load_lobby` use `confirm` (or `embark`) to ready up, `unready` to retract, `back` to leave. On `character_select` while in MP, an additional `unready` option becomes available after readying, plus a `lobby` block in state lists ascension, all_ready, and per-player roster. | +| `menu_select` | `option`: string, `seed`?: string | Choose an advertised menu option. Options are case-insensitive. Main menu may include `continue`, `abandon_run`, `singleplayer`, `multiplayer`, `compendium`, `timeline`, `settings`, and `quit`; `abandon_run` opens a confirmation popup. Submenus include `back` where visible, including `profile_select` options `profile_1`, `profile_2`, `profile_3`, and `back`. Blocking popups expose normalized button labels such as `ignore` or `back`. `game_over` supports `main_menu` only; `continue` returns an error. Supplying `seed` in unsupported contexts such as standard singleplayer character select returns an error and does not start a run. If Timeline has pending obtained epochs that require manual reveal, it may appear in `blocked_options`; selecting `timeline` returns HTTP 409 with `error_code: "timeline_manual_action_required"`, `manual_action_required: true`, and `pending_epoch_ids` instead of opening Timeline. Multiplayer flow: on `multiplayer_join` use `refresh` / `back` / `join_` / `join_`. On `multiplayer_load_lobby` use `confirm` (or `embark`) to ready up, `unready` to retract, `back` to leave. On `character_select` while in MP, an additional `unready` option becomes available after readying, plus a `lobby` block in state lists ascension, all_ready, and per-player roster. | ### Profiles diff --git a/mcp/README.md b/mcp/README.md index 0c11175c..d0b23387 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -64,7 +64,7 @@ Read endpoint startup/data-availability failures preserve endpoint `error_code` Read endpoint exceptions preserve endpoint-specific `error_code` values such as `settings_read_failed`, `bestiary_build_failed`, `glossary_build_failed`, `profile_build_failed`, `profiles_read_failed`, `compendium_build_failed`, `singleplayer_state_read_failed`, and `multiplayer_state_read_failed`. -Menu/action dispatch failures also use structured endpoint errors: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Older generic action failures carry the stable fallback `error_code: action_error` instead of omitting an error code. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. +Menu/action dispatch failures also use structured endpoint errors: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, Timeline manual-reveal blocks return HTTP 409 with `error_code: timeline_manual_action_required`, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Older generic action failures carry the stable fallback `error_code: action_error` instead of omitting an error code. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 5046c3df..7349b5a8 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -326,7 +326,7 @@ def audit_static_error_shapes(repo: Path) -> None: fail(f"POST validation missing structured error code: {required_fragment}") actions = (repo / "McpMod.Actions.cs").read_text(encoding="utf-8") multiplayer_actions = (repo / "McpMod.MultiplayerActions.cs").read_text(encoding="utf-8") - for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active"]: + for required_fragment in ["SendActionResultJson", "unknown_action", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active", "timeline_manual_action_required"]: if required_fragment not in mcp_mod + actions: fail(f"singleplayer actions missing structured dispatch error handling: {required_fragment}") for required_fragment in ["CallerFilePath", "action_error", "McpMod.Actions.cs", "McpMod.MultiplayerActions.cs"]: @@ -356,8 +356,12 @@ def audit_static_error_shapes(repo: Path) -> None: ) if "blocking_popup_active" not in docs: fail("docs must describe blocking popup action errors") + if "timeline_manual_action_required" not in docs: + fail("docs must describe timeline manual-action errors") if "action_error" not in docs: fail("docs must describe generic action error fallback code") + if re.search(r'\["status"\]\s*=\s*"error"(?![^}]*\["error_code"\])', actions, re.S): + fail("manual action error dictionaries must include error_code") if '"error_code": "action_error"' not in (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"): fail("docs/raw-full.md action error example must include action_error") for required_fragment in ["method_not_allowed", "not_found", "internal_error"]: From 38c6242123c195b03c0786776a4e17151833a954 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:08:43 -0400 Subject: [PATCH 154/190] Clarify endpoint error code contract --- docs/raw-full.md | 2 +- docs/raw-simplified.md | 4 ++-- scripts/audit_endpoints.py | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index 45fd214b..a64707bf 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -18,7 +18,7 @@ HTTP API served by the STS2_MCP mod on `localhost:15526`. No authentication. Loc - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots -HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +HTTP error responses include `status: "error"` and `error`; route, validation, read-endpoint, and action failures include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. Action failures include `error_code`; endpoint-specific conflicts keep their specific codes, while older generic action failures use the stable fallback `action_error`. diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 1fac8727..bb097aba 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -17,7 +17,7 @@ HTTP API on `localhost:15526`. No authentication. - `GET /api/v1/profiles` — list profile slots - `POST /api/v1/profiles` — switch or delete profile slots -HTTP error responses include `status: "error"` and `error`. Some route-specific errors also include `error_code`. +HTTP error responses include `status: "error"` and `error`; route, validation, read-endpoint, and action failures include `error_code`. Route-level failures include `error_code: "method_not_allowed"` for unsupported HTTP methods, `error_code: "not_found"` for unknown paths, and `error_code: "internal_error"` for unexpected top-level handler failures. POST validation failures use stable `error_code` values where possible, including `invalid_json`, `missing_action`, `invalid_action_type`, `missing_profile_id`, `invalid_profile_id_type`, and `invalid_action_payload`. Action failures include `error_code`; endpoint-specific conflicts keep their specific codes, while older generic action failures use the stable fallback `action_error`. @@ -74,7 +74,7 @@ Serialized card objects in hand, deck, piles, rewards, card selections, bundles, ## POST — Actions -All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error`. Dispatch-level failures include `error_code` where available; missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, and gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`. If a tutorial or blocking popup is visible during a run, gameplay actions return HTTP 409 with `error_code: "blocking_popup_active"`; use the advertised `menu_select` option first. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. +All POST requests use JSON body with `"action"` field. Action responses include `{ "status": "ok" | "error" }`; successful actions usually include `message`, and failed actions include `error` plus `error_code`. Missing/unknown main-menu `menu_select` options and unknown non-menu actions return HTTP 400, gameplay actions posted with no active run return HTTP 409 with `error_code: "run_not_in_progress"`, and older generic action failures use `error_code: "action_error"`. If a tutorial or blocking popup is visible during a run, gameplay actions return HTTP 409 with `error_code: "blocking_popup_active"`; use the advertised `menu_select` option first. Other failed action attempts return non-2xx structured error JSON instead of HTTP 200. ### Menu / Game Over diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 7349b5a8..e83ece28 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -360,6 +360,10 @@ def audit_static_error_shapes(repo: Path) -> None: fail("docs must describe timeline manual-action errors") if "action_error" not in docs: fail("docs must describe generic action error fallback code") + for doc_path in [repo / "docs" / "raw-full.md", repo / "docs" / "raw-simplified.md"]: + doc_text = doc_path.read_text(encoding="utf-8") + if "Some route-specific errors also include `error_code`" in doc_text: + fail(f"{doc_path.name} must not describe action error_code as optional") if re.search(r'\["status"\]\s*=\s*"error"(?![^}]*\["error_code"\])', actions, re.S): fail("manual action error dictionaries must include error_code") if '"error_code": "action_error"' not in (repo / "docs" / "raw-full.md").read_text(encoding="utf-8"): From 43e6a54bcee708c0df1153e81a1683b91e37cb25 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:11:12 -0400 Subject: [PATCH 155/190] Document glossary run-state errors --- docs/raw-full.md | 2 +- docs/raw-simplified.md | 2 +- mcp/README.md | 2 +- scripts/audit_endpoints.py | 14 +++++++++----- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index a64707bf..eb3585df 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1098,7 +1098,7 @@ The glossary endpoints expose active-run pool metadata. They require a run in pr - `GET /api/v1/glossary/potions`: active-run potion pool metadata. - `GET /api/v1/glossary/keywords`: keyword metadata collected from active-run cards, relics, and potions. -If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"` plus the same profile/save context fields so callers can identify the active profile even without a current run. +If no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"` plus the same profile/save context fields so callers can identify the active profile even without a current run. If a run is marked in progress but the run state cannot be read, they return HTTP 503 with `error_code: "run_state_unavailable"` plus the same profile/save context fields. Example success shape: diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index bb097aba..7003f131 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -90,7 +90,7 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns deterministic reflected monster and encounter metadata with `status`, `kind`, `monster_count`, `encounter_count`, `monsters`, and `encounters`. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. If run state cannot be read, they return HTTP 503 with `error_code: "run_state_unavailable"` and the same context fields. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: diff --git a/mcp/README.md b/mcp/README.md index d0b23387..afe206bc 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -66,7 +66,7 @@ Read endpoint exceptions preserve endpoint-specific `error_code` values such as Menu/action dispatch failures also use structured endpoint errors: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, Timeline manual-reveal blocks return HTTP 409 with `error_code: timeline_manual_action_required`, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Older generic action failures carry the stable fallback `error_code: action_error` instead of omitting an error code. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, while still including the active profile/save context fields. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, or `run_state_unavailable` with HTTP 503 if the active run state cannot be read, while still including the active profile/save context fields. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index e83ece28..3951a442 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -430,6 +430,9 @@ def audit_static_glossary_scope(repo: Path) -> None: fail(f"glossary success payload missing profile/save context: {required_fragment}") if required_fragment not in docs: fail(f"docs missing glossary profile/save context: {required_fragment}") + for required_fragment in ["run_not_in_progress", "run_state_unavailable", "HTTP 503"]: + if required_fragment not in docs: + fail(f"docs missing glossary error contract: {required_fragment}") print("glossary: active-run shared/scoped pools and profile context enforced") @@ -1231,11 +1234,12 @@ def audit_live(base_url: str) -> None: if monster_ids != sorted(monster_ids) or encounter_ids != sorted(encounter_ids): fail(f"{path} expected deterministic id ordering") - if path.startswith("/api/v1/glossary/") and status not in {200, 409}: - fail(f"{path} expected HTTP 200 or 409, got {status}: {data}") - if path.startswith("/api/v1/glossary/") and status == 409: - if not isinstance(data, dict) or data.get("error_code") != "run_not_in_progress": - fail(f"{path} expected run_not_in_progress glossary error, got {data}") + if path.startswith("/api/v1/glossary/") and status not in {200, 409, 503}: + fail(f"{path} expected HTTP 200, 409, or 503, got {status}: {data}") + if path.startswith("/api/v1/glossary/") and status in {409, 503}: + expected_code = "run_not_in_progress" if status == 409 else "run_state_unavailable" + if not isinstance(data, dict) or data.get("error_code") != expected_code: + fail(f"{path} expected {expected_code} glossary error, got {data}") assert_context_paths_normalized(path, data) for required_field in ["kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: From 359e8465fbb5081b7da43b5c41cd5ff7beb5f58d Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:13:31 -0400 Subject: [PATCH 156/190] Audit MCP route helper parity --- scripts/audit_endpoints.py | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3951a442..0db8af4f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -206,6 +206,50 @@ def audit_action_surface(repo: Path) -> None: if missing_docs: fail(f"implemented actions missing docs: {missing_docs}") + module = ast.parse(server, filename=str(repo / "mcp" / "server.py")) + route_helpers = { + "_get", + "_post", + "_mp_get", + "_mp_post", + "_menu_select_post", + "_profiles_post", + "_profiles_get", + "_profile_get", + "_compendium_get", + "_settings_get", + "_bestiary_get", + "_glossary_get", + "_root_get", + } + for node in module.body: + if not isinstance(node, ast.AsyncFunctionDef): + continue + is_tool = any( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and decorator.func.attr == "tool" + for decorator in node.decorator_list + ) + if not is_tool: + continue + + calls = { + call.func.id + for call in ast.walk(node) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id in route_helpers + } + if node.name.startswith("mp_"): + disallowed = calls & {"_get", "_post", "_menu_select_post"} + if disallowed: + fail(f"MCP multiplayer tool {node.name} must not call singleplayer route helpers: {sorted(disallowed)}") + if calls and not any(call in calls for call in ["_mp_get", "_mp_post"]): + fail(f"MCP multiplayer tool {node.name} must use multiplayer route helpers, got {sorted(calls)}") + elif calls & {"_mp_get", "_mp_post"}: + fail(f"MCP non-multiplayer tool {node.name} must not call multiplayer route helpers: {sorted(calls)}") + print(f"actions: {len(sp_actions)} singleplayer, {len(mp_actions)} multiplayer actions covered") From 418b8a7a7cf98962e8fabe551e8fb49957919097 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:18:31 -0400 Subject: [PATCH 157/190] Clarify current run identity availability --- docs/raw-full.md | 12 ++++++++++-- docs/raw-simplified.md | 6 +++--- mcp/README.md | 10 +++++----- scripts/audit_endpoints.py | 17 +++++++++++++---- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/docs/raw-full.md b/docs/raw-full.md index eb3585df..972acfdd 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -1031,7 +1031,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: - `status`, `kind`: response envelope fields. `kind` is `compendium`. - `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`: active profile and save-location context, matching `/api/v1/profile`. -- `current_run`: present while a run is active. Includes profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, plus `start_time`, `seed`, `save_time`, `run_time`, and run metadata read from `current_run.save`. +- `current_run`: present while a run is active. Includes profile/save context (`is_in_progress`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`) and `id_format`; when `current_run.save` can be read, it also includes save-backed fields such as `start_time`, a derived `run_id` in `{save_scope}:profile{profile_id}:{start_time}` format, `seed`, `save_time`, `run_time`, and other run metadata. - `card_library`: discovered card IDs and card pick/skip/win/loss stats. Detailed card metadata lives at `/api/v1/glossary/cards`, which currently requires a run context. - `relic_collection`: discovered relic IDs. Detailed relic metadata lives at `/api/v1/glossary/relics`, which currently requires a run context. - `potion_lab`: discovered potion IDs. Detailed potion metadata lives at `/api/v1/glossary/potions`, which currently requires a run context. @@ -1055,6 +1055,7 @@ Returns the active profile's progress grouped by the in-game Compendium cards: "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam/76561197985806660/modded/profile1/saves/progress.save", "profile_root": "modded/profile1", "save_scope": "modded", + "id_format": "{save_scope}:profile{profile_id}:{start_time}", "run_id": "modded:profile1:1778295706", "start_time": 1778295706, "seed": "2450ZAR9EF", @@ -1091,7 +1092,7 @@ Returns reflected monster and encounter metadata as a deterministic structured o ### `GET /api/v1/glossary/*` -The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), `current_run`, `players`, and `items`. +The glossary endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Successful responses are structured objects with `status`, `kind`, `scope`, `count`, profile/save context (`profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`), `current_run` save context, `players`, and `items`; `current_run.run_id` and `current_run.seed` are included when `current_run.save` exposes them. - `GET /api/v1/glossary/cards`: active-run card pool metadata, including energy/star costs, upgrade availability, plus upgraded-preview cost and description. - `GET /api/v1/glossary/relics`: active-run relic pool metadata. @@ -1114,6 +1115,13 @@ Example success shape: "profile_root": "modded/profile1", "save_scope": "modded", "current_run": { + "is_in_progress": true, + "profile_id": 1, + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam/76561197985806660/modded/profile1/saves/progress.save", + "profile_root": "modded/profile1", + "save_scope": "modded", + "id_format": "{save_scope}:profile{profile_id}:{start_time}", "run_id": "modded:profile1:1778295706", "seed": "VQY2JBY38L" }, diff --git a/docs/raw-simplified.md b/docs/raw-simplified.md index 7003f131..bbf386ae 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -44,7 +44,7 @@ Every JSON response includes: - `status: "ok"` and `kind` — `singleplayer_state` or `multiplayer_state` - `state_type` — which screen the game is on (see below) - `run` — `{ act, floor, ascension }` (absent for `menu`) -- `current_run` — run identity and save context when a run is active, including `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `run_id`, and `seed` when the save file exposes it +- `current_run` — active-run save context, including `is_in_progress`, `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, and `id_format`; also includes `run_id`, `start_time`, and `seed` when `current_run.save` exposes them - `player` — full player state: character, HP, gold, deck, relics, potions, `max_potion_slots` (belt capacity, grows with relics), and during combat: energy, hand, piles, orbs (absent for `menu`) Serialized card objects in hand, deck, piles, rewards, card selections, bundles, and glossary card items include energy/star costs plus upgrade fields: `is_upgraded`, `is_upgradable`, `current_upgrade_level`, `max_upgrade_level`, `upgrade_preview_type`, `upgrade_preview_cost`, `upgrade_preview_star_cost`, and `upgrade_preview_description`. Hand cards also include `requires_target` and `valid_targets` for enemy-targeted cards. Shop card items expose the same fields with a `card_` prefix, for example `card_upgrade_preview_description`. @@ -90,13 +90,13 @@ All POST requests use JSON body with `"action"` field. Action responses include `GET /api/v1/bestiary` returns deterministic reflected monster and encounter metadata with `status`, `kind`, `monster_count`, `encounter_count`, `monsters`, and `encounters`. Profile-specific fight stats are also summarized under `/api/v1/compendium`. -The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. If run state cannot be read, they return HTTP 503 with `error_code: "run_state_unavailable"` and the same context fields. +The `/api/v1/glossary/*` endpoints expose active-run pool metadata. They require a run in progress and are scoped to the current run/character context plus shared run pools such as Colorless cards, shared relics, and shared potions, not profile-wide discovered content. Card glossary items include upgrade availability plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run` save context, `kind`, `count`, and `items`; `current_run.run_id` and `current_run.seed` are included when `current_run.save` exposes them. If no run is active, they return HTTP 409 with `error_code: "run_not_in_progress"` and the same profile/save context fields. If run state cannot be read, they return HTTP 503 with `error_code: "run_state_unavailable"` and the same context fields. `GET /api/v1/compendium` returns the active profile grouped like the in-game Compendium: The top level includes `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, and `save_scope`, matching `/api/v1/profile`. -When a run is active, the response includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content. +When a run is active, the response includes `current_run` with profile/save context and `id_format`. When `current_run.save` exposes `start_time`, the response also includes `current_run.run_id` in `{save_scope}:profile{profile_id}:{start_time}` format. This identifies the specific run attempt, while `seed` identifies the generated run content when present. | Section | Status | |---|---| diff --git a/mcp/README.md b/mcp/README.md index afe206bc..c62fac3d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -11,10 +11,10 @@ | `get_profile()` | Profiles | Get active profile progress plus save/run context | | `get_compendium()` | Profiles | Get Compendium-shaped profile progress plus save/run context | | `get_bestiary()` | Profiles | Get deterministic monster and encounter metadata with counts | -| `get_glossary_cards()` | Active Pool | Get active-run card pool metadata with run_id/seed scope | -| `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata with run_id/seed scope | -| `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata with run_id/seed scope | -| `get_glossary_keywords()` | Active Pool | Get active-run keyword metadata with run_id/seed scope | +| `get_glossary_cards()` | Active Pool | Get active-run card pool metadata with current-run save context | +| `get_glossary_relics()` | Active Pool | Get active-run relic pool metadata with current-run save context | +| `get_glossary_potions()` | Active Pool | Get active-run potion pool metadata with current-run save context | +| `get_glossary_keywords()` | Active Pool | Get active-run keyword metadata with current-run save context | | `list_profiles()` | Profiles | List profile slots and active slot | | `switch_profile(profile_id)` | Profiles | Switch to a profile slot through the game UI | | `delete_profile(profile_id)` | Profiles | Delete an inactive profile slot | @@ -66,7 +66,7 @@ Read endpoint exceptions preserve endpoint-specific `error_code` values such as Menu/action dispatch failures also use structured endpoint errors: missing or unknown main-menu selections and unknown actions return HTTP 400, gameplay actions sent without an active run return HTTP 409, Timeline manual-reveal blocks return HTTP 409 with `error_code: timeline_manual_action_required`, and blocking tutorial/popup overlays return HTTP 409 with `error_code: blocking_popup_active`. Older generic action failures carry the stable fallback `error_code: action_error` instead of omitting an error code. Other failed actions return non-2xx structured error JSON instead of HTTP 200. MCP wrappers preserve those structured JSON error bodies and add `http_status` so callers can handle endpoint failures without parsing a flat text prefix. -Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run.run_id`, `current_run.seed`, `kind`, `count`, and `items`. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, or `run_state_unavailable` with HTTP 503 if the active run state cannot be read, while still including the active profile/save context fields. +Glossary tools require an active run. They include the current character context plus shared run pools such as Colorless cards, shared relics, and shared potions. Card glossary items include energy/star costs, upgrade availability, plus upgraded-preview cost and description. Successful responses include `profile_id`, `progress_path`, `resolved_progress_path`, `profile_root`, `save_scope`, `current_run` save context, `kind`, `count`, and `items`; `current_run.run_id` and `current_run.seed` are included when `current_run.save` exposes them. The HTTP endpoints return `run_not_in_progress` with HTTP 409 when called from the main menu, or `run_state_unavailable` with HTTP 503 if the active run state cannot be read, while still including the active profile/save context fields. ## Multiplayer diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 0db8af4f..8240f5c2 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -696,7 +696,7 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ['status: "ok"', "singleplayer_state", "multiplayer_state"]: if required_fragment not in docs: fail(f"docs missing state response envelope detail: {required_fragment}") - for required_fragment in ["run_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + for required_fragment in ["run_id", "current_run.save", "id_format", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_fragment not in docs: fail(f"docs missing current_run context: {required_fragment}") @@ -1301,11 +1301,20 @@ def audit_live(base_url: str) -> None: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") current_run = data.get("current_run") - if not isinstance(current_run, dict) or not current_run.get("run_id") or not current_run.get("seed"): - fail(f"{path} expected current_run run_id and seed, got {current_run}") - for required_field in ["progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if not isinstance(current_run, dict): + fail(f"{path} expected current_run save context, got {current_run}") + assert_context_paths_normalized(f"{path}.current_run", current_run) + for required_field in ["is_in_progress", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "id_format"]: if required_field not in current_run: fail(f"{path} current_run missing profile/save context field: {required_field}") + if current_run.get("is_in_progress") is not True: + fail(f"{path} current_run expected active run marker, got {current_run}") + if current_run.get("id_format") != "{save_scope}:profile{profile_id}:{start_time}": + fail(f"{path} current_run unexpected id_format, got {current_run.get('id_format')}") + if current_run.get("run_id") and not current_run.get("start_time"): + fail(f"{path} current_run run_id requires start_time, got {current_run}") + if current_run.get("start_time") and not current_run.get("run_id"): + fail(f"{path} current_run start_time should derive run_id, got {current_run}") glossary_payloads[expected_kind] = data if {"keywords", "relics", "potions"}.issubset(glossary_payloads): From 01df37b1b7fd9d9c400986db1f010bdd6716ecba Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:21:33 -0400 Subject: [PATCH 158/190] Clarify MCP glossary run identity docs --- mcp/server.py | 18 +++++++++++------- scripts/audit_endpoints.py | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/mcp/server.py b/mcp/server.py index 7894e66b..dece0f67 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -358,8 +358,9 @@ async def get_glossary_cards() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered cards; use get_compendium() for profile progress. - The response includes profile/save context, current_run.run_id, - current_run.seed, count, and items. + The response includes profile/save context, current_run save context, + count, and items. current_run.run_id and current_run.seed are included + when current_run.save exposes them. """ try: return await _glossary_get("cards") @@ -373,8 +374,9 @@ async def get_glossary_relics() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered relics; use get_compendium() for profile progress. - The response includes profile/save context, current_run.run_id, - current_run.seed, count, and items. + The response includes profile/save context, current_run save context, + count, and items. current_run.run_id and current_run.seed are included + when current_run.save exposes them. """ try: return await _glossary_get("relics") @@ -388,8 +390,9 @@ async def get_glossary_potions() -> str: Requires a run in progress. This is scoped to the current run/character pool, not profile-wide discovered potions; use get_compendium() for profile progress. - The response includes profile/save context, current_run.run_id, - current_run.seed, count, and items. + The response includes profile/save context, current_run save context, + count, and items. current_run.run_id and current_run.seed are included + when current_run.save exposes them. """ try: return await _glossary_get("potions") @@ -403,7 +406,8 @@ async def get_glossary_keywords() -> str: Requires a run in progress. Keywords come from cards, relics, and potions reachable in the current run context. The response includes profile/save - context, current_run.run_id, current_run.seed, count, and items. + context, current_run save context, count, and items. current_run.run_id + and current_run.seed are included when current_run.save exposes them. """ try: return await _glossary_get("keywords") diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8240f5c2..deedcab9 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -278,6 +278,13 @@ def audit_mcp_tool_docs(repo: Path) -> None: if missing_readme: fail(f"MCP tools missing mcp/README.md entries: {missing_readme}") + for doc_name, doc_text in [("mcp/server.py", server), ("mcp/README.md", readme)]: + normalized_doc_text = doc_text.replace("`", "") + if "current_run.run_id" in normalized_doc_text and "current_run.save exposes" not in normalized_doc_text: + fail(f"{doc_name} documents current_run.run_id without save-backed availability wording") + if "current_run.seed" in normalized_doc_text and "current_run.save exposes" not in normalized_doc_text: + fail(f"{doc_name} documents current_run.seed without save-backed availability wording") + print(f"mcp: {len(tools)} tools documented") From 27925966edba8047473c454c67d471673e537657 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:23:51 -0400 Subject: [PATCH 159/190] Harden glossary keyword tip collection --- McpMod.ForkEndpoints.cs | 24 +++++++++++++++++------- scripts/audit_endpoints.py | 11 +++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 275f6a1c..a769221b 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -505,16 +505,26 @@ internal static object BuildGlossaryKeywords() return result; } - private static void AddKeywordTips(IEnumerable tips, Dictionary keywords) + private static void AddKeywordTips(IEnumerable? tips, Dictionary keywords) { - foreach (var tip in tips) + if (tips == null) + return; + + foreach (var tip in IHoverTip.RemoveDupes(tips)) { - if (tip is not HoverTip ht) - continue; + try + { + if (tip is not HoverTip ht) + continue; - var title = SafeGetText(() => ht.Title); - if (!string.IsNullOrEmpty(title)) - keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + } + catch + { + // Hover-tip getters can throw during state transitions; skip unstable tips. + } } } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index deedcab9..a781ae20 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -484,6 +484,17 @@ def audit_static_glossary_scope(repo: Path) -> None: for required_fragment in ["run_not_in_progress", "run_state_unavailable", "HTTP 503"]: if required_fragment not in docs: fail(f"docs missing glossary error contract: {required_fragment}") + keyword_tip_match = re.search( + r"private static void AddKeywordTips\(.*?\n \}", + fork_endpoints, + re.S, + ) + if not keyword_tip_match: + fail("could not locate AddKeywordTips for glossary keyword stability audit") + keyword_tip_body = keyword_tip_match.group(0) + for required_fragment in ["IEnumerable?", "tips == null", "IHoverTip.RemoveDupes", "catch"]: + if required_fragment not in keyword_tip_body: + fail(f"glossary keyword collection must be null/transition safe: {required_fragment}") print("glossary: active-run shared/scoped pools and profile context enforced") From 2006023b32b839a90baf27eee848f822a69e03b5 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:25:54 -0400 Subject: [PATCH 160/190] Refresh README API index example --- README.md | 17 ++++++++++++++++- scripts/audit_endpoints.py | 8 ++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d697a71..04ef323a 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,25 @@ A successful response looks like: { "message": "Hello from STS2 MCP v0.4.0", "status": "ok", + "kind": "api_index", + "version": "0.4.0", "bound_prefixes": ["http://localhost:15526/", "http://127.0.0.1:15526/"], + "endpoint_count": 14, "endpoints": [ { "method": "GET", "path": "/api/v1/singleplayer" }, - { "method": "POST", "path": "/api/v1/singleplayer" } + { "method": "POST", "path": "/api/v1/singleplayer" }, + { "method": "GET", "path": "/api/v1/multiplayer" }, + { "method": "POST", "path": "/api/v1/multiplayer" }, + { "method": "GET", "path": "/api/v1/settings" }, + { "method": "GET", "path": "/api/v1/profile" }, + { "method": "GET", "path": "/api/v1/compendium" }, + { "method": "GET", "path": "/api/v1/bestiary" }, + { "method": "GET", "path": "/api/v1/glossary/cards" }, + { "method": "GET", "path": "/api/v1/glossary/relics" }, + { "method": "GET", "path": "/api/v1/glossary/potions" }, + { "method": "GET", "path": "/api/v1/glossary/keywords" }, + { "method": "GET", "path": "/api/v1/profiles" }, + { "method": "POST", "path": "/api/v1/profiles" } ] } ``` diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index a781ae20..da8681a0 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -118,6 +118,7 @@ def assert_context_paths_normalized(path: str, data: object) -> None: def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + readme = (repo / "README.md").read_text(encoding="utf-8") mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") expected = set(EXPECTED_ENDPOINTS) @@ -163,6 +164,13 @@ def audit_docs(repo: Path) -> None: ]: if required_fragment not in mcp_mod: fail(f"root endpoint index missing response-context description: {required_fragment}") + + for required_fragment in ['"kind": "api_index"', '"version": "0.4.0"', '"endpoint_count": 14']: + if required_fragment not in readme: + fail(f"README root endpoint example missing API index field: {required_fragment}") + for method, path in EXPECTED_ENDPOINTS: + if f'"method": "{method}", "path": "{path}"' not in readme: + fail(f"README root endpoint example missing advertised endpoint: {method} {path}") print(f"docs: {len(documented)} endpoints documented") From e7082cbd6c6f4f11552d2f2864178049008ba11a Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:28:44 -0400 Subject: [PATCH 161/190] Sort bestiary nested metadata --- McpMod.ForkEndpoints.cs | 10 ++++++++-- scripts/audit_endpoints.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index a769221b..3428e136 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -568,7 +568,10 @@ internal static object BuildBestiary() } } if (moves.Count > 0) - entry["moves"] = moves; + entry["moves"] = moves + .Distinct(StringComparer.Ordinal) + .OrderBy(move => move, StringComparer.Ordinal) + .ToList(); monsters.Add(entry); } @@ -621,7 +624,10 @@ internal static object BuildBestiary() matchingMonsters.Add(monsterClass); } if (matchingMonsters.Count > 0) - entry["likely_monsters"] = matchingMonsters; + entry["likely_monsters"] = matchingMonsters + .Distinct(StringComparer.Ordinal) + .OrderBy(monster => monster, StringComparer.Ordinal) + .ToList(); encounters.Add(entry); } diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index da8681a0..5c1e1bc8 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -507,6 +507,30 @@ def audit_static_glossary_scope(repo: Path) -> None: print("glossary: active-run shared/scoped pools and profile context enforced") +def audit_static_bestiary_determinism(repo: Path) -> None: + fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") + bestiary_match = re.search( + r"internal static object BuildBestiary\(\).*?\n \}", + fork_endpoints, + re.S, + ) + if not bestiary_match: + fail("could not locate BuildBestiary for bestiary determinism audit") + bestiary_body = bestiary_match.group(0) + for required_fragment in [ + "orderedMonsters", + "orderedEncounters", + "entry[\"moves\"] = moves", + "Distinct(StringComparer.Ordinal)", + "OrderBy(move => move, StringComparer.Ordinal)", + "entry[\"likely_monsters\"] = matchingMonsters", + "OrderBy(monster => monster, StringComparer.Ordinal)", + ]: + if required_fragment not in bestiary_body: + fail(f"bestiary endpoint missing deterministic nested ordering: {required_fragment}") + print("bestiary: deterministic nested metadata ordering enforced") + + def audit_static_card_glossary_metadata(repo: Path) -> None: fork_endpoints = (repo / "McpMod.ForkEndpoints.cs").read_text(encoding="utf-8") state_builder = (repo / "McpMod.StateBuilder.cs").read_text(encoding="utf-8") @@ -1303,6 +1327,13 @@ def audit_live(base_url: str) -> None: encounter_ids = [str(item.get("id")) for item in encounters if isinstance(item, dict)] if monster_ids != sorted(monster_ids) or encounter_ids != sorted(encounter_ids): fail(f"{path} expected deterministic id ordering") + for item in [*monsters, *encounters]: + if not isinstance(item, dict): + fail(f"{path} expected bestiary entries to be objects, got {item}") + for field in ["moves", "likely_monsters"]: + values = item.get(field) + if values is not None: + assert_sorted_strings(path, field, values) if path.startswith("/api/v1/glossary/") and status not in {200, 409, 503}: fail(f"{path} expected HTTP 200, 409, or 503, got {status}: {data}") @@ -1484,6 +1515,7 @@ def main() -> None: audit_static_formatters(repo) audit_static_error_shapes(repo) audit_static_glossary_scope(repo) + audit_static_bestiary_determinism(repo) audit_static_card_glossary_metadata(repo) audit_static_save_roots(repo) audit_state_surface(repo) From 14f6e0fd648d12188acb4bd64af57316374ddf95 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:30:31 -0400 Subject: [PATCH 162/190] Test MCP read endpoint error propagation --- scripts/test_mcp_server.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/test_mcp_server.py b/scripts/test_mcp_server.py index e762494c..a492eb3b 100644 --- a/scripts/test_mcp_server.py +++ b/scripts/test_mcp_server.py @@ -83,6 +83,28 @@ def test_non_endpoint_http_error_stays_text(server) -> None: assert rendered.startswith("Error: HTTP 502") +async def test_read_tool_preserves_structured_endpoint_error(server) -> None: + async def fake_glossary_get(kind: str) -> str: + assert kind == "cards" + raise _http_status_error( + 503, + { + "status": "error", + "error": "Could not read run state.", + "error_code": "run_state_unavailable", + "kind": "cards", + }, + ) + + server._glossary_get = fake_glossary_get + rendered = await server.get_glossary_cards() + payload = json.loads(rendered) + assert payload["status"] == "error" + assert payload["error_code"] == "run_state_unavailable" + assert payload["kind"] == "cards" + assert payload["http_status"] == 503 + + async def test_menu_select_retries_multiplayer_conflict(server) -> None: calls: list[tuple[str, dict]] = [] @@ -146,6 +168,7 @@ def main() -> None: server = _load_server_module() test_structured_http_error_is_preserved(server) test_non_endpoint_http_error_stays_text(server) + asyncio.run(test_read_tool_preserves_structured_endpoint_error(server)) asyncio.run(test_menu_select_retries_multiplayer_conflict(server)) asyncio.run(test_menu_select_does_not_retry_other_409(server)) print("mcp server tests passed") From df6ba7a561a60828f1894c95c1b3f7fd776b316d Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:32:39 -0400 Subject: [PATCH 163/190] Sort glossary item payloads --- McpMod.ForkEndpoints.cs | 6 +++--- scripts/audit_endpoints.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 3428e136..1144e704 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -306,7 +306,7 @@ internal static object BuildGlossaryCards() AddCardsFromPool(pool, poolName, result, seen); } - return result; + return SortDictionaryListByStringField(result, "id"); } private static void AddCardsFromPool( @@ -369,7 +369,7 @@ internal static object BuildGlossaryRelics() AddRelicsFromPool(pool, poolName, result, seen); } - return result; + return SortDictionaryListByStringField(result, "id"); } private static void AddRelicsFromPool( @@ -420,7 +420,7 @@ internal static object BuildGlossaryPotions() AddPotionsFromPool(pool, poolName, result, seen); } - return result; + return SortDictionaryListByStringField(result, "id"); } private static void AddPotionsFromPool( diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 5c1e1bc8..532d35f5 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -475,6 +475,14 @@ def audit_static_glossary_scope(repo: Path) -> None: ) if not match or required not in match.group(0): fail(f"{method} must include active-run shared pool {required}") + for method in ["BuildGlossaryCards", "BuildGlossaryRelics", "BuildGlossaryPotions"]: + match = re.search( + rf"internal static object {method}\(\).*?(?=\n internal static object|\n private static|\n internal static|\n\}})", + fork_endpoints, + re.S, + ) + if not match or 'SortDictionaryListByStringField(result, "id")' not in match.group(0): + fail(f"{method} must return deterministically sorted item IDs") send_match = re.search( r"private static void SendGlossaryJson\(.*?\n private static Dictionary GlossaryError\(", @@ -1354,6 +1362,8 @@ def audit_live(base_url: str) -> None: fail(f"{path} expected status ok and kind {expected_kind}, got {data}") if not isinstance(data.get("items"), list) or data.get("count") != len(data["items"]): fail(f"{path} expected items list with matching count, got {data}") + item_sort_key = "name" if expected_kind == "keywords" else "id" + assert_sorted_objects(path, "items", data["items"], item_sort_key) for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") From 3f28b323448ee7107bca379aacbfa270611a4cf9 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:34:15 -0400 Subject: [PATCH 164/190] Update current run state example --- docs/raw-full.md | 7 +++++++ scripts/audit_endpoints.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/raw-full.md b/docs/raw-full.md index 972acfdd..b168b28d 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -58,9 +58,16 @@ Every state JSON response includes `status: "ok"`, `kind` (`singleplayer_state` "ascension": 0 // Ascension level }, "current_run": { + "is_in_progress": true, "profile_id": 1, + "progress_path": "modded/profile1/saves/progress.save", + "resolved_progress_path": "C:/Users/timot/AppData/Roaming/SlayTheSpire2/steam//modded/profile1/saves/progress.save", + "profile_root": "modded/profile1", "save_scope": "modded", + "id_format": "{save_scope}:profile{profile_id}:{start_time}", + // Save-backed fields below are present when current_run.save exposes them. "run_id": "modded:profile1:1778295706", + "start_time": 1778295706, "seed": "2450ZAR9EF" }, "player": { ... }, // Full player state (see Player Object below) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 532d35f5..8d83a255 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -757,6 +757,28 @@ def audit_state_surface(repo: Path) -> None: for required_fragment in ["run_id", "current_run.save", "id_format", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_fragment not in docs: fail(f"docs missing current_run context: {required_fragment}") + raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") + common_example_match = re.search( + r"### Common Top-Level Fields.*?\"current_run\": \{(.*?)\n \},", + raw_full, + re.S, + ) + if not common_example_match: + fail("docs/raw-full.md missing common current_run example") + common_current_run_example = common_example_match.group(1) + for required_fragment in [ + "is_in_progress", + "profile_id", + "progress_path", + "resolved_progress_path", + "profile_root", + "save_scope", + "id_format", + "current_run.save exposes", + "start_time", + ]: + if required_fragment not in common_current_run_example: + fail(f"docs/raw-full.md common current_run example missing: {required_fragment}") state_types |= multiplayer_state_types missing_docs = sorted(state_type for state_type in state_types if state_type not in docs) From 1b136aa216a8a36b0da4077efb464c362deaeb77 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:36:42 -0400 Subject: [PATCH 165/190] Stabilize glossary keyword ordering --- McpMod.ForkEndpoints.cs | 6 +++--- scripts/audit_endpoints.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index 1144e704..e2abbab2 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -458,7 +458,7 @@ internal static object BuildGlossaryKeywords() if (runState == null) return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); - var keywords = new Dictionary(); + var keywords = new Dictionary(StringComparer.Ordinal); foreach (var pool in ModelDb.AllSharedCardPools) foreach (var card in pool.AllCards) @@ -493,7 +493,7 @@ internal static object BuildGlossaryKeywords() } var result = new List>(); - foreach (var kv in keywords.OrderBy(k => k.Key)) + foreach (var kv in keywords.OrderBy(k => k.Key, StringComparer.Ordinal)) { result.Add(new Dictionary { @@ -519,7 +519,7 @@ private static void AddKeywordTips(IEnumerable? tips, Dictionary ht.Title); if (!string.IsNullOrEmpty(title)) - keywords[title!] = SafeGetText(() => ht.Description) ?? ""; + keywords.TryAdd(title!, SafeGetText(() => ht.Description) ?? ""); } catch { diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8d83a255..1a0f1a81 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -483,6 +483,20 @@ def audit_static_glossary_scope(repo: Path) -> None: ) if not match or 'SortDictionaryListByStringField(result, "id")' not in match.group(0): fail(f"{method} must return deterministically sorted item IDs") + keywords_match = re.search( + r"internal static object BuildGlossaryKeywords\(\).*?\n private static void AddKeywordTips", + fork_endpoints, + re.S, + ) + if not keywords_match: + fail("could not locate BuildGlossaryKeywords for deterministic keyword audit") + keywords_body = keywords_match.group(0) + for required_fragment in [ + "new Dictionary(StringComparer.Ordinal)", + "OrderBy(k => k.Key, StringComparer.Ordinal)", + ]: + if required_fragment not in keywords_body: + fail(f"BuildGlossaryKeywords must use ordinal deterministic keyword ordering: {required_fragment}") send_match = re.search( r"private static void SendGlossaryJson\(.*?\n private static Dictionary GlossaryError\(", @@ -508,7 +522,7 @@ def audit_static_glossary_scope(repo: Path) -> None: if not keyword_tip_match: fail("could not locate AddKeywordTips for glossary keyword stability audit") keyword_tip_body = keyword_tip_match.group(0) - for required_fragment in ["IEnumerable?", "tips == null", "IHoverTip.RemoveDupes", "catch"]: + for required_fragment in ["IEnumerable?", "tips == null", "IHoverTip.RemoveDupes", "TryAdd", "catch"]: if required_fragment not in keyword_tip_body: fail(f"glossary keyword collection must be null/transition safe: {required_fragment}") From ba3bd1755576c5961b4f2817a5ae9ec88f70d01f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:38:39 -0400 Subject: [PATCH 166/190] Handle missing run manager in glossary --- McpMod.ForkEndpoints.cs | 8 ++++---- scripts/audit_endpoints.py | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/McpMod.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs index e2abbab2..67fb8096 100644 --- a/McpMod.ForkEndpoints.cs +++ b/McpMod.ForkEndpoints.cs @@ -285,7 +285,7 @@ private static void SendGlossaryJson(HttpListenerResponse response, GlossaryPayl internal static object BuildGlossaryCards() { - if (!RunManager.Instance.IsInProgress) + if (RunManager.Instance?.IsInProgress != true) return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); @@ -347,7 +347,7 @@ private static void AddCardsFromPool( internal static object BuildGlossaryRelics() { - if (!RunManager.Instance.IsInProgress) + if (RunManager.Instance?.IsInProgress != true) return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); @@ -398,7 +398,7 @@ private static void AddRelicsFromPool( internal static object BuildGlossaryPotions() { - if (!RunManager.Instance.IsInProgress) + if (RunManager.Instance?.IsInProgress != true) return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); @@ -451,7 +451,7 @@ private static void AddPotionsFromPool( internal static object BuildGlossaryKeywords() { - if (!RunManager.Instance.IsInProgress) + if (RunManager.Instance?.IsInProgress != true) return GlossaryError("No run in progress.", RunNotInProgressErrorCode); var runState = RunManager.Instance.DebugOnlyGetState(); diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 1a0f1a81..3626e6a2 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -475,6 +475,8 @@ def audit_static_glossary_scope(repo: Path) -> None: ) if not match or required not in match.group(0): fail(f"{method} must include active-run shared pool {required}") + if "RunManager.Instance?.IsInProgress != true" not in match.group(0): + fail(f"{method} must handle missing RunManager as run_not_in_progress") for method in ["BuildGlossaryCards", "BuildGlossaryRelics", "BuildGlossaryPotions"]: match = re.search( rf"internal static object {method}\(\).*?(?=\n internal static object|\n private static|\n internal static|\n\}})", From be1fcded0edf2867c50eb63668d3d329219c3c56 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:40:38 -0400 Subject: [PATCH 167/190] Audit legacy profile path normalization --- scripts/audit_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 3626e6a2..8708e5ba 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -100,7 +100,7 @@ def assert_context_paths_normalized(path: str, data: object) -> None: if not isinstance(data, dict): return - for field in ["progress_path", "resolved_progress_path", "profile_root", "save_path", "history_path"]: + for field in ["path", "resolved_path", "progress_path", "resolved_progress_path", "profile_root", "save_path", "history_path"]: value = data.get(field) if isinstance(value, str) and "\\" in value: fail(f"{path} expected {field} to use forward slashes, got {value!r}") From 3fd3eaff9b2d9306096f5e9dd7b646b85b88b2e8 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:42:24 -0400 Subject: [PATCH 168/190] Audit nested settings schema --- scripts/audit_endpoints.py | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8708e5ba..21b2dbdc 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -336,7 +336,34 @@ def audit_static_error_shapes(repo: Path) -> None: if not settings_match: fail("could not locate BuildSettings for settings audit") settings_body = settings_match.group(0) - for required_fragment in ["SaveManager.Instance", "Save manager is not available", "Settings data is not available", "status", "kind"]: + for required_fragment in [ + "SaveManager.Instance", + "Save manager is not available", + "Settings data is not available", + "status", + "kind", + "fullscreen", + "resolution", + "fps_limit", + "vsync", + "msaa", + "aspect_ratio", + "target_display", + "limit_fps_background", + "master", + "bgm", + "sfx", + "ambience", + "fast_mode", + "screen_shake", + "show_run_timer", + "show_card_indices", + "text_effects", + "long_press", + "enabled", + "language", + "skip_intro", + ]: if required_fragment not in settings_body: fail(f"settings endpoint missing startup-safe structured field: {required_fragment}") mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") @@ -1314,6 +1341,19 @@ def audit_live(base_url: str) -> None: for required_field in ["display", "audio", "gameplay", "mods", "language", "skip_intro"]: if required_field not in data: fail(f"{path} missing settings field: {required_field}") + settings_groups = { + "display": ["fullscreen", "resolution", "fps_limit", "vsync", "msaa", "aspect_ratio", "target_display", "limit_fps_background"], + "audio": ["master", "bgm", "sfx", "ambience"], + "gameplay": ["fast_mode", "screen_shake", "show_run_timer", "show_card_indices", "text_effects", "long_press"], + "mods": ["enabled"], + } + for group_name, required_fields in settings_groups.items(): + group = data.get(group_name) + if not isinstance(group, dict): + fail(f"{path} expected settings group {group_name} to be an object, got {group}") + for required_field in required_fields: + if required_field not in group: + fail(f"{path} missing settings field: {group_name}.{required_field}") if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") From b5bcd88fbbf49977f2f1e04e9bdcd0f59c7ed68b Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:43:48 -0400 Subject: [PATCH 169/190] Audit docs for normalized save paths --- scripts/audit_endpoints.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 21b2dbdc..4ec5a382 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -699,8 +699,10 @@ def audit_static_save_roots(repo: Path) -> None: ]: if "NormalizePathForJson" not in source: fail(f"{source_name} endpoint missing path normalization for profile/save context") - if "\\saves\\progress.save" in raw_full: - fail("docs/raw-full.md should show normalized profile/save paths with forward slashes") + for doc_path in [repo / "docs" / "raw-full.md", repo / "docs" / "raw-simplified.md", repo / "mcp" / "README.md"]: + doc_text = doc_path.read_text(encoding="utf-8") + if re.search(r"profile[0-9]\\+saves\\+progress\.save", doc_text, re.IGNORECASE): + fail(f"{doc_path.name} should show normalized profile/save paths with forward slashes") match = re.search( r"private static IEnumerable EnumerateSaveRoots\(\).*?\n private static IEnumerable EnumerateSteamDataRoots\(\)", From f5fdd2d1fb19bf65814526bb552efe6476e755ad Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:46:50 -0400 Subject: [PATCH 170/190] Audit glossary item schemas --- scripts/audit_endpoints.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 4ec5a382..6fafa51e 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -512,6 +512,22 @@ def audit_static_glossary_scope(repo: Path) -> None: ) if not match or 'SortDictionaryListByStringField(result, "id")' not in match.group(0): fail(f"{method} must return deterministically sorted item IDs") + glossary_item_helpers = { + "AddRelicsFromPool": ["id", "name", "description", "rarity", "pool", "keywords", "HoverTipsExcludingRelic"], + "AddPotionsFromPool": ["id", "name", "description", "rarity", "target_type", "usage", "pool", "keywords", "ExtraHoverTips"], + } + for helper_name, required_fragments in glossary_item_helpers.items(): + match = re.search( + rf"private static void {helper_name}\(.*?(?=\n internal static object|\n private static|\n\}})", + fork_endpoints, + re.S, + ) + if not match: + fail(f"could not locate {helper_name} for glossary item shape audit") + helper_body = match.group(0) + for required_fragment in required_fragments: + if required_fragment not in helper_body: + fail(f"{helper_name} missing glossary item field: {required_fragment}") keywords_match = re.search( r"internal static object BuildGlossaryKeywords\(\).*?\n private static void AddKeywordTips", fork_endpoints, @@ -1444,6 +1460,18 @@ def audit_live(base_url: str) -> None: fail(f"{path} expected items list with matching count, got {data}") item_sort_key = "name" if expected_kind == "keywords" else "id" assert_sorted_objects(path, "items", data["items"], item_sort_key) + required_item_fields = { + "cards": ["id", "name", "type", "cost", "description", "rarity", "pool", "keywords", "is_upgraded", "is_upgradable", "current_upgrade_level", "max_upgrade_level", "upgrade_preview_type", "upgrade_preview_description"], + "relics": ["id", "name", "description", "rarity", "pool", "keywords"], + "potions": ["id", "name", "description", "rarity", "target_type", "usage", "pool", "keywords"], + "keywords": ["name", "description"], + }[expected_kind] + for item in data["items"]: + if not isinstance(item, dict): + fail(f"{path} expected glossary items to be objects, got {item}") + for required_field in required_item_fields: + if required_field not in item: + fail(f"{path} glossary item missing field {required_field}: {item}") for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") From 56a364088f424b94e56632a5bdc79ef42d85144c Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:48:48 -0400 Subject: [PATCH 171/190] Audit compendium run history section --- scripts/audit_endpoints.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 6fafa51e..54623f92 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -741,9 +741,20 @@ def audit_static_save_roots(repo: Path) -> None: if not compendium_response_match: fail("could not locate BuildCompendiumResponse for profile context audit") compendium_response = compendium_response_match.group(0) - for required_fragment in ["status", "kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: + for required_fragment in ["status", "kind", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run", "run_history"]: if required_fragment not in compendium_response: fail(f"compendium endpoint missing profile/save context: {required_fragment}") + run_history_match = re.search( + r"private static Dictionary BuildRunHistorySection\(.*?\n private static Dictionary BuildRunHistoryEntry", + compendium, + re.S, + ) + if not run_history_match: + fail("could not locate BuildRunHistorySection for compendium audit") + run_history_body = run_history_match.group(0) + for required_fragment in ["ui_label", "Run History", "status", "source", "entries", "limitation", "history_path", "entry_count"]: + if required_fragment not in run_history_body: + fail(f"compendium run_history missing field: {required_fragment}") profile_match = re.search( r"internal static object BuildProfile\(\).*?\n \}", @@ -1396,7 +1407,8 @@ def audit_live(base_url: str) -> None: potion_lab = sections.get("potion_lab") bestiary = sections.get("bestiary") character_stats = sections.get("character_stats") - if not all(isinstance(section, dict) for section in [card_library, relic_collection, potion_lab, bestiary, character_stats]): + run_history = sections.get("run_history") + if not all(isinstance(section, dict) for section in [card_library, relic_collection, potion_lab, bestiary, character_stats, run_history]): fail(f"{path} expected structured compendium sections, got {sections}") assert_sorted_strings(path, "card_library.discovered_ids", card_library.get("discovered_ids")) assert_sorted_objects(path, "card_library.stats", card_library.get("stats"), "id") @@ -1405,6 +1417,16 @@ def audit_live(base_url: str) -> None: assert_sorted_objects(path, "bestiary.encounter_stats", bestiary.get("encounter_stats"), "id") assert_sorted_objects(path, "bestiary.enemy_stats", bestiary.get("enemy_stats"), "id") assert_sorted_objects(path, "character_stats.characters", character_stats.get("characters"), "id") + for required_field in ["ui_label", "status", "source", "entries", "limitation"]: + if required_field not in run_history: + fail(f"{path} run_history missing field {required_field}: {run_history}") + if run_history.get("ui_label") != "Run History": + fail(f"{path} run_history expected UI label, got {run_history}") + entries = run_history.get("entries") + if not isinstance(entries, (list, dict)): + fail(f"{path} run_history entries should be list or object, got {entries}") + if "entry_count" in run_history and isinstance(entries, list) and not isinstance(run_history.get("entry_count"), int): + fail(f"{path} run_history entry_count should be int, got {run_history}") if path == "/api/v1/profiles": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "profiles": fail(f"{path} expected structured profiles status/kind, got {data}") From 216556151a892623a412bbf76cdb1ec43d91534e Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:51:06 -0400 Subject: [PATCH 172/190] Audit profile stats schemas --- scripts/audit_endpoints.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 54623f92..87483da9 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -96,6 +96,17 @@ def assert_sorted_objects(path: str, field: str, values: object, key: str) -> No fail(f"{path} expected {field} to be sorted by {key}") +def assert_objects_have_fields(path: str, field: str, values: object, required_fields: list[str]) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + for item in values: + if not isinstance(item, dict): + fail(f"{path} expected {field} entries to be objects, got {item}") + for required_field in required_fields: + if required_field not in item: + fail(f"{path} {field} entry missing {required_field}: {item}") + + def assert_context_paths_normalized(path: str, data: object) -> None: if not isinstance(data, dict): return @@ -1396,6 +1407,17 @@ def audit_live(base_url: str) -> None: if path == "/api/v1/profile": for field in ["characters", "card_stats", "encounter_stats", "enemy_stats", "ancient_stats", "achievements", "epochs"]: assert_sorted_objects(path, field, data.get(field), "id") + profile_field_contracts = { + "characters": ["id", "max_ascension", "preferred_ascension", "total_wins", "total_losses", "fastest_win_time", "best_win_streak", "current_win_streak", "playtime"], + "card_stats": ["id", "times_picked", "times_skipped", "times_won", "times_lost"], + "encounter_stats": ["id", "total_wins", "total_losses"], + "enemy_stats": ["id", "total_wins", "total_losses"], + "ancient_stats": ["id", "total_visits", "total_wins", "total_losses"], + "achievements": ["id", "unlocked_at"], + "epochs": ["id", "state", "obtained"], + } + for field, required_fields in profile_field_contracts.items(): + assert_objects_have_fields(path, field, data.get(field), required_fields) for field in ["discovered_cards", "discovered_relics", "discovered_potions", "discovered_events", "discovered_acts"]: assert_sorted_strings(path, field, data.get(field)) if path == "/api/v1/compendium": From 82855b27bc95f972643a8e96658b663b0b46fb95 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 12:55:04 -0400 Subject: [PATCH 173/190] Test MCP profile actions --- scripts/test_mcp_server.py | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/scripts/test_mcp_server.py b/scripts/test_mcp_server.py index a492eb3b..d067b6b3 100644 --- a/scripts/test_mcp_server.py +++ b/scripts/test_mcp_server.py @@ -164,6 +164,104 @@ async def fake_mp_post(body: dict) -> str: assert calls == ["singleplayer"] +async def test_switch_profile_reposts_from_profile_screen_and_waits(server) -> None: + calls: list[tuple[str, dict | None]] = [] + + async def fake_sleep(_seconds: float) -> None: + return None + + async def fake_profiles_post(body: dict) -> str: + calls.append(("profiles_post", body)) + if len([call for call in calls if call[0] == "profiles_post"]) == 1: + return json.dumps({"status": "ok", "message": "Opened profile screen"}) + return json.dumps({"status": "ok", "message": "Switch requested"}) + + async def fake_get(params: dict | None = None) -> str: + calls.append(("get", params)) + return json.dumps({"status": "ok", "menu_screen": "profile_select"}) + + async def fake_profiles_get() -> str: + calls.append(("profiles_get", None)) + return json.dumps( + { + "status": "ok", + "current_profile_id": 2, + "profiles": [{"profile_id": 2, "is_current": True}], + } + ) + + original_sleep = asyncio.sleep + server.asyncio.sleep = fake_sleep + server._profiles_post = fake_profiles_post + server._get = fake_get + server._profiles_get = fake_profiles_get + + try: + rendered = await server.switch_profile(2) + payload = json.loads(rendered) + + assert payload["status"] == "ok" + assert payload["current_profile_id"] == 2 + assert calls == [ + ("profiles_post", {"action": "switch", "profile_id": 2}), + ("get", {"format": "json"}), + ("profiles_post", {"action": "switch", "profile_id": 2}), + ("profiles_get", None), + ] + finally: + server.asyncio.sleep = original_sleep + + +async def test_wait_for_profile_timeout_keeps_initial_response(server) -> None: + async def fake_sleep(_seconds: float) -> None: + return None + + async def fake_profiles_get() -> str: + return json.dumps( + { + "status": "ok", + "current_profile_id": 1, + "profiles": [{"profile_id": 1, "is_current": True}], + } + ) + + original_sleep = asyncio.sleep + server.asyncio.sleep = fake_sleep + server._profiles_get = fake_profiles_get + + try: + rendered = await server._wait_for_profile(3, '{"status":"ok","message":"Switch requested"}') + payload = json.loads(rendered) + + assert payload["status"] == "error" + assert payload["current_profile_id"] == 1 + assert payload["profiles"] == [{"profile_id": 1, "is_current": True}] + assert payload["initial_response"] == '{"status":"ok","message":"Switch requested"}' + finally: + server.asyncio.sleep = original_sleep + + +async def test_delete_profile_preserves_structured_endpoint_error(server) -> None: + async def fake_profiles_post(body: dict) -> str: + assert body == {"action": "delete", "profile_id": 1} + raise _http_status_error( + 409, + { + "status": "error", + "error": "Cannot delete the active profile", + "error_code": "active_profile_delete", + }, + ) + + server._profiles_post = fake_profiles_post + rendered = await server.delete_profile(1) + payload = json.loads(rendered) + + assert payload["status"] == "error" + assert payload["error_code"] == "active_profile_delete" + assert payload["http_status"] == 409 + + def main() -> None: server = _load_server_module() test_structured_http_error_is_preserved(server) @@ -171,6 +269,9 @@ def main() -> None: asyncio.run(test_read_tool_preserves_structured_endpoint_error(server)) asyncio.run(test_menu_select_retries_multiplayer_conflict(server)) asyncio.run(test_menu_select_does_not_retry_other_409(server)) + asyncio.run(test_switch_profile_reposts_from_profile_screen_and_waits(server)) + asyncio.run(test_wait_for_profile_timeout_keeps_initial_response(server)) + asyncio.run(test_delete_profile_preserves_structured_endpoint_error(server)) print("mcp server tests passed") From 6ad50db27b7a68f60b11e7f943919efb7825d3f7 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:01:34 -0400 Subject: [PATCH 174/190] Prioritize method errors before run mode guards --- McpMod.cs | 12 ++++++++++++ scripts/audit_endpoints.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/McpMod.cs b/McpMod.cs index 6fb79da2..aa2c13f2 100644 --- a/McpMod.cs +++ b/McpMod.cs @@ -292,6 +292,12 @@ private static void HandleRequest(HttpListenerContext context) } else if (path == "/api/v1/singleplayer") { + if (request.HttpMethod != "GET" && request.HttpMethod != "POST") + { + SendMethodNotAllowed(response); + return; + } + // Hard-block singleplayer endpoint during multiplayer runs // to prevent calling the non-sync-safe end_turn path if (IsMultiplayerRun()) @@ -311,6 +317,12 @@ private static void HandleRequest(HttpListenerContext context) } else if (path == "/api/v1/multiplayer") { + if (request.HttpMethod != "GET" && request.HttpMethod != "POST") + { + SendMethodNotAllowed(response); + return; + } + // Guard: reject multiplayer endpoint during singleplayer runs if (!IsMultiplayerRun()) { diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 87483da9..62c3f90c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -388,6 +388,19 @@ def audit_static_error_shapes(repo: Path) -> None: for required_fragment in ["SendMethodNotAllowed", "method_not_allowed", "SendNotFound", "not_found", "internal_error"]: if required_fragment not in mcp_mod: fail(f"route errors missing structured error code: {required_fragment}") + for path in ["/api/v1/singleplayer", "/api/v1/multiplayer"]: + route_match = re.search( + rf'else if \(path == "{re.escape(path)}"\)\s*\{{(?P.*?)\n\s*\}}\n\s*else if', + mcp_mod, + re.S, + ) + if not route_match: + fail(f"could not locate route block for {path}") + route_body = route_match.group("body") + method_guard_index = route_body.find('request.HttpMethod != "GET" && request.HttpMethod != "POST"') + mode_guard_index = route_body.find("IsMultiplayerRun()") + if method_guard_index < 0 or mode_guard_index < 0 or method_guard_index > mode_guard_index: + fail(f"{path} must reject unsupported HTTP methods before run-mode guards") read_failure_codes = [ "settings_read_failed", "bestiary_build_failed", From c4157f6833db459c25219383b0ac9ec0dfced8da Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:04:49 -0400 Subject: [PATCH 175/190] Audit unsupported endpoint methods --- scripts/audit_endpoints.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 62c3f90c..8d19a468 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1665,6 +1665,14 @@ def audit_live(base_url: str) -> None: if not isinstance(data, dict) or data.get("error_code") != "method_not_allowed": fail(f"{path} expected method_not_allowed error code for POST, got {data}") + for path in sorted({path for _, path in EXPECTED_ENDPOINTS} | {"/"}): + status, data = load_json_url(base_url.rstrip("/") + path, "PUT", b"{}") + assert_error_body(path, status, data) + if status != 405: + fail(f"{path} expected HTTP 405 for PUT, got {status}: {data}") + if not isinstance(data, dict) or data.get("error_code") != "method_not_allowed": + fail(f"{path} expected method_not_allowed error code for PUT, got {data}") + status, data = load_json_url(base_url.rstrip("/") + "/api/v1/does-not-exist") assert_error_body("/api/v1/does-not-exist", status, data) if status != 404: @@ -1675,6 +1683,7 @@ def audit_live(base_url: str) -> None: print("live: GET endpoint smoke checks passed") print("live: state format checks passed") print("live: safe POST validation checks passed") + print("live: unsupported method checks passed") def main() -> None: From 8dfd275bf731fa610bc3b3f3cfd8c9f54791d0c2 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:07:41 -0400 Subject: [PATCH 176/190] Audit profile compendium schemas --- scripts/audit_endpoints.py | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 8d19a468..864f1d00 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1418,6 +1418,20 @@ def audit_live(base_url: str) -> None: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") if path == "/api/v1/profile": + for required_field in [ + "total_playtime", + "total_unlocks", + "current_score", + "floors_climbed", + "architect_damage", + "total_wins", + "total_losses", + "fastest_victory", + "best_win_streak", + "number_of_runs", + ]: + if required_field not in data: + fail(f"{path} missing global profile total field: {required_field}") for field in ["characters", "card_stats", "encounter_stats", "enemy_stats", "ancient_stats", "achievements", "epochs"]: assert_sorted_objects(path, field, data.get(field), "id") profile_field_contracts = { @@ -1431,6 +1445,10 @@ def audit_live(base_url: str) -> None: } for field, required_fields in profile_field_contracts.items(): assert_objects_have_fields(path, field, data.get(field), required_fields) + for field in ["encounter_stats", "enemy_stats", "ancient_stats"]: + for item in data.get(field, []): + if isinstance(item, dict) and item.get("by_character") is not None: + assert_objects_have_fields(path, f"{field}.by_character", item["by_character"], ["character", "wins", "losses"]) for field in ["discovered_cards", "discovered_relics", "discovered_potions", "discovered_events", "discovered_acts"]: assert_sorted_strings(path, field, data.get(field)) if path == "/api/v1/compendium": @@ -1445,6 +1463,20 @@ def audit_live(base_url: str) -> None: run_history = sections.get("run_history") if not all(isinstance(section, dict) for section in [card_library, relic_collection, potion_lab, bestiary, character_stats, run_history]): fail(f"{path} expected structured compendium sections, got {sections}") + section_contracts = { + "card_library": ["ui_label", "status", "source", "detail_endpoint", "detail_endpoint_requires_run", "discovered_ids", "stats"], + "relic_collection": ["ui_label", "status", "source", "detail_endpoint", "detail_endpoint_requires_run", "discovered_ids", "limitation"], + "potion_lab": ["ui_label", "status", "source", "detail_endpoint", "detail_endpoint_requires_run", "discovered_ids", "limitation"], + "bestiary": ["ui_label", "status", "source", "detail_endpoint", "encounter_stats", "enemy_stats", "limitation"], + "character_stats": ["ui_label", "status", "source", "characters", "global"], + } + for section_name, required_fields in section_contracts.items(): + section = sections.get(section_name) + if not isinstance(section, dict): + fail(f"{path} expected {section_name} section object, got {section}") + for required_field in required_fields: + if required_field not in section: + fail(f"{path} {section_name} missing field {required_field}: {section}") assert_sorted_strings(path, "card_library.discovered_ids", card_library.get("discovered_ids")) assert_sorted_objects(path, "card_library.stats", card_library.get("stats"), "id") assert_sorted_strings(path, "relic_collection.discovered_ids", relic_collection.get("discovered_ids")) @@ -1452,6 +1484,30 @@ def audit_live(base_url: str) -> None: assert_sorted_objects(path, "bestiary.encounter_stats", bestiary.get("encounter_stats"), "id") assert_sorted_objects(path, "bestiary.enemy_stats", bestiary.get("enemy_stats"), "id") assert_sorted_objects(path, "character_stats.characters", character_stats.get("characters"), "id") + assert_objects_have_fields(path, "card_library.stats", card_library.get("stats"), ["id", "times_picked", "times_skipped", "times_won", "times_lost"]) + assert_objects_have_fields(path, "character_stats.characters", character_stats.get("characters"), ["id", "max_ascension", "preferred_ascension", "total_wins", "total_losses", "fastest_win_time", "best_win_streak", "current_win_streak", "playtime"]) + for field in ["encounter_stats", "enemy_stats"]: + assert_objects_have_fields(path, f"bestiary.{field}", bestiary.get(field), ["id", "total_wins", "total_losses"]) + for item in bestiary.get(field, []): + if isinstance(item, dict) and item.get("by_character") is not None: + assert_objects_have_fields(path, f"bestiary.{field}.by_character", item["by_character"], ["character", "wins", "losses"]) + global_stats = character_stats.get("global") + if not isinstance(global_stats, dict): + fail(f"{path} character_stats.global expected object, got {global_stats}") + for required_field in [ + "total_playtime", + "total_unlocks", + "current_score", + "floors_climbed", + "architect_damage", + "total_wins", + "total_losses", + "fastest_victory", + "best_win_streak", + "number_of_runs", + ]: + if required_field not in global_stats: + fail(f"{path} character_stats.global missing field {required_field}: {global_stats}") for required_field in ["ui_label", "status", "source", "entries", "limitation"]: if required_field not in run_history: fail(f"{path} run_history missing field {required_field}: {run_history}") From 840028294d7619ab08f4dc458f187732c1d599ac Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:09:23 -0400 Subject: [PATCH 177/190] Audit card upgrade previews --- scripts/audit_endpoints.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 864f1d00..aadb30f8 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1585,6 +1585,16 @@ def audit_live(base_url: str) -> None: for required_field in required_item_fields: if required_field not in item: fail(f"{path} glossary item missing field {required_field}: {item}") + if expected_kind == "cards" and item.get("is_upgradable") is True: + preview_description = item.get("upgrade_preview_description") + if not isinstance(preview_description, str) or not preview_description.strip(): + fail(f"{path} upgradable card missing upgrade preview description: {item}") + if "upgrade_preview_cost" not in item: + fail(f"{path} upgradable card missing upgrade preview cost: {item}") + current_level = item.get("current_upgrade_level") + max_level = item.get("max_upgrade_level") + if not isinstance(current_level, int) or not isinstance(max_level, int) or current_level > max_level: + fail(f"{path} upgradable card has invalid upgrade levels: {item}") for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") From d7a16a7d456eba53f92d05370749977e1d0fa949 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:12:35 -0400 Subject: [PATCH 178/190] Audit live combat state payloads --- scripts/audit_endpoints.py | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index aadb30f8..b599043f 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -127,6 +127,66 @@ def assert_context_paths_normalized(path: str, data: object) -> None: assert_context_paths_normalized(f"{path}.sections.run_history", run_history) +def assert_target_refs(path: str, field: str, values: object) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + for target in values: + if not isinstance(target, dict): + fail(f"{path} expected {field} entries to be objects, got {target}") + for required_field in ["entity_id", "combat_id", "name"]: + if required_field not in target: + fail(f"{path} {field} target missing {required_field}: {target}") + + +def assert_card_payload(path: str, field: str, values: object, *, hand: bool) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + required_fields = [ + "id", + "name", + "type", + "cost", + "star_cost", + "description", + "rarity", + "is_upgraded", + "is_upgradable", + "current_upgrade_level", + "max_upgrade_level", + "upgrade_preview_type", + "upgrade_preview_cost", + "upgrade_preview_star_cost", + "upgrade_preview_description", + "keywords", + "index", + ] + if hand: + required_fields.extend(["target_type", "requires_target", "valid_targets", "can_play", "unplayable_reason"]) + for item in values: + if not isinstance(item, dict): + fail(f"{path} expected {field} entries to be objects, got {item}") + for required_field in required_fields: + if required_field not in item: + fail(f"{path} {field} card missing {required_field}: {item}") + if item.get("is_upgradable") is True: + preview_description = item.get("upgrade_preview_description") + if not isinstance(preview_description, str) or not preview_description.strip(): + fail(f"{path} {field} upgradable card missing upgrade preview description: {item}") + current_level = item.get("current_upgrade_level") + max_level = item.get("max_upgrade_level") + if not isinstance(current_level, int) or not isinstance(max_level, int) or current_level > max_level: + fail(f"{path} {field} upgradable card has invalid upgrade levels: {item}") + if hand: + assert_target_refs(path, f"{field}.valid_targets", item["valid_targets"]) + if ( + item.get("requires_target") is True + and item.get("target_type") == "AnyEnemy" + and item.get("can_play") is True + and not item["valid_targets"] + ): + fail(f"{path} {field} playable targeted card missing valid targets: {item}") + + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") readme = (repo / "README.md").read_text(encoding="utf-8") @@ -1637,6 +1697,49 @@ def audit_live(base_url: str) -> None: assert_context_paths_normalized("/api/v1/singleplayer?format=json", data) if data.get("status") != "ok" or data.get("kind") != "singleplayer_state": fail(f"/api/v1/singleplayer?format=json expected status ok and kind singleplayer_state, got {data}") + battle = data.get("battle") + if isinstance(battle, dict): + for required_field in ["round", "turn", "is_play_phase", "player_actions_disabled", "can_end_turn", "end_turn_blocked_reason", "enemies"]: + if required_field not in battle: + fail(f"/api/v1/singleplayer?format=json battle missing {required_field}: {battle}") + enemies = battle.get("enemies") + if not isinstance(enemies, list): + fail(f"/api/v1/singleplayer?format=json battle.enemies expected list, got {enemies}") + for enemy in enemies: + if not isinstance(enemy, dict): + fail(f"/api/v1/singleplayer?format=json battle.enemies entry expected object, got {enemy}") + for required_field in ["entity_id", "combat_id", "name", "hp", "max_hp", "block", "is_alive", "is_visible", "can_target", "can_select", "status", "intents"]: + if required_field not in enemy: + fail(f"/api/v1/singleplayer?format=json battle enemy missing {required_field}: {enemy}") + player = data.get("player") + if isinstance(player, dict): + for required_field in ["character", "hp", "max_hp", "block", "gold", "deck_count", "status", "relics", "potions", "max_potion_slots"]: + if required_field not in player: + fail(f"/api/v1/singleplayer?format=json player missing {required_field}: {player}") + if "hand" in player: + assert_card_payload("/api/v1/singleplayer?format=json", "player.hand", player["hand"], hand=True) + for count_field, list_field in [ + ("draw_pile_count", "draw_pile"), + ("discard_pile_count", "discard_pile"), + ("exhaust_pile_count", "exhaust_pile"), + ]: + if count_field not in player or list_field not in player: + fail(f"/api/v1/singleplayer?format=json player missing {count_field}/{list_field}: {player}") + if isinstance(player.get(count_field), int) and isinstance(player.get(list_field), list) and player[count_field] != len(player[list_field]): + fail(f"/api/v1/singleplayer?format=json player {count_field} does not match {list_field}: {player[count_field]} vs {len(player[list_field])}") + assert_card_payload("/api/v1/singleplayer?format=json", f"player.{list_field}", player[list_field], hand=False) + potions = player.get("potions") + if not isinstance(potions, list): + fail(f"/api/v1/singleplayer?format=json player.potions expected list, got {potions}") + for potion in potions: + if not isinstance(potion, dict): + fail(f"/api/v1/singleplayer?format=json player.potions entry expected object, got {potion}") + for required_field in ["id", "name", "description", "rarity", "slot", "can_use_in_combat", "can_use", "use_blocked_reason", "can_discard", "discard_blocked_reason", "requires_target", "valid_targets", "target_type", "usage", "keywords"]: + if required_field not in potion: + fail(f"/api/v1/singleplayer?format=json potion missing {required_field}: {potion}") + assert_target_refs("/api/v1/singleplayer?format=json", "player.potions.valid_targets", potion["valid_targets"]) + if potion.get("requires_target") is True and potion.get("can_use") is True and not potion["valid_targets"]: + fail(f"/api/v1/singleplayer?format=json usable targeted potion missing valid targets: {potion}") markdown_status, markdown = load_text_url(base_url.rstrip("/") + "/api/v1/singleplayer?format=markdown") if markdown_status != 200 or "# Game State:" not in markdown: From 363681eca4cf903c12905a188badb08787cfca67 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:14:42 -0400 Subject: [PATCH 179/190] Audit active run profile switch guard --- scripts/audit_endpoints.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index b599043f..fc0df951 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1808,6 +1808,18 @@ def audit_live(base_url: str) -> None: assert_error_body("/api/v1/profiles", status, data) if status != 409 or data.get("error_code") != "active_profile_delete": fail(f"/api/v1/profiles expected HTTP 409 active_profile_delete, got HTTP {status}: {data}") + status, profile_data = load_json_url(base_url.rstrip() + "/api/v1/profile") + if status == 200 and isinstance(profile_data, dict): + current_run = profile_data.get("current_run") + if isinstance(current_run, dict) and current_run.get("is_in_progress") is True: + status, data = load_json_url( + base_url.rstrip() + "/api/v1/profiles", + "POST", + json.dumps({"action": "switch", "profile_id": current_profile_id}).encode("utf-8"), + ) + assert_error_body("/api/v1/profiles", status, data) + if status != 409 or data.get("error_code") != "run_in_progress": + fail(f"/api/v1/profiles expected HTTP 409 run_in_progress during active run, got HTTP {status}: {data}") for body in (b"{", b"{}"): status, data = load_json_url(base_url.rstrip("/") + "/api/v1/multiplayer", "POST", body) From a1be21d5a4f129f1b96e2c8739b2134b135fb093 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:16:38 -0400 Subject: [PATCH 180/190] Audit multiplayer route guards --- scripts/audit_endpoints.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index fc0df951..350ad60c 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1448,6 +1448,11 @@ def audit_live(base_url: str) -> None: if path in {"/api/v1/settings", "/api/v1/profile", "/api/v1/compendium", "/api/v1/bestiary", "/api/v1/profiles"} and status != 200: fail(f"{path} expected HTTP 200, got {status}: {data}") + if path == "/api/v1/multiplayer": + if status not in {200, 409}: + fail(f"{path} expected HTTP 200 in MP or 409 outside MP, got {status}: {data}") + if status == 409 and (not isinstance(data, dict) or data.get("error_code") != "not_multiplayer_run"): + fail(f"{path} expected not_multiplayer_run outside MP, got {data}") if path == "/api/v1/settings": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "settings": fail(f"{path} expected structured settings status/kind, got {data}") @@ -1826,6 +1831,10 @@ def audit_live(base_url: str) -> None: assert_error_body("/api/v1/multiplayer", status, data) if status not in {400, 409}: fail(f"/api/v1/multiplayer expected HTTP 400 in MP or 409 outside MP, got {status}: {data}") + if status == 409 and (not isinstance(data, dict) or data.get("error_code") != "not_multiplayer_run"): + fail(f"/api/v1/multiplayer expected not_multiplayer_run outside MP, got {data}") + if status == 400 and (not isinstance(data, dict) or data.get("error_code") not in {"invalid_json", "missing_action"}): + fail(f"/api/v1/multiplayer expected validation error inside MP, got {data}") get_only_paths = [ "/", From 1a53acf7d2bcc4eed177b01e8b5f8c00184133cf Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:19:09 -0400 Subject: [PATCH 181/190] Audit bestiary entry schemas --- scripts/audit_endpoints.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 350ad60c..a9cf80a7 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1605,14 +1605,34 @@ def audit_live(base_url: str) -> None: fail(f"{path} expected monsters list with matching monster_count, got {data}") if not isinstance(encounters, list) or data.get("encounter_count") != len(encounters): fail(f"{path} expected encounters list with matching encounter_count, got {data}") + if data.get("source") != "reflected MonsterModel and EncounterModel metadata": + fail(f"{path} expected reflected metadata source, got {data.get('source')}") monster_ids = [str(item.get("id")) for item in monsters if isinstance(item, dict)] encounter_ids = [str(item.get("id")) for item in encounters if isinstance(item, dict)] if monster_ids != sorted(monster_ids) or encounter_ids != sorted(encounter_ids): fail(f"{path} expected deterministic id ordering") - for item in [*monsters, *encounters]: + for item in monsters: if not isinstance(item, dict): - fail(f"{path} expected bestiary entries to be objects, got {item}") - for field in ["moves", "likely_monsters"]: + fail(f"{path} expected monster entries to be objects, got {item}") + for field in ["id", "class", "min_hp", "max_hp"]: + if field not in item: + fail(f"{path} monster missing field {field}: {item}") + if not isinstance(item["min_hp"], int) or not isinstance(item["max_hp"], int) or item["min_hp"] > item["max_hp"]: + fail(f"{path} monster has invalid hp range: {item}") + moves = item.get("moves") + if moves is not None: + assert_sorted_strings(path, "moves", moves) + for item in encounters: + if not isinstance(item, dict): + fail(f"{path} expected encounter entries to be objects, got {item}") + for field in ["id", "class", "room_type", "is_weak", "min_gold", "max_gold"]: + if field not in item: + fail(f"{path} encounter missing field {field}: {item}") + if not isinstance(item["min_gold"], int) or not isinstance(item["max_gold"], int) or item["min_gold"] > item["max_gold"]: + fail(f"{path} encounter has invalid gold range: {item}") + if not isinstance(item["is_weak"], bool): + fail(f"{path} encounter is_weak should be bool: {item}") + for field in ["likely_monsters"]: values = item.get(field) if values is not None: assert_sorted_strings(path, field, values) From e1d2ae59513cc511e36a9f7e1ee5c14bb6d72334 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:21:20 -0400 Subject: [PATCH 182/190] Audit settings value schemas --- scripts/audit_endpoints.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index a9cf80a7..c93708db 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1472,6 +1472,38 @@ def audit_live(base_url: str) -> None: for required_field in required_fields: if required_field not in group: fail(f"{path} missing settings field: {group_name}.{required_field}") + display = data["display"] + audio = data["audio"] + gameplay = data["gameplay"] + mods = data["mods"] + for field in ["fullscreen", "limit_fps_background"]: + if not isinstance(display.get(field), bool): + fail(f"{path} expected display.{field} to be bool, got {display.get(field)!r}") + if not isinstance(display.get("resolution"), str) or not re.match(r"^\d+x\d+$", display["resolution"]): + fail(f"{path} expected display.resolution as WIDTHxHEIGHT string, got {display.get('resolution')!r}") + for field in ["fps_limit", "msaa", "target_display"]: + if not isinstance(display.get(field), int): + fail(f"{path} expected display.{field} to be int, got {display.get(field)!r}") + for field in ["vsync", "aspect_ratio"]: + if not isinstance(display.get(field), str) or not display[field]: + fail(f"{path} expected display.{field} to be non-empty string, got {display.get(field)!r}") + for field in ["master", "bgm", "sfx", "ambience"]: + value = audio.get(field) + if not isinstance(value, (int, float)) or value < 0 or value > 1: + fail(f"{path} expected audio.{field} to be numeric 0..1, got {value!r}") + if not isinstance(gameplay.get("fast_mode"), str) or not gameplay["fast_mode"]: + fail(f"{path} expected gameplay.fast_mode to be non-empty string, got {gameplay.get('fast_mode')!r}") + if not isinstance(gameplay.get("screen_shake"), int): + fail(f"{path} expected gameplay.screen_shake to be int, got {gameplay.get('screen_shake')!r}") + for field in ["show_run_timer", "show_card_indices", "text_effects", "long_press"]: + if not isinstance(gameplay.get(field), bool): + fail(f"{path} expected gameplay.{field} to be bool, got {gameplay.get(field)!r}") + if not isinstance(mods.get("enabled"), bool): + fail(f"{path} expected mods.enabled to be bool, got {mods.get('enabled')!r}") + if not isinstance(data.get("language"), str) or not data["language"]: + fail(f"{path} expected language to be non-empty string, got {data.get('language')!r}") + if not isinstance(data.get("skip_intro"), bool): + fail(f"{path} expected skip_intro to be bool, got {data.get('skip_intro')!r}") if path in {"/api/v1/profile", "/api/v1/compendium"}: if not isinstance(data, dict): fail(f"{path} expected structured profile context object, got {type(data).__name__}") From 13ec7bb6cee68660bb61a184556c68b97546b52f Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:23:59 -0400 Subject: [PATCH 183/190] Audit glossary value schemas --- scripts/audit_endpoints.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index c93708db..67c45693 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -187,6 +187,18 @@ def assert_card_payload(path: str, field: str, values: object, *, hand: bool) -> fail(f"{path} {field} playable targeted card missing valid targets: {item}") +def assert_keyword_payload(path: str, field: str, values: object) -> None: + if not isinstance(values, list): + fail(f"{path} expected {field} to be a list, got {values}") + for keyword in values: + if not isinstance(keyword, dict): + fail(f"{path} expected {field} entries to be objects, got {keyword}") + for required_field in ["name", "description"]: + value = keyword.get(required_field) + if not isinstance(value, str) or not value.strip(): + fail(f"{path} {field} entry missing non-empty {required_field}: {keyword}") + + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") readme = (repo / "README.md").read_text(encoding="utf-8") @@ -1702,6 +1714,36 @@ def audit_live(base_url: str) -> None: for required_field in required_item_fields: if required_field not in item: fail(f"{path} glossary item missing field {required_field}: {item}") + for required_field in ["name", "description"]: + value = item.get(required_field) + if not isinstance(value, str) or not value.strip(): + fail(f"{path} glossary item missing non-empty {required_field}: {item}") + if expected_kind != "keywords": + for required_field in ["id", "rarity", "pool"]: + value = item.get(required_field) + if not isinstance(value, str) or not value.strip(): + fail(f"{path} glossary item missing non-empty {required_field}: {item}") + assert_keyword_payload(path, "keywords", item.get("keywords")) + if expected_kind == "cards": + for required_field in ["type", "cost", "upgrade_preview_type"]: + value = item.get(required_field) + if not isinstance(value, str) or not value.strip(): + fail(f"{path} card glossary item missing non-empty {required_field}: {item}") + for required_field in ["is_upgraded", "is_upgradable"]: + if not isinstance(item.get(required_field), bool): + fail(f"{path} card glossary item expected bool {required_field}: {item}") + for required_field in ["current_upgrade_level", "max_upgrade_level"]: + if not isinstance(item.get(required_field), int): + fail(f"{path} card glossary item expected int {required_field}: {item}") + for nullable_string_field in ["star_cost", "upgrade_preview_cost", "upgrade_preview_star_cost"]: + value = item.get(nullable_string_field) + if value is not None and not isinstance(value, str): + fail(f"{path} card glossary item expected nullable string {nullable_string_field}: {item}") + if expected_kind == "potions": + for required_field in ["target_type", "usage"]: + value = item.get(required_field) + if not isinstance(value, str) or not value.strip(): + fail(f"{path} potion glossary item missing non-empty {required_field}: {item}") if expected_kind == "cards" and item.get("is_upgradable") is True: preview_description = item.get("upgrade_preview_description") if not isinstance(preview_description, str) or not preview_description.strip(): From a4f296fd8b728fd66df9b7dbee56f12731f03cce Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:26:17 -0400 Subject: [PATCH 184/190] Audit profile slot schemas --- scripts/audit_endpoints.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 67c45693..52006983 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1633,13 +1633,45 @@ def audit_live(base_url: str) -> None: profiles = data.get("profiles") if not isinstance(profiles, list) or data.get("count") != len(profiles): fail(f"{path} expected profiles list with matching count, got {data}") + if data.get("count") != 3: + fail(f"{path} expected exactly three profile slots, got {data.get('count')}") + current_profile_id = data.get("current_profile_id") + if not isinstance(current_profile_id, int) or current_profile_id not in {1, 2, 3}: + fail(f"{path} expected current_profile_id 1-3, got {current_profile_id!r}") + seen_profile_ids: list[int] = [] + current_slots = 0 for profile_slot in profiles: if not isinstance(profile_slot, dict): fail(f"{path} expected profile slot objects, got {profile_slot}") assert_context_paths_normalized(f"{path}.profiles[]", profile_slot) - for required_field in ["id", "profile_id", "is_current", "has_data", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + for required_field in ["id", "profile_id", "is_current", "has_data", "path", "resolved_path", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in profile_slot: fail(f"{path} profile slot missing field {required_field}: {profile_slot}") + if not isinstance(profile_slot["id"], int) or not isinstance(profile_slot["profile_id"], int): + fail(f"{path} profile slot id/profile_id should be ints: {profile_slot}") + if profile_slot["id"] != profile_slot["profile_id"] or profile_slot["id"] not in {1, 2, 3}: + fail(f"{path} profile slot id/profile_id mismatch or out of range: {profile_slot}") + seen_profile_ids.append(profile_slot["profile_id"]) + if not isinstance(profile_slot["is_current"], bool) or not isinstance(profile_slot["has_data"], bool): + fail(f"{path} profile slot flags should be bools: {profile_slot}") + if profile_slot["is_current"]: + current_slots += 1 + if profile_slot["profile_id"] != current_profile_id: + fail(f"{path} current slot does not match current_profile_id: {profile_slot} vs {current_profile_id}") + for field in ["path", "resolved_path", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: + if not isinstance(profile_slot[field], str) or not profile_slot[field]: + fail(f"{path} profile slot field {field} should be a non-empty string: {profile_slot}") + if profile_slot["path"] != profile_slot["progress_path"]: + fail(f"{path} profile slot path/progress_path mismatch: {profile_slot}") + if profile_slot["resolved_path"] != profile_slot["resolved_progress_path"]: + fail(f"{path} profile slot resolved path mismatch: {profile_slot}") + expected_root_suffix = f"profile{profile_slot['profile_id']}" + if not profile_slot["profile_root"].endswith(expected_root_suffix): + fail(f"{path} profile slot root should end with {expected_root_suffix}: {profile_slot}") + if sorted(seen_profile_ids) != [1, 2, 3]: + fail(f"{path} expected profile slots 1, 2, 3, got {seen_profile_ids}") + if current_slots != 1: + fail(f"{path} expected exactly one current profile slot, got {current_slots}") if path == "/api/v1/bestiary": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "bestiary": fail(f"{path} expected structured bestiary status/kind, got {data}") From 71f0e68b9ea4b73a42dc9c7bbf875cc2c24656dc Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:28:27 -0400 Subject: [PATCH 185/190] Audit API index schema --- scripts/audit_endpoints.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 52006983..01921dc0 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1427,10 +1427,35 @@ def audit_live(base_url: str) -> None: fail("root response does not include endpoints list") if root.get("status") != "ok" or root.get("kind") != "api_index": fail(f"root response expected status ok and kind api_index, got {root}") - if not root.get("version"): - fail(f"root response expected explicit version field, got {root}") + if not isinstance(root.get("message"), str) or "STS2 MCP" not in root["message"]: + fail(f"root response expected STS2 MCP message, got {root.get('message')!r}") + if not isinstance(root.get("version"), str) or not re.match(r"^\d+\.\d+\.\d+$", root["version"]): + fail(f"root response expected semantic version string, got {root.get('version')!r}") + bound_prefixes = root.get("bound_prefixes") + if not isinstance(bound_prefixes, list) or not bound_prefixes: + fail(f"root response expected non-empty bound_prefixes list, got {bound_prefixes}") + for prefix in bound_prefixes: + if not isinstance(prefix, str) or not prefix.startswith("http://") or not prefix.endswith("/"): + fail(f"root response bound prefix should be an http URL string ending in slash, got {prefix!r}") if root.get("endpoint_count") != len(endpoint_rows): fail(f"root response endpoint_count mismatch, got {root.get('endpoint_count')} for {len(endpoint_rows)} endpoints") + seen_rows: set[tuple[str, str]] = set() + for row in endpoint_rows: + if not isinstance(row, dict): + fail(f"root endpoint row expected object, got {row}") + method = row.get("method") + path = row.get("path") + description = row.get("description") + if method not in {"GET", "POST"}: + fail(f"root endpoint row has invalid method: {row}") + if not isinstance(path, str) or not path.startswith("/api/v1/"): + fail(f"root endpoint row has invalid path: {row}") + if not isinstance(description, str) or not description.strip(): + fail(f"root endpoint row missing non-empty description: {row}") + row_key = (method, path) + if row_key in seen_rows: + fail(f"root endpoint row duplicated: {row}") + seen_rows.add(row_key) live_index = { (str(row.get("method")), str(row.get("path"))) From 3f1eb62e1cca5ad7c505cc55dc0b50b4f2806158 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:30:44 -0400 Subject: [PATCH 186/190] Audit safe gameplay validation errors --- scripts/audit_endpoints.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 01921dc0..b1965e13 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1951,6 +1951,21 @@ def audit_live(base_url: str) -> None: if status not in {400, 409} or data.get("error_code") not in {"unknown_action", "run_not_in_progress", "local_player_unavailable"}: fail(f"/api/v1/singleplayer expected structured unknown/no-run action error, got HTTP {status}: {data}") + safe_gameplay_payload_checks = [ + (b'{"action": "play_card"}', "Missing 'card_index'"), + (b'{"action": "use_potion"}', "Missing 'slot'"), + (b'{"action": "discard_potion"}', "Missing 'slot'"), + ] + for body, expected_error_fragment in safe_gameplay_payload_checks: + status, data = load_json_url(base_url.rstrip() + "/api/v1/singleplayer", "POST", body) + assert_error_body("/api/v1/singleplayer", status, data) + if status not in {400, 409}: + fail(f"/api/v1/singleplayer expected non-2xx safe gameplay validation error, got HTTP {status}: {data}") + if not isinstance(data, dict) or data.get("error_code") not in {"action_error", "run_not_in_progress", "local_player_unavailable", "blocking_popup_active", "multiplayer_run_active"}: + fail(f"/api/v1/singleplayer expected structured safe gameplay validation error, got HTTP {status}: {data}") + if data.get("error_code") == "action_error" and expected_error_fragment not in str(data.get("error", "")): + fail(f"/api/v1/singleplayer expected safe gameplay validation message containing {expected_error_fragment!r}, got {data}") + status, profiles_data = load_json_url(base_url.rstrip("/") + "/api/v1/profiles") if status != 200 or not isinstance(profiles_data, dict): fail(f"/api/v1/profiles expected profile data before active-delete validation, got HTTP {status}: {profiles_data}") From d68eb77dc378ff7e0ad42a97797b0df5d8cbe2a2 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:33:52 -0400 Subject: [PATCH 187/190] Audit current run context schemas --- scripts/audit_endpoints.py | 53 +++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index b1965e13..b2d80964 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -199,6 +199,40 @@ def assert_keyword_payload(path: str, field: str, values: object) -> None: fail(f"{path} {field} entry missing non-empty {required_field}: {keyword}") +def assert_current_run_context(path: str, current_run: object, *, expect_active: bool = True) -> None: + if not isinstance(current_run, dict): + fail(f"{path} expected current_run object, got {current_run}") + assert_context_paths_normalized(f"{path}.current_run", current_run) + if current_run.get("is_in_progress") is not expect_active: + fail(f"{path} current_run expected is_in_progress={expect_active}, got {current_run}") + for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "id_format"]: + if required_field not in current_run: + fail(f"{path} current_run missing profile/save context field: {required_field}") + if not isinstance(current_run["profile_id"], int) or current_run["profile_id"] not in {1, 2, 3}: + fail(f"{path} current_run profile_id should be 1-3, got {current_run}") + for field in ["progress_path", "resolved_progress_path", "profile_root", "save_scope", "id_format"]: + if not isinstance(current_run.get(field), str) or not current_run[field]: + fail(f"{path} current_run {field} should be a non-empty string, got {current_run}") + if current_run["id_format"] != "{save_scope}:profile{profile_id}:{start_time}": + fail(f"{path} current_run unexpected id_format, got {current_run.get('id_format')}") + for optional_string in ["save_path", "game_mode", "platform_type", "seed", "run_id", "limitation"]: + value = current_run.get(optional_string) + if value is not None and (not isinstance(value, str) or not value): + fail(f"{path} current_run {optional_string} should be a non-empty string when present, got {current_run}") + for optional_int in ["start_time", "save_time", "run_time", "ascension", "current_act_index", "schema_version"]: + value = current_run.get(optional_int) + if value is not None and not isinstance(value, int): + fail(f"{path} current_run {optional_int} should be int when present, got {current_run}") + if current_run.get("run_id") and not current_run.get("start_time"): + fail(f"{path} current_run run_id requires start_time, got {current_run}") + if current_run.get("start_time") and not current_run.get("run_id"): + fail(f"{path} current_run start_time should derive run_id, got {current_run}") + if current_run.get("run_id"): + expected_prefix = f"{current_run['save_scope']}:profile{current_run['profile_id']}:" + if not str(current_run["run_id"]).startswith(expected_prefix): + fail(f"{path} current_run run_id should start with {expected_prefix!r}, got {current_run}") + + def audit_docs(repo: Path) -> None: raw_full = (repo / "docs" / "raw-full.md").read_text(encoding="utf-8") readme = (repo / "README.md").read_text(encoding="utf-8") @@ -1551,6 +1585,7 @@ def audit_live(base_url: str) -> None: for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "current_run"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") + assert_current_run_context(path, data["current_run"]) if path == "/api/v1/profile": for required_field in [ "total_playtime", @@ -1814,21 +1849,7 @@ def audit_live(base_url: str) -> None: for required_field in ["profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope"]: if required_field not in data: fail(f"{path} missing profile/save context field: {required_field}") - current_run = data.get("current_run") - if not isinstance(current_run, dict): - fail(f"{path} expected current_run save context, got {current_run}") - assert_context_paths_normalized(f"{path}.current_run", current_run) - for required_field in ["is_in_progress", "profile_id", "progress_path", "resolved_progress_path", "profile_root", "save_scope", "id_format"]: - if required_field not in current_run: - fail(f"{path} current_run missing profile/save context field: {required_field}") - if current_run.get("is_in_progress") is not True: - fail(f"{path} current_run expected active run marker, got {current_run}") - if current_run.get("id_format") != "{save_scope}:profile{profile_id}:{start_time}": - fail(f"{path} current_run unexpected id_format, got {current_run.get('id_format')}") - if current_run.get("run_id") and not current_run.get("start_time"): - fail(f"{path} current_run run_id requires start_time, got {current_run}") - if current_run.get("start_time") and not current_run.get("run_id"): - fail(f"{path} current_run start_time should derive run_id, got {current_run}") + assert_current_run_context(path, data.get("current_run")) glossary_payloads[expected_kind] = data if {"keywords", "relics", "potions"}.issubset(glossary_payloads): @@ -1853,6 +1874,8 @@ def audit_live(base_url: str) -> None: assert_context_paths_normalized("/api/v1/singleplayer?format=json", data) if data.get("status") != "ok" or data.get("kind") != "singleplayer_state": fail(f"/api/v1/singleplayer?format=json expected status ok and kind singleplayer_state, got {data}") + if "current_run" in data: + assert_current_run_context("/api/v1/singleplayer?format=json", data["current_run"]) battle = data.get("battle") if isinstance(battle, dict): for required_field in ["round", "turn", "is_play_phase", "player_actions_disabled", "can_end_turn", "end_turn_blocked_reason", "enemies"]: From e51d4a58049589daa0ddf527ddb052c20035edaf Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:36:30 -0400 Subject: [PATCH 188/190] Audit profile value schemas --- scripts/audit_endpoints.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index b2d80964..145885fc 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1601,6 +1601,8 @@ def audit_live(base_url: str) -> None: ]: if required_field not in data: fail(f"{path} missing global profile total field: {required_field}") + if not isinstance(data[required_field], int): + fail(f"{path} global profile total {required_field} should be int, got {data[required_field]!r}") for field in ["characters", "card_stats", "encounter_stats", "enemy_stats", "ancient_stats", "achievements", "epochs"]: assert_sorted_objects(path, field, data.get(field), "id") profile_field_contracts = { @@ -1614,10 +1616,50 @@ def audit_live(base_url: str) -> None: } for field, required_fields in profile_field_contracts.items(): assert_objects_have_fields(path, field, data.get(field), required_fields) + for item in data.get("characters", []): + if isinstance(item, dict): + if not isinstance(item.get("id"), str) or not item["id"]: + fail(f"{path} character stat id should be non-empty string: {item}") + for numeric_field in ["max_ascension", "preferred_ascension", "total_wins", "total_losses", "fastest_win_time", "best_win_streak", "current_win_streak", "playtime"]: + if not isinstance(item.get(numeric_field), int): + fail(f"{path} character stat {numeric_field} should be int: {item}") + for field, numeric_fields in { + "card_stats": ["times_picked", "times_skipped", "times_won", "times_lost"], + "encounter_stats": ["total_wins", "total_losses"], + "enemy_stats": ["total_wins", "total_losses"], + "ancient_stats": ["total_visits", "total_wins", "total_losses"], + }.items(): + for item in data.get(field, []): + if isinstance(item, dict): + if not isinstance(item.get("id"), str) or not item["id"]: + fail(f"{path} {field} id should be non-empty string: {item}") + for numeric_field in numeric_fields: + if not isinstance(item.get(numeric_field), int): + fail(f"{path} {field}.{numeric_field} should be int: {item}") for field in ["encounter_stats", "enemy_stats", "ancient_stats"]: for item in data.get(field, []): if isinstance(item, dict) and item.get("by_character") is not None: assert_objects_have_fields(path, f"{field}.by_character", item["by_character"], ["character", "wins", "losses"]) + for by_character in item["by_character"]: + if not isinstance(by_character.get("character"), str) or not by_character["character"]: + fail(f"{path} {field}.by_character character should be non-empty string: {by_character}") + for numeric_field in ["wins", "losses"]: + if not isinstance(by_character.get(numeric_field), int): + fail(f"{path} {field}.by_character {numeric_field} should be int: {by_character}") + for item in data.get("achievements", []): + if isinstance(item, dict): + if not isinstance(item.get("id"), str) or not item["id"]: + fail(f"{path} achievement id should be non-empty string: {item}") + if not isinstance(item.get("unlocked_at"), int): + fail(f"{path} achievement unlocked_at should be int: {item}") + for item in data.get("epochs", []): + if isinstance(item, dict): + if not isinstance(item.get("id"), str) or not item["id"]: + fail(f"{path} epoch id should be non-empty string: {item}") + if not isinstance(item.get("state"), str) or not item["state"]: + fail(f"{path} epoch state should be non-empty string: {item}") + if not isinstance(item.get("obtained"), int): + fail(f"{path} epoch obtained should be int: {item}") for field in ["discovered_cards", "discovered_relics", "discovered_potions", "discovered_events", "discovered_acts"]: assert_sorted_strings(path, field, data.get(field)) if path == "/api/v1/compendium": From 625d9a334a648a867fed9add8f0ba93b63a081de Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 13:39:14 -0400 Subject: [PATCH 189/190] Audit compendium run history schemas --- scripts/audit_endpoints.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py index 145885fc..122cf2d1 100644 --- a/scripts/audit_endpoints.py +++ b/scripts/audit_endpoints.py @@ -1729,6 +1729,72 @@ def audit_live(base_url: str) -> None: fail(f"{path} run_history entries should be list or object, got {entries}") if "entry_count" in run_history and isinstance(entries, list) and not isinstance(run_history.get("entry_count"), int): fail(f"{path} run_history entry_count should be int, got {run_history}") + for field in ["status", "source", "limitation"]: + if not isinstance(run_history.get(field), str) or not run_history[field]: + fail(f"{path} run_history {field} should be non-empty string, got {run_history}") + progress_members = run_history.get("progress_members") + if progress_members is not None and not isinstance(progress_members, dict): + fail(f"{path} run_history progress_members should be object or null, got {progress_members}") + if "history_path" in run_history: + if not isinstance(run_history["history_path"], str) or not run_history["history_path"]: + fail(f"{path} run_history history_path should be non-empty string, got {run_history}") + if run_history["source"] != "Profile save history files": + fail(f"{path} run_history with history_path should use file source, got {run_history}") + if isinstance(entries, list): + entry_count = run_history.get("entry_count") + if isinstance(entry_count, int) and len(entries) > entry_count: + fail(f"{path} run_history entries should not exceed entry_count, got {len(entries)} > {entry_count}") + if isinstance(entry_count, int) and len(entries) != min(entry_count, 20): + fail(f"{path} run_history expected min(entry_count, 20) entries, got {len(entries)} for {entry_count}") + for entry in entries: + if not isinstance(entry, dict): + fail(f"{path} run_history entries should contain objects, got {entry}") + for required_field in ["id", "run_id", "file_name", "size_bytes", "last_write_time_utc"]: + if required_field not in entry: + fail(f"{path} run_history entry missing {required_field}: {entry}") + for required_field in ["id", "run_id", "file_name", "last_write_time_utc"]: + if not isinstance(entry.get(required_field), str) or not entry[required_field]: + fail(f"{path} run_history entry {required_field} should be non-empty string: {entry}") + if not isinstance(entry.get("size_bytes"), int) or entry["size_bytes"] <= 0: + fail(f"{path} run_history entry size_bytes should be positive int: {entry}") + expected_prefix = f"{data['save_scope']}:profile{data['profile_id']}:" + if not entry["run_id"].startswith(expected_prefix): + fail(f"{path} run_history entry run_id should start with {expected_prefix!r}: {entry}") + if not entry["file_name"].endswith(".run"): + fail(f"{path} run_history entry file_name should end with .run: {entry}") + for optional_int in ["start_time", "run_time", "ascension", "map_point_count"]: + if entry.get(optional_int) is not None and not isinstance(entry[optional_int], int): + fail(f"{path} run_history entry {optional_int} should be int when present: {entry}") + for optional_bool in ["win", "was_abandoned"]: + if entry.get(optional_bool) is not None and not isinstance(entry[optional_bool], bool): + fail(f"{path} run_history entry {optional_bool} should be bool when present: {entry}") + for optional_string in ["game_mode", "killed_by_encounter", "killed_by_event", "seed", "build_id", "parse_error"]: + if entry.get(optional_string) is not None and (not isinstance(entry[optional_string], str) or not entry[optional_string]): + fail(f"{path} run_history entry {optional_string} should be non-empty string when present: {entry}") + acts = entry.get("acts") + if acts is not None: + if not isinstance(acts, list): + fail(f"{path} run_history entry acts should be list when present: {entry}") + for act in acts: + if not isinstance(act, str) or not act: + fail(f"{path} run_history entry acts should contain non-empty strings: {entry}") + players = entry.get("players") + if players is not None: + if not isinstance(players, list): + fail(f"{path} run_history entry players should be list when present: {entry}") + for player in players: + if not isinstance(player, dict): + fail(f"{path} run_history player should be object: {player}") + for required_field in ["id", "character", "deck_count", "relic_count", "potion_count"]: + if required_field not in player: + fail(f"{path} run_history player missing {required_field}: {player}") + if not isinstance(player["id"], int): + fail(f"{path} run_history player id should be int: {player}") + if not isinstance(player["character"], str) or not player["character"]: + fail(f"{path} run_history player character should be non-empty string: {player}") + for numeric_field in ["deck_count", "relic_count", "potion_count"]: + if not isinstance(player[numeric_field], int) or player[numeric_field] < 0: + fail(f"{path} run_history player {numeric_field} should be non-negative int: {player}") if path == "/api/v1/profiles": if not isinstance(data, dict) or data.get("status") != "ok" or data.get("kind") != "profiles": fail(f"{path} expected structured profiles status/kind, got {data}") From 89ab1d0dc5e855eb142ee5eacb2c9c24cc765984 Mon Sep 17 00:00:00 2001 From: romgenie <5861166+romgenie@users.noreply.github.com> Date: Sat, 9 May 2026 15:24:11 -0400 Subject: [PATCH 190/190] Document endpoint contracts --- README.md | 4 + docs/endpoint-contracts.md | 231 +++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 docs/endpoint-contracts.md diff --git a/README.md b/README.md index 04ef323a..8249bc50 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,10 @@ multiplayer menu retry behavior: uv run --project mcp python scripts/test_mcp_server.py ``` +For the stable endpoint envelopes, save/run context fields, structured error +codes, glossary scope, action-readiness fields, and audit coverage, see +[`docs/endpoint-contracts.md`](docs/endpoint-contracts.md). + ### 2. Give Your AI Instructions to Interact with the Game **Clone or download the repository**, then: diff --git a/docs/endpoint-contracts.md b/docs/endpoint-contracts.md new file mode 100644 index 00000000..b6877d4f --- /dev/null +++ b/docs/endpoint-contracts.md @@ -0,0 +1,231 @@ +# Endpoint Contracts + +This document summarizes the stable HTTP and MCP contracts introduced by the endpoint audit work. The raw references remain the source of detailed field-by-field examples: + +- `docs/raw-full.md` +- `docs/raw-simplified.md` +- `mcp/README.md` + +## Endpoint Index + +`GET /` returns a structured API index instead of a plain greeting. Clients can use it as a capability discovery surface before choosing direct HTTP calls or MCP tools. + +The index includes: + +- `status: "ok"` +- `kind: "api_index"` +- `version` +- `message` +- `bound_prefixes` +- `endpoint_count` +- `endpoints` + +Each endpoint row advertises a `method`, `path`, and description. The audit script checks that the index has no duplicate method/path pairs and that documented routes stay aligned with the live API surface. + +## Response Envelopes + +JSON read endpoints use explicit envelopes so clients can branch on `status` and `kind` without inferring response type from route names. + +Examples: + +- `singleplayer_state` +- `multiplayer_state` +- `settings` +- `profile` +- `profiles` +- `compendium` +- `bestiary` +- `glossary_cards` +- `glossary_relics` +- `glossary_potions` +- `glossary_keywords` + +Markdown state output is still available through `format=markdown` for the singleplayer and multiplayer state routes. JSON state output carries the same `state_type` and screen-specific payloads as before, now wrapped with `status: "ok"` and a route-specific `kind`. + +## Current Run Context + +Profile-aware and run-aware JSON responses expose shared save/run identity fields where available: + +- `profile_id` +- `progress_path` +- `resolved_progress_path` +- `profile_root` +- `save_scope` +- `current_run` + +`current_run` always identifies the active profile/save context. Save-backed fields such as `run_id`, `seed`, `start_time`, `save_time`, and `run_time` are present when the active `current_run.save` file exposes them. + +`run_id` identifies one concrete attempt and follows this format: + +```text +{save_scope}:profile{profile_id}:{start_time} +``` + +Use `seed` separately when grouping by generated content. Multiple attempts can share a seed. + +All exposed path/context strings are normalized with forward slashes, including Windows absolute paths, profile roots, resolved progress paths, and Compendium history paths. + +## Profile And Compendium + +`GET /api/v1/profile` is the raw profile-progress view. It reports discovered content, achievements, epochs, character stats, global totals, and profile/save/current-run context. + +`GET /api/v1/compendium` is the agent-friendly profile-progress view. It groups the same durable progress into Compendium-shaped sections: + +- `card_library` +- `relic_collection` +- `potion_lab` +- `bestiary` +- `character_stats` +- `run_history` + +Run history is read from the active profile save root and summarized from saved `.run` files. The response is bounded to the 20 most recent entries and includes path/count metadata so clients can tell when more local history exists. + +The in-game Bestiary is currently marked future/locked. The Compendium `bestiary` section therefore exposes profile fight stats when available, while `GET /api/v1/bestiary` exposes deterministic model metadata. + +## Glossary Endpoints + +Glossary endpoints are active-run scoped: + +- `GET /api/v1/glossary/cards` +- `GET /api/v1/glossary/relics` +- `GET /api/v1/glossary/potions` +- `GET /api/v1/glossary/keywords` + +They expose the current character/run pool plus shared run pools where relevant. Successful responses include profile/save/current-run context, a route-specific `kind`, a `count`, and sorted `items`. + +When no run is active, glossary endpoints return HTTP 409 with `error_code: "run_not_in_progress"` while preserving active profile/save context. When run state exists but cannot be read safely, they return HTTP 503 with `error_code: "run_state_unavailable"`. + +Card glossary entries include upgrade metadata: + +- current cost and star cost +- upgraded flag +- upgradeability +- current/max upgrade levels +- upgraded-preview cost and star cost +- upgraded-preview description + +The same upgrade-preview fields are propagated into state card payloads where cards are visible. + +## Settings And Bestiary + +`GET /api/v1/settings` returns a null-safe settings envelope with display, audio, gameplay, mod, language, and skip-intro fields. + +`GET /api/v1/bestiary` returns deterministic monster and encounter metadata: + +- `monster_count` +- `encounter_count` +- sorted `monsters` +- sorted `encounters` + +Nested Bestiary metadata is sorted where the API can control ordering, including monster moves and likely encounter monster lists. + +## Structured Errors + +Endpoint failures return non-2xx structured JSON instead of HTTP 200 error payloads whenever the failure is a route, validation, action, read, or state conflict. + +Common route/validation error codes include: + +- `method_not_allowed` +- `not_found` +- `internal_error` +- `invalid_json` +- `missing_action` +- `invalid_action_type` +- `invalid_action_payload` +- `missing_profile_id` +- `invalid_profile_id` +- `invalid_profile_id_type` +- `unknown_profile_action` +- `invalid_format` + +State and action conflict error codes include: + +- `not_singleplayer_run` +- `not_multiplayer_run` +- `run_not_in_progress` +- `run_in_progress` +- `active_profile_delete` +- `blocking_popup_active` +- `timeline_manual_action_required` +- `action_error` + +Read endpoint availability/failure codes include: + +- `save_manager_unavailable` +- `settings_data_unavailable` +- `profile_data_unavailable` +- `settings_read_failed` +- `profile_build_failed` +- `profiles_read_failed` +- `compendium_build_failed` +- `bestiary_build_failed` +- `glossary_build_failed` +- `singleplayer_state_read_failed` +- `multiplayer_state_read_failed` + +The MCP bridge preserves structured endpoint error bodies and adds `http_status`, so MCP clients can branch on the same error fields that HTTP clients receive. + +## Action Readiness Fields + +State payloads expose UI/action readiness so agents can avoid clicking hidden, disabled, or stale UI elements. + +Covered surfaces include: + +- card selection +- hand selection +- card rewards +- event options +- rest options +- relic selection +- bundle selection +- shop and fake merchant purchases +- treasure relic claims +- Crystal Sphere choices +- rewards +- map travel +- combat and multiplayer end-turn controls +- potion use and discard controls +- enemy targeting + +Actions that depend on mounted UI controls now check visibility/enabled state before clicking. Proceed actions are similarly gated on visible enabled controls, while preserving the shop inventory close-then-proceed behavior. + +## MCP Coverage + +The MCP server adds wrappers for read-only metadata and profile endpoints: + +- `get_api_index` +- `get_settings` +- `get_profile` +- `get_compendium` +- `get_bestiary` +- `get_glossary_cards` +- `get_glossary_relics` +- `get_glossary_potions` +- `get_glossary_keywords` +- `list_profiles` +- `switch_profile` +- `delete_profile` + +`menu_select` retries through the multiplayer route when a singleplayer menu call is rejected because a multiplayer run is active. Static tests guard route-helper parity so multiplayer tools keep using multiplayer routes and non-multiplayer tools do not accidentally route to multiplayer helpers. + +## Audit Coverage + +`scripts/audit_endpoints.py` provides static and live checks for the endpoint contract. The static mode is suitable for CI and documentation reviews: + +```bash +python3 scripts/audit_endpoints.py --skip-live +``` + +Live mode validates the running mod: + +```bash +python3 scripts/audit_endpoints.py --base-url http://127.0.0.1:15526 +``` + +`scripts/test_mcp_server.py` covers focused MCP bridge behavior: + +```bash +uv run --project mcp python scripts/test_mcp_server.py +``` + +The audit suite checks endpoint/index/doc parity, response envelopes, structured errors, normalized save paths, profile/Compendium schemas, glossary schemas, Bestiary schemas, settings schemas, state format validation, action-readiness fields, and safe validation failures that should not mutate a run.