feat: bundle sts2 bridge and tests

This commit is contained in:
tianyufan
2026-03-26 11:39:50 +08:00
parent 67878d525c
commit 32b833dd72
26 changed files with 5766 additions and 234 deletions

View File

@@ -18,9 +18,9 @@ This project controls the real running game. It is not a headless simulator and
- `agent-harness/`
- The CLI harness package (`cli_anything/slay_the_spire_ii/`), installable via `pip install -e .`.
- `bridge/plugin/`
- `agent-harness/bridge/plugin/`
- `.NET 9` source for the bridge mod. **This mod is required** — the CLI cannot function without it.
- `bridge/install/`
- `agent-harness/bridge/install/`
- Install bundle and scripts for the bridge mod.
**Important:** Unlike other CLI-Anything harnesses that wrap standalone applications, this harness requires a custom bridge mod to be built and installed into the game. The bridge mod exposes the game's internal state via HTTP, which the CLI then consumes.
@@ -37,10 +37,10 @@ The bridge build and install scripts currently auto-detect the default macOS Ste
### 1. Install the CLI
From the `agent-harness/` directory:
From the repository root:
```bash
cd agent-harness
cd slay_the_spire_ii/agent-harness
pip install -e .
```
@@ -49,14 +49,14 @@ This installs the `cli-anything-sts2` command.
### 2. Build the bridge mod
```bash
cd bridge/plugin
cd slay_the_spire_ii/agent-harness/bridge/plugin
./build.sh
```
The script tries to auto-detect the game data directory and refreshes the local install bundle at:
```text
bridge/install/bridge_plugin/
slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/
```
If auto-detection fails, set `STS2_GAME_DATA_DIR` or pass the directory directly:
@@ -74,7 +74,7 @@ The target directory must contain at least:
### 3. Install the bridge mod into the game
```bash
cd bridge/install
cd slay_the_spire_ii/agent-harness/bridge/install
./install_bridge.sh
```
@@ -113,7 +113,7 @@ If `cli-anything-sts2 state` returns JSON, the CLI and the bridge are connected
1. Build and install `STS2_Bridge`
2. Launch the real game and enable the mod
3. Run `pip install -e .` from the `agent-harness/` directory
3. Run `pip install -e .` from `slay_the_spire_ii/agent-harness`
4. Run `cli-anything-sts2 state`
### Start from the main menu
@@ -145,7 +145,7 @@ cli-anything-sts2 claim-reward 0
cli-anything-sts2 pick-card-reward 0
cli-anything-sts2 rest 0
cli-anything-sts2 event 0
cli-anything-sts2 repl
cli-anything-sts2
```
Common command groups:
@@ -180,7 +180,7 @@ cli-anything-sts2 --base-url http://127.0.0.1:15526 --timeout 20 state
### Bridge build
- `STS2_GAME_DATA_DIR`
- Use this when `bridge/plugin/build.sh` cannot auto-detect the game data directory
- Use this when `slay_the_spire_ii/agent-harness/bridge/plugin/build.sh` cannot auto-detect the game data directory
## Troubleshooting
@@ -192,18 +192,18 @@ This usually means one of the following is still missing:
- `STS2_Bridge` is not installed or not enabled
- The local API on `localhost:15526` is not up yet
### `bridge/plugin/build.sh` cannot find the game directory
### `slay_the_spire_ii/agent-harness/bridge/plugin/build.sh` cannot find the game directory
Confirm that the game is installed, then pass `STS2_GAME_DATA_DIR` explicitly:
```bash
STS2_GAME_DATA_DIR="/path/to/data_sts2_macos_arm64" ./bridge/plugin/build.sh
STS2_GAME_DATA_DIR="/path/to/data_sts2_macos_arm64" ./slay_the_spire_ii/agent-harness/bridge/plugin/build.sh
```
## Related Docs
- [agent-harness/STS2.md](agent-harness/STS2.md)
- [bridge/plugin/README.md](bridge/plugin/README.md)
- [agent-harness/bridge/plugin/README.md](agent-harness/bridge/plugin/README.md)
## Credits

View File

@@ -45,7 +45,7 @@ sends action commands back through the same endpoint.
### Decision States
The bridge normalizes all game screens into one of 14 decision types:
The bridge normalizes all game screens into one of 15 decision types:
`menu` · `combat_play` · `hand_select` · `map_select` · `game_over` ·
`combat_rewards` · `card_reward` · `event_choice` · `rest_site` · `shop` ·

View File

@@ -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
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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"
};
}
}

View File

@@ -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

View File

@@ -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}");
}
}
}

View File

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

View File

@@ -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>

View File

@@ -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
}

View File

@@ -0,0 +1,95 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT="$SCRIPT_DIR/STS2_Bridge.csproj"
OUT_DIR="$SCRIPT_DIR/out/STS2_Bridge"
CONFIGURATION="${CONFIGURATION:-Release}"
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$SCRIPT_DIR/.dotnet-cli-home}"
INSTALL_DIR="$SCRIPT_DIR/../install/bridge_plugin"
mkdir -p "$DOTNET_CLI_HOME"
mkdir -p "$INSTALL_DIR"
export DOTNET_CLI_HOME
export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
find_dotnet() {
if [ -x "$HOME/.dotnet-arm64/dotnet" ]; then
echo "$HOME/.dotnet-arm64/dotnet"
return
fi
if [ -x "$HOME/.dotnet/dotnet" ]; then
echo "$HOME/.dotnet/dotnet"
return
fi
if command -v dotnet >/dev/null 2>&1; then
command -v dotnet
return
fi
return 1
}
detect_game_data_dir() {
if [ -n "${STS2_GAME_DATA_DIR:-}" ] && [ -d "${STS2_GAME_DATA_DIR}" ]; then
echo "${STS2_GAME_DATA_DIR}"
return
fi
local base="$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/Resources"
local arm="$base/data_sts2_macos_arm64"
local x64="$base/data_sts2_macos_x86_64"
if [ -d "$arm" ]; then
echo "$arm"
return
fi
if [ -d "$x64" ]; then
echo "$x64"
return
fi
return 1
}
DOTNET_BIN="$(find_dotnet || true)"
if [ -z "$DOTNET_BIN" ]; then
echo "ERROR: dotnet not found. Install .NET 9 SDK first." >&2
exit 1
fi
GAME_DATA_DIR="${1:-$(detect_game_data_dir || true)}"
if [ -z "$GAME_DATA_DIR" ]; then
echo "ERROR: Could not detect Slay the Spire 2 data directory." >&2
echo "Usage: ./build.sh /path/to/data_sts2_macos_arm64" >&2
exit 1
fi
if [ ! -f "$GAME_DATA_DIR/sts2.dll" ]; then
echo "ERROR: sts2.dll not found in $GAME_DATA_DIR" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
echo "Building STS2_Bridge"
echo "dotnet : $DOTNET_BIN"
echo "game data : $GAME_DATA_DIR"
echo "output dir : $OUT_DIR"
echo
"$DOTNET_BIN" build "$PROJECT" \
-c "$CONFIGURATION" \
-o "$OUT_DIR" \
-p:STS2GameDataDir="$GAME_DATA_DIR"
cp "$OUT_DIR/STS2_Bridge.dll" "$INSTALL_DIR/STS2_Bridge.dll"
cp "$SCRIPT_DIR/bridge_manifest.json" "$INSTALL_DIR/STS2_Bridge.json"
echo
echo "Build succeeded."
echo "Install these files into <game_install>/mods/:"
echo " $OUT_DIR/STS2_Bridge.dll"
echo " $SCRIPT_DIR/bridge_manifest.json -> STS2_Bridge.json"
echo
echo "Project-local install bundle updated at:"
echo " $INSTALL_DIR/STS2_Bridge.dll"
echo " $INSTALL_DIR/STS2_Bridge.json"

View File

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

View File

@@ -25,8 +25,8 @@ pip install cli-anything-slay-the-spire-ii
# Show help
cli-anything-sts2 --help
# Start interactive REPL mode
cli-anything-sts2 repl
# Start interactive REPL mode (default)
cli-anything-sts2
# Read normalized game state
cli-anything-sts2 state
@@ -37,14 +37,15 @@ cli-anything-sts2 raw-state
### REPL Mode
When invoked with the `repl` subcommand, the CLI enters an interactive session:
Run `cli-anything-sts2` with no subcommand to enter the interactive REPL. The
explicit `repl` subcommand still works too:
```bash
cli-anything-sts2 repl
cli-anything-sts2
# Enter commands interactively:
# sts2> state
# sts2> play-card 0 --target jaw_worm_0
# sts2> end-turn
# > slay_the_spire_ii [http://localhost:15526] state
# > slay_the_spire_ii [http://localhost:15526] play-card 0 --target jaw_worm_0
# > slay_the_spire_ii [http://localhost:15526] end-turn
```
## Command Groups
@@ -150,11 +151,11 @@ cli-anything-sts2 rest 0 # Rest at campfire
### Interactive REPL Session
```bash
cli-anything-sts2 repl
# sts2> state
# sts2> play-card 2
# sts2> end-turn
# sts2> exit
cli-anything-sts2
# > slay_the_spire_ii [http://localhost:15526] state
# > slay_the_spire_ii [http://localhost:15526] play-card 2
# > slay_the_spire_ii [http://localhost:15526] end-turn
# > slay_the_spire_ii [http://localhost:15526] exit
```
## Configuration
@@ -170,7 +171,7 @@ cli-anything-sts2 repl
cli_anything/slay_the_spire_ii/
├── __init__.py
├── __main__.py # python3 -m cli_anything.slay_the_spire_ii
├── slay_the_spire_ii_cli.py # CLI entry point (argparse + REPL)
├── slay_the_spire_ii_cli.py # CLI entry point (Click + default REPL)
├── core/
│ ├── __init__.py
│ ├── action_adapter.py # Action payload factories

View File

@@ -35,7 +35,7 @@ the game directory. Full instructions are in the repository README, but the
short version is:
```bash
cd CLI-Anything/slay_the_spire_ii/bridge/plugin
cd CLI-Anything/slay_the_spire_ii/agent-harness/bridge/plugin
./build.sh
cd ../install
./install_bridge.sh
@@ -72,8 +72,8 @@ If this returns JSON, the CLI and bridge are connected.
# Read normalized game state (always start here)
cli-anything-sts2 state
# Start interactive REPL mode
cli-anything-sts2 repl
# Start interactive REPL mode (default)
cli-anything-sts2
# Show all available commands
cli-anything-sts2 --help

View File

@@ -1,194 +1,358 @@
from __future__ import annotations
import argparse
import json
import shlex
import sys
from collections.abc import Callable
import click
from . import __version__
from .core import action_adapter as actions
from .utils.sts2_backend import ApiError, Sts2RawClient
from .core.state_adapter import normalize_state
from .utils.repl_skin import ReplSkin
from .utils.sts2_backend import ApiError, Sts2RawClient
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="sts2",
description="CLI adapter for controlling the real STS2 game via the local bridge plugin.",
)
parser.add_argument("--base-url", default="http://localhost:15526", help="Local bridge API base URL")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("raw-state", help="Print the raw bridge-plugin JSON state")
sub.add_parser("state", help="Print the normalized CLI-style state")
sub.add_parser("continue-game", help="Continue a saved run from the main menu")
sub.add_parser("abandon-game", help="Abandon the saved run from the main menu")
sub.add_parser("return-to-main-menu", help="Return to the main menu from an active run")
p = sub.add_parser("start-game", help="Start a new singleplayer run from the main menu")
p.add_argument("--character", default="IRONCLAD")
p.add_argument("--ascension", type=int, default=0)
p = sub.add_parser("action", help="Send a raw action by name")
p.add_argument("name", help="Raw bridge-plugin action name")
p.add_argument("--kv", action="append", default=[], help="Extra payload in key=value form")
p = sub.add_parser("play-card", help="Play a card by hand index")
p.add_argument("card_index", type=int)
p.add_argument("--target")
p = sub.add_parser("use-potion", help="Use a potion by slot index")
p.add_argument("slot", type=int)
p.add_argument("--target")
sub.add_parser("end-turn", help="End turn")
p = sub.add_parser("choose-map", help="Choose a map node by normalized index")
p.add_argument("index", type=int)
p = sub.add_parser("claim-reward", help="Claim a combat reward by index")
p.add_argument("index", type=int)
p = sub.add_parser("pick-card-reward", help="Pick a card reward by index")
p.add_argument("index", type=int)
sub.add_parser("skip-card-reward", help="Skip a card reward")
sub.add_parser("proceed", help="Proceed/leave current room when supported")
p = sub.add_parser("event", help="Choose an event option by index")
p.add_argument("index", type=int)
sub.add_parser("advance-dialogue", help="Advance ancient event dialogue")
p = sub.add_parser("rest", help="Choose a rest site option by index")
p.add_argument("index", type=int)
p = sub.add_parser("shop-buy", help="Purchase a shop item by raw item index")
p.add_argument("index", type=int)
p = sub.add_parser("select-card", help="Select a card in an overlay by index")
p.add_argument("index", type=int)
sub.add_parser("confirm-selection", help="Confirm the current card selection")
sub.add_parser("cancel-selection", help="Cancel/skip the current card selection")
p = sub.add_parser("combat-select-card", help="Select a combat hand card during hand_select")
p.add_argument("card_index", type=int)
sub.add_parser("combat-confirm-selection", help="Confirm an in-combat card selection")
p = sub.add_parser("select-relic", help="Select a relic by index")
p.add_argument("index", type=int)
sub.add_parser("skip-relic-selection", help="Skip relic selection")
p = sub.add_parser("claim-treasure-relic", help="Claim a treasure room relic by index")
p.add_argument("index", type=int)
sub.add_parser("repl", help="Start an interactive sts2 shell")
return parser
class CliRuntime:
def __init__(self, base_url: str, timeout: float):
self.base_url = base_url
self.timeout = timeout
self.client = Sts2RawClient(base_url=base_url, timeout=timeout)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
client = Sts2RawClient(base_url=args.base_url, timeout=args.timeout)
@click.group(invoke_without_command=True)
@click.option("--base-url", default="http://localhost:15526", show_default=True, help="Local bridge API base URL")
@click.option("--timeout", type=float, default=10.0, show_default=True, help="HTTP timeout in seconds")
@click.pass_context
def cli(ctx: click.Context, base_url: str, timeout: float) -> None:
"""CLI adapter for controlling the real STS2 game via the local bridge plugin.
Run without a subcommand to enter interactive REPL mode.
"""
ctx.obj = CliRuntime(base_url=base_url, timeout=timeout)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
def _get_runtime(ctx: click.Context) -> CliRuntime:
runtime = ctx.obj
if not isinstance(runtime, CliRuntime):
raise RuntimeError("CLI runtime not initialized")
return runtime
def _run_json(command: Callable[[], object]) -> None:
try:
if args.command == "raw-state":
return _print_json(client.get_state(format="json"))
if args.command == "state":
raw = client.get_state(format="json")
return _print_json(normalize_state(raw))
if args.command == "continue-game":
return _post_payload(client, actions.continue_game())
if args.command == "abandon-game":
return _post_payload(client, actions.abandon_game())
if args.command == "return-to-main-menu":
return _post_payload(client, actions.return_to_main_menu())
if args.command == "start-game":
return _post_payload(client, actions.start_new_game(args.character, args.ascension))
if args.command == "action":
payload = _parse_kv_pairs(args.kv)
return _print_json(client.post_action(args.name, **payload))
if args.command == "play-card":
return _post_payload(client, actions.play_card(args.card_index, target=args.target))
if args.command == "use-potion":
return _post_payload(client, actions.use_potion(args.slot, target=args.target))
if args.command == "end-turn":
return _post_payload(client, actions.end_turn())
if args.command == "choose-map":
return _post_payload(client, actions.choose_map_node(args.index))
if args.command == "claim-reward":
return _post_payload(client, actions.claim_reward(args.index))
if args.command == "pick-card-reward":
return _post_payload(client, actions.select_card_reward(args.index))
if args.command == "skip-card-reward":
return _post_payload(client, actions.skip_card_reward())
if args.command == "proceed":
return _post_payload(client, actions.proceed())
if args.command == "event":
return _post_payload(client, actions.choose_event_option(args.index))
if args.command == "advance-dialogue":
return _post_payload(client, actions.advance_dialogue())
if args.command == "rest":
return _post_payload(client, actions.choose_rest_option(args.index))
if args.command == "shop-buy":
return _post_payload(client, actions.shop_purchase(args.index))
if args.command == "select-card":
return _post_payload(client, actions.select_card(args.index))
if args.command == "confirm-selection":
return _post_payload(client, actions.confirm_selection())
if args.command == "cancel-selection":
return _post_payload(client, actions.cancel_selection())
if args.command == "combat-select-card":
return _post_payload(client, actions.combat_select_card(args.card_index))
if args.command == "combat-confirm-selection":
return _post_payload(client, actions.combat_confirm_selection())
if args.command == "select-relic":
return _post_payload(client, actions.select_relic(args.index))
if args.command == "skip-relic-selection":
return _post_payload(client, actions.skip_relic_selection())
if args.command == "claim-treasure-relic":
return _post_payload(client, actions.claim_treasure_relic(args.index))
if args.command == "repl":
return _run_repl(args.base_url, args.timeout)
parser.error(f"Unhandled command: {args.command}")
return 2
_print_json(command())
except (ApiError, RuntimeError, ValueError) as exc:
print(str(exc), file=sys.stderr)
return 1
raise click.ClickException(str(exc)) from exc
def _post_payload(client: Sts2RawClient, payload: dict[str, object]) -> int:
def _run_post(client: Sts2RawClient, payload: dict[str, object]) -> None:
action = str(payload.pop("action"))
return _print_json(client.post_action(action, **payload))
_run_json(lambda: client.post_action(action, **payload))
@cli.command("raw-state")
@click.pass_context
def raw_state(ctx: click.Context) -> None:
"""Print the raw bridge-plugin JSON state."""
runtime = _get_runtime(ctx)
_run_json(lambda: runtime.client.get_state(format="json"))
@cli.command("state")
@click.pass_context
def state(ctx: click.Context) -> None:
"""Print the normalized CLI-style state."""
runtime = _get_runtime(ctx)
_run_json(lambda: normalize_state(runtime.client.get_state(format="json")))
@cli.command("continue-game")
@click.pass_context
def continue_game(ctx: click.Context) -> None:
"""Continue a saved run from the main menu."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.continue_game())
@cli.command("abandon-game")
@click.pass_context
def abandon_game(ctx: click.Context) -> None:
"""Abandon the saved run from the main menu."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.abandon_game())
@cli.command("return-to-main-menu")
@click.pass_context
def return_to_main_menu(ctx: click.Context) -> None:
"""Return to the main menu from an active run."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.return_to_main_menu())
@cli.command("start-game")
@click.option("--character", default="IRONCLAD", show_default=True)
@click.option("--ascension", type=int, default=0, show_default=True)
@click.pass_context
def start_game(ctx: click.Context, character: str, ascension: int) -> None:
"""Start a new singleplayer run from the main menu."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.start_new_game(character, ascension))
@cli.command("action")
@click.argument("name")
@click.option("--kv", multiple=True, help="Extra payload in key=value form")
@click.pass_context
def action(ctx: click.Context, name: str, kv: tuple[str, ...]) -> None:
"""Send a raw action by name."""
runtime = _get_runtime(ctx)
_run_json(lambda: runtime.client.post_action(name, **_parse_kv_pairs(list(kv))))
@cli.command("play-card")
@click.argument("card_index", type=int)
@click.option("--target")
@click.pass_context
def play_card(ctx: click.Context, card_index: int, target: str | None) -> None:
"""Play a card by hand index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.play_card(card_index, target=target))
@cli.command("use-potion")
@click.argument("slot", type=int)
@click.option("--target")
@click.pass_context
def use_potion(ctx: click.Context, slot: int, target: str | None) -> None:
"""Use a potion by slot index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.use_potion(slot, target=target))
@cli.command("end-turn")
@click.pass_context
def end_turn(ctx: click.Context) -> None:
"""End turn."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.end_turn())
@cli.command("choose-map")
@click.argument("index", type=int)
@click.pass_context
def choose_map(ctx: click.Context, index: int) -> None:
"""Choose a map node by normalized index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.choose_map_node(index))
@cli.command("claim-reward")
@click.argument("index", type=int)
@click.pass_context
def claim_reward(ctx: click.Context, index: int) -> None:
"""Claim a combat reward by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.claim_reward(index))
@cli.command("pick-card-reward")
@click.argument("index", type=int)
@click.pass_context
def pick_card_reward(ctx: click.Context, index: int) -> None:
"""Pick a card reward by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.select_card_reward(index))
@cli.command("skip-card-reward")
@click.pass_context
def skip_card_reward(ctx: click.Context) -> None:
"""Skip a card reward."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.skip_card_reward())
@cli.command("proceed")
@click.pass_context
def proceed(ctx: click.Context) -> None:
"""Proceed/leave current room when supported."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.proceed())
@cli.command("event")
@click.argument("index", type=int)
@click.pass_context
def event(ctx: click.Context, index: int) -> None:
"""Choose an event option by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.choose_event_option(index))
@cli.command("advance-dialogue")
@click.pass_context
def advance_dialogue(ctx: click.Context) -> None:
"""Advance ancient event dialogue."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.advance_dialogue())
@cli.command("rest")
@click.argument("index", type=int)
@click.pass_context
def rest(ctx: click.Context, index: int) -> None:
"""Choose a rest site option by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.choose_rest_option(index))
@cli.command("shop-buy")
@click.argument("index", type=int)
@click.pass_context
def shop_buy(ctx: click.Context, index: int) -> None:
"""Purchase a shop item by raw item index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.shop_purchase(index))
@cli.command("select-card")
@click.argument("index", type=int)
@click.pass_context
def select_card(ctx: click.Context, index: int) -> None:
"""Select a card in an overlay by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.select_card(index))
@cli.command("confirm-selection")
@click.pass_context
def confirm_selection(ctx: click.Context) -> None:
"""Confirm the current card selection."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.confirm_selection())
@cli.command("cancel-selection")
@click.pass_context
def cancel_selection(ctx: click.Context) -> None:
"""Cancel/skip the current card selection."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.cancel_selection())
@cli.command("combat-select-card")
@click.argument("card_index", type=int)
@click.pass_context
def combat_select_card(ctx: click.Context, card_index: int) -> None:
"""Select a combat hand card during hand_select."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.combat_select_card(card_index))
@cli.command("combat-confirm-selection")
@click.pass_context
def combat_confirm_selection(ctx: click.Context) -> None:
"""Confirm an in-combat card selection."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.combat_confirm_selection())
@cli.command("select-relic")
@click.argument("index", type=int)
@click.pass_context
def select_relic(ctx: click.Context, index: int) -> None:
"""Select a relic by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.select_relic(index))
@cli.command("skip-relic-selection")
@click.pass_context
def skip_relic_selection(ctx: click.Context) -> None:
"""Skip relic selection."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.skip_relic_selection())
@cli.command("claim-treasure-relic")
@click.argument("index", type=int)
@click.pass_context
def claim_treasure_relic(ctx: click.Context, index: int) -> None:
"""Claim a treasure room relic by index."""
runtime = _get_runtime(ctx)
_run_post(runtime.client, actions.claim_treasure_relic(index))
@cli.command()
@click.pass_context
def repl(ctx: click.Context) -> None:
"""Start an interactive sts2 shell."""
runtime = _get_runtime(ctx)
skin = ReplSkin("slay_the_spire_ii", version=__version__)
skin.print_banner()
skin.hint("Type a command such as `state` or `play-card 0 --target NIBBIT_0`.")
skin.hint("Type `help` to show shortcuts. Type `quit` or `exit` to leave.")
print()
pt_session = skin.create_prompt_session()
while True:
try:
line = skin.get_input(pt_session, context=runtime.base_url)
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
return
if not line:
continue
lowered = line.lower()
if lowered in {"quit", "exit"}:
skin.print_goodbye()
return
if lowered == "help":
skin.help(_repl_commands())
continue
try:
argv = shlex.split(line)
except ValueError as exc:
skin.warning(str(exc))
continue
if argv and argv[0] == "repl":
skin.warning("Already in REPL. Run a command directly instead.")
continue
try:
cli.main(
args=["--base-url", runtime.base_url, "--timeout", str(runtime.timeout), *argv],
prog_name="cli-anything-sts2",
standalone_mode=False,
)
except click.ClickException as exc:
skin.error(exc.format_message())
except click.exceptions.Exit as exc:
if exc.exit_code not in (None, 0):
skin.error(f"Command exited with status {exc.exit_code}")
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else 1
if code not in (None, 0):
skin.error(f"Command exited with status {code}")
except Exception as exc:
skin.error(str(exc))
def _repl_commands() -> dict[str, str]:
commands = {name: cmd.short_help or "" for name, cmd in cli.commands.items() if name != "repl"}
commands["help"] = "Show this help"
commands["quit"] = "Exit REPL"
return commands
def _parse_kv_pairs(entries: list[str]) -> dict[str, object]:
@@ -211,39 +375,23 @@ def _coerce_value(raw: str) -> object:
return raw
def _print_json(value: object) -> int:
def _print_json(value: object) -> None:
json.dump(value, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
def _run_repl(base_url: str, timeout: float) -> int:
print("STS2CLI REPL")
print("Type a normal subcommand such as `state` or `play-card 0 --target NIBBIT_0`.")
print("Type `help` to show command help. Type `exit` or `quit` to leave.")
while True:
try:
line = input("sts2> ").strip()
except EOFError:
sys.stdout.write("\n")
return 0
except KeyboardInterrupt:
sys.stdout.write("\n")
return 0
if not line:
continue
if line in {"exit", "quit"}:
return 0
if line == "help":
build_parser().print_help()
continue
argv = ["--base-url", base_url, "--timeout", str(timeout), *shlex.split(line)]
code = main(argv)
if code != 0:
print(f"[exit {code}]", file=sys.stderr)
def main(argv: list[str] | None = None) -> int:
try:
cli.main(args=argv, prog_name="cli-anything-sts2", standalone_mode=False)
return 0
except click.ClickException as exc:
exc.show(file=sys.stderr)
return exc.exit_code
except click.exceptions.Exit as exc:
return exc.exit_code
except click.Abort:
click.echo("Aborted!", err=True)
return 1
if __name__ == "__main__":

View File

@@ -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"}`

View File

@@ -0,0 +1 @@
"""Tests for the Slay the Spire II CLI harness."""

View File

@@ -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()

View File

@@ -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()

View File

@@ -28,7 +28,9 @@ class Sts2RawClient:
return self._request_json("GET", url) if format == "json" else self._request_text("GET", url)
def post_action(self, action: str, **payload: Any) -> JsonDict:
body: JsonDict = {"action": action, **payload}
if "action" in payload:
raise ValueError("`action` must be provided as the first argument to post_action, not in **payload")
body: JsonDict = {**payload, "action": action}
return self._request_json("POST", self.singleplayer_url, body)
def _request_text(self, method: str, url: str, body: JsonDict | None = None) -> str:

View File

@@ -32,7 +32,9 @@ setup(
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[],
install_requires=[
"click>=8.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",