diff --git a/.gitignore b/.gitignore index fb3d7b16a..d4bdbcde3 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ !/krita/ !/freecad/ !/iterm2/ +!/slay_the_spire_ii/ !/rms/ !/renderdoc/ !/cloudcompare/ @@ -109,6 +110,8 @@ /freecad/.* /iterm2/* /iterm2/.* +/slay_the_spire_ii/* +/slay_the_spire_ii/.* /rms/* /rms/.* /renderdoc/* @@ -141,6 +144,8 @@ !/krita/agent-harness/ !/freecad/agent-harness/ !/iterm2/agent-harness/ +!/slay_the_spire_ii/agent-harness/ +!/slay_the_spire_ii/README.md !/rms/agent-harness/ !/renderdoc/agent-harness/ !/cloudcompare/agent-harness/ diff --git a/registry.json b/registry.json index 288ffd633..b20c870f7 100644 --- a/registry.json +++ b/registry.json @@ -2,7 +2,7 @@ "meta": { "repo": "https://github.com/HKUDS/CLI-Anything", "description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services", - "updated": "2026-03-29" + "updated": "2026-03-26" }, "clis": [ { @@ -355,6 +355,20 @@ "contributor": "voidfreud", "contributor_url": "https://github.com/voidfreud" }, + { + "name": "slay_the_spire_ii", + "display_name": "Slay the Spire II", + "version": "1.0.0", + "description": "Control the real Slay the Spire 2 game via local STS2_Bridge HTTP API", + "requires": "Slay the Spire 2 (Steam) with STS2_Bridge mod enabled", + "homepage": "https://github.com/HKUDS/CLI-Anything/tree/main/slay_the_spire_ii", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=slay_the_spire_ii/agent-harness", + "entry_point": "cli-anything-sts2", + "skill_md": "slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md", + "category": "game", + "contributor": "TianyuFan0504", + "contributor_url": "https://github.com/TianyuFan0504" + }, { "name": "rms", "display_name": "Teltonika RMS", diff --git a/slay_the_spire_ii/README.md b/slay_the_spire_ii/README.md new file mode 100644 index 000000000..b7cd36fa9 --- /dev/null +++ b/slay_the_spire_ii/README.md @@ -0,0 +1,214 @@ +# CLI-Anything-Slay-the-Spire-2 + +`CLI-Anything-Slay-the-Spire-2` is a CLI harness that connects the real Steam version of *Slay the Spire 2* to a stateful command-line interface, making it agent-native. + +

+ CLI-Anything-Slay-the-Spire-2 demo +

+ +It works like this: + +1. The in-game bridge mod `STS2_Bridge` runs inside the game process. +2. The mod exposes a local HTTP API at `http://localhost:15526/api/v1/singleplayer`. +3. The CLI reads state and sends actions through that API. + +This project controls the real running game. It is not a headless simulator and it does not use screen automation. + +## What Is Included + +- `agent-harness/` + - The CLI harness package (`cli_anything/slay_the_spire_ii/`), installable via `pip install -e .`. +- `agent-harness/bridge/plugin/` + - `.NET 9` source for the bridge mod. **This mod is required** — the CLI cannot function without it. +- `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. + +## Requirements + +- A working Steam installation of *Slay the Spire 2* +- Python `>= 3.10` +- `.NET 9 SDK` (only for building the bridge mod) + +The bridge build and install scripts currently auto-detect the default macOS Steam path. If your game is installed elsewhere, pass the path explicitly or override it with an environment variable. + +## Installation + +### 1. Install the CLI + +From the repository root: + +```bash +cd slay_the_spire_ii/agent-harness +pip install -e . +``` + +This installs the `cli-anything-sts2` command. + +### 2. Build the bridge mod + +```bash +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 +slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/ +``` + +If auto-detection fails, set `STS2_GAME_DATA_DIR` or pass the directory directly: + +```bash +STS2_GAME_DATA_DIR="/path/to/data_sts2_macos_arm64" ./build.sh +``` + +The target directory must contain at least: + +- `sts2.dll` +- `GodotSharp.dll` +- `0Harmony.dll` + +### 3. Install the bridge mod into the game + +```bash +cd slay_the_spire_ii/agent-harness/bridge/install +./install_bridge.sh +``` + +The default target path is: + +```text +~/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge/ +``` + +If your game is not installed in the default location, pass the game root explicitly: + +```bash +./install_bridge.sh "/path/to/Slay the Spire 2" +``` + +### 4. Enable the mod in game + +Launch the game and make sure `STS2_Bridge` is loaded and enabled. Once enabled, the mod listens on: + +```text +http://localhost:15526/ +``` + +### 5. Verify the setup + +```bash +cli-anything-sts2 --help +cli-anything-sts2 state +``` + +If `cli-anything-sts2 state` returns JSON, the CLI and the bridge are connected correctly. + +## Usage + +### Shortest path + +1. Build and install `STS2_Bridge` +2. Launch the real game and enable the mod +3. Run `pip install -e .` from `slay_the_spire_ii/agent-harness` +4. Run `cli-anything-sts2 state` + +### Start from the main menu + +```bash +cli-anything-sts2 state +cli-anything-sts2 continue-game +cli-anything-sts2 start-game --character IRONCLAD --ascension 0 +cli-anything-sts2 abandon-game +cli-anything-sts2 return-to-main-menu +``` + +`start-game --character` currently recognizes: + +- `IRONCLAD` +- `SILENT` +- `DEFECT` +- `NECROBINDER` +- `REGENT` + +### Manual control during a run + +```bash +cli-anything-sts2 state +cli-anything-sts2 choose-map 0 +cli-anything-sts2 play-card 0 --target jaw_worm_0 +cli-anything-sts2 end-turn +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 +``` + +Common command groups: + +- State inspection: `state`, `raw-state` +- Menu actions: `continue-game`, `start-game`, `abandon-game`, `return-to-main-menu` +- Combat: `play-card`, `use-potion`, `end-turn` +- Map and rooms: `choose-map`, `event`, `advance-dialogue`, `rest`, `proceed` +- Rewards and overlays: `claim-reward`, `pick-card-reward`, `skip-card-reward`, `select-card`, `confirm-selection`, `select-relic` + +For the full command surface: + +```bash +cli-anything-sts2 --help +``` + +## Configuration + +### CLI + +- `--base-url` + - Default: `http://localhost:15526` +- `--timeout` + - Default: `10.0` + +Example: + +```bash +cli-anything-sts2 --base-url http://127.0.0.1:15526 --timeout 20 state +``` + +### Bridge build + +- `STS2_GAME_DATA_DIR` + - Use this when `slay_the_spire_ii/agent-harness/bridge/plugin/build.sh` cannot auto-detect the game data directory + +## Troubleshooting + +### `cli-anything-sts2 state` cannot connect + +This usually means one of the following is still missing: + +- The game is not running +- `STS2_Bridge` is not installed or not enabled +- The local API on `localhost:15526` is not up yet + +### `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" ./slay_the_spire_ii/agent-harness/bridge/plugin/build.sh +``` + +## Related Docs + +- [agent-harness/STS2.md](agent-harness/STS2.md) +- [agent-harness/bridge/plugin/README.md](agent-harness/bridge/plugin/README.md) + +## Credits + +This project was informed by: + +- [`wuhao21/sts2-cli`](https://github.com/wuhao21/sts2-cli) +- [`Gennadiyev/STS2MCP`](https://github.com/Gennadiyev/STS2MCP) +- [`HKUDS/CLI-Anything`](https://github.com/HKUDS/CLI-Anything) diff --git a/slay_the_spire_ii/agent-harness/STS2.md b/slay_the_spire_ii/agent-harness/STS2.md new file mode 100644 index 000000000..f104131b0 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/STS2.md @@ -0,0 +1,63 @@ +# Slay the Spire 2: Project-Specific Analysis & SOP + +## Architecture Summary + +Slay the Spire 2 is a real-time roguelike deckbuilder running as a native +Steam game. Unlike other CLI-Anything harnesses that wrap desktop applications +via subprocess, this harness communicates with a live game process through an +in-game bridge mod (`STS2_Bridge`) that exposes a local HTTP API. + +``` +┌────────────────────────────────────────────┐ +│ Slay the Spire 2 (Steam) │ +│ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ Combat │ │ Map │ │ Menu │ │ +│ └─────┬────┘ └────┬─────┘ └────┬────┘ │ +│ │ │ │ │ +│ ┌─────┴─────────────┴─────────────┴─────┐ │ +│ │ STS2_Bridge (.NET mod) │ │ +│ │ Reads game state, executes actions │ │ +│ └──────────────────┬────────────────────┘ │ +│ │ │ +│ http://localhost:15526 │ +└─────────────────────┼──────────────────────┘ + │ + ┌─────────────┴────────────────┐ + │ cli-anything-sts2 │ + │ state · play-card · rest … │ + └──────────────────────────────┘ +``` + +## CLI Strategy: HTTP Bridge + Normalized State + +The CLI communicates with the bridge mod over HTTP at +`localhost:15526/api/v1/singleplayer`. It reads normalized JSON state and +sends action commands back through the same endpoint. + +### Core Domains + +| Domain | Module | Key Operations | +|--------|--------|----------------| +| State | `state_adapter.py` | Normalize raw bridge JSON into decision-based state | +| Actions | `action_adapter.py` | Build typed action payloads for every game command | +| Backend | `sts2_backend.py` | HTTP client wrapping GET/POST to the bridge API | +| Types | `types.py` | `JsonDict` type alias, `PlannedAction` dataclass | + +### Decision States + +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` · +`card_select` · `relic_select` · `treasure` · `overlay` · `unknown` + +### Characters + +`IRONCLAD` · `SILENT` · `DEFECT` · `NECROBINDER` · `REGENT` + +## Backend: Low Translation Gap + +The bridge mod has direct access to the game's internal state and action API. +The CLI translates cleanly to HTTP calls. The only requirement is that the +game must be running with the `STS2_Bridge` mod enabled and listening on +`localhost:15526`. 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 000000000..7f629ac1d Binary files /dev/null and b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll differ 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 new file mode 100644 index 000000000..b97a6916e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md @@ -0,0 +1,205 @@ +# cli-anything-slay-the-spire-ii + +A stateful command-line interface for controlling the real Slay the Spire 2 +game through the local `STS2_Bridge` mod. Reads normalized game state and +sends action commands via a local HTTP API. + +## Installation + +This CLI is installed as part of the cli-anything-slay-the-spire-ii package: + +```bash +pip install cli-anything-slay-the-spire-ii +``` + +**Prerequisites:** +- Python 3.10+ +- Slay the Spire 2 (Steam) with `STS2_Bridge` mod installed and enabled +- The bridge mod listening on `http://localhost:15526` + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-sts2 --help + +# Start interactive REPL mode (default) +cli-anything-sts2 + +# Read normalized game state +cli-anything-sts2 state + +# Read raw bridge JSON +cli-anything-sts2 raw-state +``` + +### REPL Mode + +Run `cli-anything-sts2` with no subcommand to enter the interactive REPL. The +explicit `repl` subcommand still works too: + +```bash +cli-anything-sts2 +# Enter commands interactively: +# > 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 + +### State Inspection + +| Command | Description | +|---------|-------------| +| `state` | Print normalized game state with `decision` field | +| `raw-state` | Print raw bridge-plugin JSON | + +### Main Menu + +| Command | Description | +|---------|-------------| +| `continue-game` | Continue a saved run | +| `start-game --character IRONCLAD --ascension 0` | Start a new run | +| `abandon-game` | Abandon the current save | +| `return-to-main-menu` | Return to the main menu | + +### Combat + +| Command | Description | +|---------|-------------| +| `play-card [--target ]` | Play a card from hand | +| `use-potion [--target ]` | Use a potion | +| `end-turn` | End the current turn | + +### Map & Room Flow + +| Command | Description | +|---------|-------------| +| `choose-map ` | Select a map node | +| `proceed` | Leave the current room | + +### Rewards + +| Command | Description | +|---------|-------------| +| `claim-reward ` | Claim a combat reward | +| `pick-card-reward ` | Pick a card reward | +| `skip-card-reward` | Skip the card reward | +| `claim-treasure-relic ` | Claim a treasure relic | +| `select-relic ` | Select a relic | +| `skip-relic-selection` | Skip relic selection | + +### Events & Rest Sites + +| Command | Description | +|---------|-------------| +| `event ` | Choose an event option | +| `advance-dialogue` | Advance dialogue-only events | +| `rest ` | Choose a campfire action | + +### Shop + +| Command | Description | +|---------|-------------| +| `shop-buy ` | Buy an item from the shop | + +### Card/Relic Selection Overlays + +| Command | Description | +|---------|-------------| +| `select-card ` | Select a card in overlay | +| `confirm-selection` | Confirm the current selection | +| `cancel-selection` | Cancel the current selection | +| `combat-select-card ` | Select a card during combat overlay | +| `combat-confirm-selection` | Confirm combat card selection | + +### Raw Action + +| Command | Description | +|---------|-------------| +| `action --kv key=value` | Send a raw action with payload | + +## Examples + +### Start a New Run + +```bash +cli-anything-sts2 start-game --character IRONCLAD --ascension 0 +cli-anything-sts2 state +``` + +### Play Through Combat + +```bash +cli-anything-sts2 state # Check game state +cli-anything-sts2 play-card 0 --target jaw_worm_0 # Play first card +cli-anything-sts2 state # Re-check after play +cli-anything-sts2 end-turn # End turn +``` + +### Navigate Map and Events + +```bash +cli-anything-sts2 choose-map 0 # Pick a map node +cli-anything-sts2 event 1 # Choose event option +cli-anything-sts2 rest 0 # Rest at campfire +``` + +### Interactive REPL Session + +```bash +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 + +| Option | Default | Description | +|--------|---------|-------------| +| `--base-url` | `http://localhost:15526` | Bridge API URL | +| `--timeout` | `10.0` | HTTP timeout in seconds | + +## Architecture + +``` +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 (Click + default REPL) +├── core/ +│ ├── __init__.py +│ ├── action_adapter.py # Action payload factories +│ ├── state_adapter.py # Raw → normalized state mapping +│ └── types.py # JsonDict, PlannedAction +├── utils/ +│ ├── __init__.py +│ ├── sts2_backend.py # HTTP client for the bridge API +│ └── repl_skin.py # Shared REPL interface +└── skills/ + └── SKILL.md # Skill definition for AI agents +``` + +## For AI Agents + +When using this CLI programmatically: + +1. **Always read `state` first** to get the current `decision` field +2. **Check return codes** - 0 for success, non-zero for errors +3. **Parse stdout** for JSON output +4. **Re-read state after each action** - hand indices and energy change during combat +5. **Use absolute paths** for any file operations + +## More Information + +- Architecture: See STS2.md in the agent-harness root +- Methodology: See HARNESS.md in the cli-anything-plugin + +## Version + +1.0.0 diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__init__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__init__.py new file mode 100644 index 000000000..a3b11547b --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__init__.py @@ -0,0 +1,5 @@ +"""Slay the Spire 2 CLI - A stateful CLI for controlling the real STS2 game.""" + +__all__ = ["__version__"] + +__version__ = "1.0.0" diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__main__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__main__.py new file mode 100644 index 000000000..d0b80ac67 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/__main__.py @@ -0,0 +1,4 @@ +"""Allow running as python3 -m cli_anything.slay_the_spire_ii""" +from cli_anything.slay_the_spire_ii.slay_the_spire_ii_cli import main + +raise SystemExit(main()) diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/__init__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/__init__.py new file mode 100644 index 000000000..faabfc27f --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/__init__.py @@ -0,0 +1 @@ +"""Core domain logic for Slay the Spire 2 CLI.""" diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/action_adapter.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/action_adapter.py new file mode 100644 index 000000000..5eec0657a --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/action_adapter.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from .types import JsonDict + + +def play_card(card_index: int, target: str | None = None) -> JsonDict: + payload: JsonDict = {"action": "play_card", "card_index": card_index} + if target is not None: + payload["target"] = target + return payload + + +def use_potion(slot: int, target: str | None = None) -> JsonDict: + payload: JsonDict = {"action": "use_potion", "slot": slot} + if target is not None: + payload["target"] = target + return payload + + +def end_turn() -> JsonDict: + return {"action": "end_turn"} + + +def choose_map_node(index: int) -> JsonDict: + return {"action": "choose_map_node", "index": index} + + +def choose_event_option(index: int) -> JsonDict: + return {"action": "choose_event_option", "index": index} + + +def advance_dialogue() -> JsonDict: + return {"action": "advance_dialogue"} + + +def choose_rest_option(index: int) -> JsonDict: + return {"action": "choose_rest_option", "index": index} + + +def shop_purchase(index: int) -> JsonDict: + return {"action": "shop_purchase", "index": index} + + +def claim_reward(index: int) -> JsonDict: + return {"action": "claim_reward", "index": index} + + +def select_card_reward(card_index: int) -> JsonDict: + return {"action": "select_card_reward", "card_index": card_index} + + +def skip_card_reward() -> JsonDict: + return {"action": "skip_card_reward"} + + +def proceed() -> JsonDict: + return {"action": "proceed"} + + +def select_card(index: int) -> JsonDict: + return {"action": "select_card", "index": index} + + +def confirm_selection() -> JsonDict: + return {"action": "confirm_selection"} + + +def cancel_selection() -> JsonDict: + return {"action": "cancel_selection"} + + +def combat_select_card(card_index: int) -> JsonDict: + return {"action": "combat_select_card", "card_index": card_index} + + +def combat_confirm_selection() -> JsonDict: + return {"action": "combat_confirm_selection"} + + +def select_relic(index: int) -> JsonDict: + return {"action": "select_relic", "index": index} + + +def skip_relic_selection() -> JsonDict: + return {"action": "skip_relic_selection"} + + +def claim_treasure_relic(index: int) -> JsonDict: + return {"action": "claim_treasure_relic", "index": index} + + +def continue_game() -> JsonDict: + return {"action": "continue_game"} + + +def start_new_game(character: str = "IRONCLAD", ascension: int = 0) -> JsonDict: + return {"action": "start_new_game", "character": character, "ascension": ascension} + + +def abandon_game() -> JsonDict: + return {"action": "abandon_game"} + + +def return_to_main_menu() -> JsonDict: + return {"action": "return_to_main_menu"} + + +def from_name(name: str, **kwargs: object) -> JsonDict: + factories = { + "play_card": play_card, + "use_potion": use_potion, + "end_turn": end_turn, + "choose_map_node": choose_map_node, + "choose_event_option": choose_event_option, + "advance_dialogue": advance_dialogue, + "choose_rest_option": choose_rest_option, + "shop_purchase": shop_purchase, + "claim_reward": claim_reward, + "select_card_reward": select_card_reward, + "skip_card_reward": skip_card_reward, + "proceed": proceed, + "select_card": select_card, + "confirm_selection": confirm_selection, + "cancel_selection": cancel_selection, + "combat_select_card": combat_select_card, + "combat_confirm_selection": combat_confirm_selection, + "select_relic": select_relic, + "skip_relic_selection": skip_relic_selection, + "claim_treasure_relic": claim_treasure_relic, + "continue_game": continue_game, + "start_new_game": start_new_game, + "abandon_game": abandon_game, + "return_to_main_menu": return_to_main_menu, + } + try: + factory = factories[name] + except KeyError as exc: + raise ValueError(f"Unknown action name: {name}") from exc + return factory(**kwargs) diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/state_adapter.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/state_adapter.py new file mode 100644 index 000000000..b36b6eb8c --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/state_adapter.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from typing import Any + +from .types import JsonDict + + +def normalize_state(raw_state: JsonDict) -> JsonDict: + state_type = str(raw_state.get("state_type") or "unknown") + run = _copy_dict(raw_state.get("run")) + context = { + "act": run.get("act"), + "floor": run.get("floor"), + "ascension": run.get("ascension"), + } + + if state_type in {"monster", "elite", "boss"}: + return _normalize_combat(raw_state, state_type, context) + if state_type == "hand_select": + return _normalize_hand_select(raw_state, context) + if state_type == "card_reward": + return _normalize_card_reward(raw_state, context) + if state_type == "combat_rewards": + return _normalize_combat_rewards(raw_state, context) + if state_type == "map": + return _normalize_map(raw_state, context) + if state_type == "event": + return _normalize_event(raw_state, context) + if state_type == "rest_site": + return _normalize_rest_site(raw_state, context) + if state_type == "shop": + return _normalize_shop(raw_state, context) + if state_type == "card_select": + return _normalize_card_select(raw_state, context) + if state_type == "relic_select": + return _normalize_relic_select(raw_state, context) + if state_type == "treasure": + return _normalize_treasure(raw_state, context) + if state_type == "game_over": + return _normalize_game_over(raw_state, context) + if state_type == "menu": + menu = _copy_dict(raw_state.get("menu")) + return { + "type": "status", + "decision": "menu", + "context": context, + "run": run, + "message": raw_state.get("message"), + "screen": menu.get("screen"), + "can_continue_game": menu.get("can_continue_game", False), + "can_start_new_game": menu.get("can_start_new_game", False), + "can_abandon_game": menu.get("can_abandon_game", False), + "characters": _copy_list(menu.get("characters")), + "ascension": menu.get("ascension"), + "menu": menu, + } + if state_type == "overlay": + return { + "type": "status", + "decision": "overlay", + "context": context, + "run": run, + "overlay": _copy_dict(raw_state.get("overlay")), + } + return { + "type": "status", + "decision": "unknown", + "context": context, + "run": run, + "raw_state_type": state_type, + "message": raw_state.get("message"), + "raw": raw_state, + } + + +def _normalize_combat(raw_state: JsonDict, state_type: str, context: JsonDict) -> JsonDict: + battle = _copy_dict(raw_state.get("battle")) + player = _copy_dict(battle.get("player")) + return { + "type": "decision", + "decision": "combat_play", + "room_type": state_type, + "context": context, + "run": _copy_dict(raw_state.get("run")), + "round": battle.get("round"), + "turn": battle.get("turn"), + "is_play_phase": battle.get("is_play_phase"), + "energy": player.get("energy", 0), + "max_energy": player.get("max_energy", 0), + "hand": _copy_list(player.get("hand")), + "enemies": _copy_list(battle.get("enemies")), + "player": player, + "draw_pile_count": player.get("draw_pile_count", 0), + "discard_pile_count": player.get("discard_pile_count", 0), + "exhaust_pile_count": player.get("exhaust_pile_count", 0), + "battle": battle, + } + + +def _normalize_hand_select(raw_state: JsonDict, context: JsonDict) -> JsonDict: + hand_select = _copy_dict(raw_state.get("hand_select")) + battle = _copy_dict(raw_state.get("battle")) + player = _copy_dict(battle.get("player")) + return { + "type": "decision", + "decision": "hand_select", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "mode": hand_select.get("mode"), + "prompt": hand_select.get("prompt"), + "cards": _copy_list(hand_select.get("cards")), + "selected_cards": _copy_list(hand_select.get("selected_cards")), + "can_confirm": hand_select.get("can_confirm", False), + "player": player, + "battle": battle, + "hand_select": hand_select, + } + + +def _normalize_card_reward(raw_state: JsonDict, context: JsonDict) -> JsonDict: + reward = _copy_dict(raw_state.get("card_reward")) + return { + "type": "decision", + "decision": "card_reward", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "cards": _copy_list(reward.get("cards")), + "can_skip": reward.get("can_skip", False), + "player": _copy_dict(reward.get("player")), + "card_reward": reward, + } + + +def _normalize_combat_rewards(raw_state: JsonDict, context: JsonDict) -> JsonDict: + rewards = _copy_dict(raw_state.get("rewards")) + return { + "type": "decision", + "decision": "combat_rewards", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "items": _copy_list(rewards.get("items")), + "can_proceed": rewards.get("can_proceed", False), + "player": _copy_dict(rewards.get("player")), + "rewards": rewards, + } + + +def _normalize_map(raw_state: JsonDict, context: JsonDict) -> JsonDict: + map_state = _copy_dict(raw_state.get("map")) + return { + "type": "decision", + "decision": "map_select", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "choices": _copy_list(map_state.get("next_options")), + "player": _copy_dict(map_state.get("player")), + "current_position": _copy_dict(map_state.get("current_position")), + "visited": _copy_list(map_state.get("visited")), + "nodes": _copy_list(map_state.get("nodes")), + "boss": _copy_dict(map_state.get("boss")), + "map": map_state, + } + + +def _normalize_event(raw_state: JsonDict, context: JsonDict) -> JsonDict: + event = _copy_dict(raw_state.get("event")) + return { + "type": "decision", + "decision": "event_choice", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "event_name": event.get("event_name"), + "event_id": event.get("event_id"), + "description": event.get("body"), + "options": _copy_list(event.get("options")), + "player": _copy_dict(event.get("player")), + "in_dialogue": event.get("in_dialogue", False), + "is_ancient": event.get("is_ancient", False), + "event": event, + } + + +def _normalize_rest_site(raw_state: JsonDict, context: JsonDict) -> JsonDict: + rest = _copy_dict(raw_state.get("rest_site")) + return { + "type": "decision", + "decision": "rest_site", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "options": _copy_list(rest.get("options")), + "player": _copy_dict(rest.get("player")), + "can_proceed": rest.get("can_proceed", False), + "rest_site": rest, + } + + +def _normalize_shop(raw_state: JsonDict, context: JsonDict) -> JsonDict: + shop = _copy_dict(raw_state.get("shop")) + items = _copy_list(shop.get("items")) + return { + "type": "decision", + "decision": "shop", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "items": items, + "cards": [item for item in items if item.get("category") == "card"], + "relics": [item for item in items if item.get("category") == "relic"], + "potions": [item for item in items if item.get("category") == "potion"], + "card_removal": next((item for item in items if item.get("category") == "card_removal"), None), + "player": _copy_dict(shop.get("player")), + "can_proceed": shop.get("can_proceed", False), + "shop": shop, + } + + +def _normalize_card_select(raw_state: JsonDict, context: JsonDict) -> JsonDict: + card_select = _copy_dict(raw_state.get("card_select")) + return { + "type": "decision", + "decision": "card_select", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "screen_type": card_select.get("screen_type"), + "prompt": card_select.get("prompt"), + "cards": _copy_list(card_select.get("cards")), + "player": _copy_dict(card_select.get("player")), + "preview_showing": card_select.get("preview_showing", False), + "can_skip": card_select.get("can_skip", False), + "can_confirm": card_select.get("can_confirm", False), + "can_cancel": card_select.get("can_cancel", False), + "card_select": card_select, + } + + +def _normalize_relic_select(raw_state: JsonDict, context: JsonDict) -> JsonDict: + relic_select = _copy_dict(raw_state.get("relic_select")) + return { + "type": "decision", + "decision": "relic_select", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "prompt": relic_select.get("prompt"), + "relics": _copy_list(relic_select.get("relics")), + "player": _copy_dict(relic_select.get("player")), + "can_skip": relic_select.get("can_skip", False), + "relic_select": relic_select, + } + + +def _normalize_treasure(raw_state: JsonDict, context: JsonDict) -> JsonDict: + treasure = _copy_dict(raw_state.get("treasure")) + return { + "type": "decision", + "decision": "treasure", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "relics": _copy_list(treasure.get("relics")), + "player": _copy_dict(treasure.get("player")), + "can_proceed": treasure.get("can_proceed", False), + "message": treasure.get("message"), + "treasure": treasure, + } + + +def _normalize_game_over(raw_state: JsonDict, context: JsonDict) -> JsonDict: + game_over = _copy_dict(raw_state.get("game_over")) + return { + "type": "decision", + "decision": "game_over", + "context": context, + "run": _copy_dict(raw_state.get("run")), + "player": _copy_dict(game_over.get("player")), + "screen_type": game_over.get("screen_type"), + "can_return_to_main_menu": game_over.get("can_return_to_main_menu", False), + "can_continue": game_over.get("can_continue", False), + "can_view_run": game_over.get("can_view_run", False), + "options": _copy_list(game_over.get("options")), + "game_over": game_over, + } + + +def _copy_dict(value: Any) -> JsonDict: + return dict(value) if isinstance(value, dict) else {} + + +def _copy_list(value: Any) -> list[Any]: + return list(value) if isinstance(value, list) else [] diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/types.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/types.py new file mode 100644 index 000000000..23512f86f --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/core/types.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +JsonDict = dict[str, Any] + + +@dataclass(slots=True) +class PlannedAction: + action: str + payload: JsonDict + reason: str + 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 new file mode 100644 index 000000000..a9677c39d --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md @@ -0,0 +1,191 @@ +--- +name: >- + cli-anything-sts2 +description: >- + Command-line interface for Slay the Spire 2 - Control the real game through a local bridge mod HTTP API. Reads normalized game state and sends action commands for combat, navigation, rewards, and menu management. +--- + +# cli-anything-slay-the-spire-ii + +A stateful command-line interface for controlling the real Slay the Spire 2 +game through the local `STS2_Bridge` mod. The CLI reads normalized game state +and sends action commands via a local HTTP API at `localhost:15526`. + +## Installation + +This CLI requires a bridge mod that runs inside the game process. Both the CLI +and the bridge are distributed from the same repository: + +``` +https://github.com/HKUDS/CLI-Anything +``` + +### 1. Install the CLI + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything/slay_the_spire_ii/agent-harness +pip install -e . +``` + +### 2. Build and install the bridge mod + +The bridge mod is a `.NET 9` plugin that must be compiled and installed into +the game directory. Full instructions are in the repository README, but the +short version is: + +```bash +cd CLI-Anything/slay_the_spire_ii/agent-harness/bridge/plugin +./build.sh +cd ../install +./install_bridge.sh +``` + +If the build script cannot auto-detect the game data directory, set +`STS2_GAME_DATA_DIR` explicitly: + +```bash +STS2_GAME_DATA_DIR="/path/to/data_sts2" ./build.sh +``` + +### 3. Enable the mod and verify + +Launch Slay the Spire 2 via Steam, enable the `STS2_Bridge` mod in the mod +manager, then verify the connection: + +```bash +cli-anything-sts2 state +``` + +If this returns JSON, the CLI and bridge are connected. + +**Prerequisites:** +- Python 3.10+ +- Slay the Spire 2 (Steam) with `STS2_Bridge` mod enabled +- `.NET 9 SDK` (only needed to build the bridge mod) + +## Usage + +### Basic Commands + +```bash +# Read normalized game state (always start here) +cli-anything-sts2 state + +# Start interactive REPL mode (default) +cli-anything-sts2 + +# Show all available commands +cli-anything-sts2 --help +``` + +## Command Groups + +### State Inspection + +| Command | Description | +|---------|-------------| +| `state` | Normalized state with `decision` field | +| `raw-state` | Raw bridge JSON | + +### Main Menu + +| Command | Description | +|---------|-------------| +| `continue-game` | Continue a saved run | +| `start-game --character IRONCLAD --ascension 0` | Start a new run | +| `abandon-game` | Abandon the current save | +| `return-to-main-menu` | Return to menu from any screen | + +Characters: `IRONCLAD`, `SILENT`, `DEFECT`, `NECROBINDER`, `REGENT` + +### Combat + +| Command | Description | +|---------|-------------| +| `play-card [--target ]` | Play a card from hand | +| `use-potion [--target ]` | Use a potion | +| `end-turn` | End the current turn | + +### Map & Room Flow + +| Command | Description | +|---------|-------------| +| `choose-map ` | Select a map node | +| `proceed` | Leave the current room | + +### Rewards + +| Command | Description | +|---------|-------------| +| `claim-reward ` | Claim a combat reward | +| `pick-card-reward ` | Pick a card reward | +| `skip-card-reward` | Skip the card reward | +| `claim-treasure-relic ` | Claim a treasure relic | +| `select-relic ` | Select a relic | +| `skip-relic-selection` | Skip relic selection | + +### Events & Rest Sites + +| Command | Description | +|---------|-------------| +| `event ` | Choose an event option | +| `advance-dialogue` | Advance dialogue-only events | +| `rest ` | Choose a campfire action | + +### Shop + +| Command | Description | +|---------|-------------| +| `shop-buy ` | Buy from the shop | + +### Card/Relic Selection Overlays + +| Command | Description | +|---------|-------------| +| `select-card ` | Select a card in overlay | +| `confirm-selection` | Confirm the current selection | +| `cancel-selection` | Cancel the current selection | +| `combat-select-card ` | Select a card during combat overlay | +| `combat-confirm-selection` | Confirm combat card selection | + +### Raw Action + +| Command | Description | +|---------|-------------| +| `action --kv key=value` | Send a raw bridge action | + +## Decision States + +The `state` command returns JSON with a `decision` field indicating the current +game screen. Route your next command based on this value: + +| Decision | Meaning | Typical Next Commands | +|----------|---------|----------------------| +| `menu` | Main menu | `continue-game`, `start-game` | +| `combat_play` | In combat, your turn | `play-card`, `use-potion`, `end-turn` | +| `hand_select` | Card selection overlay | `combat-select-card`, `combat-confirm-selection` | +| `map_select` | Map node selection | `choose-map` | +| `game_over` | Run ended | `return-to-main-menu` | +| `combat_rewards` | Post-combat rewards | `claim-reward`, `proceed` | +| `card_reward` | Card reward pick | `pick-card-reward`, `skip-card-reward` | +| `event_choice` | Event screen | `event`, `advance-dialogue` | +| `rest_site` | Campfire | `rest` | +| `shop` | Shop screen | `shop-buy`, `proceed` | +| `card_select` | Card selection screen | `select-card`, `confirm-selection` | +| `relic_select` | Relic selection | `select-relic`, `skip-relic-selection` | +| `treasure` | Treasure room | `claim-treasure-relic`, `proceed` | + +## Configuration + +| Option | Default | Description | +|--------|---------|-------------| +| `--base-url` | `http://localhost:15526` | Bridge API URL | +| `--timeout` | `10.0` | HTTP timeout in seconds | + +## For AI Agents + +1. **Always read `state` first** to get the `decision` field +2. **Re-read state after each action** - indices and energy change during combat +3. **Check return codes** - 0 for success, non-zero for errors +4. **Parse stdout** for JSON output 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 new file mode 100644 index 000000000..e432c1a70 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import json +import shlex +import sys +from collections.abc import Callable + +import click + +from . import __version__ +from .core import action_adapter as actions +from .core.state_adapter import normalize_state +from .utils.repl_skin import ReplSkin +from .utils.sts2_backend import ApiError, Sts2RawClient + + +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) + + +@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: + _print_json(command()) + except (ApiError, RuntimeError, ValueError) as exc: + raise click.ClickException(str(exc)) from exc + + +def _run_post(client: Sts2RawClient, payload: dict[str, object]) -> None: + action = str(payload.pop("action")) + _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]: + result: dict[str, object] = {} + for entry in entries: + if "=" not in entry: + raise ValueError(f"Expected key=value, got: {entry}") + key, raw_value = entry.split("=", 1) + result[key] = _coerce_value(raw_value) + return result + + +def _coerce_value(raw: str) -> object: + if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()): + return int(raw) + if raw.lower() == "true": + return True + if raw.lower() == "false": + return False + return raw + + +def _print_json(value: object) -> None: + json.dump(value, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + + +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__": + raise SystemExit(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/__init__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/__init__.py new file mode 100644 index 000000000..0d0dcef14 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for Slay the Spire 2 CLI.""" diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/repl_skin.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything//utils/repl_skin.py (this file) + # cli_anything//skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "◇") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} 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 new file mode 100644 index 000000000..965bcb78c --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from ..core.types import JsonDict + + +class ApiError(RuntimeError): + """Raised when the local game bridge returns an error.""" + + +class Sts2RawClient: + def __init__(self, base_url: str = "http://localhost:15526", timeout: float = 10.0): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + @property + def singleplayer_url(self) -> str: + return f"{self.base_url}/api/v1/singleplayer" + + def get_state(self, *, format: str = "json") -> JsonDict | str: + query = urllib.parse.urlencode({"format": format}) + url = f"{self.singleplayer_url}?{query}" + return self._request_json("GET", url) if format == "json" else self._request_text("GET", url) + + def post_action(self, action: str, **payload: Any) -> JsonDict: + 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: + req = urllib.request.Request(url, method=method) + if body is not None: + data = json.dumps(body).encode("utf-8") + req.add_header("Content-Type", "application/json") + else: + data = None + + try: + with urllib.request.urlopen(req, data=data, timeout=self.timeout) as resp: + return resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8", errors="replace") + raise ApiError(f"HTTP {exc.code}: {text}") from exc + except urllib.error.URLError as exc: + raise ApiError( + "Cannot connect to the game bridge API. Is the game running with the bridge mod enabled?" + ) from exc + + def _request_json(self, method: str, url: str, body: JsonDict | None = None) -> JsonDict: + text = self._request_text(method, url, body) + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ApiError(f"Expected JSON response, got: {text[:200]}") from exc + if not isinstance(data, dict): + raise ApiError(f"Expected object response, got: {type(data).__name__}") + return data diff --git a/slay_the_spire_ii/agent-harness/setup.py b/slay_the_spire_ii/agent-harness/setup.py new file mode 100644 index 000000000..914f40a17 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/setup.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-slay-the-spire-ii + +Install with: pip install -e . +Or publish to PyPI: python -m build && twine upload dist/* +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/slay_the_spire_ii/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-slay-the-spire-ii", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for Slay the Spire 2 - Control the real game via a local bridge mod. Requires: Slay the Spire 2 (Steam) with STS2_Bridge mod enabled", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Games/Entertainment", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-sts2=cli_anything.slay_the_spire_ii.slay_the_spire_ii_cli:main", + ], + }, + package_data={ + "cli_anything.slay_the_spire_ii": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +)