mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-31 02:42:19 +08:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
Binary file not shown.
@@ -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
@@ -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."""
|
||||
+139
@@ -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
|
||||
+398
@@ -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()
|
||||
+153
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user