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.
+
+
+
+
+
+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