From 32b833dd72bb2aadee587349ca7d3f51ee709ab9 Mon Sep 17 00:00:00 2001 From: tianyufan Date: Thu, 26 Mar 2026 11:39:50 +0800 Subject: [PATCH] feat: bundle sts2 bridge and tests --- slay_the_spire_ii/README.md | 26 +- slay_the_spire_ii/agent-harness/STS2.md | 2 +- .../install/bridge_plugin/STS2_Bridge.dll | Bin 0 -> 125952 bytes .../install/bridge_plugin/STS2_Bridge.json | 10 + .../bridge/install/install_bridge.sh | 24 + .../bridge/plugin/BridgeMod.Actions.cs | 1039 ++++++++++++ .../bridge/plugin/BridgeMod.Formatting.cs | 868 ++++++++++ .../bridge/plugin/BridgeMod.Helpers.cs | 190 +++ .../plugin/BridgeMod.MultiplayerActions.cs | 120 ++ .../plugin/BridgeMod.MultiplayerState.cs | 475 ++++++ .../bridge/plugin/BridgeMod.StateBuilder.cs | 1446 +++++++++++++++++ .../agent-harness/bridge/plugin/BridgeMod.cs | 319 ++++ .../agent-harness/bridge/plugin/README.md | 72 + .../bridge/plugin/STS2_Bridge.csproj | 32 + .../bridge/plugin/bridge_manifest.json | 10 + .../agent-harness/bridge/plugin/build.sh | 95 ++ .../bridge/plugin/docs/raw_api.md | 318 ++++ .../cli_anything/slay_the_spire_ii/README.md | 27 +- .../slay_the_spire_ii/skills/SKILL.md | 6 +- .../slay_the_spire_ii_cli.py | 552 ++++--- .../slay_the_spire_ii/tests/TEST.md | 65 + .../slay_the_spire_ii/tests/__init__.py | 1 + .../slay_the_spire_ii/tests/test_core.py | 142 ++ .../slay_the_spire_ii/tests/test_full_e2e.py | 153 ++ .../slay_the_spire_ii/utils/sts2_backend.py | 4 +- slay_the_spire_ii/agent-harness/setup.py | 4 +- 26 files changed, 5766 insertions(+), 234 deletions(-) create mode 100644 slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll create mode 100644 slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json create mode 100755 slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/README.md create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json create mode 100755 slay_the_spire_ii/agent-harness/bridge/plugin/build.sh create mode 100644 slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md create mode 100644 slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md create mode 100644 slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py create mode 100644 slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py create mode 100644 slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py diff --git a/slay_the_spire_ii/README.md b/slay_the_spire_ii/README.md index 9f66b3091..b7cd36fa9 100644 --- a/slay_the_spire_ii/README.md +++ b/slay_the_spire_ii/README.md @@ -18,9 +18,9 @@ This project controls the real running game. It is not a headless simulator and - `agent-harness/` - The CLI harness package (`cli_anything/slay_the_spire_ii/`), installable via `pip install -e .`. -- `bridge/plugin/` +- `agent-harness/bridge/plugin/` - `.NET 9` source for the bridge mod. **This mod is required** — the CLI cannot function without it. -- `bridge/install/` +- `agent-harness/bridge/install/` - Install bundle and scripts for the bridge mod. **Important:** Unlike other CLI-Anything harnesses that wrap standalone applications, this harness requires a custom bridge mod to be built and installed into the game. The bridge mod exposes the game's internal state via HTTP, which the CLI then consumes. @@ -37,10 +37,10 @@ The bridge build and install scripts currently auto-detect the default macOS Ste ### 1. Install the CLI -From the `agent-harness/` directory: +From the repository root: ```bash -cd agent-harness +cd slay_the_spire_ii/agent-harness pip install -e . ``` @@ -49,14 +49,14 @@ This installs the `cli-anything-sts2` command. ### 2. Build the bridge mod ```bash -cd bridge/plugin +cd slay_the_spire_ii/agent-harness/bridge/plugin ./build.sh ``` The script tries to auto-detect the game data directory and refreshes the local install bundle at: ```text -bridge/install/bridge_plugin/ +slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/ ``` If auto-detection fails, set `STS2_GAME_DATA_DIR` or pass the directory directly: @@ -74,7 +74,7 @@ The target directory must contain at least: ### 3. Install the bridge mod into the game ```bash -cd bridge/install +cd slay_the_spire_ii/agent-harness/bridge/install ./install_bridge.sh ``` @@ -113,7 +113,7 @@ If `cli-anything-sts2 state` returns JSON, the CLI and the bridge are connected 1. Build and install `STS2_Bridge` 2. Launch the real game and enable the mod -3. Run `pip install -e .` from the `agent-harness/` directory +3. Run `pip install -e .` from `slay_the_spire_ii/agent-harness` 4. Run `cli-anything-sts2 state` ### Start from the main menu @@ -145,7 +145,7 @@ cli-anything-sts2 claim-reward 0 cli-anything-sts2 pick-card-reward 0 cli-anything-sts2 rest 0 cli-anything-sts2 event 0 -cli-anything-sts2 repl +cli-anything-sts2 ``` Common command groups: @@ -180,7 +180,7 @@ cli-anything-sts2 --base-url http://127.0.0.1:15526 --timeout 20 state ### Bridge build - `STS2_GAME_DATA_DIR` - - Use this when `bridge/plugin/build.sh` cannot auto-detect the game data directory + - Use this when `slay_the_spire_ii/agent-harness/bridge/plugin/build.sh` cannot auto-detect the game data directory ## Troubleshooting @@ -192,18 +192,18 @@ This usually means one of the following is still missing: - `STS2_Bridge` is not installed or not enabled - The local API on `localhost:15526` is not up yet -### `bridge/plugin/build.sh` cannot find the game directory +### `slay_the_spire_ii/agent-harness/bridge/plugin/build.sh` cannot find the game directory Confirm that the game is installed, then pass `STS2_GAME_DATA_DIR` explicitly: ```bash -STS2_GAME_DATA_DIR="/path/to/data_sts2_macos_arm64" ./bridge/plugin/build.sh +STS2_GAME_DATA_DIR="/path/to/data_sts2_macos_arm64" ./slay_the_spire_ii/agent-harness/bridge/plugin/build.sh ``` ## Related Docs - [agent-harness/STS2.md](agent-harness/STS2.md) -- [bridge/plugin/README.md](bridge/plugin/README.md) +- [agent-harness/bridge/plugin/README.md](agent-harness/bridge/plugin/README.md) ## Credits diff --git a/slay_the_spire_ii/agent-harness/STS2.md b/slay_the_spire_ii/agent-harness/STS2.md index f58cbf1b3..f104131b0 100644 --- a/slay_the_spire_ii/agent-harness/STS2.md +++ b/slay_the_spire_ii/agent-harness/STS2.md @@ -45,7 +45,7 @@ sends action commands back through the same endpoint. ### Decision States -The bridge normalizes all game screens into one of 14 decision types: +The bridge normalizes all game screens into one of 15 decision types: `menu` · `combat_play` · `hand_select` · `map_select` · `game_over` · `combat_rewards` · `card_reward` · `event_choice` · `rest_site` · `shop` · diff --git a/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll new file mode 100644 index 0000000000000000000000000000000000000000..7f629ac1de7d76be98c3e72fcdf1baff2af137a7 GIT binary patch literal 125952 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 literal 0 HcmV?d00001 diff --git a/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json new file mode 100644 index 000000000..5ff39c5a8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json @@ -0,0 +1,10 @@ +{ + "id": "STS2_Bridge", + "name": "STS2 Bridge", + "author": "kunology", + "description": "Local bridge plugin for Slay the Spire 2", + "version": "0.3.0", + "has_pck": false, + "has_dll": true, + "affects_gameplay": false +} diff --git a/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh b/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh new file mode 100755 index 000000000..c1ef3f7ce --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUNDLE_DIR="$SCRIPT_DIR/bridge_plugin" +DLL="$BUNDLE_DIR/STS2_Bridge.dll" +JSON="$BUNDLE_DIR/STS2_Bridge.json" + +if [ ! -f "$DLL" ] || [ ! -f "$JSON" ]; then + echo "ERROR: bridge plugin files not found in $BUNDLE_DIR" >&2 + echo "Build the bridge first: ../plugin/build.sh" >&2 + exit 1 +fi + +GAME_ROOT="${1:-$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2}" +MOD_DIR="$GAME_ROOT/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge" + +mkdir -p "$MOD_DIR" +cp "$DLL" "$MOD_DIR/STS2_Bridge.dll" +cp "$JSON" "$MOD_DIR/STS2_Bridge.json" + +echo "Installed bridge plugin to:" +echo " $MOD_DIR/STS2_Bridge.dll" +echo " $MOD_DIR/STS2_Bridge.json" diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs new file mode 100644 index 000000000..be7e09696 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs @@ -0,0 +1,1039 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using MegaCrit.Sts2.Core.Nodes.Cards; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Nodes.Screens; +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.Screens.Overlays; +using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Entities.Merchant; +using MegaCrit.Sts2.Core.Nodes.Events; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Map; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Nodes.RestSite; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Rewards; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Entities.Potions; +using MegaCrit.Sts2.Core.GameActions; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary ExecuteAction(string action, Dictionary data) + { + if (!RunManager.Instance.IsInProgress) + return ExecuteMenuAction(action, data); + + var runState = RunManager.Instance.DebugOnlyGetState()!; + var player = LocalContext.GetMe(runState); + if (player == null) + return Error("Could not find local player"); + + return action switch + { + "play_card" => ExecutePlayCard(player, data), + "use_potion" => ExecuteUsePotion(player, data), + "end_turn" => ExecuteEndTurn(player), + "choose_map_node" => ExecuteChooseMapNode(data), + "choose_event_option" => ExecuteChooseEventOption(data), + "advance_dialogue" => ExecuteAdvanceDialogue(), + "choose_rest_option" => ExecuteChooseRestOption(data), + "shop_purchase" => ExecuteShopPurchase(player, data), + "claim_reward" => ExecuteClaimReward(data), + "select_card_reward" => ExecuteSelectCardReward(data), + "skip_card_reward" => ExecuteSkipCardReward(), + "proceed" => ExecuteProceed(), + "select_card" => ExecuteSelectCard(data), + "confirm_selection" => ExecuteConfirmSelection(), + "cancel_selection" => ExecuteCancelSelection(), + "combat_select_card" => ExecuteCombatSelectCard(data), + "combat_confirm_selection" => ExecuteCombatConfirmSelection(), + "select_relic" => ExecuteSelectRelic(data), + "skip_relic_selection" => ExecuteSkipRelicSelection(), + "claim_treasure_relic" => ExecuteClaimTreasureRelic(data), + "return_to_main_menu" => ExecuteReturnToMainMenu(), + _ => Error($"Unknown action: {action}") + }; + } + + private static Dictionary ExecuteMenuAction(string action, Dictionary data) + { + return action switch + { + "continue_game" => ExecuteContinueGame(), + "start_new_game" => ExecuteStartNewGame(data), + "abandon_game" => ExecuteAbandonGame(), + "return_to_main_menu" => ExecuteReturnToMainMenu(), + _ => Error("No run in progress") + }; + } + + private static Dictionary ExecuteContinueGame() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var continueInfo = mainMenu.ContinueRunInfo; + if (continueInfo == null || !continueInfo.IsVisibleInTree()) + return Error("No continueable run found"); + + var continueButton = GetFieldValue(mainMenu, "_continueButton"); + if (continueButton == null || !continueButton.IsVisibleInTree()) + return Error("Continue button is not available on this game build"); + + continueButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing saved run" + }; + } + + private static Dictionary ExecuteAbandonGame() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var continueInfo = mainMenu.ContinueRunInfo; + if (continueInfo == null || !continueInfo.IsVisibleInTree()) + return Error("No continueable run found"); + + var abandonButton = GetFieldValue(mainMenu, "_abandonRunButton"); + if (abandonButton == null || !abandonButton.IsVisibleInTree()) + return Error("Abandon button is not available on this game build"); + + abandonButton.ForceClick(); + + var popup = FindFirst(root); + if (popup != null && popup.IsVisibleInTree()) + { + var yesButton = FindAll(popup) + .FirstOrDefault(button => button.IsVisibleInTree()); + yesButton?.ForceClick(); + } + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Abandoning saved run" + }; + } + + private static Dictionary ExecuteStartNewGame(Dictionary data) + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var characterSelect = FindFirst(root); + + if (characterSelect == null || !characterSelect.IsVisibleInTree()) + { + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var singleplayerButton = GetFieldValue(mainMenu, "_singleplayerButton"); + if (singleplayerButton == null || !singleplayerButton.IsVisibleInTree()) + return Error("Singleplayer button is not available"); + singleplayerButton.ForceClick(); + + var submenu = mainMenu.OpenSingleplayerSubmenu(); + if (submenu == null || !submenu.IsVisibleInTree()) + return Error("Singleplayer submenu could not be opened"); + + var standardButton = GetFieldValue(submenu, "_standardButton"); + if (standardButton == null || !standardButton.IsVisibleInTree()) + return Error("Standard new game button is not available"); + standardButton.ForceClick(); + + characterSelect = FindFirst(root); + if (characterSelect == null || !characterSelect.IsVisibleInTree()) + return Error("Character select could not be opened"); + } + + string characterId = NormalizeCharacterId( + data.TryGetValue("character", out var charElem) ? charElem.GetString() : null + ); + + int ascension = 0; + if (data.TryGetValue("ascension", out var ascElem)) + ascension = ascElem.GetInt32(); + + var button = FindAll(characterSelect) + .FirstOrDefault(b => b.Character?.Id.Entry == characterId); + if (button == null || button.Character == null) + return Error($"Character '{characterId}' is not available on the character select screen"); + + characterSelect.SelectCharacter(button, button.Character); + + var ascensionPanel = FindFirst(characterSelect); + ascensionPanel?.SetAscensionLevel(ascension); + + var embarkButton = GetFieldValue(characterSelect, "_embarkButton"); + if (embarkButton == null || !embarkButton.IsVisibleInTree() || !embarkButton.IsEnabled) + return Error("Embark button is not available on this game build"); + embarkButton.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Starting new singleplayer run as {characterId} on Ascension {ascension}" + }; + } + + private static T? GetFieldValue(object target, string fieldName) where T : class + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return field?.GetValue(target) as T; + } + + private static string NormalizeCharacterId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "IRONCLAD"; + + return value.Trim().ToUpperInvariant() switch + { + "IRONCLAD" or "铁甲战士" => "IRONCLAD", + "SILENT" or "静默猎手" => "SILENT", + "DEFECT" or "故障机器人" => "DEFECT", + "NECROBINDER" or "亡灵契约师" => "NECROBINDER", + "REGENT" or "储君" => "REGENT", + var other => other + }; + } + + private static Dictionary ExecuteReturnToMainMenu() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var gameOverScreen = FindFirst(root); + if (gameOverScreen != null && gameOverScreen.IsVisibleInTree()) + { + var returnButton = FindFirst(gameOverScreen); + if (returnButton != null && returnButton.IsVisibleInTree() && returnButton.IsEnabled) + { + returnButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Leaving game over screen and returning to main menu" + }; + } + + var continueButton = FindFirst(gameOverScreen); + if (continueButton != null && continueButton.IsVisibleInTree() && continueButton.IsEnabled) + { + continueButton.ForceClick(); + + returnButton = FindFirst(gameOverScreen); + if (returnButton != null && returnButton.IsVisibleInTree() && returnButton.IsEnabled) + { + returnButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing past game over screen and returning to main menu" + }; + } + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing past game over screen" + }; + } + + return Error("Game over screen is open but no usable main-menu button is available"); + } + + var game = FindFirst(((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (game == null) + return Error("Game node is not available"); + + game.ReturnToMainMenu().GetAwaiter().GetResult(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Returning to main menu" + }; + } + + private static Dictionary ExecutePlayCard(Player player, Dictionary data) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + if (!player.Creature.IsAlive) + return Error("Player creature is dead — cannot play cards"); + + var combatState = player.Creature.CombatState; + if (combatState == null) + return Error("No combat state"); + + // Get card by index in hand + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index'"); + + int cardIndex = indexElem.GetInt32(); + var hand = player.PlayerCombatState?.Hand; + if (hand == null) + return Error("No hand available"); + + if (cardIndex < 0 || cardIndex >= hand.Cards.Count) + return Error($"card_index {cardIndex} out of range (hand has {hand.Cards.Count} cards)"); + + var card = hand.Cards[cardIndex]; + + if (!card.CanPlay(out var reason, out _)) + return Error($"Card '{card.Title}' cannot be played: {reason}"); + + // Resolve target + Creature? target = null; + if (card.TargetType == TargetType.AnyEnemy) + { + if (!data.TryGetValue("target", out var targetElem)) + return Error("Card requires a target. Provide 'target' with an entity_id."); + + string targetId = targetElem.GetString() ?? ""; + target = ResolveTarget(combatState, targetId); + if (target == null) + return Error($"Target '{targetId}' not found among alive enemies"); + } + + // Play the card via the action queue (same path as the game UI) + RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(new PlayCardAction(card, target)); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Playing '{card.Title}'" + (target != null ? $" targeting {SafeGetText(() => target.Monster?.Title) ?? "target"}" : "") + }; + } + + private static Dictionary ExecuteEndTurn(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled (turn may already be ending)"); + + // Match the game's own CanTurnBeEnded guard (NEndTurnButton.cs:114-123) + var hand = NCombatRoom.Instance?.Ui?.Hand; + if (hand != null && (hand.InCardPlay || hand.CurrentMode != NPlayerHand.Mode.Play)) + return Error("Cannot end turn while a card is being played or hand is in selection mode"); + + PlayerCmd.EndTurn(player, canBackOut: false); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Ending turn" + }; + } + + private static Dictionary ExecuteUsePotion(Player player, Dictionary data) + { + if (!data.TryGetValue("slot", out var slotElem)) + return Error("Missing 'slot' (potion slot index)"); + + int slot = slotElem.GetInt32(); + if (slot < 0 || slot >= player.PotionSlots.Count) + return Error($"Potion slot {slot} out of range (player has {player.PotionSlots.Count} slots)"); + + 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 for use"); + if (potion.Owner.Creature.IsDead) + return Error("Cannot use potion — player creature is dead"); + if (!potion.PassesCustomUsabilityCheck) + return Error($"Potion '{SafeGetText(() => potion.Title)}' cannot be used right now"); + + bool inCombat = CombatManager.Instance.IsInProgress; + if (potion.Usage == PotionUsage.CombatOnly) + { + if (!inCombat) + return Error($"Potion '{SafeGetText(() => potion.Title)}' can only be used in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Cannot use potions outside of play phase"); + } + else if (potion.Usage == PotionUsage.Automatic) + return Error($"Potion '{SafeGetText(() => potion.Title)}' is automatic and cannot be manually used"); + + if (inCombat && CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + + // Resolve target + Creature? target = null; + var combatState = player.Creature.CombatState; + + switch (potion.TargetType) + { + case TargetType.AnyEnemy: + if (!data.TryGetValue("target", out var targetElem)) + return Error("Potion requires a target enemy. Provide 'target' with an entity_id."); + string targetId = targetElem.GetString() ?? ""; + if (combatState == null) + return Error("No combat state for target resolution"); + target = ResolveTarget(combatState, targetId); + if (target == null) + return Error($"Target '{targetId}' not found among alive enemies"); + break; + case TargetType.Self: + case TargetType.AnyAlly: + case TargetType.AnyPlayer: + target = player.Creature; + break; + default: + target = null; + break; + } + + potion.EnqueueManualUse(target); + + string targetMsg = potion.TargetType switch + { + TargetType.AnyEnemy => $" targeting {SafeGetText(() => target?.Monster?.Title) ?? "enemy"}", + TargetType.Self or TargetType.AnyPlayer or TargetType.AnyAlly => " on self", + _ => "" + }; + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Using potion '{SafeGetText(() => potion.Title)}' from slot {slot}{targetMsg}" + }; + } + + private static Dictionary ExecuteChooseEventOption(Dictionary data) + { + var uiRoom = NEventRoom.Instance; + if (uiRoom == null) + return Error("Event room is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (event option index)"); + + int index = indexElem.GetInt32(); + + var buttons = FindAll(uiRoom) + .Where(b => !b.Option.IsLocked) + .ToList(); + + if (buttons.Count == 0) + return Error("No unlocked event options available"); + if (index < 0 || index >= buttons.Count) + return Error($"Event option index {index} out of range ({buttons.Count} unlocked options)"); + + var button = buttons[index]; + string title = SafeGetText(() => button.Option.Title) ?? "option"; + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Choosing event option: {title}" + }; + } + + private static Dictionary ExecuteAdvanceDialogue() + { + var uiRoom = NEventRoom.Instance; + if (uiRoom == null) + return Error("Event room is not open"); + + var ancientLayout = FindFirst(uiRoom); + if (ancientLayout == null) + return Error("No ancient dialogue active"); + + var hitbox = ancientLayout.GetNodeOrNull("%DialogueHitbox"); + if (hitbox == null || !hitbox.Visible || !hitbox.IsEnabled) + return Error("Dialogue hitbox not available — dialogue may have ended"); + + hitbox.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Advancing dialogue" + }; + } + + private static Dictionary ExecuteChooseRestOption(Dictionary data) + { + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (rest site option index)"); + + int index = indexElem.GetInt32(); + + var restRoom = NRestSiteRoom.Instance; + if (restRoom == null) + return Error("Rest site room is not open"); + + var buttons = FindAll(restRoom) + .Where(b => b.Option.IsEnabled) + .ToList(); + + if (index < 0 || index >= buttons.Count) + return Error($"Rest option index {index} out of range ({buttons.Count} enabled options)"); + + var button = buttons[index]; + string optionName = SafeGetText(() => button.Option.Title) ?? button.Option.OptionId; + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting rest site option: {optionName}" + }; + } + + private static Dictionary ExecuteShopPurchase(Player player, Dictionary data) + { + if (player.RunState.CurrentRoom is not MerchantRoom merchantRoom) + return Error("Not in a shop"); + + // Auto-open inventory if needed + var merchUI = NMerchantRoom.Instance; + if (merchUI != null && !merchUI.Inventory.IsOpen) + merchUI.OpenInventory(); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (shop item index)"); + + int index = indexElem.GetInt32(); + + var allEntries = merchantRoom.Inventory.AllEntries.ToList(); + if (index < 0 || index >= allEntries.Count) + return Error($"Shop item index {index} out of range ({allEntries.Count} items)"); + + var entry = allEntries[index]; + if (!entry.IsStocked) + return Error("Item is sold out"); + if (!entry.EnoughGold) + return Error($"Not enough gold (need {entry.Cost}, have {player.Gold})"); + + // Fire-and-forget purchase (same path as AutoSlay) + _ = entry.OnTryPurchaseWrapper(merchantRoom.Inventory); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Purchasing item for {entry.Cost} gold" + }; + } + + private static Dictionary ExecuteChooseMapNode(Dictionary data) + { + var mapScreen = NMapScreen.Instance; + if (mapScreen == null || !mapScreen.IsOpen) + return Error("Map screen is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (map node index from next_options)"); + + int index = indexElem.GetInt32(); + + var travelable = FindAll(mapScreen) + .Where(mp => mp.State == MapPointState.Travelable) + .OrderBy(mp => mp.Point.coord.col) + .ToList(); + + if (travelable.Count == 0) + return Error("No travelable map nodes available"); + if (index < 0 || index >= travelable.Count) + return Error($"Map node index {index} out of range ({travelable.Count} options available)"); + + var target = travelable[index]; + mapScreen.OnMapPointSelectedLocally(target); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Traveling to {target.Point.PointType} at ({target.Point.coord.col},{target.Point.coord.row})" + }; + } + + private static Dictionary ExecuteClaimReward(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NRewardsScreen rewardsScreen) + return Error("Rewards screen is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (reward index)"); + + int index = indexElem.GetInt32(); + + var enabledButtons = FindAll(rewardsScreen) + .Where(b => b.IsEnabled && b.Reward != null) + .ToList(); + + if (index < 0 || index >= enabledButtons.Count) + return Error($"Reward index {index} out of range (screen has {enabledButtons.Count} claimable rewards)"); + + var button = enabledButtons[index]; + var reward = button.Reward!; + string rewardDesc = GetRewardTypeName(reward); + if (reward is GoldReward g) + rewardDesc = $"gold ({g.Amount})"; + else if (reward is PotionReward p) + rewardDesc = $"potion ({SafeGetText(() => p.Potion?.Title)})"; + else if (reward is CardReward) + rewardDesc = "card (opens card selection)"; + + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Claiming reward: {rewardDesc}" + }; + } + + private static Dictionary ExecuteSelectCardReward(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NCardRewardSelectionScreen cardScreen) + return Error("Card reward selection screen is not open"); + + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index'"); + + int cardIndex = indexElem.GetInt32(); + + var cardHolders = FindAllSortedByPosition(cardScreen); + if (cardIndex < 0 || cardIndex >= cardHolders.Count) + return Error($"Card index {cardIndex} out of range (screen has {cardHolders.Count} cards)"); + + var holder = cardHolders[cardIndex]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting card: {cardName}" + }; + } + + private static Dictionary ExecuteSkipCardReward() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NCardRewardSelectionScreen cardScreen) + return Error("Card reward selection screen is not open"); + + var altButtons = FindAll(cardScreen); + if (altButtons.Count == 0) + return Error("No skip option available on this card reward"); + + altButtons[0].ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping card reward" + }; + } + + private static Dictionary ExecuteProceed() + { + // Try rewards overlay + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NRewardsScreen rewardsScreen) + { + var btn = FindFirst(rewardsScreen); + if (btn is { IsEnabled: true }) + { + btn.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rewards" }; + } + } + + // Try rest site + if (NRestSiteRoom.Instance is { } restRoom && restRoom.ProceedButton.IsEnabled) + { + restRoom.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rest site" }; + } + + // Try merchant — close inventory first if open, then proceed + if (NMerchantRoom.Instance is { } merchRoom) + { + if (merchRoom.Inventory.IsOpen) + { + var backBtn = FindFirst(merchRoom); + if (backBtn is { IsEnabled: true }) + backBtn.ForceClick(); + } + if (merchRoom.ProceedButton.IsEnabled) + { + merchRoom.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from shop" }; + } + } + + // Try treasure room + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (treasureUI != null && treasureUI.ProceedButton.IsEnabled) + { + treasureUI.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from treasure room" }; + } + + return Error("No proceed button available or enabled"); + } + + private static Dictionary ExecuteSelectCard(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (card index in the grid)"); + + int index = indexElem.GetInt32(); + + if (overlay is NCardGridSelectionScreen gridScreen) + { + var grid = FindFirst(gridScreen); + if (grid == null) + return Error("Card grid not found in selection screen"); + + var holders = FindAllSortedByPosition(gridScreen); + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} cards available)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + grid.EmitSignal(NCardGrid.SignalName.HolderPressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Toggling card selection: {cardName}" + }; + } + else if (overlay is NChooseACardSelectionScreen chooseScreen) + { + var holders = FindAllSortedByPosition(chooseScreen); + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} cards available)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Choosing card: {cardName}" + }; + } + + return Error("No card selection screen is open"); + } + + private static Dictionary ExecuteConfirmSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NChooseACardSelectionScreen) + return Error("Choose-a-card screen requires no confirmation — use select_card(index) to pick directly"); + if (overlay is not NCardGridSelectionScreen screen) + return Error("No card selection screen is open"); + + // Check all preview containers (upgrade uses UpgradeSinglePreviewContainer / UpgradeMultiPreviewContainer, + // NDeckCardSelectScreen uses PreviewContainer with %PreviewConfirm) + foreach (var containerName in new[] { "%UpgradeSinglePreviewContainer", "%UpgradeMultiPreviewContainer", "%PreviewContainer" }) + { + var container = screen.GetNodeOrNull(containerName); + if (container?.Visible == true) + { + var confirm = container.GetNodeOrNull("Confirm") + ?? container.GetNodeOrNull("%PreviewConfirm"); + if (confirm is { IsEnabled: true }) + { + confirm.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection from preview" + }; + } + } + } + + // Try main confirm button + var mainConfirm = screen.GetNodeOrNull("Confirm") + ?? screen.GetNodeOrNull("%Confirm"); + if (mainConfirm is { IsEnabled: true }) + { + mainConfirm.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection" + }; + } + + // Fallback: find ANY enabled NConfirmButton in the screen tree. + // Covers NCardGridSelectionScreen subclasses (like NDeckEnchantSelectScreen) + // whose confirm button isn't in any of the known container paths above. + var allConfirmButtons = FindAll(screen); + foreach (var btn in allConfirmButtons) + { + if (btn.IsEnabled && btn.IsVisibleInTree()) + { + btn.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection" + }; + } + } + + return Error("No confirm button is currently enabled — select more cards first"); + } + + private static Dictionary ExecuteCancelSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + + // Handle choose-a-card screen (skip button) + if (overlay is NChooseACardSelectionScreen chooseScreen) + { + var skipButton = chooseScreen.GetNodeOrNull("SkipButton"); + if (skipButton is { IsEnabled: true }) + { + skipButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping card choice" + }; + } + return Error("No skip option available — a card must be chosen"); + } + + if (overlay is not NCardGridSelectionScreen screen) + return Error("No card selection screen is open"); + + // If preview is showing, cancel back to selection + foreach (var containerName in new[] { "%UpgradeSinglePreviewContainer", "%UpgradeMultiPreviewContainer", "%PreviewContainer" }) + { + var container = screen.GetNodeOrNull(containerName); + if (container?.Visible == true) + { + var cancelBtn = container.GetNodeOrNull("Cancel") + ?? container.GetNodeOrNull("%PreviewCancel"); + if (cancelBtn is { IsEnabled: true }) + { + cancelBtn.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Cancelling preview — returning to card selection" + }; + } + } + } + + // Close the screen entirely + var closeButton = screen.GetNodeOrNull("%Close"); + if (closeButton is { IsEnabled: true }) + { + closeButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Closing card selection screen" + }; + } + + return Error("No cancel/close button is currently enabled — selection may be mandatory"); + } + + private static Dictionary ExecuteCombatSelectCard(Dictionary data) + { + var hand = NPlayerHand.Instance; + if (hand == null || !hand.IsInCardSelection) + return Error("No in-combat card selection is active"); + + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index' (index of the card in hand)"); + + int index = indexElem.GetInt32(); + var holders = hand.ActiveHolders; + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} selectable cards)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + + // Emit the Pressed signal — same path the game UI uses + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting card from hand: {cardName}" + }; + } + + private static Dictionary ExecuteCombatConfirmSelection() + { + var hand = NPlayerHand.Instance; + if (hand == null || !hand.IsInCardSelection) + return Error("No in-combat card selection is active"); + + var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); + if (confirmBtn == null || !confirmBtn.IsEnabled) + return Error("Confirm button is not enabled — select more cards first"); + + confirmBtn.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming combat card selection" + }; + } + + private static Dictionary ExecuteSelectRelic(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NChooseARelicSelection screen) + return Error("No relic selection screen is open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (relic index)"); + + int index = indexElem.GetInt32(); + + var holders = FindAll(screen); + if (index < 0 || index >= holders.Count) + return Error($"Relic index {index} out of range ({holders.Count} relics available)"); + + var holder = holders[index]; + string relicName = SafeGetText(() => holder.Relic?.Model?.Title) ?? "unknown"; + holder.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting relic: {relicName}" + }; + } + + private static Dictionary ExecuteSkipRelicSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NChooseARelicSelection screen) + return Error("No relic selection screen is open"); + + var skipButton = screen.GetNodeOrNull("SkipButton"); + if (skipButton is not { IsEnabled: true }) + return Error("No skip option available"); + + skipButton.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping relic selection" + }; + } + + private static Dictionary ExecuteClaimTreasureRelic(Dictionary data) + { + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (treasureUI == null) + return Error("Treasure room is not open"); + + var relicCollection = treasureUI.GetNodeOrNull("%RelicCollection"); + if (relicCollection?.Visible != true) + return Error("Relic collection is not visible — chest may not be opened yet"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (relic index)"); + + int index = indexElem.GetInt32(); + + var holders = FindAll(relicCollection) + .Where(h => h.IsEnabled && h.Visible) + .ToList(); + + if (index < 0 || index >= holders.Count) + return Error($"Relic index {index} out of range ({holders.Count} relics available)"); + + var holder = holders[index]; + string relicName = SafeGetText(() => holder.Relic?.Model?.Title) ?? "unknown"; + holder.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Claiming treasure relic: {relicName}" + }; + } + + private static Creature? ResolveTarget(CombatState combatState, string entityId) + { + // Try to match by entity_id pattern: "model_entry_N" + // First try matching by combat_id if it's a pure number + if (uint.TryParse(entityId, out uint combatId)) + return combatState.GetCreature(combatId); + + // Match by entity_id pattern (e.g., "jaw_worm_0") + // We rebuild the entity IDs the same way as BuildEnemyState + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (!creature.IsAlive) continue; + string baseId = creature.Monster?.Id.Entry ?? "unknown"; + if (!entityCounts.TryGetValue(baseId, out int count)) + count = 0; + entityCounts[baseId] = count + 1; + string generatedId = $"{baseId}_{count}"; + + if (generatedId == entityId) + return creature; + } + + return null; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs new file mode 100644 index 000000000..35fa93900 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs @@ -0,0 +1,868 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static string FormatAsMarkdown(Dictionary state) + { + var sb = new StringBuilder(); + string stateType = state.TryGetValue("state_type", out var st) ? st?.ToString() ?? "unknown" : "unknown"; + bool isMultiplayer = state.TryGetValue("game_mode", out var gm) && gm?.ToString() == "multiplayer"; + + if (isMultiplayer) + sb.AppendLine($"# Multiplayer Game State: {stateType}"); + else + sb.AppendLine($"# Game State: {stateType}"); + sb.AppendLine(); + + if (state.TryGetValue("run", out var runObj) && runObj is Dictionary run) + { + sb.AppendLine($"**Act {run["act"]}** | Floor {run["floor"]} | Ascension {run["ascension"]}"); + sb.AppendLine(); + } + + if (state.TryGetValue("message", out var msg) && msg != null) + { + sb.AppendLine(msg.ToString()); + return sb.ToString(); + } + + // Multiplayer players summary (top-level) + if (isMultiplayer && state.TryGetValue("players", out var playersListObj) + && playersListObj is List> playersList && playersList.Count > 0) + { + sb.AppendLine("## Party"); + foreach (var p in playersList) + { + string youTag = p["is_local"] is true ? " **(YOU)**" : ""; + string aliveTag = p["is_alive"] is false ? " [DEAD]" : ""; + sb.AppendLine($"- **{p["character"]}**{youTag}{aliveTag} — HP: {p["hp"]}/{p["max_hp"]} | Gold: {p["gold"]}"); + } + sb.AppendLine(); + } + + if (state.TryGetValue("battle", out var battleObj) && battleObj is Dictionary battle) + { + if (isMultiplayer) + FormatMultiplayerBattleMarkdown(sb, battle); + else + FormatBattleMarkdown(sb, battle); + } + + if (state.TryGetValue("event", out var eventObj) && eventObj is Dictionary eventData) + { + FormatEventMarkdown(sb, eventData); + if (isMultiplayer) + FormatEventVotesMarkdown(sb, eventData); + } + + if (state.TryGetValue("rest_site", out var restObj) && restObj is Dictionary restData) + { + FormatRestSiteMarkdown(sb, restData); + } + + if (state.TryGetValue("shop", out var shopObj) && shopObj is Dictionary shopData) + { + FormatShopMarkdown(sb, shopData); + } + + if (state.TryGetValue("map", out var mapObj) && mapObj is Dictionary mapData) + { + FormatMapMarkdown(sb, mapData); + if (isMultiplayer) + FormatMapVotesMarkdown(sb, mapData); + } + + if (state.TryGetValue("rewards", out var rewardsObj) && rewardsObj is Dictionary rewards) + { + FormatRewardsMarkdown(sb, rewards); + } + + if (state.TryGetValue("card_reward", out var cardRewardObj) && cardRewardObj is Dictionary cardReward) + { + FormatCardRewardMarkdown(sb, cardReward); + } + + if (state.TryGetValue("hand_select", out var handSelectObj) && handSelectObj is Dictionary handSelect) + { + FormatHandSelectMarkdown(sb, handSelect); + } + + if (state.TryGetValue("card_select", out var cardSelectObj) && cardSelectObj is Dictionary cardSelect) + { + FormatCardSelectMarkdown(sb, cardSelect); + } + + if (state.TryGetValue("relic_select", out var relicSelectObj) && relicSelectObj is Dictionary relicSelect) + { + FormatRelicSelectMarkdown(sb, relicSelect); + } + + if (state.TryGetValue("treasure", out var treasureObj) && treasureObj is Dictionary treasureData) + { + FormatTreasureMarkdown(sb, treasureData); + if (isMultiplayer) + FormatTreasureBidsMarkdown(sb, treasureData); + } + + if (state.TryGetValue("overlay", out var overlayObj) && overlayObj is Dictionary overlayData) + { + sb.AppendLine($"## Overlay: {overlayData.GetValueOrDefault("screen_type")}"); + sb.AppendLine(overlayData.GetValueOrDefault("message")?.ToString()); + sb.AppendLine(); + } + + // Keyword glossary — collect all unique keyword definitions + var glossary = new Dictionary(); + CollectKeywordsFromState(state, glossary); + if (glossary.Count > 0) + { + sb.AppendLine("## Keyword Glossary"); + foreach (var (name, description) in glossary.OrderBy(kv => kv.Key)) + sb.AppendLine($"- **{name}**: {description}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void FormatBattleMarkdown(StringBuilder sb, Dictionary battle) + { + sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]}"); + sb.AppendLine(); + + if (battle.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : ""; + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]} | Energy: {player["energy"]}/{player["max_energy"]}{stars} | Gold: {player["gold"]}"); + sb.AppendLine(); + + FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + FormatListSection(sb, "Relics", player, "relics", r => + { + string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : ""; + return $"- **{r["name"]}**{counter}: {r["description"]}"; + }); + FormatListSection(sb, "Potions", player, "potions", p => $"- [{p["slot"]}] **{p["name"]}**: {p["description"]}"); + + if (player.TryGetValue("hand", out var handObj) && handObj is List> hand && hand.Count > 0) + { + sb.AppendLine("### Hand"); + foreach (var card in hand) + { + string playable = card["can_play"] is true ? "✓" : "✗"; + string keywords = card.TryGetValue("keywords", out var kw) && kw is List 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(); + } + + FormatDeckPilesMarkdown(sb, player); + + if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List> orbs && orbs.Count > 0) + { + int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count; + int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0; + sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)"); + foreach (var orb in orbs) + { + string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : ""; + sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}"); + } + if (empty > 0) + sb.AppendLine($"- *{empty} empty slot(s)*"); + sb.AppendLine(); + } + } + + if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List> enemies && enemies.Count > 0) + { + sb.AppendLine("## Enemies"); + foreach (var enemy in enemies) + { + sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)"); + sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}"); + + if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List> intents && intents.Count > 0) + { + sb.Append("**Intent:** "); + sb.AppendLine(string.Join(", ", intents.Select(i => + { + string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!; + string typeTag = $" ({i["type"]})"; + string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : ""; + string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : ""; + return $"{title}{typeTag}{label}{desc}"; + }))); + } + + FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + sb.AppendLine(); + } + } + } + + private static void FormatDeckPilesMarkdown(StringBuilder sb, Dictionary player) + { + sb.AppendLine("### Deck Information"); + sb.AppendLine(); + + sb.AppendLine($"#### Draw Pile ({player["draw_pile_count"]} cards, in random order)"); + if (player.TryGetValue("draw_pile", out var drawObj) && drawObj is List> drawPile && drawPile.Count > 0) + { + foreach (var card in drawPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + + sb.AppendLine($"#### Discard Pile ({player["discard_pile_count"]} cards)"); + if (player.TryGetValue("discard_pile", out var discardObj) && discardObj is List> discardPile && discardPile.Count > 0) + { + foreach (var card in discardPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + + sb.AppendLine($"#### Exhaust Pile ({player["exhaust_pile_count"]} cards)"); + if (player.TryGetValue("exhaust_pile", out var exhaustObj) && exhaustObj is List> exhaustPile && exhaustPile.Count > 0) + { + foreach (var card in exhaustPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + } + + private static void FormatEventMarkdown(StringBuilder sb, Dictionary evt) + { + string name = evt.TryGetValue("event_name", out var n) && n != null ? n.ToString()! : "Unknown Event"; + bool isAncient = evt.TryGetValue("is_ancient", out var a) && a is true; + sb.AppendLine($"## {(isAncient ? "Ancient" : "Event")}: {name}"); + sb.AppendLine(); + + if (evt.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + bool inDialogue = evt.TryGetValue("in_dialogue", out var d) && d is true; + if (inDialogue) + { + sb.AppendLine("*Ancient dialogue in progress — use `advance_dialogue` to continue.*"); + sb.AppendLine(); + return; + } + + if (evt.TryGetValue("options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("### Options"); + foreach (var opt in options) + { + bool locked = opt["is_locked"] is true; + bool proceed = opt["is_proceed"] is true; + bool chosen = opt["was_chosen"] is true; + + string tag = locked ? " (LOCKED)" : chosen ? " (CHOSEN)" : proceed ? " (PROCEED)" : ""; + string relic = opt.TryGetValue("relic_name", out var rn) && rn != null ? $" [Relic: {rn}]" : ""; + sb.AppendLine($"- [{opt["index"]}] **{opt["title"]}**{tag}{relic} — {opt["description"]}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("No options available."); + sb.AppendLine(); + } + } + + private static void FormatRestSiteMarkdown(StringBuilder sb, Dictionary restSite) + { + if (restSite.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (restSite.TryGetValue("options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("## Rest Site Options"); + foreach (var opt in options) + { + string enabled = opt["is_enabled"] is true ? "" : " (DISABLED)"; + sb.AppendLine($"- [{opt["index"]}] **{opt["name"]}**{enabled} — {opt["description"]}"); + } + sb.AppendLine(); + } + + bool canProceed = restSite.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatShopMarkdown(StringBuilder sb, Dictionary shop) + { + if (shop.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + if (shop.TryGetValue("items", out var itemsObj) && itemsObj is List> items) + { + sb.AppendLine("## Shop Inventory"); + string? lastCategory = null; + foreach (var item in items) + { + string category = item["category"]?.ToString() ?? ""; + if (category != lastCategory) + { + string header = category switch { "card" => "Cards", "relic" => "Relics", "potion" => "Potions", "card_removal" => "Services", _ => category }; + sb.AppendLine($"### {header}"); + lastCategory = category; + } + + bool stocked = item["is_stocked"] is true; + bool afford = item["can_afford"] is true; + string costTag = stocked ? $"{item["cost"]}g" : "SOLD"; + string affordTag = stocked && !afford ? " (can't afford)" : ""; + string saleTag = item.TryGetValue("on_sale", out var os) && os is true ? " **SALE**" : ""; + + string desc = category switch + { + "card" => $"**{item.GetValueOrDefault("card_name")}** [{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_removal" => "**Remove a card** from your deck", + _ => "Unknown item" + }; + sb.AppendLine($"- [{item["index"]}] {desc} — {costTag}{saleTag}{affordTag}"); + } + sb.AppendLine(); + } + + bool canProceed = shop.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatMapMarkdown(StringBuilder sb, Dictionary map) + { + // Player summary + if (map.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + // Path taken + if (map.TryGetValue("visited", out var visitedObj) && visitedObj is List> visited && visited.Count > 0) + { + sb.AppendLine("## Path Taken"); + var parts = visited.Select((v, i) => $"{i + 1}. {v["type"]} ({v["col"]},{v["row"]})"); + sb.AppendLine(string.Join(" → ", parts) + " ← current"); + sb.AppendLine(); + } + + // Next options — the key decision section + if (map.TryGetValue("next_options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("## Choose Next Node"); + foreach (var opt in options) + { + string lookahead = ""; + if (opt.TryGetValue("leads_to", out var leadsObj) && leadsObj is List> leads && leads.Count > 0) + lookahead = " → leads to: " + string.Join(", ", leads.Select(l => $"{l["type"]}({l["col"]},{l["row"]})")); + sb.AppendLine($"- [{opt["index"]}] **{opt["type"]}** ({opt["col"]},{opt["row"]}){lookahead}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("## Map"); + sb.AppendLine("No travelable nodes available."); + sb.AppendLine(); + } + + // Full map overview — compact row-by-row + if (map.TryGetValue("nodes", out var nodesObj) && nodesObj is List> nodes && nodes.Count > 0) + { + // Collect visited and travelable coords for markers + var visitedSet = new HashSet(); + if (map.TryGetValue("visited", out var v2) && v2 is List> vList) + foreach (var vn in vList) + visitedSet.Add($"{vn["col"]},{vn["row"]}"); + + var travelableSet = new HashSet(); + if (map.TryGetValue("next_options", out var o2) && o2 is List> oList) + foreach (var on in oList) + travelableSet.Add($"{on["col"]},{on["row"]}"); + + string? currentKey = null; + if (map.TryGetValue("current_position", out var cpObj) && cpObj is Dictionary cp) + currentKey = $"{cp["col"]},{cp["row"]}"; + + // Group nodes by row + var byRow = new SortedDictionary>>(); + foreach (var node in nodes) + { + int row = node["row"] is int r ? r : Convert.ToInt32(node["row"]); + if (!byRow.TryGetValue(row, out var rowList)) + byRow[row] = rowList = new List>(); + rowList.Add(node); + } + + sb.AppendLine("## Map Overview"); + sb.AppendLine("```"); + sb.AppendLine("Legend: · = visited, * = current, → = next option"); + sb.AppendLine(); + foreach (var (row, rowNodes) in byRow) + { + var sorted = rowNodes.OrderBy(n => n["col"] is int c ? c : Convert.ToInt32(n["col"])).ToList(); + var labels = new List(); + foreach (var node in sorted) + { + string type = node["type"]?.ToString() ?? "Unknown"; + string key = $"{node["col"]},{node["row"]}"; + + string marker = ""; + if (key == currentKey) marker = "*"; + else if (travelableSet.Contains(key)) marker = "→"; + else if (visitedSet.Contains(key)) marker = "·"; + + labels.Add($"{marker}{type}({node["col"]},{node["row"]})"); + } + sb.AppendLine($" Row {row,2}: {string.Join(" ", labels)}"); + } + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + private static void FormatRewardsMarkdown(StringBuilder sb, Dictionary rewards) + { + if (rewards.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + if (rewards.TryGetValue("items", out var itemsObj) && itemsObj is List> items && items.Count > 0) + { + sb.AppendLine("## Rewards"); + foreach (var item in items) + { + string extra = ""; + if (item.TryGetValue("gold_amount", out var gold) && gold != null) + extra = $" ({gold} gold)"; + else if (item.TryGetValue("potion_name", out var pName) && pName != null) + extra = $" ({pName})"; + sb.AppendLine($"- [{item["index"]}] **{item["type"]}**: {item["description"]}{extra}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("## Rewards"); + sb.AppendLine("No rewards available."); + sb.AppendLine(); + } + + bool canProceed = rewards.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatCardRewardMarkdown(StringBuilder sb, Dictionary cardReward) + { + sb.AppendLine("## Card Reward Selection"); + sb.AppendLine("Choose a card to add to your deck:"); + sb.AppendLine(); + + if (cardReward.TryGetValue("cards", out var cardsObj) && cardsObj is List> 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(); + } + + bool canSkip = cardReward.TryGetValue("can_skip", out var cs) && cs is true; + sb.AppendLine($"**Can skip:** {(canSkip ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatRelicSelectMarkdown(StringBuilder sb, Dictionary relicSelect) + { + sb.AppendLine("## Relic Selection"); + if (relicSelect.TryGetValue("prompt", out var p) && p != null) + sb.AppendLine($"*{p}*"); + sb.AppendLine(); + + if (relicSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (relicSelect.TryGetValue("relics", out var relicsObj) && relicsObj is List> relics) + { + foreach (var relic in relics) + sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}** — {relic["description"]}"); + sb.AppendLine(); + } + + bool canSkip = relicSelect.TryGetValue("can_skip", out var cs) && cs is true; + sb.AppendLine($"Use `select_relic(index)` to choose. Can skip: {(canSkip ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatHandSelectMarkdown(StringBuilder sb, Dictionary handSelect) + { + sb.AppendLine("## In-Combat Card Selection"); + + if (handSelect.TryGetValue("prompt", out var promptObj) && promptObj != null) + sb.AppendLine($"*{promptObj}*"); + sb.AppendLine(); + + string mode = handSelect.TryGetValue("mode", out var m) ? m?.ToString() ?? "simple_select" : "simple_select"; + if (mode == "upgrade_select") + sb.AppendLine("**Mode:** Upgrade selection"); + sb.AppendLine(); + + if (handSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List> cards && cards.Count > 0) + { + sb.AppendLine("### Selectable Cards"); + foreach (var card in cards) + { + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] — {card["description"]}"); + } + sb.AppendLine(); + } + + if (handSelect.TryGetValue("selected_cards", out var selObj) && selObj is List> selected && selected.Count > 0) + { + sb.AppendLine("### Already Selected"); + foreach (var card in selected) + sb.AppendLine($"- {card["name"]}"); + sb.AppendLine(); + } + + bool canConfirm = handSelect.TryGetValue("can_confirm", out var cc) && cc is true; + sb.AppendLine($"Use `combat_select_card(card_index)` to select. Can confirm: {(canConfirm ? "Yes — use `combat_confirm_selection`" : "No — select more cards")}"); + sb.AppendLine(); + } + + private static void FormatCardSelectMarkdown(StringBuilder sb, Dictionary cardSelect) + { + string screenType = cardSelect.TryGetValue("screen_type", out var st) ? st?.ToString() ?? "select" : "select"; + string screenLabel = screenType switch + { + "transform" => "Transform", + "upgrade" => "Upgrade", + "select" => "Select", + "simple_select" => "Select", + _ => screenType + }; + sb.AppendLine($"## Card Selection: {screenLabel}"); + + if (cardSelect.TryGetValue("prompt", out var promptObj) && promptObj != null) + { + sb.AppendLine($"*{promptObj}*"); + } + sb.AppendLine(); + + if (cardSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (cardSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List> cards) + { + sb.AppendLine("### Cards"); + foreach (var card in cards) + { + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] {card["rarity"]} — {card["description"]}"); + } + sb.AppendLine(); + } + + bool preview = cardSelect.TryGetValue("preview_showing", out var pv) && pv is true; + bool canConfirm = cardSelect.TryGetValue("can_confirm", out var cc) && cc is true; + bool canCancel = cardSelect.TryGetValue("can_cancel", out var cn) && cn is true; + + if (preview) + sb.AppendLine("**Preview is showing** — use `confirm_selection` to confirm or `cancel_selection` to go back."); + else + sb.AppendLine($"**Select cards** using `select_card(index)`. Can confirm: {(canConfirm ? "Yes" : "No")} | Can cancel: {(canCancel ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatTreasureMarkdown(StringBuilder sb, Dictionary treasure) + { + if (treasure.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (treasure.TryGetValue("relics", out var relicsObj) && relicsObj is List> relics && relics.Count > 0) + { + sb.AppendLine("## Treasure Relics"); + foreach (var relic in relics) + { + string rarity = relic.TryGetValue("rarity", out var r) && r != null ? $" ({r})" : ""; + sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}**{rarity} — {relic["description"]}"); + } + sb.AppendLine(); + sb.AppendLine("Use `treasure_claim_relic(relic_index)` to claim a relic."); + } + else + { + sb.AppendLine("Chest is opening..."); + } + sb.AppendLine(); + + bool canProceed = treasure.TryGetValue("can_proceed", out var cp) && cp is true; + if (canProceed) + sb.AppendLine("**Can proceed:** Yes"); + sb.AppendLine(); + } + + private static string FormatStatusAmount(object? amount) + { + if (amount is int i && i == -1) return "indefinite"; + return amount?.ToString() ?? "0"; + } + + private static void FormatListSection(StringBuilder sb, string title, Dictionary parent, string key, + Func, string> formatter) + { + if (parent.TryGetValue(key, out var listObj) && listObj is List> list && list.Count > 0) + { + sb.AppendLine($"### {title}"); + foreach (var item in list) + sb.AppendLine(formatter(item)); + sb.AppendLine(); + } + } + + private static void FormatMultiplayerBattleMarkdown(StringBuilder sb, Dictionary battle) + { + if (battle.TryGetValue("error", out var err) && err != null) + { + sb.AppendLine($"**Combat Error:** {err}"); + sb.AppendLine(); + return; + } + + bool allReady = battle.TryGetValue("all_players_ready", out var ar) && ar is true; + sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]} | All Ready: {allReady}"); + sb.AppendLine(); + + // All players + if (battle.TryGetValue("players", out var playersObj) && playersObj is List> players) + { + foreach (var player in players) + { + string youTag = player["is_local"] is true ? " **(YOU)**" : ""; + string aliveTag = player["is_alive"] is false ? " [DEAD]" : ""; + string readyTag = player["is_ready_to_end_turn"] is true ? " [READY]" : ""; + string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : ""; + + sb.AppendLine($"## Player: {player["character"]}{youTag}{aliveTag}{readyTag}"); + string energyStr = player.TryGetValue("energy", out var en) && player.TryGetValue("max_energy", out var men) + ? $" | Energy: {en}/{men}" : ""; + sb.AppendLine($"HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]}{energyStr}{stars} | Gold: {player["gold"]}"); + sb.AppendLine(); + + FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + FormatListSection(sb, "Relics", player, "relics", r => + { + string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : ""; + return $"- **{r["name"]}**{counter}: {r["description"]}"; + }); + FormatListSection(sb, "Potions", player, "potions", p => + { + string desc = p.TryGetValue("description", out var d) && d != null ? $": {d}" : ""; + return $"- [{p["slot"]}] **{p["name"]}**{desc}"; + }); + + if (player["is_local"] is true) + { + if (player.TryGetValue("hand", out var handObj) && handObj is List> hand && hand.Count > 0) + { + sb.AppendLine("### Hand"); + foreach (var card in hand) + { + string playable = card["can_play"] is true ? "\u2713" : "\u2717"; + string keywords = card.TryGetValue("keywords", out var kw) && kw is List 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(); + } + + FormatDeckPilesMarkdown(sb, player); + + if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List> orbs && orbs.Count > 0) + { + int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count; + int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0; + sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)"); + foreach (var orb in orbs) + { + string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : ""; + sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}"); + } + if (empty > 0) + sb.AppendLine($"- *{empty} empty slot(s)*"); + sb.AppendLine(); + } + } + } + } + + if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List> enemies && enemies.Count > 0) + { + sb.AppendLine("## Enemies"); + foreach (var enemy in enemies) + { + sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)"); + sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}"); + + if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List> intents && intents.Count > 0) + { + sb.Append("**Intent:** "); + sb.AppendLine(string.Join(", ", intents.Select(i => + { + string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!; + string typeTag = $" ({i["type"]})"; + string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : ""; + string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : ""; + return $"{title}{typeTag}{label}{desc}"; + }))); + } + + FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + sb.AppendLine(); + } + } + } + + private static void FormatMapVotesMarkdown(StringBuilder sb, Dictionary mapData) + { + if (!mapData.TryGetValue("votes", out var votesObj) || votesObj is not List> votes || votes.Count == 0) + return; + + sb.AppendLine("## Map Votes"); + foreach (var vote in votes) + { + string youTag = vote["is_local"] is true ? " (YOU)" : ""; + if (vote["voted"] is true) + sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for ({vote["vote_col"]},{vote["vote_row"]})"); + else + sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*"); + } + bool allVoted = mapData.TryGetValue("all_voted", out var av) && av is true; + if (allVoted) + sb.AppendLine("**All players have voted!**"); + sb.AppendLine(); + } + + private static void FormatEventVotesMarkdown(StringBuilder sb, Dictionary eventData) + { + bool isShared = eventData.TryGetValue("is_shared", out var sh) && sh is true; + if (!isShared) return; + + if (!eventData.TryGetValue("votes", out var votesObj) || votesObj is not List> votes || votes.Count == 0) + return; + + sb.AppendLine("## Event Votes (Shared Event)"); + foreach (var vote in votes) + { + string youTag = vote["is_local"] is true ? " (YOU)" : ""; + if (vote["voted"] is true) + sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for option {vote["vote_option"]}"); + else + sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*"); + } + bool allVoted = eventData.TryGetValue("all_voted", out var av) && av is true; + if (allVoted) + sb.AppendLine("**All players have voted!**"); + sb.AppendLine(); + } + + private static void FormatTreasureBidsMarkdown(StringBuilder sb, Dictionary treasureData) + { + if (treasureData.TryGetValue("is_bidding_phase", out var bp) && bp is not true) + return; + + if (!treasureData.TryGetValue("bids", out var bidsObj) || bidsObj is not List> bids || bids.Count == 0) + return; + + sb.AppendLine("## Treasure Bids"); + foreach (var bid in bids) + { + string youTag = bid["is_local"] is true ? " (YOU)" : ""; + if (bid["voted"] is true) + sb.AppendLine($"- **{bid["player"]}**{youTag}: bid on relic #{bid["vote_relic_index"]}"); + else + sb.AppendLine($"- **{bid["player"]}**{youTag}: *waiting...*"); + } + bool allBid = treasureData.TryGetValue("all_bid", out var ab) && ab is true; + if (allBid) + sb.AppendLine("**All players have bid!**"); + sb.AppendLine(); + } + + private static void CollectKeywordsFromState(object? obj, Dictionary glossary) + { + if (obj is Dictionary dict) + { + if (dict.TryGetValue("keywords", out var kw) && kw is List> keywords) + { + foreach (var keyword in keywords) + { + string? name = keyword.GetValueOrDefault("name")?.ToString(); + string? desc = keyword.GetValueOrDefault("description")?.ToString(); + if (name != null && desc != null) + glossary.TryAdd(name, desc); + } + } + foreach (var (key, value) in dict) + { + if (key != "keywords") + CollectKeywordsFromState(value, glossary); + } + } + else if (obj is List> list) + { + foreach (var item in list) + CollectKeywordsFromState(item, glossary); + } + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs new file mode 100644 index 000000000..c50a9523c --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using System.Text.Json; +using Godot; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Models; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static string? SafeGetCardDescription(CardModel card, PileType pile = PileType.Hand) + { + try { return StripRichTextTags(card.GetDescriptionForPile(pile)).Replace("\n", " "); } + catch { return SafeGetText(() => card.Description)?.Replace("\n", " "); } + } + + internal static string? SafeGetText(Func getter) + { + try + { + var result = getter(); + if (result == null) return null; + // If it's a LocString, call GetFormattedText + if (result is MegaCrit.Sts2.Core.Localization.LocString locString) + return StripRichTextTags(locString.GetFormattedText()); + return result.ToString(); + } + catch { return null; } + } + + internal static string StripRichTextTags(string text) + { + // Remove BBCode-style tags like [color=red], [/color], etc. + // Special case: [img]res://path/to/file.png[/img] → [file.png] + var sb = new StringBuilder(); + int i = 0; + while (i < text.Length) + { + if (text[i] == '[') + { + // Check for [img]...[/img] pattern + if (text.AsSpan(i).StartsWith("[img]")) + { + int contentStart = i + 5; // length of "[img]" + int closeTag = text.IndexOf("[/img]", contentStart, StringComparison.Ordinal); + if (closeTag >= 0) + { + string path = text[contentStart..closeTag]; + int lastSlash = path.LastIndexOf('/'); + string filename = lastSlash >= 0 ? path[(lastSlash + 1)..] : path; + sb.Append('[').Append(filename).Append(']'); + i = closeTag + 6; // length of "[/img]" + continue; + } + } + + int end = text.IndexOf(']', i); + if (end >= 0) { i = end + 1; continue; } + } + sb.Append(text[i]); + i++; + } + return sb.ToString(); + } + + internal static void SendJson(HttpListenerResponse response, object data) + { + string json = JsonSerializer.Serialize(data, _jsonOptions); + byte[] buffer = Encoding.UTF8.GetBytes(json); + response.ContentType = "application/json; charset=utf-8"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + + internal static void SendText(HttpListenerResponse response, string text, string contentType = "text/plain") + { + byte[] buffer = Encoding.UTF8.GetBytes(text); + response.ContentType = $"{contentType}; charset=utf-8"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + + internal static void SendError(HttpListenerResponse response, int statusCode, string message) + { + response.StatusCode = statusCode; + SendJson(response, new Dictionary { ["error"] = message }); + } + + private static Dictionary Error(string message) + { + return new Dictionary { ["status"] = "error", ["error"] = message }; + } + + internal static List FindAll(Node start) where T : Node + { + var list = new List(); + if (GodotObject.IsInstanceValid(start)) + FindAllRecursive(start, list); + return list; + } + + /// + /// FindAll variant that sorts results by visual position (row-major: top-to-bottom, left-to-right). + /// NGridCardHolder.OnFocus() calls MoveToFront() which scrambles child order for z-rendering. + /// Sorting by GlobalPosition restores the correct visual order for both single-row (card rewards, + /// choose-a-card) and multi-row (deck selection grids) layouts. + /// + internal static List FindAllSortedByPosition(Node start) where T : Control + { + var list = FindAll(start); + list.Sort((a, b) => + { + int cmp = a.GlobalPosition.Y.CompareTo(b.GlobalPosition.Y); + return cmp != 0 ? cmp : a.GlobalPosition.X.CompareTo(b.GlobalPosition.X); + }); + return list; + } + + private static void FindAllRecursive(Node node, List found) where T : Node + { + if (!GodotObject.IsInstanceValid(node)) + return; + if (node is T item) + found.Add(item); + foreach (var child in node.GetChildren()) + FindAllRecursive(child, found); + } + + private static List> BuildHoverTips(IEnumerable tips) + { + var result = new List>(); + try + { + var seen = new HashSet(); + foreach (var tip in IHoverTip.RemoveDupes(tips)) + { + try + { + string? title = null; + string? description = null; + + if (tip is HoverTip ht) + { + title = ht.Title != null ? StripRichTextTags(ht.Title) : null; + description = StripRichTextTags(ht.Description); + } + else if (tip is CardHoverTip cardTip) + { + title = SafeGetText(() => cardTip.Card.Title); + description = SafeGetCardDescription(cardTip.Card); + } + + if (title == null && description == null) continue; + + string key = title ?? description!; + if (!seen.Add(key)) continue; + + result.Add(new Dictionary + { + ["name"] = title, + ["description"] = description + }); + } + catch { /* skip individual tip on error */ } + } + } + catch { /* return partial results */ } + return result; + } + + internal static T? FindFirst(Node start) where T : Node + { + if (!GodotObject.IsInstanceValid(start)) + return null; + if (start is T result) + return result; + foreach (var child in start.GetChildren()) + { + var val = FindFirst(child); + if (val != null) return val; + } + return null; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs new file mode 100644 index 000000000..000632f37 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Text.Json; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions; +using MegaCrit.Sts2.Core.Multiplayer.Game; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary ExecuteMultiplayerAction(string action, Dictionary data) + { + if (!RunManager.Instance.IsInProgress) + return Error("No run in progress"); + + if (!RunManager.Instance.NetService.Type.IsMultiplayer()) + return Error("Not in a multiplayer run. Use /api/v1/singleplayer instead."); + + var runState = RunManager.Instance.DebugOnlyGetState()!; + var player = LocalContext.GetMe(runState); + if (player == null) + return Error("Could not find local player"); + + return action switch + { + // Delegated to existing sync-safe handlers + "play_card" => ExecutePlayCard(player, data), + "use_potion" => ExecuteUsePotion(player, data), + "choose_map_node" => ExecuteChooseMapNode(data), + "choose_event_option" => ExecuteChooseEventOption(data), + "advance_dialogue" => ExecuteAdvanceDialogue(), + "choose_rest_option" => ExecuteChooseRestOption(data), + "shop_purchase" => ExecuteShopPurchase(player, data), + "claim_reward" => ExecuteClaimReward(data), + "select_card_reward" => ExecuteSelectCardReward(data), + "skip_card_reward" => ExecuteSkipCardReward(), + "proceed" => ExecuteProceed(), + "select_card" => ExecuteSelectCard(data), + "confirm_selection" => ExecuteConfirmSelection(), + "cancel_selection" => ExecuteCancelSelection(), + "combat_select_card" => ExecuteCombatSelectCard(data), + "combat_confirm_selection" => ExecuteCombatConfirmSelection(), + "select_relic" => ExecuteSelectRelic(data), + "skip_relic_selection" => ExecuteSkipRelicSelection(), + "claim_treasure_relic" => ExecuteClaimTreasureRelic(data), + + // Multiplayer-specific actions + "end_turn" => ExecuteMultiplayerEndTurn(player), + "undo_end_turn" => ExecuteUndoEndTurn(player), + + _ => Error($"Unknown multiplayer action: {action}") + }; + } + + private static Dictionary ExecuteMultiplayerEndTurn(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + if (!player.Creature.IsAlive) + return Error("Player creature is dead — cannot end turn"); + if (CombatManager.Instance.IsPlayerReadyToEndTurn(player)) + return Error("Already submitted end turn — use 'undo_end_turn' to retract"); + + // Match the game's own CanTurnBeEnded guard (NEndTurnButton.cs:114-123) + var hand = NCombatRoom.Instance?.Ui?.Hand; + if (hand != null && (hand.InCardPlay || hand.CurrentMode != NPlayerHand.Mode.Play)) + return Error("Cannot end turn while a card is being played or hand is in selection mode"); + + var combatState = player.Creature.CombatState; + if (combatState == null) + return Error("No combat state"); + + int roundNumber = combatState.RoundNumber; + RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue( + new EndPlayerTurnAction(player, roundNumber)); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Submitted end turn (waiting for other players)" + }; + } + + private static Dictionary ExecuteUndoEndTurn(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + if (!player.Creature.IsAlive) + return Error("Player creature is dead"); + if (!CombatManager.Instance.IsPlayerReadyToEndTurn(player)) + return Error("Not ready to end turn — nothing to undo"); + + var combatState = player.Creature.CombatState; + if (combatState == null) + return Error("No combat state"); + + int roundNumber = combatState.RoundNumber; + RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue( + new UndoEndPlayerTurnAction(player, roundNumber)); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Undid end turn — continue playing cards" + }; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs new file mode 100644 index 000000000..c23111c37 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Multiplayer.Game; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Events; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +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.Map; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; +using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary BuildMultiplayerGameState() + { + var result = new Dictionary(); + + if (!RunManager.Instance.IsInProgress) + { + result["state_type"] = "menu"; + result["message"] = "No run in progress. Player is in the main menu."; + return result; + } + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + { + result["state_type"] = "unknown"; + return result; + } + + if (!RunManager.Instance.NetService.Type.IsMultiplayer()) + { + result["state_type"] = "error"; + result["message"] = "Not in a multiplayer run. Use /api/v1/singleplayer instead."; + return result; + } + + // Multiplayer metadata + result["game_mode"] = "multiplayer"; + result["net_type"] = RunManager.Instance.NetService.Type.ToString(); + result["player_count"] = runState.Players.Count; + var localPlayer = LocalContext.GetMe(runState); + if (localPlayer != null) + { + for (int i = 0; i < runState.Players.Count; i++) + { + if (runState.Players[i] == localPlayer) + { + result["local_player_slot"] = i; + break; + } + } + } + + // Same overlay-first detection logic as singleplayer + var topOverlay = NOverlayStack.Instance?.Peek(); + var currentRoom = runState.CurrentRoom; + + if (topOverlay is NCardGridSelectionScreen cardSelectScreen) + { + result["state_type"] = "card_select"; + result["card_select"] = BuildCardSelectState(cardSelectScreen, runState); + } + else if (topOverlay is NChooseACardSelectionScreen chooseCardScreen) + { + result["state_type"] = "card_select"; + result["card_select"] = BuildChooseCardState(chooseCardScreen, runState); + } + else if (topOverlay is NChooseARelicSelection relicSelectScreen) + { + result["state_type"] = "relic_select"; + result["relic_select"] = BuildRelicSelectState(relicSelectScreen, runState); + } + else if (topOverlay is IOverlayScreen + && topOverlay is not NRewardsScreen + && topOverlay is not NCardRewardSelectionScreen) + { + result["state_type"] = "overlay"; + result["overlay"] = new Dictionary + { + ["screen_type"] = topOverlay.GetType().Name, + ["message"] = $"An overlay ({topOverlay.GetType().Name}) is active. It may require manual interaction in-game." + }; + } + else if (currentRoom is CombatRoom combatRoom) + { + if (CombatManager.Instance.IsInProgress) + { + var playerHand = NPlayerHand.Instance; + if (playerHand != null && playerHand.IsInCardSelection) + { + result["state_type"] = "hand_select"; + result["hand_select"] = BuildHandSelectState(playerHand, runState); + result["battle"] = BuildMultiplayerBattleState(runState, combatRoom); + } + else + { + result["state_type"] = combatRoom.RoomType.ToString().ToLower(); + result["battle"] = BuildMultiplayerBattleState(runState, combatRoom); + } + } + else + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NCardRewardSelectionScreen cardScreen) + { + result["state_type"] = "card_reward"; + result["card_reward"] = BuildCardRewardState(cardScreen); + } + else if (overlay is NRewardsScreen rewardsScreen) + { + result["state_type"] = "combat_rewards"; + result["rewards"] = BuildRewardsState(rewardsScreen, runState); + } + else + { + result["state_type"] = combatRoom.RoomType.ToString().ToLower(); + result["message"] = "Combat ended. Waiting for rewards..."; + } + } + } + } + else if (currentRoom is EventRoom eventRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else + { + result["state_type"] = "event"; + result["event"] = BuildMultiplayerEventState(eventRoom, runState); + } + } + else if (currentRoom is MapRoom) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else if (currentRoom is MerchantRoom merchantRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else + { + var merchUI = NMerchantRoom.Instance; + if (merchUI != null && !merchUI.Inventory.IsOpen) + merchUI.OpenInventory(); + + result["state_type"] = "shop"; + result["shop"] = BuildShopState(merchantRoom, runState); + } + } + else if (currentRoom is RestSiteRoom restSiteRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else + { + result["state_type"] = "rest_site"; + result["rest_site"] = BuildRestSiteState(restSiteRoom, runState); + } + } + else if (currentRoom is TreasureRoom treasureRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMultiplayerMapState(runState); + } + else + { + result["state_type"] = "treasure"; + result["treasure"] = BuildMultiplayerTreasureState(treasureRoom, runState); + } + } + else + { + result["state_type"] = "unknown"; + result["room_type"] = currentRoom?.GetType().Name; + } + + // Common run info + result["run"] = new Dictionary + { + ["act"] = runState.CurrentActIndex + 1, + ["floor"] = runState.TotalFloor, + ["ascension"] = runState.AscensionLevel + }; + + // All players summary (always included for multiplayer) + result["players"] = BuildAllPlayersState(runState); + + return result; + } + + private static Dictionary BuildMultiplayerBattleState(RunState runState, CombatRoom combatRoom) + { + var combatState = CombatManager.Instance.DebugOnlyGetState(); + var battle = new Dictionary(); + + if (combatState == null) + { + battle["error"] = "Combat state unavailable"; + return battle; + } + + battle["round"] = combatState.RoundNumber; + battle["turn"] = combatState.CurrentSide.ToString().ToLower(); + battle["is_play_phase"] = CombatManager.Instance.IsPlayPhase; + battle["all_players_ready"] = CombatManager.Instance.AllPlayersReadyToEndTurn(); + + // All players in combat — full state for local player, summary for others + var players = new List>(); + Dictionary? localPlayerState = null; + foreach (var player in runState.Players) + { + bool isLocal = LocalContext.IsMe(player); + // Full hand/piles/orbs only for local player; others get summary only + var playerState = isLocal ? BuildPlayerState(player) : BuildPlayerStateSummary(player); + playerState["is_local"] = isLocal; + playerState["is_alive"] = player.Creature.IsAlive; + playerState["is_ready_to_end_turn"] = CombatManager.Instance.IsPlayerReadyToEndTurn(player); + players.Add(playerState); + if (isLocal) + localPlayerState = playerState; + } + battle["players"] = players; + + // Local player shortcut (same dict as the is_local=true entry in players) + if (localPlayerState != null) + battle["player"] = localPlayerState; + + // Enemies + var enemies = new List>(); + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (creature.IsAlive) + enemies.Add(BuildEnemyState(creature, entityCounts)); + } + battle["enemies"] = enemies; + + return battle; + } + + private static Dictionary BuildMultiplayerMapState(RunState runState) + { + // Start with the standard map state + var state = BuildMapState(runState); + + // Add per-player vote data + try + { + var mapSync = RunManager.Instance.MapSelectionSynchronizer; + var votes = new List>(); + + foreach (var player in runState.Players) + { + var vote = mapSync.GetVote(player); + votes.Add(new Dictionary + { + ["player"] = SafeGetText(() => player.Character.Title), + ["is_local"] = LocalContext.IsMe(player), + ["voted"] = vote != null, + ["vote_col"] = vote?.coord.col, + ["vote_row"] = vote?.coord.row + }); + } + + state["votes"] = votes; + state["all_voted"] = votes.All(v => v["voted"] is true); + } + catch + { + // MapSelectionSynchronizer may not be available in all contexts + } + + // All players summary + state["players"] = BuildAllPlayersState(runState); + + return state; + } + + private static Dictionary BuildMultiplayerEventState(EventRoom eventRoom, RunState runState) + { + // Start with the standard event state + var state = BuildEventState(eventRoom, runState); + + // Add multiplayer-specific event data + try + { + var eventSync = RunManager.Instance.EventSynchronizer; + bool isShared = false; + try { isShared = eventSync.IsShared; } catch { /* throws if no event in progress */ } + state["is_shared"] = isShared; + + if (isShared) + { + var votes = new List>(); + foreach (var player in runState.Players) + { + var vote = eventSync.GetPlayerVote(player); + votes.Add(new Dictionary + { + ["player"] = SafeGetText(() => player.Character.Title), + ["is_local"] = LocalContext.IsMe(player), + ["voted"] = vote != null, + ["vote_option"] = vote + }); + } + state["votes"] = votes; + state["all_voted"] = votes.All(v => v["voted"] is true); + } + } + catch + { + // EventSynchronizer may not be available + } + + // All players summary + state["players"] = BuildAllPlayersState(runState); + + return state; + } + + private static Dictionary BuildMultiplayerTreasureState(TreasureRoom treasureRoom, RunState runState) + { + // Auto-open chest same as singleplayer. BeginRelicPicking() runs during + // TreasureRoom.Enter(), so relics are already generated. The chest click + // just triggers the UI animation + gold via OneOffSynchronizer — same path + // as a human click or the game's own AutoSlay handler. + var state = BuildTreasureState(treasureRoom, runState); + + // Add per-player bid data + try + { + var treasureSync = RunManager.Instance.TreasureRoomRelicSynchronizer; + var currentRelics = treasureSync.CurrentRelics; + + state["is_bidding_phase"] = currentRelics != null; + + if (currentRelics != null) + { + var bids = new List>(); + foreach (var player in runState.Players) + { + var vote = treasureSync.GetPlayerVote(player); + bids.Add(new Dictionary + { + ["player"] = SafeGetText(() => player.Character.Title), + ["is_local"] = LocalContext.IsMe(player), + ["voted"] = vote != null, + ["vote_relic_index"] = vote + }); + } + state["bids"] = bids; + state["all_bid"] = bids.All(b => b["voted"] is true); + } + } + catch + { + // TreasureRoomRelicSynchronizer may not be available + } + + // All players summary + state["players"] = BuildAllPlayersState(runState); + + return state; + } + + /// + /// Builds player combat state without private info (hand, draw/discard/exhaust piles, orbs). + /// Used for non-local players in multiplayer — shows HP, block, energy, powers, relics, potions. + /// + private static Dictionary BuildPlayerStateSummary(Player player) + { + var state = new Dictionary(); + var creature = player.Creature; + var combatState = player.PlayerCombatState; + + state["character"] = SafeGetText(() => player.Character.Title); + state["hp"] = creature.CurrentHp; + state["max_hp"] = creature.MaxHp; + state["block"] = creature.Block; + + if (combatState != null) + { + state["energy"] = combatState.Energy; + state["max_energy"] = combatState.MaxEnergy; + + if (player.Character.ShouldAlwaysShowStarCounter || combatState.Stars > 0) + state["stars"] = combatState.Stars; + } + + state["gold"] = player.Gold; + state["status"] = BuildPowersState(creature); + + var relics = new List>(); + foreach (var relic in player.Relics) + { + relics.Add(new Dictionary + { + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["counter"] = relic.ShowCounter ? relic.DisplayAmount : null, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + state["relics"] = relics; + + var potions = new List>(); + int slotIndex = 0; + foreach (var potion in player.PotionSlots) + { + if (potion != null) + { + potions.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["slot"] = slotIndex + }); + } + slotIndex++; + } + state["potions"] = potions; + + return state; + } + + private static List> BuildAllPlayersState(RunState runState) + { + var players = new List>(); + foreach (var player in runState.Players) + { + players.Add(new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["is_local"] = LocalContext.IsMe(player), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["is_alive"] = player.Creature.IsAlive + }); + } + return players; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs new file mode 100644 index 000000000..965ca64cf --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs @@ -0,0 +1,1446 @@ +using System.Collections.Generic; +using System.Linq; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Entities.Potions; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.MonsterMoves.Intents; +using MegaCrit.Sts2.Core.MonsterMoves.MonsterMoveStateMachine; +using MegaCrit.Sts2.Core.Entities.Merchant; +using MegaCrit.Sts2.Core.Entities.RestSite; +using MegaCrit.Sts2.Core.Events; +using MegaCrit.Sts2.Core.Nodes.Events; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Map; +using MegaCrit.Sts2.Core.Nodes.Cards; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Nodes.Screens; +using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; +using MegaCrit.Sts2.Core.Nodes.Screens.Map; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.Relics; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; +using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Rewards; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary BuildGameState() + { + var result = new Dictionary(); + + if (!RunManager.Instance.IsInProgress) + { + result["state_type"] = "menu"; + result["message"] = "No run in progress. Player is in the main menu."; + result["menu"] = BuildMenuState(); + return result; + } + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + { + result["state_type"] = "unknown"; + return result; + } + + // Card selection overlays can appear on top of any room (events, rest sites, combat) + var topOverlay = NOverlayStack.Instance?.Peek(); + var currentRoom = runState.CurrentRoom; + if (topOverlay is NCardGridSelectionScreen cardSelectScreen) + { + result["state_type"] = "card_select"; + result["card_select"] = BuildCardSelectState(cardSelectScreen, runState); + } + else if (topOverlay is NChooseACardSelectionScreen chooseCardScreen) + { + result["state_type"] = "card_select"; + result["card_select"] = BuildChooseCardState(chooseCardScreen, runState); + } + else if (topOverlay is NChooseARelicSelection relicSelectScreen) + { + result["state_type"] = "relic_select"; + result["relic_select"] = BuildRelicSelectState(relicSelectScreen, runState); + } + else if (topOverlay is NGameOverScreen gameOverScreen) + { + result["state_type"] = "game_over"; + result["game_over"] = BuildGameOverState(gameOverScreen, runState); + } + else if (topOverlay is IOverlayScreen + && topOverlay is not NRewardsScreen + && topOverlay is not NCardRewardSelectionScreen) + { + // Catch-all for unhandled overlays — prevents soft-locks + result["state_type"] = "overlay"; + result["overlay"] = new Dictionary + { + ["screen_type"] = topOverlay.GetType().Name, + ["message"] = $"An overlay ({topOverlay.GetType().Name}) is active. It may require manual interaction in-game." + }; + } + else if (currentRoom is CombatRoom combatRoom) + { + if (CombatManager.Instance.IsInProgress) + { + // Check for in-combat hand card selection (e.g., "Select a card to exhaust") + var playerHand = NPlayerHand.Instance; + if (playerHand != null && playerHand.IsInCardSelection) + { + result["state_type"] = "hand_select"; + result["hand_select"] = BuildHandSelectState(playerHand, runState); + result["battle"] = BuildBattleState(runState, combatRoom); + } + else + { + result["state_type"] = combatRoom.RoomType.ToString().ToLower(); // monster, elite, boss + result["battle"] = BuildBattleState(runState, combatRoom); + } + } + else + { + // After combat ends, check: map open (post-rewards) > overlays > fallback + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NCardRewardSelectionScreen cardScreen) + { + result["state_type"] = "card_reward"; + result["card_reward"] = BuildCardRewardState(cardScreen); + } + else if (overlay is NRewardsScreen rewardsScreen) + { + result["state_type"] = "combat_rewards"; + result["rewards"] = BuildRewardsState(rewardsScreen, runState); + } + else + { + result["state_type"] = combatRoom.RoomType.ToString().ToLower(); + result["message"] = "Combat ended. Waiting for rewards..."; + } + } + } + } + else if (currentRoom is EventRoom eventRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + result["state_type"] = "event"; + result["event"] = BuildEventState(eventRoom, runState); + } + } + else if (currentRoom is MapRoom) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else if (currentRoom is MerchantRoom merchantRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + // Auto-open the shopkeeper's inventory if not already open + var merchUI = NMerchantRoom.Instance; + if (merchUI != null && !merchUI.Inventory.IsOpen) + { + merchUI.OpenInventory(); + } + result["state_type"] = "shop"; + result["shop"] = BuildShopState(merchantRoom, runState); + } + } + else if (currentRoom is RestSiteRoom restSiteRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + result["state_type"] = "rest_site"; + result["rest_site"] = BuildRestSiteState(restSiteRoom, runState); + } + } + else if (currentRoom is TreasureRoom treasureRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + result["state_type"] = "treasure"; + result["treasure"] = BuildTreasureState(treasureRoom, runState); + } + } + else + { + result["state_type"] = "unknown"; + result["room_type"] = currentRoom?.GetType().Name; + } + + // Common run info + result["run"] = new Dictionary + { + ["act"] = runState.CurrentActIndex + 1, + ["floor"] = runState.TotalFloor, + ["ascension"] = runState.AscensionLevel + }; + + return result; + } + + private static Dictionary BuildGameOverState(NGameOverScreen screen, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + }; + } + + state["screen_type"] = nameof(NGameOverScreen); + + var returnButton = FindFirst(screen); + var continueButton = FindFirst(screen); + var viewRunButton = FindFirst(screen); + + state["can_return_to_main_menu"] = returnButton?.IsVisibleInTree() == true && returnButton.IsEnabled; + state["can_continue"] = continueButton?.IsVisibleInTree() == true && continueButton.IsEnabled; + state["can_view_run"] = viewRunButton?.IsVisibleInTree() == true && viewRunButton.IsEnabled; + + var options = new List>(); + if (returnButton != null && returnButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "return_to_main_menu", + ["title"] = "Return To Main Menu", + ["is_enabled"] = returnButton.IsEnabled, + }); + } + if (continueButton != null && continueButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "continue", + ["title"] = "Continue", + ["is_enabled"] = continueButton.IsEnabled, + }); + } + if (viewRunButton != null && viewRunButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "view_run", + ["title"] = "View Run", + ["is_enabled"] = viewRunButton.IsEnabled, + }); + } + state["options"] = options; + + return state; + } + + private static Dictionary BuildMenuState() + { + var state = new Dictionary(); + + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var characterSelect = FindFirst(root); + if (characterSelect != null && characterSelect.IsVisibleInTree()) + { + state["screen"] = "character_select"; + + var characters = new List>(); + foreach (var button in FindAll(characterSelect)) + { + var character = button.Character; + if (character == null) continue; + + characters.Add(new Dictionary + { + ["id"] = character.Id.Entry, + ["name"] = SafeGetText(() => character.Title), + }); + } + state["characters"] = characters; + + var ascensionPanel = FindFirst(characterSelect); + if (ascensionPanel != null) + state["ascension"] = ascensionPanel.Ascension; + + var embarkButton = FindAll(characterSelect) + .FirstOrDefault(button => button.IsVisibleInTree()); + state["can_start_new_game"] = embarkButton?.IsEnabled ?? false; + state["can_continue_game"] = false; + state["can_abandon_game"] = false; + + return state; + } + + var mainMenu = FindFirst(root); + if (mainMenu != null && mainMenu.IsVisibleInTree()) + { + state["screen"] = "main_menu"; + + var continueInfo = mainMenu.ContinueRunInfo; + bool canContinue = continueInfo != null && continueInfo.IsVisibleInTree(); + + state["can_continue_game"] = canContinue; + state["can_abandon_game"] = canContinue; + state["can_start_new_game"] = true; + return state; + } + + state["screen"] = "unknown"; + state["can_continue_game"] = false; + state["can_abandon_game"] = false; + state["can_start_new_game"] = false; + return state; + } + + private static Dictionary BuildBattleState(RunState runState, CombatRoom combatRoom) + { + var combatState = CombatManager.Instance.DebugOnlyGetState(); + var battle = new Dictionary(); + + if (combatState == null) + { + battle["error"] = "Combat state unavailable"; + return battle; + } + + battle["round"] = combatState.RoundNumber; + battle["turn"] = combatState.CurrentSide.ToString().ToLower(); + battle["is_play_phase"] = CombatManager.Instance.IsPlayPhase; + + // Player state + var player = LocalContext.GetMe(runState); + if (player != null) + { + battle["player"] = BuildPlayerState(player); + } + + // Enemies + var enemies = new List>(); + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (creature.IsAlive) + { + enemies.Add(BuildEnemyState(creature, entityCounts)); + } + } + battle["enemies"] = enemies; + + return battle; + } + + private static Dictionary BuildPlayerState(Player player) + { + var state = new Dictionary(); + var creature = player.Creature; + var combatState = player.PlayerCombatState; + + state["character"] = SafeGetText(() => player.Character.Title); + state["hp"] = creature.CurrentHp; + state["max_hp"] = creature.MaxHp; + state["block"] = creature.Block; + + if (combatState != null) + { + state["energy"] = combatState.Energy; + state["max_energy"] = combatState.MaxEnergy; + + // Stars (The Regent's resource, conditionally shown) + if (player.Character.ShouldAlwaysShowStarCounter || combatState.Stars > 0) + { + state["stars"] = combatState.Stars; + } + + // Hand + var hand = new List>(); + int cardIndex = 0; + foreach (var card in combatState.Hand.Cards) + { + hand.Add(BuildCardState(card, cardIndex)); + cardIndex++; + } + state["hand"] = hand; + + // Pile counts + state["draw_pile_count"] = combatState.DrawPile.Cards.Count; + state["discard_pile_count"] = combatState.DiscardPile.Cards.Count; + state["exhaust_pile_count"] = combatState.ExhaustPile.Cards.Count; + + // Pile contents + state["draw_pile"] = BuildPileCardList(combatState.DrawPile.Cards, PileType.Draw); + state["discard_pile"] = BuildPileCardList(combatState.DiscardPile.Cards, PileType.Discard); + state["exhaust_pile"] = BuildPileCardList(combatState.ExhaustPile.Cards, PileType.Exhaust); + + // Orbs + if (combatState.OrbQueue.Capacity > 0) + { + var orbs = new List>(); + foreach (var orb in combatState.OrbQueue.Orbs) + { + // Populate SmartDescription placeholders with Focus-modified values, + // mirroring OrbModel.HoverTips getter (OrbModel.cs:92-94) + string? description = SafeGetText(() => + { + var desc = orb.SmartDescription; + desc.Add("energyPrefix", orb.Owner.Character.CardPool.Title); + desc.Add("Passive", orb.PassiveVal); + desc.Add("Evoke", orb.EvokeVal); + return desc; + }); + orbs.Add(new Dictionary + { + ["id"] = orb.Id.Entry, + ["name"] = SafeGetText(() => orb.Title), + ["description"] = description, + ["passive_val"] = orb.PassiveVal, + ["evoke_val"] = orb.EvokeVal, + ["keywords"] = BuildHoverTips(orb.HoverTips) + }); + } + state["orbs"] = orbs; + state["orb_slots"] = combatState.OrbQueue.Capacity; + state["orb_empty_slots"] = combatState.OrbQueue.Capacity - combatState.OrbQueue.Orbs.Count; + } + } + + state["gold"] = player.Gold; + + // Powers (status effects) + state["status"] = BuildPowersState(creature); + + // Relics + var relics = new List>(); + foreach (var relic in player.Relics) + { + relics.Add(new Dictionary + { + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["counter"] = relic.ShowCounter ? relic.DisplayAmount : null, + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + } + state["relics"] = relics; + + // Potions + var potions = new List>(); + int slotIndex = 0; + foreach (var potion in player.PotionSlots) + { + if (potion != null) + { + potions.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["slot"] = slotIndex, + ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, + ["target_type"] = potion.TargetType.ToString(), + ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) + }); + } + slotIndex++; + } + state["potions"] = potions; + + return state; + } + + private static Dictionary BuildCardState(CardModel card, int index) + { + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + { + int cost = card.EnergyCost.GetAmountToSpend(); + costDisplay = cost.ToString(); + } + + card.CanPlay(out var unplayableReason, out _); + + // Star cost (The Regent's cards; CanonicalStarCost >= 0 means card has a star cost) + string? starCostDisplay = null; + if (card.HasStarCostX) + starCostDisplay = "X"; + else if (card.CurrentStarCost >= 0) + starCostDisplay = card.GetStarCostWithModifiers().ToString(); + + return new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = card.Title, + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["star_cost"] = starCostDisplay, + ["description"] = SafeGetCardDescription(card), + ["target_type"] = card.TargetType.ToString(), + ["can_play"] = unplayableReason == UnplayableReason.None, + ["unplayable_reason"] = unplayableReason != UnplayableReason.None ? unplayableReason.ToString() : null, + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }; + } + + private static List> BuildPileCardList(IEnumerable cards, PileType pile) + { + var list = new List>(); + foreach (var card in cards) + { + list.Add(new Dictionary + { + ["name"] = SafeGetText(() => card.Title), + ["description"] = SafeGetCardDescription(card, pile) + }); + } + return list; + } + + private static Dictionary BuildEnemyState(Creature creature, Dictionary entityCounts) + { + var monster = creature.Monster; + string baseId = monster?.Id.Entry ?? "unknown"; + + // Generate entity_id like "jaw_worm_0" + if (!entityCounts.TryGetValue(baseId, out int count)) + count = 0; + entityCounts[baseId] = count + 1; + string entityId = $"{baseId}_{count}"; + + var state = new Dictionary + { + ["entity_id"] = entityId, + ["combat_id"] = creature.CombatId, + ["name"] = SafeGetText(() => monster?.Title), + ["hp"] = creature.CurrentHp, + ["max_hp"] = creature.MaxHp, + ["block"] = creature.Block, + ["status"] = BuildPowersState(creature) + }; + + // Intents + if (monster?.NextMove is MoveState moveState) + { + var intents = new List>(); + foreach (var intent in moveState.Intents) + { + var intentData = new Dictionary + { + ["type"] = intent.IntentType.ToString() + }; + try + { + var targets = creature.CombatState?.PlayerCreatures; + if (targets != null) + { + string label = intent.GetIntentLabel(targets, creature).GetFormattedText(); + intentData["label"] = StripRichTextTags(label); + + var hoverTip = intent.GetHoverTip(targets, creature); + if (hoverTip.Title != null) + intentData["title"] = StripRichTextTags(hoverTip.Title); + if (hoverTip.Description != null) + intentData["description"] = StripRichTextTags(hoverTip.Description); + } + } + catch { /* intent label may fail for some types */ } + intents.Add(intentData); + } + state["intents"] = intents; + } + + return state; + } + + private static Dictionary BuildEventState(EventRoom eventRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var eventModel = eventRoom.CanonicalEvent; + bool isAncient = eventModel is AncientEventModel; + state["event_id"] = eventModel.Id.Entry; + state["event_name"] = SafeGetText(() => eventModel.Title); + state["is_ancient"] = isAncient; + + // Check dialogue state for ancients + bool inDialogue = false; + var uiRoom = NEventRoom.Instance; + if (isAncient && uiRoom != null) + { + var ancientLayout = FindFirst(uiRoom); + if (ancientLayout != null) + { + var hitbox = ancientLayout.GetNodeOrNull("%DialogueHitbox"); + inDialogue = hitbox != null && hitbox.Visible && hitbox.IsEnabled; + } + } + state["in_dialogue"] = inDialogue; + + // Event body text + state["body"] = SafeGetText(() => eventModel.Description); + + // Options from UI + var options = new List>(); + if (uiRoom != null) + { + var buttons = FindAll(uiRoom); + int index = 0; + foreach (var button in buttons) + { + var opt = button.Option; + var optData = new Dictionary + { + ["index"] = index, + ["title"] = SafeGetText(() => opt.Title), + ["description"] = SafeGetText(() => opt.Description), + ["is_locked"] = opt.IsLocked, + ["is_proceed"] = opt.IsProceed, + ["was_chosen"] = opt.WasChosen + }; + if (opt.Relic != null) + { + optData["relic_name"] = SafeGetText(() => opt.Relic.Title); + optData["relic_description"] = SafeGetText(() => opt.Relic.DynamicDescription); + } + optData["keywords"] = BuildHoverTips(opt.HoverTips); + options.Add(optData); + index++; + } + } + state["options"] = options; + + return state; + } + + private static Dictionary BuildRestSiteState(RestSiteRoom restSiteRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var options = new List>(); + int index = 0; + foreach (var opt in restSiteRoom.Options) + { + options.Add(new Dictionary + { + ["index"] = index, + ["id"] = opt.OptionId, + ["name"] = SafeGetText(() => opt.Title), + ["description"] = SafeGetText(() => opt.Description), + ["is_enabled"] = opt.IsEnabled + }); + index++; + } + state["options"] = options; + + var proceedButton = NRestSiteRoom.Instance?.ProceedButton; + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildShopState(MerchantRoom merchantRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = player.PotionSlots.Count, + ["open_potion_slots"] = player.PotionSlots.Count(s => s == null) + }; + } + + var inventory = merchantRoom.Inventory; + var items = new List>(); + int index = 0; + + // Cards + foreach (var entry in inventory.CardEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "card", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = entry.EnoughGold, + ["on_sale"] = entry.IsOnSale + }; + if (entry.CreationResult?.Card is { } card) + { + item["card_id"] = card.Id.Entry; + item["card_name"] = SafeGetText(() => card.Title); + item["card_type"] = card.Type.ToString(); + item["card_rarity"] = card.Rarity.ToString(); + item["card_description"] = SafeGetCardDescription(card, PileType.None); + item["keywords"] = BuildHoverTips(card.HoverTips); + } + items.Add(item); + index++; + } + + // Relics + foreach (var entry in inventory.RelicEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "relic", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = 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["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic); + } + items.Add(item); + index++; + } + + // Potions + foreach (var entry in inventory.PotionEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "potion", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = 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["keywords"] = BuildHoverTips(potion.ExtraHoverTips); + } + items.Add(item); + index++; + } + + // Card removal + if (inventory.CardRemovalEntry is { } removal) + { + items.Add(new Dictionary + { + ["index"] = index, + ["category"] = "card_removal", + ["cost"] = removal.Cost, + ["is_stocked"] = removal.IsStocked, + ["can_afford"] = removal.EnoughGold + }); + } + + state["items"] = items; + + var proceedButton = NMerchantRoom.Instance?.ProceedButton; + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildMapState(RunState runState) + { + var state = new Dictionary(); + + // Player summary + var player = LocalContext.GetMe(runState); + if (player != null) + { + int totalSlots = player.PotionSlots.Count; + int openSlots = player.PotionSlots.Count(s => s == null); + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = totalSlots, + ["open_potion_slots"] = openSlots + }; + } + + var map = runState.Map; + var visitedCoords = runState.VisitedMapCoords; + + // Current position + if (visitedCoords.Count > 0) + { + var cur = visitedCoords[visitedCoords.Count - 1]; + state["current_position"] = new Dictionary + { + ["col"] = cur.col, ["row"] = cur.row, + ["type"] = map.GetPoint(cur)?.PointType.ToString() + }; + } + + // Visited path + var visited = new List>(); + foreach (var coord in visitedCoords) + { + visited.Add(new Dictionary + { + ["col"] = coord.col, ["row"] = coord.row, + ["type"] = map.GetPoint(coord)?.PointType.ToString() + }); + } + state["visited"] = visited; + + // Next options — read travelable state from UI nodes + var nextOptions = new List>(); + var mapScreen = NMapScreen.Instance; + if (mapScreen != null) + { + var travelable = FindAll(mapScreen) + .Where(mp => mp.State == MapPointState.Travelable) + .OrderBy(mp => mp.Point.coord.col) + .ToList(); + + int index = 0; + foreach (var nmp in travelable) + { + var pt = nmp.Point; + var option = new Dictionary + { + ["index"] = index, + ["col"] = pt.coord.col, + ["row"] = pt.coord.row, + ["type"] = pt.PointType.ToString() + }; + + // 1-level lookahead + var children = pt.Children + .OrderBy(c => c.coord.col) + .Select(c => new Dictionary + { + ["col"] = c.coord.col, ["row"] = c.coord.row, + ["type"] = c.PointType.ToString() + }).ToList(); + if (children.Count > 0) + option["leads_to"] = children; + + nextOptions.Add(option); + index++; + } + } + state["next_options"] = nextOptions; + + // Full map — all nodes organized for planning + var nodes = new List>(); + + // Starting point + var start = map.StartingMapPoint; + nodes.Add(BuildMapNode(start)); + + // Grid nodes + foreach (var pt in map.GetAllMapPoints()) + nodes.Add(BuildMapNode(pt)); + + // Boss + nodes.Add(BuildMapNode(map.BossMapPoint)); + if (map.SecondBossMapPoint != null) + nodes.Add(BuildMapNode(map.SecondBossMapPoint)); + + state["nodes"] = nodes; + state["boss"] = new Dictionary + { + ["col"] = map.BossMapPoint.coord.col, + ["row"] = map.BossMapPoint.coord.row + }; + + return state; + } + + private static Dictionary BuildMapNode(MapPoint pt) + { + return new Dictionary + { + ["col"] = pt.coord.col, + ["row"] = pt.coord.row, + ["type"] = pt.PointType.ToString(), + ["children"] = pt.Children + .OrderBy(c => c.coord.col) + .Select(c => new List { c.coord.col, c.coord.row }) + .ToList() + }; + } + + private static Dictionary BuildRewardsState(NRewardsScreen rewardsScreen, RunState runState) + { + var state = new Dictionary(); + + // Player summary for decision-making context + var player = LocalContext.GetMe(runState); + if (player != null) + { + int totalSlots = player.PotionSlots.Count; + int openSlots = player.PotionSlots.Count(s => s == null); + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = totalSlots, + ["open_potion_slots"] = openSlots + }; + } + + // Reward items + var rewardButtons = FindAll(rewardsScreen); + var items = new List>(); + int index = 0; + foreach (var button in rewardButtons) + { + if (button.Reward == null || !button.IsEnabled) continue; + var reward = button.Reward; + + var item = new Dictionary + { + ["index"] = index, + ["type"] = GetRewardTypeName(reward), + ["description"] = SafeGetText(() => reward.Description) + }; + + // Type-specific details + if (reward is GoldReward goldReward) + item["gold_amount"] = goldReward.Amount; + else if (reward is PotionReward potionReward && potionReward.Potion != null) + { + item["potion_id"] = potionReward.Potion.Id.Entry; + item["potion_name"] = SafeGetText(() => potionReward.Potion.Title); + } + + items.Add(item); + index++; + } + state["items"] = items; + + // Proceed button + var proceedButton = FindFirst(rewardsScreen); + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildCardRewardState(NCardRewardSelectionScreen cardScreen) + { + var state = new Dictionary(); + + var cardHolders = FindAllSortedByPosition(cardScreen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + string costDisplay = card.EnergyCost.CostsX + ? "X" + : card.EnergyCost.GetAmountToSpend().ToString(); + + string? starCostDisplay = null; + if (card.HasStarCostX) + starCostDisplay = "X"; + else if (card.CurrentStarCost >= 0) + starCostDisplay = card.GetStarCostWithModifiers().ToString(); + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["star_cost"] = starCostDisplay, + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + var altButtons = FindAll(cardScreen); + state["can_skip"] = altButtons.Count > 0; + + return state; + } + + private static Dictionary BuildCardSelectState(NCardGridSelectionScreen screen, RunState runState) + { + var state = new Dictionary(); + + // Screen type + state["screen_type"] = screen switch + { + NDeckTransformSelectScreen => "transform", + NDeckUpgradeSelectScreen => "upgrade", + NDeckCardSelectScreen => "select", + NSimpleCardSelectScreen => "simple_select", + _ => screen.GetType().Name + }; + + // Player summary + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + // Prompt text from UI label + var bottomLabel = screen.GetNodeOrNull("%BottomLabel"); + if (bottomLabel != null) + { + var textVariant = bottomLabel.Get("text"); + string? prompt = textVariant.VariantType != Godot.Variant.Type.Nil ? StripRichTextTags(textVariant.AsString()) : null; + state["prompt"] = prompt; + } + + // Cards in the grid (sorted by visual position — MoveToFront can reorder children) + var cardHolders = FindAllSortedByPosition(screen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + // Preview container showing? (selection complete, awaiting confirm) + // Upgrade screens use UpgradeSinglePreviewContainer / UpgradeMultiPreviewContainer + var previewSingle = screen.GetNodeOrNull("%UpgradeSinglePreviewContainer"); + var previewMulti = screen.GetNodeOrNull("%UpgradeMultiPreviewContainer"); + var previewGeneric = screen.GetNodeOrNull("%PreviewContainer"); + bool previewShowing = (previewSingle?.Visible ?? false) + || (previewMulti?.Visible ?? false) + || (previewGeneric?.Visible ?? false); + state["preview_showing"] = previewShowing; + + // Button states + var closeButton = screen.GetNodeOrNull("%Close"); + state["can_cancel"] = closeButton?.IsEnabled ?? false; + + // Confirm button — search all preview containers and main screen + bool canConfirm = false; + foreach (var container in new[] { previewSingle, previewMulti, previewGeneric }) + { + if (container?.Visible == true) + { + var confirm = container.GetNodeOrNull("Confirm") + ?? container.GetNodeOrNull("%PreviewConfirm"); + if (confirm?.IsEnabled == true) { canConfirm = true; break; } + } + } + if (!canConfirm) + { + var mainConfirm = screen.GetNodeOrNull("Confirm") + ?? screen.GetNodeOrNull("%Confirm"); + if (mainConfirm?.IsEnabled == true) 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()); + } + state["can_confirm"] = canConfirm; + + return state; + } + + private static Dictionary BuildChooseCardState(NChooseACardSelectionScreen screen, RunState runState) + { + var state = new Dictionary(); + state["screen_type"] = "choose"; + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + state["prompt"] = "Choose a card."; + + var cardHolders = FindAllSortedByPosition(screen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + var skipButton = screen.GetNodeOrNull("SkipButton"); + state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + state["preview_showing"] = false; + state["can_confirm"] = false; + state["can_cancel"] = state["can_skip"]; + + return state; + } + + private static Dictionary BuildHandSelectState(NPlayerHand hand, RunState runState) + { + var state = new Dictionary(); + + // Mode + state["mode"] = hand.CurrentMode switch + { + NPlayerHand.Mode.SimpleSelect => "simple_select", + NPlayerHand.Mode.UpgradeSelect => "upgrade_select", + _ => hand.CurrentMode.ToString() + }; + + // Prompt text from %SelectionHeader + var headerLabel = hand.GetNodeOrNull("%SelectionHeader"); + if (headerLabel != null) + { + var textVariant = headerLabel.Get("text"); + string? prompt = textVariant.VariantType != Godot.Variant.Type.Nil + ? StripRichTextTags(textVariant.AsString()) + : null; + state["prompt"] = prompt; + } + + // Selectable cards (visible holders in the hand) + var selectableCards = new List>(); + int index = 0; + foreach (var holder in hand.ActiveHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + selectableCards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = selectableCards; + + // Already-selected cards (in the SelectedHandCardContainer) + var selectedContainer = hand.GetNodeOrNull("%SelectedHandCardContainer"); + if (selectedContainer != null) + { + var selectedCards = new List>(); + var selectedHolders = FindAll(selectedContainer); + int selIdx = 0; + foreach (var holder in selectedHolders) + { + var card = holder.CardModel; + if (card == null) continue; + selectedCards.Add(new Dictionary + { + ["index"] = selIdx, + ["name"] = SafeGetText(() => card.Title) + }); + selIdx++; + } + if (selectedCards.Count > 0) + state["selected_cards"] = selectedCards; + } + + // Confirm button state + var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); + state["can_confirm"] = confirmBtn?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildRelicSelectState(NChooseARelicSelection screen, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + state["prompt"] = "Choose a relic."; + + var relicHolders = FindAll(screen); + var relics = new List>(); + int index = 0; + foreach (var holder in relicHolders) + { + var relic = holder.Relic?.Model; + if (relic == null) continue; + + relics.Add(new Dictionary + { + ["index"] = index, + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + index++; + } + state["relics"] = relics; + + var skipButton = screen.GetNodeOrNull("SkipButton"); + state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + + return state; + } + + private static Dictionary BuildTreasureState(TreasureRoom treasureRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + + if (treasureUI == null) + { + state["message"] = "Treasure room loading..."; + return state; + } + + // Auto-open chest if not yet opened + var chestButton = treasureUI.GetNodeOrNull("Chest"); + if (chestButton is { IsEnabled: true }) + { + chestButton.ForceClick(); + state["message"] = "Opening chest..."; + return state; + } + + // Show relics available for picking + var relicCollection = treasureUI.GetNodeOrNull("%RelicCollection"); + if (relicCollection?.Visible == true) + { + var holders = FindAll(relicCollection) + .Where(h => h.IsEnabled && h.Visible) + .ToList(); + + var relics = new List>(); + int index = 0; + foreach (var holder in holders) + { + var relic = holder.Relic?.Model; + if (relic == null) continue; + relics.Add(new Dictionary + { + ["index"] = index, + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + index++; + } + state["relics"] = relics; + } + + state["can_proceed"] = treasureUI.ProceedButton?.IsEnabled ?? false; + + return state; + } + + private static string GetRewardTypeName(Reward reward) => reward switch + { + GoldReward => "gold", + PotionReward => "potion", + RelicReward => "relic", + CardReward => "card", + SpecialCardReward => "special_card", + CardRemovalReward => "card_removal", + _ => reward.GetType().Name.ToLower() + }; + + private static List> BuildPowersState(Creature creature) + { + var powers = new List>(); + foreach (var power in creature.Powers) + { + if (!power.IsVisible) continue; + + // HoverTips resolves all dynamic vars (Amount, DynamicVars, etc.) + // The first tip is the power's own description; the rest are extra keywords + var allTips = power.HoverTips.ToList(); + string? resolvedDesc = null; + var extraTips = new List(); + foreach (var tip in allTips) + { + if (tip.Id == power.Id.ToString()) + { + // This is the power's own hover tip — extract its resolved description + if (tip is HoverTip ht) + resolvedDesc = StripRichTextTags(ht.Description); + } + else + { + extraTips.Add(tip); + } + } + // Fallback to raw SmartDescription if HoverTips extraction failed + resolvedDesc ??= SafeGetText(() => power.SmartDescription); + + powers.Add(new Dictionary + { + ["id"] = power.Id.Entry, + ["name"] = SafeGetText(() => power.Title), + ["amount"] = power.DisplayAmount, + ["type"] = power.Type.ToString(), + ["description"] = resolvedDesc, + ["keywords"] = BuildHoverTips(extraTips) + }); + } + return powers; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs new file mode 100644 index 000000000..662412f8e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Godot; +using MegaCrit.Sts2.Core.Modding; +using MegaCrit.Sts2.Core.Multiplayer.Game; + +namespace STS2_Bridge; + +[ModInitializer("Initialize")] +public static partial class BridgeMod +{ + public const string Version = "0.3.0"; + + private static HttpListener? _listener; + private static Thread? _serverThread; + private static readonly ConcurrentQueue _mainThreadQueue = new(); + internal static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public static void Initialize() + { + try + { + // Connect to main thread process frame for action execution + var tree = (SceneTree)Engine.GetMainLoop(); + tree.Connect(SceneTree.SignalName.ProcessFrame, Callable.From(ProcessMainThreadQueue)); + + _listener = new HttpListener(); + _listener.Prefixes.Add("http://localhost:15526/"); + _listener.Prefixes.Add("http://127.0.0.1:15526/"); + _listener.Start(); + + _serverThread = new Thread(ServerLoop) + { + IsBackground = true, + Name = "STS2_Bridge_Server" + }; + _serverThread.Start(); + + GD.Print($"[STS2 Bridge] v{Version} server started on http://localhost:15526/"); + } + catch (Exception ex) + { + GD.PrintErr($"[STS2 Bridge] Failed to start: {ex}"); + } + } + + private static void ProcessMainThreadQueue() + { + int processed = 0; + while (_mainThreadQueue.TryDequeue(out var action) && processed < 10) + { + try { action(); } + catch (Exception ex) { GD.PrintErr($"[STS2 Bridge] Main thread action error: {ex}"); } + processed++; + } + } + + internal static Task RunOnMainThread(Func func) + { + var tcs = new TaskCompletionSource(); + _mainThreadQueue.Enqueue(() => + { + try { tcs.SetResult(func()); } + catch (Exception ex) { tcs.SetException(ex); } + }); + return tcs.Task; + } + + internal static Task RunOnMainThread(Action action) + { + var tcs = new TaskCompletionSource(); + _mainThreadQueue.Enqueue(() => + { + try { action(); tcs.SetResult(true); } + catch (Exception ex) { tcs.SetException(ex); } + }); + return tcs.Task; + } + + private static void ServerLoop() + { + while (_listener?.IsListening == true) + { + try + { + var context = _listener.GetContext(); + // Handle each request asynchronously so we don't block the listener + ThreadPool.QueueUserWorkItem(_ => HandleRequest(context)); + } + catch (HttpListenerException) { break; } + catch (ObjectDisposedException) { break; } + } + } + + private static void HandleRequest(HttpListenerContext context) + { + try + { + var request = context.Request; + var response = context.Response; + response.Headers.Add("Access-Control-Allow-Origin", "*"); + response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); + + if (request.HttpMethod == "OPTIONS") + { + response.StatusCode = 204; + response.Close(); + return; + } + + string path = request.Url?.AbsolutePath ?? "/"; + + if (path == "/") + { + SendJson(response, new { message = $"Hello from STS2 Bridge v{Version}", status = "ok" }); + } + else if (path == "/api/v1/singleplayer") + { + // 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."); + return; + } + + if (request.HttpMethod == "GET") + HandleGetState(request, response); + else if (request.HttpMethod == "POST") + HandlePostAction(request, response); + else + SendError(response, 405, "Method not allowed"); + } + else if (path == "/api/v1/multiplayer") + { + // Guard: reject multiplayer endpoint during singleplayer runs + if (!IsMultiplayerRun()) + { + SendError(response, 409, + "Not in a multiplayer run. Use /api/v1/singleplayer instead."); + return; + } + + if (request.HttpMethod == "GET") + HandleGetMultiplayerState(request, response); + else if (request.HttpMethod == "POST") + HandlePostMultiplayerAction(request, response); + else + SendError(response, 405, "Method not allowed"); + } + else + { + SendError(response, 404, "Not found"); + } + } + catch (Exception ex) + { + try + { + SendError(context.Response, 500, $"Internal error: {ex.Message}"); + } + catch { /* response may already be closed */ } + } + } + + // 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. + internal static bool IsMultiplayerRun() + { + try + { + return MegaCrit.Sts2.Core.Runs.RunManager.Instance.IsInProgress + && MegaCrit.Sts2.Core.Runs.RunManager.Instance.NetService.Type.IsMultiplayer(); + } + catch { return false; } + } + + private static void HandleGetMultiplayerState(HttpListenerRequest request, HttpListenerResponse response) + { + string format = request.QueryString["format"] ?? "json"; + + try + { + var stateTask = RunOnMainThread(() => BuildMultiplayerGameState()); + var state = stateTask.GetAwaiter().GetResult(); + + if (format == "markdown") + { + string md = FormatAsMarkdown(state); + SendText(response, md, "text/markdown"); + } + else + { + SendJson(response, state); + } + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to read multiplayer game state: {ex.Message}"); + } + } + + private static void HandlePostMultiplayerAction(HttpListenerRequest request, HttpListenerResponse response) + { + string body; + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding)) + body = reader.ReadToEnd(); + + Dictionary? parsed; + try + { + parsed = JsonSerializer.Deserialize>(body); + } + catch + { + SendError(response, 400, "Invalid JSON"); + return; + } + + if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) + { + SendError(response, 400, "Missing 'action' field"); + return; + } + + string action = actionElem.GetString() ?? ""; + + try + { + var resultTask = RunOnMainThread(() => ExecuteMultiplayerAction(action, parsed)); + var result = resultTask.GetAwaiter().GetResult(); + SendJson(response, result); + } + catch (Exception ex) + { + SendError(response, 500, $"Multiplayer action failed: {ex.Message}"); + } + } + + private static void HandleGetState(HttpListenerRequest request, HttpListenerResponse response) + { + string format = request.QueryString["format"] ?? "json"; + + try + { + var stateTask = RunOnMainThread(() => BuildGameState()); + var state = stateTask.GetAwaiter().GetResult(); + + if (format == "markdown") + { + string md = FormatAsMarkdown(state); + SendText(response, md, "text/markdown"); + } + else + { + SendJson(response, state); + } + } + catch (Exception ex) + { + SendError(response, 500, $"Failed to read game state: {ex.Message}"); + } + } + + private static void HandlePostAction(HttpListenerRequest request, HttpListenerResponse response) + { + string body; + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding)) + body = reader.ReadToEnd(); + + Dictionary? parsed; + try + { + parsed = JsonSerializer.Deserialize>(body); + } + catch + { + SendError(response, 400, "Invalid JSON"); + return; + } + + if (parsed == null || !parsed.TryGetValue("action", out var actionElem)) + { + SendError(response, 400, "Missing 'action' field"); + return; + } + + string action = actionElem.GetString() ?? ""; + + try + { + var resultTask = RunOnMainThread(() => ExecuteAction(action, parsed)); + var result = resultTask.GetAwaiter().GetResult(); + SendJson(response, result); + } + catch (Exception ex) + { + SendError(response, 500, $"Action failed: {ex.Message}"); + } + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/README.md b/slay_the_spire_ii/agent-harness/bridge/plugin/README.md new file mode 100644 index 000000000..6cfa65d4e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/README.md @@ -0,0 +1,72 @@ +# STS2 Bridge Plugin + +This directory contains the source code for `STS2_Bridge`, the in-game mod used +by the CLI-Anything Slay the Spire II harness. + +The bridge runs inside the real Steam game process and exposes a local HTTP API +at `http://localhost:15526/api/v1/singleplayer`. The CLI in +`slay_the_spire_ii/agent-harness/` reads game state from that API and sends +actions back through it. + +## What Is Here + +- `build.sh` + - Builds the `.NET 9` plugin against your local Slay the Spire II install. +- `bridge_manifest.json` + - Manifest copied into the install bundle as `STS2_Bridge.json`. +- `docs/raw_api.md` + - Raw bridge API notes. +- `../install/bridge_plugin/` + - Project-local install bundle updated by `build.sh`. +- `../install/install_bridge.sh` + - Copies the built bundle into the game's `mods/STS2_Bridge/` directory. + +## Requirements + +- `.NET 9 SDK` +- A local Steam install of Slay the Spire II + +## Build + +From the repository root: + +```bash +cd slay_the_spire_ii/agent-harness/bridge/plugin +./build.sh +``` + +If auto-detection fails, pass the game data directory explicitly: + +```bash +./build.sh "/Users/your_name/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/Resources/data_sts2_macos_arm64" +``` + +The build writes a fresh install bundle to: + +```text +slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/ +``` + +## Install Into The Game + +From the repository root: + +```bash +cd slay_the_spire_ii/agent-harness/bridge/install +./install_bridge.sh +``` + +This copies: + +```text +STS2_Bridge.dll +STS2_Bridge.json +``` + +into: + +```text +/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge/ +``` + +After that, launch the game and enable the `STS2_Bridge` mod. diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj b/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj new file mode 100644 index 000000000..e150c74be --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj @@ -0,0 +1,32 @@ + + + net9.0 + Library + enable + 12.0 + STS2_Bridge + STS2_Bridge + /path/to/sts2/data_dir + + + + + + + + + + + $(STS2GameDataDir)/sts2.dll + false + + + $(STS2GameDataDir)/GodotSharp.dll + false + + + $(STS2GameDataDir)/0Harmony.dll + false + + + diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json b/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json new file mode 100644 index 000000000..5ff39c5a8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json @@ -0,0 +1,10 @@ +{ + "id": "STS2_Bridge", + "name": "STS2 Bridge", + "author": "kunology", + "description": "Local bridge plugin for Slay the Spire 2", + "version": "0.3.0", + "has_pck": false, + "has_dll": true, + "affects_gameplay": false +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh b/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh new file mode 100755 index 000000000..b41ad1e3d --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT="$SCRIPT_DIR/STS2_Bridge.csproj" +OUT_DIR="$SCRIPT_DIR/out/STS2_Bridge" +CONFIGURATION="${CONFIGURATION:-Release}" +DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$SCRIPT_DIR/.dotnet-cli-home}" +INSTALL_DIR="$SCRIPT_DIR/../install/bridge_plugin" + +mkdir -p "$DOTNET_CLI_HOME" +mkdir -p "$INSTALL_DIR" +export DOTNET_CLI_HOME +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 + +find_dotnet() { + if [ -x "$HOME/.dotnet-arm64/dotnet" ]; then + echo "$HOME/.dotnet-arm64/dotnet" + return + fi + if [ -x "$HOME/.dotnet/dotnet" ]; then + echo "$HOME/.dotnet/dotnet" + return + fi + if command -v dotnet >/dev/null 2>&1; then + command -v dotnet + return + fi + return 1 +} + +detect_game_data_dir() { + if [ -n "${STS2_GAME_DATA_DIR:-}" ] && [ -d "${STS2_GAME_DATA_DIR}" ]; then + echo "${STS2_GAME_DATA_DIR}" + return + fi + + local base="$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/Resources" + local arm="$base/data_sts2_macos_arm64" + local x64="$base/data_sts2_macos_x86_64" + + if [ -d "$arm" ]; then + echo "$arm" + return + fi + if [ -d "$x64" ]; then + echo "$x64" + return + fi + return 1 +} + +DOTNET_BIN="$(find_dotnet || true)" +if [ -z "$DOTNET_BIN" ]; then + echo "ERROR: dotnet not found. Install .NET 9 SDK first." >&2 + exit 1 +fi + +GAME_DATA_DIR="${1:-$(detect_game_data_dir || true)}" +if [ -z "$GAME_DATA_DIR" ]; then + echo "ERROR: Could not detect Slay the Spire 2 data directory." >&2 + echo "Usage: ./build.sh /path/to/data_sts2_macos_arm64" >&2 + exit 1 +fi + +if [ ! -f "$GAME_DATA_DIR/sts2.dll" ]; then + echo "ERROR: sts2.dll not found in $GAME_DATA_DIR" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +echo "Building STS2_Bridge" +echo "dotnet : $DOTNET_BIN" +echo "game data : $GAME_DATA_DIR" +echo "output dir : $OUT_DIR" +echo + +"$DOTNET_BIN" build "$PROJECT" \ + -c "$CONFIGURATION" \ + -o "$OUT_DIR" \ + -p:STS2GameDataDir="$GAME_DATA_DIR" + +cp "$OUT_DIR/STS2_Bridge.dll" "$INSTALL_DIR/STS2_Bridge.dll" +cp "$SCRIPT_DIR/bridge_manifest.json" "$INSTALL_DIR/STS2_Bridge.json" + +echo +echo "Build succeeded." +echo "Install these files into /mods/:" +echo " $OUT_DIR/STS2_Bridge.dll" +echo " $SCRIPT_DIR/bridge_manifest.json -> STS2_Bridge.json" +echo +echo "Project-local install bundle updated at:" +echo " $INSTALL_DIR/STS2_Bridge.dll" +echo " $INSTALL_DIR/STS2_Bridge.json" diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md b/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md new file mode 100644 index 000000000..5f2b73593 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md @@ -0,0 +1,318 @@ +# Raw API Reference + +These API endpoints are available for direct HTTP requests *without* using the MCP server. For example, you can use `curl` or Postman to interact with the mod directly. + +The mod exposes two endpoints: +- `http://localhost:15526/api/v1/singleplayer` — for singleplayer runs +- `http://localhost:15526/api/v1/multiplayer` — for multiplayer (co-op) runs + +The endpoints are mutually exclusive: calling the singleplayer endpoint during a multiplayer run (or vice versa) returns HTTP 409. + +:::note +These endpoints are designed for local use and do not have authentication or security measures, so they should not be exposed publicly - unless you know what you're doing! +::: + +## `GET /api/v1/singleplayer` + +Query parameters: +| Parameter | Values | Default | Description | +|-----------|--------|---------|-------------| +| `format` | `json`, `markdown` | `json` | Response format | + +Returns the current game state. The `state_type` field indicates the screen: +- `monster` / `elite` / `boss` — In combat (full battle state returned) +- `hand_select` — In-combat card selection prompt (exhaust, discard, etc.) with battle state +- `combat_rewards` — Post-combat rewards screen (reward items, proceed button) +- `card_reward` — Card reward selection screen (card choices, skip option) +- `map` — Map navigation screen (full DAG, next options with lookahead, visited path) +- `rest_site` — Rest site (available options: rest, smith, etc.) +- `shop` — Shop (full inventory: cards, relics, potions, card removal with costs) +- `event` — Event or Ancient (options with descriptions, ancient dialogue detection) +- `card_select` — Deck card selection (transform, upgrade, remove, discard) or choose-a-card (potions, effects) +- `relic_select` — Relic choice screen (boss relics, immediate pick + skip) +- `treasure` — Treasure room (chest auto-opens, relic claiming) +- `overlay` — Catch-all for unhandled overlay screens (prevents soft-locks) +- `menu` — No run in progress + +### State details + +**Battle state includes:** +- Player: HP, block, energy, stars (Regent), gold, character, status, relics, potions, hand (with card details including star costs), pile counts, pile contents, orbs +- Enemies: entity_id, name, HP, block, status, intents with title/label/description +- Keywords on all entities (cards, relics, potions, status) + +**Hand select state includes:** +- Mode: `simple_select` (exhaust/discard) or `upgrade_select` (in-combat upgrade) +- Prompt text (e.g., "Select a card to Exhaust.") +- Selectable cards: index, id, name, type, cost, description, upgrade status, keywords +- Already-selected cards (if multi-select): index, name +- Confirm button state +- Full battle state is also included for combat context + +**Rewards state includes:** +- Player summary: character, HP, gold, potion slot availability +- Reward items: index, type (`gold`, `potion`, `relic`, `card`, `special_card`, `card_removal`), description, and type-specific details (gold amount, potion id/name) +- Proceed button state + +**Event state includes:** +- Event metadata: id, name, whether it's an Ancient, dialogue phase status +- Player summary: character, HP, gold +- Options: index, title, description, locked/proceed/chosen status, attached relic (for Ancients), keywords + +**Rest site state includes:** +- Player summary: character, HP, gold +- Available options: index, id, name, description, enabled status +- Proceed button state + +**Shop state includes:** +- Player summary: character, HP, gold, potion slot availability +- Full inventory by category: cards (with details, cost, on_sale, keywords), relics (with keywords), potions (with keywords), card removal +- Each item: index, cost, stocked status, affordability +- Shop inventory is auto-opened when state is queried + +**Map state includes:** +- Player summary: character, HP, gold, potion slot availability +- Current position and visited path +- Next options: index, coordinate, node type, with 1-level lookahead (children types) +- Full map DAG: all nodes with coordinates, types, and edges (children) + +**Card select state includes:** +- Screen type: `transform`, `upgrade`, `select`, `simple_select`, `choose` +- Player summary: character, HP, gold +- Prompt text (e.g., "Choose 2 cards to Transform.") +- Cards: index, id, name, type, cost, description, rarity, upgrade status, keywords +- Preview state, confirm/cancel button availability +- For `choose` type (e.g., Colorless Potion): immediate pick on select, skip availability + +**Relic select state includes:** +- Prompt text +- Player summary: character, HP, gold +- Relics: index, id, name, description, keywords +- Skip availability + +**Card reward state includes:** +- Card choices: index, id, name, type, energy cost, star cost (Regent), description, rarity, upgrade status, keywords +- Skip availability + +**Treasure state includes:** +- Player summary: character, HP, gold +- Relics: index, id, name, description, rarity, keywords +- Proceed button state +- Chest is auto-opened when state is queried + +## `POST /api/v1/singleplayer` + +**Play a card:** +```json +{ + "action": "play_card", + "card_index": 0, + "target": "jaw_worm_0" +} +``` +- `card_index`: 0-based index in hand (from GET response) +- `target`: entity_id of the target (required for `AnyEnemy` cards, omit for self-targeting/AoE cards) + +**Use a potion:** +```json +{ + "action": "use_potion", + "slot": 0, + "target": "jaw_worm_0" +} +``` +- `slot`: potion slot index (from GET response) +- `target`: entity_id of the target (required for `AnyEnemy` potions, omit otherwise) + +**End turn:** +```json +{ "action": "end_turn" } +``` + +**Select a card from hand during combat selection:** +```json +{ "action": "combat_select_card", "card_index": 0 } +``` +- `card_index`: 0-based index of the card in the selectable hand (from GET response) +- Used when a card effect prompts "Select a card to exhaust/discard/etc." + +**Confirm in-combat card selection:** +```json +{ "action": "combat_confirm_selection" } +``` +- Confirms the current in-combat hand card selection +- Only works when the confirm button is enabled (enough cards selected) + +**Claim a reward:** +```json +{ "action": "claim_reward", "index": 0 } +``` +- `index`: 0-based index of the reward on the rewards screen (from GET response) +- Gold, potion, and relic rewards are claimed immediately +- Card rewards open the card selection screen (state changes to `card_reward`) + +**Select a card reward:** +```json +{ "action": "select_card_reward", "card_index": 1 } +``` +- `card_index`: 0-based index of the card to add to the deck (from GET response) + +**Skip card reward:** +```json +{ "action": "skip_card_reward" } +``` + +**Proceed:** +```json +{ "action": "proceed" } +``` +- Proceeds from the current screen to the map +- Works from: rewards screen, rest site, shop (auto-closes inventory), treasure room +- Does NOT work for events — use `choose_event_option` with the Proceed option's index + +**Choose a rest site option:** +```json +{ "action": "choose_rest_option", "index": 0 } +``` +- `index`: 0-based index of the enabled option (from GET response) +- Options include Rest (heal), Smith (upgrade a card), and relic-granted options + +**Purchase a shop item:** +```json +{ "action": "shop_purchase", "index": 0 } +``` +- `index`: 0-based index of the item in the shop inventory (from GET response) +- Item must be stocked and affordable +- Shop inventory is auto-opened if not already open + +**Choose an event option:** +```json +{ "action": "choose_event_option", "index": 0 } +``` +- `index`: 0-based index of the unlocked option (from GET response) +- Works for both regular events and ancients (after dialogue) + +**Advance ancient dialogue:** +```json +{ "action": "advance_dialogue" } +``` +- Clicks through dialogue text in ancient events +- Call repeatedly until `in_dialogue` becomes `false` and options appear + +**Choose a map node:** +```json +{ "action": "choose_map_node", "index": 0 } +``` +- `index`: 0-based index from the `next_options` array in the map state +- Node types: Monster, Elite, Boss, RestSite, Shop, Treasure, Unknown, Ancient + +**Select a card in the selection screen:** +```json +{ "action": "select_card", "index": 0 } +``` +- `index`: 0-based index of the card in the grid (from GET response) +- For grid screens (transform, upgrade, select): toggles selection. When enough cards are selected, a preview may appear automatically +- For choose-a-card screens (potions, effects): picks immediately + +**Confirm card selection:** +```json +{ "action": "confirm_selection" } +``` +- Confirms the current selection (from preview or main confirm button) +- Works with upgrade previews (single and multi), transform previews, and generic confirm buttons +- Not needed for choose-a-card screens where picking is immediate + +**Cancel card selection:** +```json +{ "action": "cancel_selection" } +``` +- If a preview is showing (upgrade/transform), goes back to the selection grid +- For choose-a-card screens, clicks the skip button (if available) +- Otherwise, closes the card selection screen (only if cancellation is allowed) + +**Select a relic:** +```json +{ "action": "select_relic", "index": 0 } +``` +- `index`: 0-based index of the relic (from GET response) +- Used for boss relic selection. Pick is immediate. + +**Skip relic selection:** +```json +{ "action": "skip_relic_selection" } +``` + +**Claim a treasure relic:** +```json +{ "action": "claim_treasure_relic", "index": 0 } +``` +- `index`: 0-based index of the relic (from GET response) +- Chest is auto-opened when state is queried; this claims a revealed relic + +### Error responses + +All errors return: +```json +{ + "status": "error", + "error": "Description of what went wrong" +} +``` + +--- + +## `GET /api/v1/multiplayer` + +Query parameters: +| Parameter | Values | Default | Description | +|-----------|--------|---------|-------------| +| `format` | `json`, `markdown` | `json` | Response format | + +Returns the multiplayer game state. Shares the same `state_type` values as singleplayer, with these additions: + +**Additional top-level fields:** +- `game_mode`: always `"multiplayer"` +- `net_type`: network service type (e.g., `"SteamMultiplayer"`) +- `player_count`: number of players in the run +- `local_player_slot`: index of the local player in the players array +- `players`: summary of all players (character, HP, gold, alive status, local flag) + +**Battle state additions:** +- `all_players_ready`: whether all players have submitted end turn +- `players[]`: full state for the local player, summary (HP, block, energy, status, relics, potions) for others +- Each player entry includes `is_local`, `is_alive`, and `is_ready_to_end_turn` + +**Map state additions:** +- `votes[]`: per-player map node votes (`player`, `is_local`, `voted`, `vote_col`, `vote_row`) +- `all_voted`: whether all players have voted + +**Event state additions:** +- `is_shared`: whether the event is a shared vote +- `votes[]` (shared events only): per-player option votes +- `all_voted`: whether all players have voted + +**Treasure state additions:** +- `is_bidding_phase`: whether relics are revealed and bidding is active +- `bids[]`: per-player relic bids (`player`, `is_local`, `voted`, `vote_relic_index`) +- `all_bid`: whether all players have bid +- Chest is auto-opened when state is queried (same as singleplayer) + +## `POST /api/v1/multiplayer` + +Supports all the same actions as the singleplayer endpoint (play_card, use_potion, choose_map_node, etc.), plus these multiplayer-specific actions: + +**End turn (vote):** +```json +{ "action": "end_turn" } +``` +- In multiplayer, this is a vote — the turn only ends when ALL players submit +- Returns an error if already submitted (use `undo_end_turn` to retract first) + +**Undo end turn:** +```json +{ "action": "undo_end_turn" } +``` +- Retracts the end-turn vote so the player can continue playing cards +- Only works if the turn hasn't actually ended yet (i.e., not all players committed) + +All other actions (`play_card`, `use_potion`, `choose_map_node`, `choose_event_option`, etc.) work identically to their singleplayer counterparts but are routed through multiplayer sync. diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md index 805d7c0d0..b97a6916e 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md @@ -25,8 +25,8 @@ pip install cli-anything-slay-the-spire-ii # Show help cli-anything-sts2 --help -# Start interactive REPL mode -cli-anything-sts2 repl +# Start interactive REPL mode (default) +cli-anything-sts2 # Read normalized game state cli-anything-sts2 state @@ -37,14 +37,15 @@ cli-anything-sts2 raw-state ### REPL Mode -When invoked with the `repl` subcommand, the CLI enters an interactive session: +Run `cli-anything-sts2` with no subcommand to enter the interactive REPL. The +explicit `repl` subcommand still works too: ```bash -cli-anything-sts2 repl +cli-anything-sts2 # Enter commands interactively: -# sts2> state -# sts2> play-card 0 --target jaw_worm_0 -# sts2> end-turn +# > slay_the_spire_ii [http://localhost:15526] ❯ state +# > slay_the_spire_ii [http://localhost:15526] ❯ play-card 0 --target jaw_worm_0 +# > slay_the_spire_ii [http://localhost:15526] ❯ end-turn ``` ## Command Groups @@ -150,11 +151,11 @@ cli-anything-sts2 rest 0 # Rest at campfire ### Interactive REPL Session ```bash -cli-anything-sts2 repl -# sts2> state -# sts2> play-card 2 -# sts2> end-turn -# sts2> exit +cli-anything-sts2 +# > slay_the_spire_ii [http://localhost:15526] ❯ state +# > slay_the_spire_ii [http://localhost:15526] ❯ play-card 2 +# > slay_the_spire_ii [http://localhost:15526] ❯ end-turn +# > slay_the_spire_ii [http://localhost:15526] ❯ exit ``` ## Configuration @@ -170,7 +171,7 @@ cli-anything-sts2 repl cli_anything/slay_the_spire_ii/ ├── __init__.py ├── __main__.py # python3 -m cli_anything.slay_the_spire_ii -├── slay_the_spire_ii_cli.py # CLI entry point (argparse + REPL) +├── slay_the_spire_ii_cli.py # CLI entry point (Click + default REPL) ├── core/ │ ├── __init__.py │ ├── action_adapter.py # Action payload factories diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md index b675a727b..a9677c39d 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md @@ -35,7 +35,7 @@ the game directory. Full instructions are in the repository README, but the short version is: ```bash -cd CLI-Anything/slay_the_spire_ii/bridge/plugin +cd CLI-Anything/slay_the_spire_ii/agent-harness/bridge/plugin ./build.sh cd ../install ./install_bridge.sh @@ -72,8 +72,8 @@ If this returns JSON, the CLI and bridge are connected. # Read normalized game state (always start here) cli-anything-sts2 state -# Start interactive REPL mode -cli-anything-sts2 repl +# Start interactive REPL mode (default) +cli-anything-sts2 # Show all available commands cli-anything-sts2 --help diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py index 62b1098cd..e432c1a70 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py @@ -1,194 +1,358 @@ from __future__ import annotations -import argparse import json import shlex import sys +from collections.abc import Callable +import click + +from . import __version__ from .core import action_adapter as actions -from .utils.sts2_backend import ApiError, Sts2RawClient from .core.state_adapter import normalize_state +from .utils.repl_skin import ReplSkin +from .utils.sts2_backend import ApiError, Sts2RawClient -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="sts2", - description="CLI adapter for controlling the real STS2 game via the local bridge plugin.", - ) - parser.add_argument("--base-url", default="http://localhost:15526", help="Local bridge API base URL") - parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds") - - sub = parser.add_subparsers(dest="command", required=True) - - sub.add_parser("raw-state", help="Print the raw bridge-plugin JSON state") - sub.add_parser("state", help="Print the normalized CLI-style state") - sub.add_parser("continue-game", help="Continue a saved run from the main menu") - sub.add_parser("abandon-game", help="Abandon the saved run from the main menu") - sub.add_parser("return-to-main-menu", help="Return to the main menu from an active run") - - p = sub.add_parser("start-game", help="Start a new singleplayer run from the main menu") - p.add_argument("--character", default="IRONCLAD") - p.add_argument("--ascension", type=int, default=0) - - p = sub.add_parser("action", help="Send a raw action by name") - p.add_argument("name", help="Raw bridge-plugin action name") - p.add_argument("--kv", action="append", default=[], help="Extra payload in key=value form") - - p = sub.add_parser("play-card", help="Play a card by hand index") - p.add_argument("card_index", type=int) - p.add_argument("--target") - - p = sub.add_parser("use-potion", help="Use a potion by slot index") - p.add_argument("slot", type=int) - p.add_argument("--target") - - sub.add_parser("end-turn", help="End turn") - - p = sub.add_parser("choose-map", help="Choose a map node by normalized index") - p.add_argument("index", type=int) - - p = sub.add_parser("claim-reward", help="Claim a combat reward by index") - p.add_argument("index", type=int) - - p = sub.add_parser("pick-card-reward", help="Pick a card reward by index") - p.add_argument("index", type=int) - - sub.add_parser("skip-card-reward", help="Skip a card reward") - sub.add_parser("proceed", help="Proceed/leave current room when supported") - - p = sub.add_parser("event", help="Choose an event option by index") - p.add_argument("index", type=int) - sub.add_parser("advance-dialogue", help="Advance ancient event dialogue") - - p = sub.add_parser("rest", help="Choose a rest site option by index") - p.add_argument("index", type=int) - - p = sub.add_parser("shop-buy", help="Purchase a shop item by raw item index") - p.add_argument("index", type=int) - - p = sub.add_parser("select-card", help="Select a card in an overlay by index") - p.add_argument("index", type=int) - sub.add_parser("confirm-selection", help="Confirm the current card selection") - sub.add_parser("cancel-selection", help="Cancel/skip the current card selection") - - p = sub.add_parser("combat-select-card", help="Select a combat hand card during hand_select") - p.add_argument("card_index", type=int) - sub.add_parser("combat-confirm-selection", help="Confirm an in-combat card selection") - - p = sub.add_parser("select-relic", help="Select a relic by index") - p.add_argument("index", type=int) - sub.add_parser("skip-relic-selection", help="Skip relic selection") - - p = sub.add_parser("claim-treasure-relic", help="Claim a treasure room relic by index") - p.add_argument("index", type=int) - - sub.add_parser("repl", help="Start an interactive sts2 shell") - - return parser +class CliRuntime: + def __init__(self, base_url: str, timeout: float): + self.base_url = base_url + self.timeout = timeout + self.client = Sts2RawClient(base_url=base_url, timeout=timeout) -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - client = Sts2RawClient(base_url=args.base_url, timeout=args.timeout) +@click.group(invoke_without_command=True) +@click.option("--base-url", default="http://localhost:15526", show_default=True, help="Local bridge API base URL") +@click.option("--timeout", type=float, default=10.0, show_default=True, help="HTTP timeout in seconds") +@click.pass_context +def cli(ctx: click.Context, base_url: str, timeout: float) -> None: + """CLI adapter for controlling the real STS2 game via the local bridge plugin. + Run without a subcommand to enter interactive REPL mode. + """ + ctx.obj = CliRuntime(base_url=base_url, timeout=timeout) + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +def _get_runtime(ctx: click.Context) -> CliRuntime: + runtime = ctx.obj + if not isinstance(runtime, CliRuntime): + raise RuntimeError("CLI runtime not initialized") + return runtime + + +def _run_json(command: Callable[[], object]) -> None: try: - if args.command == "raw-state": - return _print_json(client.get_state(format="json")) - - if args.command == "state": - raw = client.get_state(format="json") - return _print_json(normalize_state(raw)) - - if args.command == "continue-game": - return _post_payload(client, actions.continue_game()) - - if args.command == "abandon-game": - return _post_payload(client, actions.abandon_game()) - - if args.command == "return-to-main-menu": - return _post_payload(client, actions.return_to_main_menu()) - - if args.command == "start-game": - return _post_payload(client, actions.start_new_game(args.character, args.ascension)) - - if args.command == "action": - payload = _parse_kv_pairs(args.kv) - return _print_json(client.post_action(args.name, **payload)) - - if args.command == "play-card": - return _post_payload(client, actions.play_card(args.card_index, target=args.target)) - - if args.command == "use-potion": - return _post_payload(client, actions.use_potion(args.slot, target=args.target)) - - if args.command == "end-turn": - return _post_payload(client, actions.end_turn()) - - if args.command == "choose-map": - return _post_payload(client, actions.choose_map_node(args.index)) - - if args.command == "claim-reward": - return _post_payload(client, actions.claim_reward(args.index)) - - if args.command == "pick-card-reward": - return _post_payload(client, actions.select_card_reward(args.index)) - - if args.command == "skip-card-reward": - return _post_payload(client, actions.skip_card_reward()) - - if args.command == "proceed": - return _post_payload(client, actions.proceed()) - - if args.command == "event": - return _post_payload(client, actions.choose_event_option(args.index)) - - if args.command == "advance-dialogue": - return _post_payload(client, actions.advance_dialogue()) - - if args.command == "rest": - return _post_payload(client, actions.choose_rest_option(args.index)) - - if args.command == "shop-buy": - return _post_payload(client, actions.shop_purchase(args.index)) - - if args.command == "select-card": - return _post_payload(client, actions.select_card(args.index)) - - if args.command == "confirm-selection": - return _post_payload(client, actions.confirm_selection()) - - if args.command == "cancel-selection": - return _post_payload(client, actions.cancel_selection()) - - if args.command == "combat-select-card": - return _post_payload(client, actions.combat_select_card(args.card_index)) - - if args.command == "combat-confirm-selection": - return _post_payload(client, actions.combat_confirm_selection()) - - if args.command == "select-relic": - return _post_payload(client, actions.select_relic(args.index)) - - if args.command == "skip-relic-selection": - return _post_payload(client, actions.skip_relic_selection()) - - if args.command == "claim-treasure-relic": - return _post_payload(client, actions.claim_treasure_relic(args.index)) - - if args.command == "repl": - return _run_repl(args.base_url, args.timeout) - - parser.error(f"Unhandled command: {args.command}") - return 2 + _print_json(command()) except (ApiError, RuntimeError, ValueError) as exc: - print(str(exc), file=sys.stderr) - return 1 + raise click.ClickException(str(exc)) from exc -def _post_payload(client: Sts2RawClient, payload: dict[str, object]) -> int: +def _run_post(client: Sts2RawClient, payload: dict[str, object]) -> None: action = str(payload.pop("action")) - return _print_json(client.post_action(action, **payload)) + _run_json(lambda: client.post_action(action, **payload)) + + +@cli.command("raw-state") +@click.pass_context +def raw_state(ctx: click.Context) -> None: + """Print the raw bridge-plugin JSON state.""" + runtime = _get_runtime(ctx) + _run_json(lambda: runtime.client.get_state(format="json")) + + +@cli.command("state") +@click.pass_context +def state(ctx: click.Context) -> None: + """Print the normalized CLI-style state.""" + runtime = _get_runtime(ctx) + _run_json(lambda: normalize_state(runtime.client.get_state(format="json"))) + + +@cli.command("continue-game") +@click.pass_context +def continue_game(ctx: click.Context) -> None: + """Continue a saved run from the main menu.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.continue_game()) + + +@cli.command("abandon-game") +@click.pass_context +def abandon_game(ctx: click.Context) -> None: + """Abandon the saved run from the main menu.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.abandon_game()) + + +@cli.command("return-to-main-menu") +@click.pass_context +def return_to_main_menu(ctx: click.Context) -> None: + """Return to the main menu from an active run.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.return_to_main_menu()) + + +@cli.command("start-game") +@click.option("--character", default="IRONCLAD", show_default=True) +@click.option("--ascension", type=int, default=0, show_default=True) +@click.pass_context +def start_game(ctx: click.Context, character: str, ascension: int) -> None: + """Start a new singleplayer run from the main menu.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.start_new_game(character, ascension)) + + +@cli.command("action") +@click.argument("name") +@click.option("--kv", multiple=True, help="Extra payload in key=value form") +@click.pass_context +def action(ctx: click.Context, name: str, kv: tuple[str, ...]) -> None: + """Send a raw action by name.""" + runtime = _get_runtime(ctx) + _run_json(lambda: runtime.client.post_action(name, **_parse_kv_pairs(list(kv)))) + + +@cli.command("play-card") +@click.argument("card_index", type=int) +@click.option("--target") +@click.pass_context +def play_card(ctx: click.Context, card_index: int, target: str | None) -> None: + """Play a card by hand index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.play_card(card_index, target=target)) + + +@cli.command("use-potion") +@click.argument("slot", type=int) +@click.option("--target") +@click.pass_context +def use_potion(ctx: click.Context, slot: int, target: str | None) -> None: + """Use a potion by slot index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.use_potion(slot, target=target)) + + +@cli.command("end-turn") +@click.pass_context +def end_turn(ctx: click.Context) -> None: + """End turn.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.end_turn()) + + +@cli.command("choose-map") +@click.argument("index", type=int) +@click.pass_context +def choose_map(ctx: click.Context, index: int) -> None: + """Choose a map node by normalized index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.choose_map_node(index)) + + +@cli.command("claim-reward") +@click.argument("index", type=int) +@click.pass_context +def claim_reward(ctx: click.Context, index: int) -> None: + """Claim a combat reward by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.claim_reward(index)) + + +@cli.command("pick-card-reward") +@click.argument("index", type=int) +@click.pass_context +def pick_card_reward(ctx: click.Context, index: int) -> None: + """Pick a card reward by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.select_card_reward(index)) + + +@cli.command("skip-card-reward") +@click.pass_context +def skip_card_reward(ctx: click.Context) -> None: + """Skip a card reward.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.skip_card_reward()) + + +@cli.command("proceed") +@click.pass_context +def proceed(ctx: click.Context) -> None: + """Proceed/leave current room when supported.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.proceed()) + + +@cli.command("event") +@click.argument("index", type=int) +@click.pass_context +def event(ctx: click.Context, index: int) -> None: + """Choose an event option by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.choose_event_option(index)) + + +@cli.command("advance-dialogue") +@click.pass_context +def advance_dialogue(ctx: click.Context) -> None: + """Advance ancient event dialogue.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.advance_dialogue()) + + +@cli.command("rest") +@click.argument("index", type=int) +@click.pass_context +def rest(ctx: click.Context, index: int) -> None: + """Choose a rest site option by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.choose_rest_option(index)) + + +@cli.command("shop-buy") +@click.argument("index", type=int) +@click.pass_context +def shop_buy(ctx: click.Context, index: int) -> None: + """Purchase a shop item by raw item index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.shop_purchase(index)) + + +@cli.command("select-card") +@click.argument("index", type=int) +@click.pass_context +def select_card(ctx: click.Context, index: int) -> None: + """Select a card in an overlay by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.select_card(index)) + + +@cli.command("confirm-selection") +@click.pass_context +def confirm_selection(ctx: click.Context) -> None: + """Confirm the current card selection.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.confirm_selection()) + + +@cli.command("cancel-selection") +@click.pass_context +def cancel_selection(ctx: click.Context) -> None: + """Cancel/skip the current card selection.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.cancel_selection()) + + +@cli.command("combat-select-card") +@click.argument("card_index", type=int) +@click.pass_context +def combat_select_card(ctx: click.Context, card_index: int) -> None: + """Select a combat hand card during hand_select.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.combat_select_card(card_index)) + + +@cli.command("combat-confirm-selection") +@click.pass_context +def combat_confirm_selection(ctx: click.Context) -> None: + """Confirm an in-combat card selection.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.combat_confirm_selection()) + + +@cli.command("select-relic") +@click.argument("index", type=int) +@click.pass_context +def select_relic(ctx: click.Context, index: int) -> None: + """Select a relic by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.select_relic(index)) + + +@cli.command("skip-relic-selection") +@click.pass_context +def skip_relic_selection(ctx: click.Context) -> None: + """Skip relic selection.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.skip_relic_selection()) + + +@cli.command("claim-treasure-relic") +@click.argument("index", type=int) +@click.pass_context +def claim_treasure_relic(ctx: click.Context, index: int) -> None: + """Claim a treasure room relic by index.""" + runtime = _get_runtime(ctx) + _run_post(runtime.client, actions.claim_treasure_relic(index)) + + +@cli.command() +@click.pass_context +def repl(ctx: click.Context) -> None: + """Start an interactive sts2 shell.""" + runtime = _get_runtime(ctx) + skin = ReplSkin("slay_the_spire_ii", version=__version__) + skin.print_banner() + skin.hint("Type a command such as `state` or `play-card 0 --target NIBBIT_0`.") + skin.hint("Type `help` to show shortcuts. Type `quit` or `exit` to leave.") + print() + + pt_session = skin.create_prompt_session() + + while True: + try: + line = skin.get_input(pt_session, context=runtime.base_url) + except (EOFError, KeyboardInterrupt): + skin.print_goodbye() + return + + if not line: + continue + + lowered = line.lower() + if lowered in {"quit", "exit"}: + skin.print_goodbye() + return + if lowered == "help": + skin.help(_repl_commands()) + continue + + try: + argv = shlex.split(line) + except ValueError as exc: + skin.warning(str(exc)) + continue + + if argv and argv[0] == "repl": + skin.warning("Already in REPL. Run a command directly instead.") + continue + + try: + cli.main( + args=["--base-url", runtime.base_url, "--timeout", str(runtime.timeout), *argv], + prog_name="cli-anything-sts2", + standalone_mode=False, + ) + except click.ClickException as exc: + skin.error(exc.format_message()) + except click.exceptions.Exit as exc: + if exc.exit_code not in (None, 0): + skin.error(f"Command exited with status {exc.exit_code}") + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 + if code not in (None, 0): + skin.error(f"Command exited with status {code}") + except Exception as exc: + skin.error(str(exc)) + + +def _repl_commands() -> dict[str, str]: + commands = {name: cmd.short_help or "" for name, cmd in cli.commands.items() if name != "repl"} + commands["help"] = "Show this help" + commands["quit"] = "Exit REPL" + return commands def _parse_kv_pairs(entries: list[str]) -> dict[str, object]: @@ -211,39 +375,23 @@ def _coerce_value(raw: str) -> object: return raw -def _print_json(value: object) -> int: +def _print_json(value: object) -> None: json.dump(value, sys.stdout, ensure_ascii=False, indent=2) sys.stdout.write("\n") - return 0 -def _run_repl(base_url: str, timeout: float) -> int: - print("STS2CLI REPL") - print("Type a normal subcommand such as `state` or `play-card 0 --target NIBBIT_0`.") - print("Type `help` to show command help. Type `exit` or `quit` to leave.") - - while True: - try: - line = input("sts2> ").strip() - except EOFError: - sys.stdout.write("\n") - return 0 - except KeyboardInterrupt: - sys.stdout.write("\n") - return 0 - - if not line: - continue - if line in {"exit", "quit"}: - return 0 - if line == "help": - build_parser().print_help() - continue - - argv = ["--base-url", base_url, "--timeout", str(timeout), *shlex.split(line)] - code = main(argv) - if code != 0: - print(f"[exit {code}]", file=sys.stderr) +def main(argv: list[str] | None = None) -> int: + try: + cli.main(args=argv, prog_name="cli-anything-sts2", standalone_mode=False) + return 0 + except click.ClickException as exc: + exc.show(file=sys.stderr) + return exc.exit_code + except click.exceptions.Exit as exc: + return exc.exit_code + except click.Abort: + click.echo("Aborted!", err=True) + return 1 if __name__ == "__main__": diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md new file mode 100644 index 000000000..b919a9315 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md @@ -0,0 +1,65 @@ +# Slay the Spire II CLI Harness - Test Documentation + +## Test Inventory + +| File | Test Classes | Test Count | Focus | +|------|-------------|------------|-------| +| `test_core.py` | 2 | 9 | Unit tests for action payload factories and normalized state mapping | +| `test_full_e2e.py` | 2 | 5 | CLI subprocess tests against a mocked local bridge server | +| **Total** | **4** | **14** | | + +## Unit Tests (`test_core.py`) + +All unit tests use synthetic state payloads and direct function calls. No game +process or network access is required. + +### `TestActionAdapter` (4 tests) + +- `play_card()` includes `target` only when provided +- `start_new_game()` preserves character and ascension +- `from_name()` dispatches to the correct factory +- `from_name()` rejects unknown action names + +### `TestStateAdapter` (5 tests) + +- Combat state normalizes to `combat_play` +- Shop state splits items into cards, relics, potions, and card removal +- Menu state exposes launcher capabilities +- Overlay state preserves overlay payload +- Unknown state falls back to `decision="unknown"` and includes raw payload + +## E2E Tests (`test_full_e2e.py`) + +These tests start a local fake HTTP server that mimics the bridge plugin API, so +they run without Slay the Spire II installed. + +### `TestBridgeSubprocess` (4 tests) + +- `--help` exits 0 and shows the CLI name +- `raw-state` returns the raw bridge JSON object +- `state` returns normalized JSON with the expected `decision` +- `action --kv key=value` posts the expected body to the fake bridge + +### `TestCommandSubprocess` (1 test) + +- `continue-game` posts the action produced by `action_adapter.continue_game()` + +## Realistic Workflow Scenarios + +### Scenario 1: Inspect game state before acting + +- **Simulates**: An agent polling the live game to decide its next move +- **Operations**: `raw-state` -> `state` +- **Verified**: Raw JSON is preserved, normalized JSON contains the expected decision + +### Scenario 2: Send a bridge action from the CLI + +- **Simulates**: An agent issuing a one-shot command during a run +- **Operations**: `action custom --kv floor=12 --kv urgent=true` +- **Verified**: The fake bridge receives the exact action payload and CLI exits 0 + +### Scenario 3: Trigger a typed action factory + +- **Simulates**: An agent using a higher-level command rather than raw JSON +- **Operations**: `continue-game` +- **Verified**: The fake bridge receives `{"action": "continue_game"}` diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py new file mode 100644 index 000000000..33078d736 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Slay the Spire II CLI harness.""" diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py new file mode 100644 index 000000000..2ca5a088e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py @@ -0,0 +1,142 @@ +"""Unit tests for the Slay the Spire II CLI harness core modules.""" + +from __future__ import annotations + +import unittest + +from cli_anything.slay_the_spire_ii.core import action_adapter +from cli_anything.slay_the_spire_ii.core.state_adapter import normalize_state + + +class TestActionAdapter(unittest.TestCase): + def test_play_card_without_target_omits_target_field(self) -> None: + payload = action_adapter.play_card(2) + self.assertEqual(payload, {"action": "play_card", "card_index": 2}) + + def test_play_card_with_target_includes_target_field(self) -> None: + payload = action_adapter.play_card(1, target="slime_0") + self.assertEqual( + payload, + {"action": "play_card", "card_index": 1, "target": "slime_0"}, + ) + + def test_start_new_game_preserves_parameters(self) -> None: + payload = action_adapter.start_new_game("REGENT", 12) + self.assertEqual( + payload, + {"action": "start_new_game", "character": "REGENT", "ascension": 12}, + ) + + def test_from_name_dispatches_and_rejects_unknown_actions(self) -> None: + payload = action_adapter.from_name("choose_rest_option", index=1) + self.assertEqual(payload, {"action": "choose_rest_option", "index": 1}) + + with self.assertRaisesRegex(ValueError, "Unknown action name"): + action_adapter.from_name("missing_action") + + +class TestStateAdapter(unittest.TestCase): + def test_normalize_combat_state(self) -> None: + raw_state = { + "state_type": "monster", + "run": {"act": 1, "floor": 3, "ascension": 7}, + "battle": { + "round": 2, + "turn": 1, + "is_play_phase": True, + "player": { + "energy": 3, + "max_energy": 3, + "hand": [{"name": "Strike", "cost": 1}], + "draw_pile_count": 10, + "discard_pile_count": 2, + "exhaust_pile_count": 0, + }, + "enemies": [{"id": "slime_0", "hp": 12}], + }, + } + + normalized = normalize_state(raw_state) + + self.assertEqual(normalized["decision"], "combat_play") + self.assertEqual(normalized["room_type"], "monster") + self.assertEqual(normalized["context"], {"act": 1, "floor": 3, "ascension": 7}) + self.assertEqual(normalized["energy"], 3) + self.assertEqual(normalized["hand"][0]["name"], "Strike") + self.assertEqual(normalized["enemies"][0]["id"], "slime_0") + + def test_normalize_shop_state_groups_items(self) -> None: + raw_state = { + "state_type": "shop", + "run": {"act": 2, "floor": 20, "ascension": 5}, + "shop": { + "items": [ + {"name": "Bash", "category": "card"}, + {"name": "Anchor", "category": "relic"}, + {"name": "Dexterity Potion", "category": "potion"}, + {"name": "Remove a card", "category": "card_removal"}, + ], + "player": {"gold": 222}, + "can_proceed": True, + }, + } + + normalized = normalize_state(raw_state) + + self.assertEqual(normalized["decision"], "shop") + self.assertEqual(len(normalized["cards"]), 1) + self.assertEqual(len(normalized["relics"]), 1) + self.assertEqual(len(normalized["potions"]), 1) + self.assertEqual(normalized["card_removal"]["category"], "card_removal") + self.assertTrue(normalized["can_proceed"]) + + def test_normalize_menu_state(self) -> None: + raw_state = { + "state_type": "menu", + "run": {"act": None, "floor": None, "ascension": None}, + "menu": { + "screen": "main_menu", + "can_continue_game": True, + "can_start_new_game": True, + "can_abandon_game": False, + "characters": ["IRONCLAD", "SILENT"], + "ascension": 10, + }, + } + + normalized = normalize_state(raw_state) + + self.assertEqual(normalized["decision"], "menu") + self.assertTrue(normalized["can_continue_game"]) + self.assertTrue(normalized["can_start_new_game"]) + self.assertEqual(normalized["characters"], ["IRONCLAD", "SILENT"]) + + def test_normalize_overlay_state(self) -> None: + raw_state = { + "state_type": "overlay", + "run": {"act": 1, "floor": 5, "ascension": 0}, + "overlay": {"screen_type": "confirm", "message": "Choose one"}, + } + + normalized = normalize_state(raw_state) + + self.assertEqual(normalized["decision"], "overlay") + self.assertEqual(normalized["overlay"]["screen_type"], "confirm") + + def test_normalize_unknown_state_preserves_raw_payload(self) -> None: + raw_state = { + "state_type": "mystery_screen", + "message": "Unexpected", + "run": {"act": 3, "floor": 42, "ascension": 20}, + } + + normalized = normalize_state(raw_state) + + self.assertEqual(normalized["decision"], "unknown") + self.assertEqual(normalized["raw_state_type"], "mystery_screen") + self.assertEqual(normalized["message"], "Unexpected") + self.assertEqual(normalized["raw"], raw_state) + + +if __name__ == "__main__": + unittest.main() diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py new file mode 100644 index 000000000..09d42c6c8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py @@ -0,0 +1,153 @@ +"""CLI subprocess tests for the Slay the Spire II harness using a fake bridge.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +def _resolve_cli(name: str) -> list[str]: + """Resolve installed CLI command; falls back to python -m for dev.""" + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") + module = "cli_anything.slay_the_spire_ii" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +class _BridgeHandler(BaseHTTPRequestHandler): + raw_state = { + "state_type": "menu", + "run": {"act": None, "floor": None, "ascension": None}, + "menu": { + "screen": "main_menu", + "can_continue_game": True, + "can_start_new_game": True, + "can_abandon_game": False, + "characters": ["IRONCLAD", "SILENT", "DEFECT"], + "ascension": 4, + }, + "message": "Ready", + } + requests: list[dict[str, object]] = [] + + def do_GET(self) -> None: # noqa: N802 + if self.path != "/api/v1/singleplayer?format=json": + self.send_error(404) + return + body = json.dumps(type(self).raw_state).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/api/v1/singleplayer": + self.send_error(404) + return + + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) + type(self).requests.append(payload) + + body = json.dumps({"ok": True, "received": payload}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A003 + return + + +class BridgeServerTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), _BridgeHandler) + cls.port = cls.httpd.server_address[1] + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + + cls.agent_harness_dir = Path(__file__).resolve().parents[3] + cls.cli_base = _resolve_cli("cli-anything-sts2") + + @classmethod + def tearDownClass(cls) -> None: + cls.httpd.shutdown() + cls.thread.join(timeout=5) + cls.httpd.server_close() + + def setUp(self) -> None: + _BridgeHandler.requests.clear() + + def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONPATH"] = str(self.agent_harness_dir) + return subprocess.run( + self.cli_base + ["--base-url", f"http://127.0.0.1:{self.port}", *args], + cwd=self.agent_harness_dir, + capture_output=True, + text=True, + env=env, + check=check, + ) + + +class TestBridgeSubprocess(BridgeServerTestCase): + def test_help(self) -> None: + result = self._run(["--help"]) + self.assertEqual(result.returncode, 0) + self.assertIn("cli-anything-sts2", result.stdout) + self.assertIn("Run without a subcommand to enter interactive REPL mode.", result.stdout) + + def test_raw_state_returns_server_payload(self) -> None: + result = self._run(["raw-state"]) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertEqual(data["state_type"], "menu") + self.assertEqual(data["menu"]["screen"], "main_menu") + + def test_state_returns_normalized_decision(self) -> None: + result = self._run(["state"]) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertEqual(data["decision"], "menu") + self.assertTrue(data["can_continue_game"]) + self.assertEqual(data["characters"], ["IRONCLAD", "SILENT", "DEFECT"]) + + def test_action_posts_kv_payload(self) -> None: + result = self._run(["action", "custom_ping", "--kv", "floor=12", "--kv", "urgent=true"]) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertTrue(data["ok"]) + self.assertEqual(data["received"]["action"], "custom_ping") + self.assertEqual(data["received"]["floor"], 12) + self.assertEqual(data["received"]["urgent"], True) + self.assertEqual(_BridgeHandler.requests[-1]["action"], "custom_ping") + + +class TestCommandSubprocess(BridgeServerTestCase): + def test_continue_game_uses_action_adapter_payload(self) -> None: + result = self._run(["continue-game"]) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertEqual(data["received"], {"action": "continue_game"}) + self.assertEqual(_BridgeHandler.requests[-1], {"action": "continue_game"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py index b79768e9e..965bcb78c 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py @@ -28,7 +28,9 @@ class Sts2RawClient: return self._request_json("GET", url) if format == "json" else self._request_text("GET", url) def post_action(self, action: str, **payload: Any) -> JsonDict: - body: JsonDict = {"action": action, **payload} + if "action" in payload: + raise ValueError("`action` must be provided as the first argument to post_action, not in **payload") + body: JsonDict = {**payload, "action": action} return self._request_json("POST", self.singleplayer_url, body) def _request_text(self, method: str, url: str, body: JsonDict | None = None) -> str: diff --git a/slay_the_spire_ii/agent-harness/setup.py b/slay_the_spire_ii/agent-harness/setup.py index 0e7cee475..914f40a17 100644 --- a/slay_the_spire_ii/agent-harness/setup.py +++ b/slay_the_spire_ii/agent-harness/setup.py @@ -32,7 +32,9 @@ setup( "Programming Language :: Python :: 3.12", ], python_requires=">=3.10", - install_requires=[], + install_requires=[ + "click>=8.0.0", + ], extras_require={ "dev": [ "pytest>=7.0.0",