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.Actions.cs b/McpMod.Actions.cs index 7a537665..8f34f6ee 100644 --- a/McpMod.Actions.cs +++ b/McpMod.Actions.cs @@ -47,16 +47,16 @@ 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)) - 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 { @@ -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") }; } @@ -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() ?? ""; @@ -256,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); @@ -278,7 +282,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 +294,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(); @@ -332,7 +340,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"); @@ -342,6 +352,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(); @@ -434,7 +446,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(); @@ -466,7 +478,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) @@ -502,7 +514,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 +537,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"); @@ -543,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" }; @@ -551,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" }; @@ -563,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" }; @@ -583,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" }; @@ -598,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" }; @@ -618,15 +634,29 @@ 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"); - 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); @@ -638,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); @@ -673,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 @@ -688,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 @@ -704,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 @@ -726,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 @@ -749,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 @@ -763,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 @@ -790,7 +827,12 @@ 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 + && IsNodeVisible(bundle.Hitbox)) + .ToList(); if (index < 0 || index >= bundles.Count) return Error($"Bundle index {index} out of range ({bundles.Count} bundles available)"); @@ -809,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(); @@ -827,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(); @@ -848,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 @@ -872,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(); @@ -895,7 +944,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)"); @@ -917,7 +968,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(); @@ -946,7 +997,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) @@ -982,7 +1033,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(); @@ -1011,7 +1062,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); @@ -1029,7 +1080,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(); @@ -1071,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) @@ -1380,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) @@ -1394,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.Compendium.cs b/McpMod.Compendium.cs new file mode 100644 index 00000000..f42645cb --- /dev/null +++ b/McpMod.Compendium.cs @@ -0,0 +1,728 @@ +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.Platform.Steam; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Saves.Managers; + +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 snapshotTask = RunOnMainThread(BuildCompendiumSnapshot); + var snapshot = snapshotTask.GetAwaiter().GetResult(); + SendReadResultJson(response, BuildCompendiumResponse(snapshot)); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build compendium: {ex.Message}", "compendium_build_failed"); + } + } + + 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 CompendiumSnapshot { Error = "No profile data available.", ErrorCode = "profile_data_unavailable" }; + + var profileId = saveManager.CurrentProfileId; + var progressPath = GetProfileProgressPath(profileId); + var resolvedProgressPath = ResolveProfileProgressPath(profileId); + var profileRoot = GetProfileRootFromProgressPath(progressPath, profileId); + + 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 + { + ProfileId = profileId, + ProgressPath = progressPath, + ResolvedProgressPath = resolvedProgressPath, + ProfileRoot = profileRoot, + SaveScope = GetSaveScope(profileRoot), + IsRunInProgress = RunManager.Instance?.IsInProgress == true, + 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), + 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 Error(snapshot.Error, snapshot.ErrorCode); + + var runHistory = BuildRunHistorySection(snapshot); + + return new Dictionary + { + ["status"] = "ok", + ["kind"] = "compendium", + ["profile_id"] = snapshot.ProfileId, + ["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", + ["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"] = snapshot.DiscoveredCards, + ["stats"] = snapshot.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"] = snapshot.DiscoveredRelics, + ["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"] = snapshot.DiscoveredPotions, + ["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"] = 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 + { + ["ui_label"] = "Character Stats", + ["status"] = "exposed", + ["source"] = "/api/v1/profile characters and global totals", + ["characters"] = snapshot.CharacterStats, + ["global"] = snapshot.GlobalStats + }, + ["run_history"] = runHistory + } + }; + } + + 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; } + 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>(); + foreach (var kv in progress.EncounterStats) + result.Add(BuildFightStatsEntry(kv.Key.Entry, kv.Value)); + return SortDictionaryListByStringField(result, "id"); + } + + 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 SortDictionaryListByStringField(result, "id"); + } + + 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 + .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(); + foreach (var member in GetRunHistoryMembers(progress.GetType())) + { + 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 { } + } + 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; + } + } + + private static Dictionary BuildRunHistorySection(CompendiumSnapshot snapshot) + { + var values = snapshot.RunHistoryProgressMembers; + var historyDirectory = FindRunHistoryDirectory(snapshot); + 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"] = 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, + ["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, int profileId, string saveScope) + { + 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 + }; + + try + { + using var stream = file.OpenRead(); + using var document = JsonDocument.Parse(stream); + 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 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, + string saveScope, + string? progressPath, + string profileRoot) + { + if (!isRunInProgress) + return null; + + var result = new Dictionary + { + ["is_in_progress"] = true, + ["profile_id"] = profileId, + ["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}" + }; + + 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."; + return result; + } + + result["save_path"] = NormalizePathForJson(currentRunPath); + + try + { + using var stream = File.OpenRead(currentRunPath); + using var document = JsonDocument.Parse(stream); + 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) + 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(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, snapshot.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 moddedProfile = $"modded/profile{profileId}"; + if (normalized.Contains($"{moddedProfile}/", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(moddedProfile, StringComparison.OrdinalIgnoreCase)) + return $"modded/profile{profileId}"; + + var profile = $"profile{profileId}"; + if (normalized.Contains($"/{profile}/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith($"{profile}/", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(profile, StringComparison.OrdinalIgnoreCase)) + return $"profile{profileId}"; + } + + return $"profile{profileId}"; + } + + private static string GetSaveScope(string profileRoot) + { + return profileRoot.StartsWith("modded/", StringComparison.OrdinalIgnoreCase) + ? "modded" + : "vanilla"; + } + + private static IEnumerable EnumerateSaveRoots() + { + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + var steamRoots = EnumerateSteamDataRoots().ToList(); + + 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; + } + } + + 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() + { + 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? GetActiveSteamId() + { + 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; + + return steamId; + } + catch + { + return null; + } + } + + private static string? GetProfileProgressPath(int profileId) + { + try { return ProgressSaveManager.GetProgressPathForProfile(profileId); } + catch { return null; } + } + + 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()) + { + var absolutePath = Path.Combine(saveRoot, profileRoot, "saves", "progress.save"); + if (File.Exists(absolutePath)) + return absolutePath; + } + + return progressPath; + } + + private static string? ResolveCurrentRunPath(CompendiumSnapshot snapshot) + => ResolveCurrentRunPath(snapshot.ProgressPath, snapshot.ProfileRoot); + + private static string? ResolveCurrentRunPath(string? progressPath, string profileRoot) + { + var saveDirectory = GetSaveDirectoryFromProgressPath(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"); + if (File.Exists(absolutePath)) + return absolutePath; + } + + 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(); + 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.ForkEndpoints.cs b/McpMod.ForkEndpoints.cs new file mode 100644 index 00000000..67fb8096 --- /dev/null +++ b/McpMod.ForkEndpoints.cs @@ -0,0 +1,653 @@ +using System; +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.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; + +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 + { + var dataTask = RunOnMainThread(BuildSettings); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to read settings: {ex.Message}", "settings_read_failed"); + } + } + + internal static object BuildSettings() + { + var sm = SaveManager.Instance; + if (sm == null) + 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", "settings_data_unavailable"); + + 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 + { + var dataTask = RunOnMainThread(BuildBestiary); + SendJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build bestiary: {ex.Message}", "bestiary_build_failed"); + } + } + + private static void HandleGetGlossaryCards(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("cards", BuildGlossaryCards)); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); + } + } + + private static void HandleGetGlossaryKeywords(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("keywords", BuildGlossaryKeywords)); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); + } + } + + private static void HandleGetGlossaryPotions(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("potions", BuildGlossaryPotions)); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); + } + } + + private static void HandleGetGlossaryRelics(HttpListenerResponse response) + { + try + { + var dataTask = RunOnMainThread(() => BuildGlossaryPayload("relics", BuildGlossaryRelics)); + SendGlossaryJson(response, dataTask.GetAwaiter().GetResult()); + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to build glossary: {ex.Message}", "glossary_build_failed"); + } + } + + 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? ResolvedProgressPath { 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 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 + { + ["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, + ResolvedProgressPath = resolvedProgressPath, + ProfileRoot = profileRoot, + SaveScope = GetSaveScope(profileRoot), + 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; + if (data is Dictionary error + && error.TryGetValue("error_code", out var errorCode)) + { + response.StatusCode = (errorCode as string) switch + { + RunNotInProgressErrorCode => 409, + RunStateUnavailableErrorCode => 503, + _ => 500 + }; + + error["kind"] = payload.Kind; + error["scope"] = "active_run"; + error["profile_id"] = payload.ProfileId; + 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); + return; + } + + 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, + ["profile_id"] = payload.ProfileId, + ["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, + 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) + { + return new Dictionary + { + ["status"] = "error", + ["error"] = message, + ["error_code"] = errorCode + }; + } + + internal static object BuildGlossaryCards() + { + if (RunManager.Instance?.IsInProgress != true) + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); + + var result = new List>(); + var seen = new HashSet(); + + foreach (var pool in ModelDb.AllSharedCardPools) + AddCardsFromPool(pool, SafeGetText(() => pool.Title) ?? pool.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); + } + + return SortDictionaryListByStringField(result, "id"); + } + + 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); + + var upgradedPreview = SafeBuildUpgradedCardPreview(card); + result.Add(new Dictionary + { + ["id"] = id, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = GetCostDisplay(card), + ["star_cost"] = GetStarCostDisplay(card), + ["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, + ["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), + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + } + } + + internal static object BuildGlossaryRelics() + { + if (RunManager.Instance?.IsInProgress != true) + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); + + 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"; + AddRelicsFromPool(pool, poolName, result, seen); + } + + return SortDictionaryListByStringField(result, "id"); + } + + 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 != true) + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); + + 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"; + AddPotionsFromPool(pool, poolName, result, seen); + } + + return SortDictionaryListByStringField(result, "id"); + } + + 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 != true) + return GlossaryError("No run in progress.", RunNotInProgressErrorCode); + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + return GlossaryError("Could not read run state.", RunStateUnavailableErrorCode); + + var keywords = new Dictionary(StringComparer.Ordinal); + + 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) + AddKeywordTips(potion.ExtraHoverTips, keywords); + + foreach (var player in runState.Players) + { + var cardPool = player.Character?.CardPool; + if (cardPool != null) + { + foreach (var card in cardPool.AllCards) + AddKeywordTips(card.HoverTips, keywords); + } + + var relicPool = player.Character?.RelicPool; + if (relicPool != null) + { + foreach (var relic in relicPool.AllRelics) + AddKeywordTips(relic.HoverTipsExcludingRelic, keywords); + } + + var potionPool = player.Character?.PotionPool; + if (potionPool != null) + { + foreach (var potion in potionPool.AllPotions) + AddKeywordTips(potion.ExtraHoverTips, keywords); + } + } + + var result = new List>(); + foreach (var kv in keywords.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + result.Add(new Dictionary + { + ["name"] = kv.Key, + ["description"] = kv.Value + }); + } + + return result; + } + + private static void AddKeywordTips(IEnumerable? tips, Dictionary keywords) + { + if (tips == null) + return; + + foreach (var tip in IHoverTip.RemoveDupes(tips)) + { + try + { + if (tip is not HoverTip ht) + continue; + + var title = SafeGetText(() => ht.Title); + if (!string.IsNullOrEmpty(title)) + keywords.TryAdd(title!, SafeGetText(() => ht.Description) ?? ""); + } + catch + { + // Hover-tip getters can throw during state transitions; skip unstable tips. + } + } + } + + internal static object BuildBestiary() + { + var bindFlags = System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Instance; + + 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 + { + 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; + if (maxHp != null) entry["max_hp"] = maxHp; + } + catch { } + + var moves = new List(); + foreach (var method in type.GetMethods(bindFlags)) + { + if (method.Name.EndsWith("Move") + && method.DeclaringType == type + && method.Name != "PerformMove" + && method.Name != "RollMove" + && method.Name != "SetMoveImmediate") + { + moves.Add(method.Name.Replace("Move", "")); + } + } + if (moves.Count > 0) + entry["moves"] = moves + .Distinct(StringComparer.Ordinal) + .OrderBy(move => move, StringComparer.Ordinal) + .ToList(); + + monsters.Add(entry); + } + + 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 = RuntimeHelpers.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 { } + + try + { + var allMonstersMethod = type.GetProperty("AllPossibleMonsters"); + if (allMonstersMethod != null) + { + foreach (var method in type.GetMethods(bindFlags)) + { + if (method.Name == "GenerateMonsters" && method.DeclaringType == type) + break; + } + } + } + catch { } + + var baseName = type.Name.Replace("Normal", "").Replace("Weak", "").Replace("Elite", "").Replace("Boss", ""); + var matchingMonsters = new List(); + foreach (var monsterEntry in monsters) + { + var monsterClass = monsterEntry["class"] as string ?? ""; + if (baseName.Contains(monsterClass) || monsterClass.Contains(baseName.TrimEnd('s'))) + matchingMonsters.Add(monsterClass); + } + if (matchingMonsters.Count > 0) + entry["likely_monsters"] = matchingMonsters + .Distinct(StringComparer.Ordinal) + .OrderBy(monster => monster, StringComparer.Ordinal) + .ToList(); + + encounters.Add(entry); + } + + 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/McpMod.Formatting.cs b/McpMod.Formatting.cs index 350d591b..0836646a 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 kwList && kwList.Count > 0 - ? $" [{string.Join(", ", kwList)}]" : ""; - 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"]}] {FormatCardDetails(card, includeTarget: true)} {playable}"); } sb.AppendLine(); } @@ -431,6 +428,7 @@ private static void FormatDeckPilesMarkdown(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" : ""; - sb.AppendLine($"- {card["name"]} ({card["cost"]}{starCost}): {card["description"]}"); - } + sb.AppendLine($"- {FormatCardDetails(card, energyLabel: false)}"); } else sb.AppendLine("- *(empty)*"); sb.AppendLine(); } + private static string FormatUpgradeMarker(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) + 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"; @@ -566,11 +601,25 @@ 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")}{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", _ => "Unknown item" }; @@ -716,9 +765,11 @@ private static void FormatRewardsMarkdown(StringBuilder sb, Dictionary> cards) { foreach (var card in cards) - { - string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; - string keywords = card.TryGetValue("keywords", out var kw) && kw is List kwList && kwList.Count > 0 - ? $" [{string.Join(", ", kwList)}]" : ""; - sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} - {card["description"]}"); - } + sb.AppendLine($"- [{card["index"]}] {FormatCardDetails(card)}"); sb.AppendLine(); } @@ -768,7 +814,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(); } @@ -794,10 +840,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(); @@ -892,10 +929,7 @@ private static void FormatBundleSelectMarkdown(StringBuilder sb, Dictionary 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 @@ -89,17 +120,64 @@ 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 + { + ["status"] = "error", + ["error"] = message + }; + if (!string.IsNullOrWhiteSpace(errorCode)) + data["error_code"] = errorCode; + SendJson(response, data); } - private static Dictionary Error(string message) + internal static void SendReadResultJson(HttpListenerResponse response, object result) { - return new Dictionary { ["status"] = "error", ["error"] = message }; + 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, + [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; + } + + private static Dictionary WithStateEnvelope(Dictionary state, string kind) + { + state.TryAdd("status", "ok"); + state.TryAdd("kind", kind); + 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.MultiplayerActions.cs b/McpMod.MultiplayerActions.cs index 7ee02cd0..a21c0d47 100644 --- a/McpMod.MultiplayerActions.cs +++ b/McpMod.MultiplayerActions.cs @@ -17,19 +17,19 @@ 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)) - 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 { @@ -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.MultiplayerState.cs b/McpMod.MultiplayerState.cs index 49439791..b1f5f044 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; @@ -26,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 @@ -37,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) @@ -118,6 +123,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) @@ -242,6 +256,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); @@ -270,8 +285,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(); @@ -285,6 +316,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/McpMod.Profile.cs b/McpMod.Profile.cs index d725495a..e95d63d5 100644 --- a/McpMod.Profile.cs +++ b/McpMod.Profile.cs @@ -20,11 +20,11 @@ private static void HandleGetProfile(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildProfile); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { - SendError(response, 500, $"Failed to build profile: {ex.Message}"); + SendError(response, 500, $"Failed to build profile: {ex.Message}", "profile_build_failed"); } } @@ -33,11 +33,11 @@ private static void HandleGetProfiles(HttpListenerResponse response) try { var dataTask = RunOnMainThread(BuildProfilesSummary); - SendJson(response, dataTask.GetAwaiter().GetResult()); + SendReadResultJson(response, dataTask.GetAwaiter().GetResult()); } catch (Exception ex) { - SendError(response, 500, $"Failed to get profiles: {ex.Message}"); + SendError(response, 500, $"Failed to get profiles: {ex.Message}", "profiles_read_failed"); } } @@ -54,37 +54,72 @@ 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", "invalid_action_type"); return; } string action = actionElem.GetString() ?? ""; - int profileId = parsed.TryGetValue("profile_id", out var idElem) && idElem.ValueKind == JsonValueKind.Number - ? idElem.GetInt32() - : 0; + if (!parsed.TryGetValue("profile_id", out var idElem)) + { + 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", "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", "invalid_profile_id_type"); + return; + } try { var resultTask = RunOnMainThread(() => ExecuteProfileAction(action, profileId)); - SendJson(response, resultTask.GetAwaiter().GetResult()); + SendProfileActionJson(response, resultTask.GetAwaiter().GetResult()); } catch (Exception ex) { - SendError(response, 500, $"Profile action failed: {ex.Message}"); + SendActionError(response, "Profile action failed", ex); + } + } + + 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; 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++) @@ -92,26 +127,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); - profileData["has_data"] = File.Exists(path); - profileData["path"] = path; + 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"] = 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); } return new Dictionary { + ["status"] = "ok", + ["kind"] = "profiles", ["current_profile_id"] = sm.CurrentProfileId, + ["count"] = profiles.Count, ["profiles"] = profiles }; } @@ -120,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) @@ -169,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 @@ -179,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) @@ -212,14 +260,27 @@ private static bool TrySwitchProfileViaOpenScreen(Node root, int profileId) internal static object BuildProfile() { - var progress = SaveManager.Instance?.Progress; - if (progress == null) - return new Dictionary { ["error"] = "No profile data available." }; + var saveManager = SaveManager.Instance; + var progress = saveManager?.Progress; + if (saveManager == null || progress == null) + return Error("No profile data available.", "profile_data_unavailable"); var result = new Dictionary(); + var profileId = saveManager.CurrentProfileId; + 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"] = NormalizePathForJson(progressPath); + result["resolved_progress_path"] = NormalizePathForJson(resolvedProgressPath); + result["profile_root"] = NormalizePathForJson(profileRoot); + result["save_scope"] = GetSaveScope(profileRoot); + 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 @@ -238,7 +299,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 @@ -253,7 +314,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 { @@ -272,13 +333,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 { @@ -297,13 +358,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 { @@ -323,16 +384,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) @@ -343,14 +404,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/McpMod.StateBuilder.cs b/McpMod.StateBuilder.cs index 6673de3b..8df1575a 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; @@ -60,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) @@ -600,6 +605,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); @@ -968,7 +974,17 @@ private static void AddMultiplayerLoadLobbyMenuState( // the StartRunLobby semantic of 'every joined player is ready') and "is_about_to_begin". // Without these fields, FormatLobbyMarkdown printed "All ready: false" unconditionally for load lobbies. bool aboutToBegin = false; - try { aboutToBegin = lobby.IsAboutToBeginGame(); } catch { } + try + { + var aboutToBeginMethod = lobby.GetType().GetMethod( + "IsAboutToBeginGame", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + if (aboutToBeginMethod?.Invoke(lobby, null) is bool value) + aboutToBegin = value; + } + catch { } info["all_ready"] = aboutToBegin; info["is_about_to_begin"] = aboutToBegin; @@ -1056,6 +1072,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>(); @@ -1072,6 +1092,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(); @@ -1102,7 +1140,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; @@ -1163,6 +1201,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); @@ -1175,6 +1218,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) }); @@ -1188,14 +1232,24 @@ 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, ["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, + ["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(), + ["usage"] = potion.Usage.ToString(), ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) }); } @@ -1207,6 +1261,83 @@ 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 && !inCombat) + return "EnemyTargetRequiresCombat"; + + 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; + return BuildEnemyTargetRefs(combatState); + } + + private static List> BuildEnemyTargetRefs(CombatState? combatState) + { + var targets = new List>(); + 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(); @@ -1223,6 +1354,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, @@ -1233,40 +1365,140 @@ 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) }; } - 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 + ? 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["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; } 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; + } + + 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 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"); + + cardInfo["is_selected"] = isSelected ?? false; + 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) { var monster = creature.Monster; @@ -1286,6 +1518,10 @@ private static string GetCostDisplay(CardModel card) ["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) }; @@ -1327,7 +1563,7 @@ private static string GetCostDisplay(CardModel card) { 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); @@ -1354,7 +1590,9 @@ private static string GetCostDisplay(CardModel card) 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) { @@ -1365,13 +1603,19 @@ private static string GetCostDisplay(CardModel card) ["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 }; 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); @@ -1432,11 +1676,19 @@ private static string GetCostDisplay(CardModel card) // 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 && IsControlVisibleOrActionable(backButton); + shopState["inventory_open"] = inventoryOpen; + shopState["can_close_inventory"] = canCloseInventory; + shopState["can_proceed"] = IsControlVisibleOrActionable(proceedButton) || canCloseInventory; } else { + shopState["inventory_open"] = false; + shopState["can_close_inventory"] = false; shopState["can_proceed"] = false; } @@ -1467,13 +1719,15 @@ private static string GetCostDisplay(CardModel card) ["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) { 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); @@ -1488,24 +1742,33 @@ private static string GetCostDisplay(CardModel card) { 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++; } state["options"] = options; var proceedButton = NRestSiteRoom.Instance?.ProceedButton; - state["can_proceed"] = proceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton); return state; } @@ -1518,7 +1781,7 @@ private static string GetCostDisplay(CardModel card) 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; @@ -1537,6 +1800,7 @@ private static string GetCostDisplay(CardModel card) ["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) @@ -1549,6 +1813,14 @@ 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["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); @@ -1564,13 +1836,15 @@ private static string GetCostDisplay(CardModel card) ["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) { 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); @@ -1586,13 +1860,17 @@ private static string GetCostDisplay(CardModel card) ["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) { 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); @@ -1608,14 +1886,22 @@ private static string GetCostDisplay(CardModel card) ["category"] = "card_removal", ["price"] = removal.Cost, ["is_stocked"] = removal.IsStocked, - ["can_afford"] = removal.EnoughGold + ["can_afford"] = removal.EnoughGold, + ["can_purchase"] = removal.IsStocked && removal.EnoughGold }); } 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 && IsControlVisibleOrActionable(backButton); + state["inventory_open"] = inventoryOpen; + state["can_close_inventory"] = canCloseInventory; + + var proceedButton = merchantRoomNode?.ProceedButton; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton) || canCloseInventory; return state; } @@ -1656,7 +1942,7 @@ private static string GetCostDisplay(CardModel card) 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(); @@ -1669,7 +1955,9 @@ private static string GetCostDisplay(CardModel card) ["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 @@ -1734,19 +2022,22 @@ private static string GetCostDisplay(CardModel card) 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 @@ -1757,6 +2048,18 @@ 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); + } + 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); @@ -1766,16 +2069,45 @@ private static string GetCostDisplay(CardModel card) // Proceed button var proceedButton = FindFirst(rewardsScreen); - state["can_proceed"] = proceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(proceedButton); 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(); - 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) @@ -1785,12 +2117,15 @@ private static string GetCostDisplay(CardModel card) 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; @@ -1823,6 +2158,7 @@ private static string GetCostDisplay(CardModel card) // 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) { @@ -1831,10 +2167,17 @@ private static string GetCostDisplay(CardModel card) 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 @@ -1846,6 +2189,24 @@ private static string GetCostDisplay(CardModel card) || (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. @@ -1858,14 +2219,14 @@ private static string GetCostDisplay(CardModel card) { 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; @@ -1877,20 +2238,20 @@ private static string GetCostDisplay(CardModel card) { 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; @@ -1914,13 +2275,14 @@ private static string GetCostDisplay(CardModel card) var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; + AddCardHolderState(cardInfo, holder); cards.Add(cardInfo); index++; } 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"]; @@ -1937,7 +2299,8 @@ private static string GetCostDisplay(CardModel card) 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; @@ -1953,7 +2316,9 @@ private static string GetCostDisplay(CardModel card) { ["index"] = index, ["card_count"] = cards.Count, - ["cards"] = cards + ["cards"] = cards, + ["is_visible"] = true, + ["can_select"] = bundle.Hitbox.IsEnabled && IsNodeVisible(bundle.Hitbox) }); index++; } @@ -1968,7 +2333,8 @@ private static string GetCostDisplay(CardModel card) 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; @@ -1983,8 +2349,8 @@ private static string GetCostDisplay(CardModel card) 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; } @@ -2023,6 +2389,7 @@ private static string GetCostDisplay(CardModel card) var cardInfo = BuildCardInfo(card); cardInfo["index"] = index; cardInfo["description"] = SafeGetCardDescription(card); // hand cards use default pile + AddCardHolderState(cardInfo, holder); selectableCards.Add(cardInfo); index++; } @@ -2039,11 +2406,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) @@ -2052,7 +2418,7 @@ private static string GetCostDisplay(CardModel card) // Confirm button state var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); - state["can_confirm"] = confirmBtn?.IsEnabled ?? false; + state["can_confirm"] = IsControlVisibleOrActionable(confirmBtn); return state; } @@ -2063,7 +2429,9 @@ private static string GetCostDisplay(CardModel card) 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) @@ -2078,6 +2446,8 @@ private static string GetCostDisplay(CardModel card) ["name"] = SafeGetText(() => relic.Title), ["description"] = SafeGetText(() => relic.DynamicDescription), ["rarity"] = relic.Rarity.ToString(), + ["is_visible"] = true, + ["can_select"] = holder.IsEnabled, ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) }); index++; @@ -2085,7 +2455,7 @@ private static string GetCostDisplay(CardModel card) 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; } @@ -2118,12 +2488,13 @@ private static string GetCostDisplay(CardModel card) 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 }; @@ -2135,7 +2506,7 @@ private static string GetCostDisplay(CardModel card) } cellStates.Add(cellState); - if (cell.Entity.IsHidden && cell.Visible) + if (cell.Entity.IsHidden && cellVisible) { clickableCells.Add(new Dictionary { @@ -2165,16 +2536,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.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; 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) @@ -2185,7 +2556,7 @@ private static string GetCostDisplay(CardModel card) } var proceedButton = screen.GetNodeOrNull("%ProceedButton"); - state["can_proceed"] = proceedButton?.IsEnabled == true; + state["can_proceed"] = proceedButton?.IsEnabled == true && proceedButton.Visible && proceedButton.IsVisibleInTree(); return state; } @@ -2217,7 +2588,7 @@ private static string GetCostDisplay(CardModel card) 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>(); @@ -2233,6 +2604,8 @@ private static string GetCostDisplay(CardModel card) ["name"] = SafeGetText(() => relic.Title), ["description"] = SafeGetText(() => relic.DynamicDescription), ["rarity"] = relic.Rarity.ToString(), + ["is_visible"] = true, + ["can_claim"] = holder.IsEnabled, ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) }); index++; @@ -2240,7 +2613,7 @@ private static string GetCostDisplay(CardModel card) state["relics"] = relics; } - state["can_proceed"] = treasureUI.ProceedButton?.IsEnabled ?? false; + state["can_proceed"] = IsControlVisibleOrActionable(treasureUI.ProceedButton); return state; } diff --git a/McpMod.cs b/McpMod.cs index b5b9f403..aa2c13f2 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; @@ -25,6 +27,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() { @@ -90,11 +93,7 @@ public static void Initialize() tree.Connect(SceneTree.SignalName.ProcessFrame, Callable.From(ProcessMainThreadQueue)); int port = LoadPort(); - - _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) { @@ -103,7 +102,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) { @@ -124,6 +123,85 @@ private static void TryApplyHarmonyPatches() } } + 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; @@ -193,16 +271,40 @@ private static void HandleRequest(HttpListenerContext context) if (path == "/") { - SendJson(response, new { message = $"Hello from STS2 MCP v{Version}", status = "ok" }); + if (request.HttpMethod == "GET") + { + var endpoints = BuildEndpointIndex(); + SendJson(response, new + { + message = $"Hello from STS2 MCP v{Version}", + status = "ok", + kind = "api_index", + version = Version, + bound_prefixes = _boundPrefixes, + endpoint_count = endpoints.Count, + endpoints + }); + } + else + { + SendMethodNotAllowed(response); + } } 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()) { 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; } @@ -211,15 +313,22 @@ 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") { + if (request.HttpMethod != "GET" && request.HttpMethod != "POST") + { + SendMethodNotAllowed(response); + return; + } + // Guard: reject multiplayer endpoint during singleplayer runs 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; } @@ -228,7 +337,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 + SendMethodNotAllowed(response); } else if (path == "/api/v1/profiles") { @@ -237,30 +353,93 @@ 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 + SendMethodNotAllowed(response); + } + else if (path == "/api/v1/bestiary") + { + if (request.HttpMethod == "GET") + HandleGetBestiary(response); + else + SendMethodNotAllowed(response); + } + else if (path == "/api/v1/glossary/cards") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryCards(response); + else + SendMethodNotAllowed(response); + } + else if (path == "/api/v1/glossary/relics") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryRelics(response); + else + SendMethodNotAllowed(response); + } + else if (path == "/api/v1/glossary/potions") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryPotions(response); + else + SendMethodNotAllowed(response); + } + else if (path == "/api/v1/glossary/keywords") + { + if (request.HttpMethod == "GET") + HandleGetGlossaryKeywords(response); + else + 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 */ } } } + private static List> BuildEndpointIndex() + { + return new List> + { + 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 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 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 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" } + }; + } + // 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. @@ -274,9 +453,17 @@ 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"; + if (!TryValidateStateFormat(response, format)) + return; try { @@ -298,13 +485,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}", "multiplayer_state_read_failed"); } catch { /* response may be unusable */ } } @@ -323,13 +504,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", "invalid_action_type"); return; } @@ -347,11 +533,11 @@ 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) { - SendError(response, 500, $"Menu action failed: {ex.Message}"); + SendActionError(response, "Menu action failed", ex); } return; } @@ -360,17 +546,19 @@ 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) { - SendError(response, 500, $"Multiplayer action failed: {ex.Message}"); + SendActionError(response, "Multiplayer action failed", ex); } } private static void HandleGetState(HttpListenerRequest request, HttpListenerResponse response) { string format = request.QueryString["format"] ?? "json"; + if (!TryValidateStateFormat(response, format)) + return; try { @@ -399,18 +587,21 @@ 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}", "singleplayer_state_read_failed"); } catch { /* response may be unusable */ } } } + 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; @@ -424,13 +615,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", "invalid_action_type"); return; } @@ -445,11 +641,11 @@ 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) { - SendError(response, 500, $"Menu action failed: {ex.Message}"); + SendActionError(response, "Menu action failed", ex); } return; } @@ -458,11 +654,43 @@ 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) { - 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; + var errorCode = statusCode == 400 ? "invalid_action_payload" : "action_failed"; + SendError(response, statusCode, $"{prefix}: {ex.Message}", errorCode); + } + + 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 + { + "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 + }; + } + else + { + response.StatusCode = 400; + } } + SendJson(response, result); } } diff --git a/README.md b/README.md index 596839ee..8249bc50 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, plus full menu and lobby control: profile switching, character select (SP and MP host/client) with optional seed, multiplayer host / Steam-friend join / FastMP localhost join, multiplayer load lobby for resuming saved co-op runs, game-over dismissal, FTUE/tutorial popup handling, and Timeline visibility. Tested against STS2 `v0.103.2`. > [!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. > [!note] > The release DLL is a platform-agnostic .NET assembly — the same `STS2_MCP.dll` and `STS2_MCP.json` work on Windows, Linux, and macOS. No separate builds are needed. @@ -55,11 +55,51 @@ 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", + "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": "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" } + ] +} ``` 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 +``` + +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 +``` + +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: @@ -159,7 +199,7 @@ cp out/STS2_MCP/STS2_MCP.dll "$MODS_DIR/" cp mod_manifest.json "$MODS_DIR/STS2_MCP.json" ``` -> [!NOTE] +> [!NOTE] > `mod_manifest.json` is renamed to `STS2_MCP.json` on copy — the game's mod loader expects the manifest filename to match the mod ID. ## License 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. diff --git a/docs/raw-full.md b/docs/raw-full.md index b01ab1f6..b168b28d 100644 --- a/docs/raw-full.md +++ b/docs/raw-full.md @@ -7,11 +7,29 @@ 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 -The endpoints are mutually exclusive: calling singleplayer during a multiplayer run (or vice versa) returns HTTP 409. +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`. +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. + +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. --- @@ -23,18 +41,35 @@ The endpoints are mutually exclusive: calling singleplayer during a multiplayer |-----------|--------------------|---------|-----------------| | `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: +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) "floor": 3, // Total floors visited "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) // ... state-specific fields } @@ -80,11 +115,14 @@ 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", "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 */ ] } @@ -94,9 +132,17 @@ 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, + "can_use": true, // true when use_potion can run now, assuming any required target is supplied + "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, + "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 */ ] } ], @@ -116,9 +162,20 @@ 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", "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, + "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 */ ] } ``` @@ -127,10 +184,23 @@ 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, + "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 */ ] } ``` @@ -184,12 +254,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: @@ -339,6 +410,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 @@ -347,6 +421,7 @@ Run state or room type not recognized. "hp": 44, "max_hp": 44, "block": 0, + "is_alive": true, "status": [ /* Power Objects */ ], "intents": [ { @@ -355,7 +430,10 @@ Run state or room type not recognized. "title": "Attack", // Hover tip title "description": "Deals 11 damage." // Hover tip description } - ] + ], + "is_visible": true, + "can_target": true, + "can_select": true } ] }, @@ -388,7 +466,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 }, @@ -411,6 +499,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 }, { @@ -418,10 +508,25 @@ 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, + "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 @@ -453,10 +558,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": { ... } @@ -482,6 +590,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" } @@ -519,10 +629,16 @@ 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 "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 */ ] } ] @@ -544,7 +660,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 @@ -556,7 +674,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 { @@ -570,6 +688,7 @@ Shop inventory is auto-opened when state is queried. "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", @@ -578,6 +697,14 @@ 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, + "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 @@ -587,9 +714,11 @@ Shop inventory is auto-opened when state is queried. "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.", + "relic_rarity": "Common", "keywords": [ /* Keyword Objects */ ] }, // Potion entry @@ -599,9 +728,13 @@ Shop inventory is auto-opened when state is queried. "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.", + "potion_rarity": "Common", + "potion_target_type": "Enemy", + "potion_usage": "Manual", "keywords": [ /* Keyword Objects */ ] }, // Card removal entry @@ -610,9 +743,12 @@ Shop inventory is auto-opened when state is queried. "category": "card_removal", "price": 75, "is_stocked": true, - "can_afford": true + "can_afford": true, + "can_purchase": true } ], + "inventory_open": true, + "can_close_inventory": true, "can_proceed": true, "error": "..." // Only present if inventory isn't ready; retry in a moment }, @@ -640,12 +776,16 @@ 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.", + "relic_rarity": "Common", "keywords": [ /* Keyword Objects */ ] } ], + "inventory_open": true, + "can_close_inventory": true, "can_proceed": true }, // After fight: @@ -677,6 +817,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 */ ] } ], @@ -708,10 +850,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 @@ -779,6 +929,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 */ ] } ], @@ -872,9 +1024,135 @@ 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 as a structured object with `status: "ok"`, `kind: "settings"`, 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. +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 (`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. +- `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 +{ + "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", + "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", + "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"] }, + "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", + "history_path": "C:/.../SlayTheSpire2/steam//modded/profile1/saves/history", + "entry_count": 10, + "entries": [ + { + "id": "1774869148", + "run_id": "modded:profile1:1774869148", + "players": [{ "id": 1, "character": "CHARACTER.IRONCLAD" }], + "ascension": 0, + "win": false, + "run_time": 6541 + } + ] + } + } +} +``` + +### `GET /api/v1/bestiary` + +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/*` + +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. +- `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 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: + +```json +{ + "status": "ok", + "kind": "cards", + "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", + "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" + }, + "items": [ + { + "id": "STRIKE_RED", + "name": "Strike", + "type": "Attack", + "cost": "1", + "star_cost": null, + "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": [] + } + ] +} +``` ### `GET /api/v1/profiles` @@ -882,11 +1160,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" + } ] } ``` @@ -911,12 +1219,15 @@ 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"`. --- ## 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. 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 @@ -927,7 +1238,11 @@ All POST requests use a JSON body with an `"action"` field and action-specific p ### 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" +} ``` --- @@ -950,7 +1265,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. --- @@ -965,7 +1280,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, or the target combat_id as a string | **Errors:** Not in combat, not play phase, card unplayable, invalid index, missing target. @@ -982,9 +1297,9 @@ 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. +**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` @@ -998,7 +1313,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` @@ -1008,7 +1323,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` @@ -1020,11 +1335,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" } @@ -1070,7 +1385,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 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" } @@ -1150,7 +1465,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. @@ -1164,7 +1479,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` @@ -1175,9 +1490,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` @@ -1189,11 +1504,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" } @@ -1201,7 +1516,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" } @@ -1301,7 +1616,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). }, @@ -1398,12 +1718,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 1ff7b9ba..bbf386ae 100644 --- a/docs/raw-simplified.md +++ b/docs/raw-simplified.md @@ -6,11 +6,29 @@ 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 -Singleplayer and multiplayer endpoints are mutually exclusive (HTTP 409 if mismatched). +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`. +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. + +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 @@ -18,12 +36,18 @@ 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: +- `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`) -- `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`) +- `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`. | `state_type` | Screen | Available Actions | |---|---|---| @@ -36,8 +60,8 @@ Every JSON response includes: | `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` | @@ -50,27 +74,55 @@ 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` 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 | 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 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 `GET /api/v1/profile` returns persistent progress for the active profile, including character stats, discoveries, achievements, epochs, and global run totals. -`GET /api/v1/profiles` returns the three profile slots: +`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`. + +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` 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 | +|---|---| +| `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 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 `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 { + "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" } ] } ``` @@ -82,27 +134,29 @@ All POST requests use JSON body with `"action"` field. All responses include `{ | `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 | |---|---|---| -| `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. | -| `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. | +| `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`. | ### In-Combat Hand Selection (`hand_select`) | 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`) | 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`) @@ -116,58 +170,58 @@ All POST requests use JSON body with `"action"` field. All responses include `{ | 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`) | 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`) | Action | Parameters | When to Use | |---|---|---| -| `choose_rest_option` | `index`: int | Choose rest, smith, or other option. | -| `proceed` | _(none)_ | Leave the rest site. | +| `choose_rest_option` | `index`: int | Choose a visible enabled rest, smith, or other option. | +| `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 relic from the opened chest. | -| `proceed` | _(none)_ | Leave the treasure room. | +| `claim_treasure_relic` | `index`: int | Claim a visible enabled relic from the opened chest. | +| `proceed` | _(none)_ | Leave the treasure room when the visible proceed button is enabled. | ### Card Selection Overlay (`card_select`) | Action | Parameters | When to Use | |---|---|---| -| `select_card` | `index`: int | Grid screens: toggle card selection. 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 bundle preview. | -| `confirm_bundle_selection` | _(none)_ | Confirm the previewed bundle. | -| `cancel_bundle_selection` | _(none)_ | Cancel the bundle preview. | +| `select_bundle` | `index`: int | Open a visible enabled 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`) | 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`) @@ -183,5 +237,5 @@ All POST requests use JSON body with `"action"` field. All responses include `{ | 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/README.md b/mcp/README.md index d36f9048..c62fac3d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -4,9 +4,17 @@ | 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 | -| `get_profile()` | Profiles | Get active profile progress | +| `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 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 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 | @@ -38,6 +46,28 @@ | `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`, `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. + +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. + +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`. + +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: 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` 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 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. @@ -50,7 +80,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 5716310a..dece0f67 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -7,6 +7,7 @@ import argparse import asyncio import json +import os import sys import httpx @@ -17,12 +18,19 @@ _base_url: str = "http://localhost:15526" _trust_env: bool = True _http: httpx.AsyncClient | None = None +DEFAULT_HOST = os.environ.get("STS2_HOST", "localhost") +DEFAULT_PORT = int(os.environ.get("STS2_PORT", "15526")) +_base_url = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}" 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" @@ -31,6 +39,22 @@ def _profile_url() -> str: return f"{_base_url}/api/v1/profile" +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" @@ -48,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() @@ -72,6 +102,30 @@ 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 _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() @@ -126,15 +180,66 @@ 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() + 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 # --------------------------------------------------------------------------- +@mcp.tool() +async def get_api_index() -> str: + """Get the STS2_MCP HTTP server status and endpoint index. + + 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() + 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. @@ -145,6 +250,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}) @@ -181,7 +287,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) + return await _menu_select_post(body) except Exception as e: return _handle_error(e) @@ -191,7 +297,9 @@ 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 status/kind, profile_id, progress_path, + resolved_progress_path, profile_root, save_scope, and current_run when a run + is active. """ try: return await _profile_get() @@ -199,11 +307,122 @@ 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. The top level includes the + 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() + except Exception as e: + return _handle_error(e) + + +@mcp.tool() +async def get_settings() -> str: + """Get current game settings and preferences. + + 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() + 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 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() + 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. + 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") + 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. + 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") + 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. + 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") + 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. 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("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. - 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() @@ -216,7 +435,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. @@ -254,7 +474,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. @@ -270,10 +491,12 @@ 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). - 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: @@ -289,7 +512,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). @@ -304,7 +527,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: @@ -324,7 +547,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 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, 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. @@ -340,7 +564,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: @@ -392,6 +616,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. @@ -436,6 +661,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. """ @@ -454,6 +681,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. """ @@ -495,6 +724,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. @@ -529,7 +759,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). @@ -577,6 +808,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. """ @@ -614,6 +847,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). @@ -626,7 +860,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: @@ -644,6 +878,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). @@ -711,6 +946,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}) @@ -727,7 +963,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 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, 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: @@ -743,7 +980,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"}) @@ -756,6 +994,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: @@ -768,9 +1007,12 @@ 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. + 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: @@ -785,6 +1027,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). """ @@ -800,6 +1044,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. @@ -816,6 +1061,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. @@ -839,6 +1085,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: @@ -869,6 +1117,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. """ @@ -904,7 +1154,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"}) @@ -916,6 +1166,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. """ @@ -947,6 +1200,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. """ @@ -1000,6 +1255,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. """ @@ -1011,7 +1268,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: @@ -1024,6 +1281,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. @@ -1072,8 +1330,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() diff --git a/scripts/audit_endpoints.py b/scripts/audit_endpoints.py new file mode 100644 index 00000000..122cf2d1 --- /dev/null +++ b/scripts/audit_endpoints.py @@ -0,0 +1,2198 @@ +#!/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 ast +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, 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) + 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 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 + 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 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 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 + + 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}") + + current_run = data.get("current_run") + 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 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 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 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") + mcp_mod = (repo / "McpMod.cs").read_text(encoding="utf-8") + 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", + "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}") + + 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") + + +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}") + + 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") + + +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}") + + 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") + + +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_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)}") + 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", + 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", + "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") + 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}") + 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", + "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", + "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", "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"]: + 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"]: + 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", "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") + 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") + 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") + 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") + 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"): + 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}") + 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}") + 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") + 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, + 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") + required_shared_pools = { + "BuildGlossaryCards": "ModelDb.AllSharedCardPools", + "BuildGlossaryRelics": "ModelDb.RelicPool", + "BuildGlossaryPotions": "ModelDb.PotionPool", + "BuildGlossaryKeywords": "ModelDb.AllSharedCardPools", + } + 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}") + 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\}})", + 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") + 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, + 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\(", + 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", "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: + 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}") + 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", "TryAdd", "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") + + +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") + 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 = [ + "GetCostDisplay", + "star_cost", + "GetStarCostDisplay", + "is_upgraded", + "is_upgradable", + "current_upgrade_level", + "max_upgrade_level", + "upgrade_preview_type", + "upgrade_preview_cost", + "upgrade_preview_star_cost", + "upgrade_preview_description", + "SafeGetCardUpgradePreviewDescription", + ] + missing = [field for field in required if field not in body] + if missing: + fail(f"card glossary missing upgrade metadata: {missing}") + + 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}") + + 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_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}") + + 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}") + + 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 = [ + "inventory_open", + "can_close_inventory", + "can_purchase", + "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}") + 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") + + +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") + 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\(\)", + 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") + + 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 ["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 \}", + 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 ["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}") + + 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 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"]: + 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") + + +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"), + (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)) + 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}") + + 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", "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) + 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}") + + 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") + 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\(", + 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}") + + 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}") + 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}") + + 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, + 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}") + 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}") + + 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}") + + 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}") + 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") + + 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, + 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}") + 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}") + + 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") + + 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") + 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", + "use_blocked_reason", + "can_discard", + "discard_blocked_reason", + "requires_target", + "valid_targets", + "GetPotionUseBlockedReason", + "EnemyTargetRequiresCombat", + ]: + 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\(", + 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}") + + 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}") + + 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}") + 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}") + + 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}") + + 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") + + +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") + 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 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"))) + 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)}") + 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] = {} + 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 == "/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}") + 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}") + 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__}") + 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}") + 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", + "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}") + 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 = { + "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 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": + 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") + 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")) + 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") + 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}") + 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}") + 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}") + 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", "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}") + 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}") + 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: + if not isinstance(item, dict): + 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) + + 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: + 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): + 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"]): + 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 ["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(): + 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}") + assert_current_run_context(path, data.get("current_run")) + 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: + 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}") + 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"]: + 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: + 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, "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, 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", + "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", + 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", "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}") + 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}") + 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) + 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 = [ + "/", + "/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}") + 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: + 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") + print("live: safe POST validation checks passed") + print("live: unsupported method 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) + audit_action_surface(repo) + audit_mcp_tool_docs(repo) + 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) + if not args.skip_live: + audit_live(args.base_url) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_mcp_server.py b/scripts/test_mcp_server.py new file mode 100644 index 00000000..d067b6b3 --- /dev/null +++ b/scripts/test_mcp_server.py @@ -0,0 +1,279 @@ +#!/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_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]] = [] + + 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"] + + +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) + 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)) + 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") + + +if __name__ == "__main__": + main()