Merge pull request #148 from HKUDS/feature/slay_the_spire_ii_cli

feat: add Slay the Spire II CLI harness -- the first video game CLI in CLI-Anything!
This commit is contained in:
Yuhao
2026-04-01 16:18:28 +08:00
committed by GitHub
36 changed files with 7559 additions and 1 deletions
+5
View File
@@ -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/
+15 -1
View File
@@ -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",
+214
View File
@@ -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.
<p align="center">
<img src="assets/example_gif.gif" alt="CLI-Anything-Slay-the-Spire-2 demo" width="900">
</p>
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)
+63
View File
@@ -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`.
@@ -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
}
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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<string, object?> 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<string, object?> 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<Dictionary<string, object?>> 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<string, object?> battle)
{
if (isMultiplayer)
FormatMultiplayerBattleMarkdown(sb, battle);
else
FormatBattleMarkdown(sb, battle);
}
if (state.TryGetValue("event", out var eventObj) && eventObj is Dictionary<string, object?> eventData)
{
FormatEventMarkdown(sb, eventData);
if (isMultiplayer)
FormatEventVotesMarkdown(sb, eventData);
}
if (state.TryGetValue("rest_site", out var restObj) && restObj is Dictionary<string, object?> restData)
{
FormatRestSiteMarkdown(sb, restData);
}
if (state.TryGetValue("shop", out var shopObj) && shopObj is Dictionary<string, object?> shopData)
{
FormatShopMarkdown(sb, shopData);
}
if (state.TryGetValue("map", out var mapObj) && mapObj is Dictionary<string, object?> mapData)
{
FormatMapMarkdown(sb, mapData);
if (isMultiplayer)
FormatMapVotesMarkdown(sb, mapData);
}
if (state.TryGetValue("rewards", out var rewardsObj) && rewardsObj is Dictionary<string, object?> rewards)
{
FormatRewardsMarkdown(sb, rewards);
}
if (state.TryGetValue("card_reward", out var cardRewardObj) && cardRewardObj is Dictionary<string, object?> cardReward)
{
FormatCardRewardMarkdown(sb, cardReward);
}
if (state.TryGetValue("hand_select", out var handSelectObj) && handSelectObj is Dictionary<string, object?> handSelect)
{
FormatHandSelectMarkdown(sb, handSelect);
}
if (state.TryGetValue("card_select", out var cardSelectObj) && cardSelectObj is Dictionary<string, object?> cardSelect)
{
FormatCardSelectMarkdown(sb, cardSelect);
}
if (state.TryGetValue("relic_select", out var relicSelectObj) && relicSelectObj is Dictionary<string, object?> relicSelect)
{
FormatRelicSelectMarkdown(sb, relicSelect);
}
if (state.TryGetValue("treasure", out var treasureObj) && treasureObj is Dictionary<string, object?> treasureData)
{
FormatTreasureMarkdown(sb, treasureData);
if (isMultiplayer)
FormatTreasureBidsMarkdown(sb, treasureData);
}
if (state.TryGetValue("overlay", out var overlayObj) && overlayObj is Dictionary<string, object?> 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<string, string>();
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<string, object?> 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<string, object?> 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<Dictionary<string, object?>> 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<string> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<string, object?> 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<string, object?> 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<Dictionary<string, object?>> 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<string, object?> restSite)
{
if (restSite.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> 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<Dictionary<string, object?>> 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<string, object?> shop)
{
if (shop.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> 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<Dictionary<string, object?>> 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<string, object?> map)
{
// Player summary
if (map.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> nodes && nodes.Count > 0)
{
// Collect visited and travelable coords for markers
var visitedSet = new HashSet<string>();
if (map.TryGetValue("visited", out var v2) && v2 is List<Dictionary<string, object?>> vList)
foreach (var vn in vList)
visitedSet.Add($"{vn["col"]},{vn["row"]}");
var travelableSet = new HashSet<string>();
if (map.TryGetValue("next_options", out var o2) && o2 is List<Dictionary<string, object?>> 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<string, object?> cp)
currentKey = $"{cp["col"]},{cp["row"]}";
// Group nodes by row
var byRow = new SortedDictionary<int, List<Dictionary<string, object?>>>();
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<Dictionary<string, object?>>();
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<string>();
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<string, object?> rewards)
{
if (rewards.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> 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<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<string> 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<string, object?> 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<string, object?> 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<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<string, object?> 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<string, object?> 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<Dictionary<string, object?>> 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<string, object?> treasure)
{
if (treasure.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> 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<Dictionary<string, object?>> 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<string, object?> parent, string key,
Func<Dictionary<string, object?>, string> formatter)
{
if (parent.TryGetValue(key, out var listObj) && listObj is List<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<string> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<Dictionary<string, object?>> 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<string, object?> mapData)
{
if (!mapData.TryGetValue("votes", out var votesObj) || votesObj is not List<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<string, object?> 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<Dictionary<string, object?>> 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<string, string> glossary)
{
if (obj is Dictionary<string, object?> dict)
{
if (dict.TryGetValue("keywords", out var kw) && kw is List<Dictionary<string, object?>> 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<Dictionary<string, object?>> list)
{
foreach (var item in list)
CollectKeywordsFromState(item, glossary);
}
}
}
@@ -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<object?> 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<string, object?> { ["error"] = message });
}
private static Dictionary<string, object?> Error(string message)
{
return new Dictionary<string, object?> { ["status"] = "error", ["error"] = message };
}
internal static List<T> FindAll<T>(Node start) where T : Node
{
var list = new List<T>();
if (GodotObject.IsInstanceValid(start))
FindAllRecursive(start, list);
return list;
}
/// <summary>
/// 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.
/// </summary>
internal static List<T> FindAllSortedByPosition<T>(Node start) where T : Control
{
var list = FindAll<T>(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<T>(Node node, List<T> 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<Dictionary<string, object?>> BuildHoverTips(IEnumerable<IHoverTip> tips)
{
var result = new List<Dictionary<string, object?>>();
try
{
var seen = new HashSet<string>();
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<string, object?>
{
["name"] = title,
["description"] = description
});
}
catch { /* skip individual tip on error */ }
}
}
catch { /* return partial results */ }
return result;
}
internal static T? FindFirst<T>(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<T>(child);
if (val != null) return val;
}
return null;
}
}
@@ -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<string, object?> ExecuteMultiplayerAction(string action, Dictionary<string, JsonElement> 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<string, object?> 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<string, object?>
{
["status"] = "ok",
["message"] = "Submitted end turn (waiting for other players)"
};
}
private static Dictionary<string, object?> 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<string, object?>
{
["status"] = "ok",
["message"] = "Undid end turn — continue playing cards"
};
}
}
@@ -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<string, object?> BuildMultiplayerGameState()
{
var result = new Dictionary<string, object?>();
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<string, object?>
{
["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<string, object?>
{
["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<string, object?> BuildMultiplayerBattleState(RunState runState, CombatRoom combatRoom)
{
var combatState = CombatManager.Instance.DebugOnlyGetState();
var battle = new Dictionary<string, object?>();
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<string, object?>>();
Dictionary<string, object?>? 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<Dictionary<string, object?>>();
var entityCounts = new Dictionary<string, int>();
foreach (var creature in combatState.Enemies)
{
if (creature.IsAlive)
enemies.Add(BuildEnemyState(creature, entityCounts));
}
battle["enemies"] = enemies;
return battle;
}
private static Dictionary<string, object?> 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<Dictionary<string, object?>>();
foreach (var player in runState.Players)
{
var vote = mapSync.GetVote(player);
votes.Add(new Dictionary<string, object?>
{
["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<string, object?> 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<Dictionary<string, object?>>();
foreach (var player in runState.Players)
{
var vote = eventSync.GetPlayerVote(player);
votes.Add(new Dictionary<string, object?>
{
["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<string, object?> 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<Dictionary<string, object?>>();
foreach (var player in runState.Players)
{
var vote = treasureSync.GetPlayerVote(player);
bids.Add(new Dictionary<string, object?>
{
["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;
}
/// <summary>
/// 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.
/// </summary>
private static Dictionary<string, object?> BuildPlayerStateSummary(Player player)
{
var state = new Dictionary<string, object?>();
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<Dictionary<string, object?>>();
foreach (var relic in player.Relics)
{
relics.Add(new Dictionary<string, object?>
{
["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<Dictionary<string, object?>>();
int slotIndex = 0;
foreach (var potion in player.PotionSlots)
{
if (potion != null)
{
potions.Add(new Dictionary<string, object?>
{
["id"] = potion.Id.Entry,
["name"] = SafeGetText(() => potion.Title),
["slot"] = slotIndex
});
}
slotIndex++;
}
state["potions"] = potions;
return state;
}
private static List<Dictionary<string, object?>> BuildAllPlayersState(RunState runState)
{
var players = new List<Dictionary<string, object?>>();
foreach (var player in runState.Players)
{
players.Add(new Dictionary<string, object?>
{
["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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Action> _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<T> RunOnMainThread<T>(Func<T> func)
{
var tcs = new TaskCompletionSource<T>();
_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<bool>();
_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<string, JsonElement>? parsed;
try
{
parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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<string, JsonElement>? parsed;
try
{
parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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}");
}
}
}
@@ -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
<game_install>/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge/
```
After that, launch the game and enable the `STS2_Bridge` mod.
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Library</OutputType>
<Nullable>enable</Nullable>
<LangVersion>12.0</LangVersion>
<AssemblyName>STS2_Bridge</AssemblyName>
<RootNamespace>STS2_Bridge</RootNamespace>
<STS2GameDataDir Condition="'$(STS2GameDataDir)' == ''">/path/to/sts2/data_dir</STS2GameDataDir>
</PropertyGroup>
<Target Name="ValidateGameDir" BeforeTargets="ResolveReferences">
<Error Condition="!Exists('$(STS2GameDataDir)/sts2.dll')" Text="STS2GameDataDir is invalid. Expected sts2.dll at $(STS2GameDataDir)/sts2.dll" />
<Error Condition="!Exists('$(STS2GameDataDir)/GodotSharp.dll')" Text="STS2GameDataDir is invalid. Expected GodotSharp.dll at $(STS2GameDataDir)/GodotSharp.dll" />
<Error Condition="!Exists('$(STS2GameDataDir)/0Harmony.dll')" Text="STS2GameDataDir is invalid. Expected 0Harmony.dll at $(STS2GameDataDir)/0Harmony.dll" />
</Target>
<ItemGroup>
<Reference Include="sts2">
<HintPath>$(STS2GameDataDir)/sts2.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="GodotSharp">
<HintPath>$(STS2GameDataDir)/GodotSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(STS2GameDataDir)/0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
@@ -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
}
+95
View File
@@ -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 <game_install>/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"
@@ -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.
@@ -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 <index> [--target <enemy_id>]` | Play a card from hand |
| `use-potion <slot> [--target <enemy_id>]` | Use a potion |
| `end-turn` | End the current turn |
### Map & Room Flow
| Command | Description |
|---------|-------------|
| `choose-map <index>` | Select a map node |
| `proceed` | Leave the current room |
### Rewards
| Command | Description |
|---------|-------------|
| `claim-reward <index>` | Claim a combat reward |
| `pick-card-reward <index>` | Pick a card reward |
| `skip-card-reward` | Skip the card reward |
| `claim-treasure-relic <index>` | Claim a treasure relic |
| `select-relic <index>` | Select a relic |
| `skip-relic-selection` | Skip relic selection |
### Events & Rest Sites
| Command | Description |
|---------|-------------|
| `event <index>` | Choose an event option |
| `advance-dialogue` | Advance dialogue-only events |
| `rest <index>` | Choose a campfire action |
### Shop
| Command | Description |
|---------|-------------|
| `shop-buy <index>` | Buy an item from the shop |
### Card/Relic Selection Overlays
| Command | Description |
|---------|-------------|
| `select-card <index>` | Select a card in overlay |
| `confirm-selection` | Confirm the current selection |
| `cancel-selection` | Cancel the current selection |
| `combat-select-card <index>` | Select a card during combat overlay |
| `combat-confirm-selection` | Confirm combat card selection |
### Raw Action
| Command | Description |
|---------|-------------|
| `action <name> --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
@@ -0,0 +1,5 @@
"""Slay the Spire 2 CLI - A stateful CLI for controlling the real STS2 game."""
__all__ = ["__version__"]
__version__ = "1.0.0"
@@ -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())
@@ -0,0 +1 @@
"""Core domain logic for Slay the Spire 2 CLI."""
@@ -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)
@@ -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 []
@@ -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
@@ -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 <index> [--target <enemy_id>]` | Play a card from hand |
| `use-potion <slot> [--target <enemy_id>]` | Use a potion |
| `end-turn` | End the current turn |
### Map & Room Flow
| Command | Description |
|---------|-------------|
| `choose-map <index>` | Select a map node |
| `proceed` | Leave the current room |
### Rewards
| Command | Description |
|---------|-------------|
| `claim-reward <index>` | Claim a combat reward |
| `pick-card-reward <index>` | Pick a card reward |
| `skip-card-reward` | Skip the card reward |
| `claim-treasure-relic <index>` | Claim a treasure relic |
| `select-relic <index>` | Select a relic |
| `skip-relic-selection` | Skip relic selection |
### Events & Rest Sites
| Command | Description |
|---------|-------------|
| `event <index>` | Choose an event option |
| `advance-dialogue` | Advance dialogue-only events |
| `rest <index>` | Choose a campfire action |
### Shop
| Command | Description |
|---------|-------------|
| `shop-buy <index>` | Buy from the shop |
### Card/Relic Selection Overlays
| Command | Description |
|---------|-------------|
| `select-card <index>` | Select a card in overlay |
| `confirm-selection` | Confirm the current selection |
| `cancel-selection` | Cancel the current selection |
| `combat-select-card <index>` | Select a card during combat overlay |
| `combat-confirm-selection` | Confirm combat card selection |
### Raw Action
| Command | Description |
|---------|-------------|
| `action <name> --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
@@ -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())
@@ -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 <name> --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"}`
@@ -0,0 +1 @@
"""Tests for the Slay the Spire II CLI harness."""
@@ -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()
@@ -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()
@@ -0,0 +1 @@
"""Utility modules for Slay the Spire 2 CLI."""
@@ -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/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/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
}
@@ -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
+54
View File
@@ -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,
)