mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-20 21:00:28 +08:00
feat: bundle sts2 bridge and tests
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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` ·
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "STS2_Bridge",
|
||||
"name": "STS2 Bridge",
|
||||
"author": "kunology",
|
||||
"description": "Local bridge plugin for Slay the Spire 2",
|
||||
"version": "0.3.0",
|
||||
"has_pck": false,
|
||||
"has_dll": true,
|
||||
"affects_gameplay": false
|
||||
}
|
||||
24
slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh
Executable file
24
slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh
Executable 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"
|
||||
1039
slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs
Normal file
1039
slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace STS2_Bridge;
|
||||
|
||||
public static partial class BridgeMod
|
||||
{
|
||||
private static string FormatAsMarkdown(Dictionary<string, object?> state)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
string stateType = state.TryGetValue("state_type", out var st) ? st?.ToString() ?? "unknown" : "unknown";
|
||||
bool isMultiplayer = state.TryGetValue("game_mode", out var gm) && gm?.ToString() == "multiplayer";
|
||||
|
||||
if (isMultiplayer)
|
||||
sb.AppendLine($"# Multiplayer Game State: {stateType}");
|
||||
else
|
||||
sb.AppendLine($"# Game State: {stateType}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (state.TryGetValue("run", out var runObj) && runObj is Dictionary<string, object?> run)
|
||||
{
|
||||
sb.AppendLine($"**Act {run["act"]}** | Floor {run["floor"]} | Ascension {run["ascension"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (state.TryGetValue("message", out var msg) && msg != null)
|
||||
{
|
||||
sb.AppendLine(msg.ToString());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Multiplayer players summary (top-level)
|
||||
if (isMultiplayer && state.TryGetValue("players", out var playersListObj)
|
||||
&& playersListObj is List<Dictionary<string, object?>> playersList && playersList.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Party");
|
||||
foreach (var p in playersList)
|
||||
{
|
||||
string youTag = p["is_local"] is true ? " **(YOU)**" : "";
|
||||
string aliveTag = p["is_alive"] is false ? " [DEAD]" : "";
|
||||
sb.AppendLine($"- **{p["character"]}**{youTag}{aliveTag} — HP: {p["hp"]}/{p["max_hp"]} | Gold: {p["gold"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (state.TryGetValue("battle", out var battleObj) && battleObj is Dictionary<string, object?> battle)
|
||||
{
|
||||
if (isMultiplayer)
|
||||
FormatMultiplayerBattleMarkdown(sb, battle);
|
||||
else
|
||||
FormatBattleMarkdown(sb, battle);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("event", out var eventObj) && eventObj is Dictionary<string, object?> eventData)
|
||||
{
|
||||
FormatEventMarkdown(sb, eventData);
|
||||
if (isMultiplayer)
|
||||
FormatEventVotesMarkdown(sb, eventData);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("rest_site", out var restObj) && restObj is Dictionary<string, object?> restData)
|
||||
{
|
||||
FormatRestSiteMarkdown(sb, restData);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("shop", out var shopObj) && shopObj is Dictionary<string, object?> shopData)
|
||||
{
|
||||
FormatShopMarkdown(sb, shopData);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("map", out var mapObj) && mapObj is Dictionary<string, object?> mapData)
|
||||
{
|
||||
FormatMapMarkdown(sb, mapData);
|
||||
if (isMultiplayer)
|
||||
FormatMapVotesMarkdown(sb, mapData);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("rewards", out var rewardsObj) && rewardsObj is Dictionary<string, object?> rewards)
|
||||
{
|
||||
FormatRewardsMarkdown(sb, rewards);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("card_reward", out var cardRewardObj) && cardRewardObj is Dictionary<string, object?> cardReward)
|
||||
{
|
||||
FormatCardRewardMarkdown(sb, cardReward);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("hand_select", out var handSelectObj) && handSelectObj is Dictionary<string, object?> handSelect)
|
||||
{
|
||||
FormatHandSelectMarkdown(sb, handSelect);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("card_select", out var cardSelectObj) && cardSelectObj is Dictionary<string, object?> cardSelect)
|
||||
{
|
||||
FormatCardSelectMarkdown(sb, cardSelect);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("relic_select", out var relicSelectObj) && relicSelectObj is Dictionary<string, object?> relicSelect)
|
||||
{
|
||||
FormatRelicSelectMarkdown(sb, relicSelect);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("treasure", out var treasureObj) && treasureObj is Dictionary<string, object?> treasureData)
|
||||
{
|
||||
FormatTreasureMarkdown(sb, treasureData);
|
||||
if (isMultiplayer)
|
||||
FormatTreasureBidsMarkdown(sb, treasureData);
|
||||
}
|
||||
|
||||
if (state.TryGetValue("overlay", out var overlayObj) && overlayObj is Dictionary<string, object?> overlayData)
|
||||
{
|
||||
sb.AppendLine($"## Overlay: {overlayData.GetValueOrDefault("screen_type")}");
|
||||
sb.AppendLine(overlayData.GetValueOrDefault("message")?.ToString());
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Keyword glossary — collect all unique keyword definitions
|
||||
var glossary = new Dictionary<string, string>();
|
||||
CollectKeywordsFromState(state, glossary);
|
||||
if (glossary.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Keyword Glossary");
|
||||
foreach (var (name, description) in glossary.OrderBy(kv => kv.Key))
|
||||
sb.AppendLine($"- **{name}**: {description}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void FormatBattleMarkdown(StringBuilder sb, Dictionary<string, object?> battle)
|
||||
{
|
||||
sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (battle.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : "";
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]} | Energy: {player["energy"]}/{player["max_energy"]}{stars} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
|
||||
FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}");
|
||||
FormatListSection(sb, "Relics", player, "relics", r =>
|
||||
{
|
||||
string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : "";
|
||||
return $"- **{r["name"]}**{counter}: {r["description"]}";
|
||||
});
|
||||
FormatListSection(sb, "Potions", player, "potions", p => $"- [{p["slot"]}] **{p["name"]}**: {p["description"]}");
|
||||
|
||||
if (player.TryGetValue("hand", out var handObj) && handObj is List<Dictionary<string, object?>> hand && hand.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Hand");
|
||||
foreach (var card in hand)
|
||||
{
|
||||
string playable = card["can_play"] is true ? "✓" : "✗";
|
||||
string keywords = card.TryGetValue("keywords", out var kw) && kw is List<string> kwList && kwList.Count > 0
|
||||
? $" [{string.Join(", ", kwList)}]" : "";
|
||||
string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : "";
|
||||
sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} — {card["description"]} (target: {card["target_type"]})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
FormatDeckPilesMarkdown(sb, player);
|
||||
|
||||
if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List<Dictionary<string, object?>> orbs && orbs.Count > 0)
|
||||
{
|
||||
int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count;
|
||||
int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0;
|
||||
sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)");
|
||||
foreach (var orb in orbs)
|
||||
{
|
||||
string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : "";
|
||||
sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}");
|
||||
}
|
||||
if (empty > 0)
|
||||
sb.AppendLine($"- *{empty} empty slot(s)*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List<Dictionary<string, object?>> enemies && enemies.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Enemies");
|
||||
foreach (var enemy in enemies)
|
||||
{
|
||||
sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)");
|
||||
sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}");
|
||||
|
||||
if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List<Dictionary<string, object?>> intents && intents.Count > 0)
|
||||
{
|
||||
sb.Append("**Intent:** ");
|
||||
sb.AppendLine(string.Join(", ", intents.Select(i =>
|
||||
{
|
||||
string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!;
|
||||
string typeTag = $" ({i["type"]})";
|
||||
string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : "";
|
||||
string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : "";
|
||||
return $"{title}{typeTag}{label}{desc}";
|
||||
})));
|
||||
}
|
||||
|
||||
FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void FormatDeckPilesMarkdown(StringBuilder sb, Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("### Deck Information");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"#### Draw Pile ({player["draw_pile_count"]} cards, in random order)");
|
||||
if (player.TryGetValue("draw_pile", out var drawObj) && drawObj is List<Dictionary<string, object?>> drawPile && drawPile.Count > 0)
|
||||
{
|
||||
foreach (var card in drawPile)
|
||||
sb.AppendLine($"- {card["name"]}: {card["description"]}");
|
||||
}
|
||||
else
|
||||
sb.AppendLine("- *(empty)*");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"#### Discard Pile ({player["discard_pile_count"]} cards)");
|
||||
if (player.TryGetValue("discard_pile", out var discardObj) && discardObj is List<Dictionary<string, object?>> discardPile && discardPile.Count > 0)
|
||||
{
|
||||
foreach (var card in discardPile)
|
||||
sb.AppendLine($"- {card["name"]}: {card["description"]}");
|
||||
}
|
||||
else
|
||||
sb.AppendLine("- *(empty)*");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"#### Exhaust Pile ({player["exhaust_pile_count"]} cards)");
|
||||
if (player.TryGetValue("exhaust_pile", out var exhaustObj) && exhaustObj is List<Dictionary<string, object?>> exhaustPile && exhaustPile.Count > 0)
|
||||
{
|
||||
foreach (var card in exhaustPile)
|
||||
sb.AppendLine($"- {card["name"]}: {card["description"]}");
|
||||
}
|
||||
else
|
||||
sb.AppendLine("- *(empty)*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatEventMarkdown(StringBuilder sb, Dictionary<string, object?> evt)
|
||||
{
|
||||
string name = evt.TryGetValue("event_name", out var n) && n != null ? n.ToString()! : "Unknown Event";
|
||||
bool isAncient = evt.TryGetValue("is_ancient", out var a) && a is true;
|
||||
sb.AppendLine($"## {(isAncient ? "Ancient" : "Event")}: {name}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (evt.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool inDialogue = evt.TryGetValue("in_dialogue", out var d) && d is true;
|
||||
if (inDialogue)
|
||||
{
|
||||
sb.AppendLine("*Ancient dialogue in progress — use `advance_dialogue` to continue.*");
|
||||
sb.AppendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.TryGetValue("options", out var optObj) && optObj is List<Dictionary<string, object?>> options && options.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Options");
|
||||
foreach (var opt in options)
|
||||
{
|
||||
bool locked = opt["is_locked"] is true;
|
||||
bool proceed = opt["is_proceed"] is true;
|
||||
bool chosen = opt["was_chosen"] is true;
|
||||
|
||||
string tag = locked ? " (LOCKED)" : chosen ? " (CHOSEN)" : proceed ? " (PROCEED)" : "";
|
||||
string relic = opt.TryGetValue("relic_name", out var rn) && rn != null ? $" [Relic: {rn}]" : "";
|
||||
sb.AppendLine($"- [{opt["index"]}] **{opt["title"]}**{tag}{relic} — {opt["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("No options available.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void FormatRestSiteMarkdown(StringBuilder sb, Dictionary<string, object?> restSite)
|
||||
{
|
||||
if (restSite.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (restSite.TryGetValue("options", out var optObj) && optObj is List<Dictionary<string, object?>> options && options.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Rest Site Options");
|
||||
foreach (var opt in options)
|
||||
{
|
||||
string enabled = opt["is_enabled"] is true ? "" : " (DISABLED)";
|
||||
sb.AppendLine($"- [{opt["index"]}] **{opt["name"]}**{enabled} — {opt["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canProceed = restSite.TryGetValue("can_proceed", out var cp) && cp is true;
|
||||
sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatShopMarkdown(StringBuilder sb, Dictionary<string, object?> shop)
|
||||
{
|
||||
if (shop.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (shop.TryGetValue("items", out var itemsObj) && itemsObj is List<Dictionary<string, object?>> items)
|
||||
{
|
||||
sb.AppendLine("## Shop Inventory");
|
||||
string? lastCategory = null;
|
||||
foreach (var item in items)
|
||||
{
|
||||
string category = item["category"]?.ToString() ?? "";
|
||||
if (category != lastCategory)
|
||||
{
|
||||
string header = category switch { "card" => "Cards", "relic" => "Relics", "potion" => "Potions", "card_removal" => "Services", _ => category };
|
||||
sb.AppendLine($"### {header}");
|
||||
lastCategory = category;
|
||||
}
|
||||
|
||||
bool stocked = item["is_stocked"] is true;
|
||||
bool afford = item["can_afford"] is true;
|
||||
string costTag = stocked ? $"{item["cost"]}g" : "SOLD";
|
||||
string affordTag = stocked && !afford ? " (can't afford)" : "";
|
||||
string saleTag = item.TryGetValue("on_sale", out var os) && os is true ? " **SALE**" : "";
|
||||
|
||||
string desc = category switch
|
||||
{
|
||||
"card" => $"**{item.GetValueOrDefault("card_name")}** [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")} — {item.GetValueOrDefault("card_description")}",
|
||||
"relic" => $"**{item.GetValueOrDefault("relic_name")}** — {item.GetValueOrDefault("relic_description")}",
|
||||
"potion" => $"**{item.GetValueOrDefault("potion_name")}** — {item.GetValueOrDefault("potion_description")}",
|
||||
"card_removal" => "**Remove a card** from your deck",
|
||||
_ => "Unknown item"
|
||||
};
|
||||
sb.AppendLine($"- [{item["index"]}] {desc} — {costTag}{saleTag}{affordTag}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canProceed = shop.TryGetValue("can_proceed", out var cp) && cp is true;
|
||||
sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatMapMarkdown(StringBuilder sb, Dictionary<string, object?> map)
|
||||
{
|
||||
// Player summary
|
||||
if (map.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Path taken
|
||||
if (map.TryGetValue("visited", out var visitedObj) && visitedObj is List<Dictionary<string, object?>> visited && visited.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Path Taken");
|
||||
var parts = visited.Select((v, i) => $"{i + 1}. {v["type"]} ({v["col"]},{v["row"]})");
|
||||
sb.AppendLine(string.Join(" → ", parts) + " ← current");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Next options — the key decision section
|
||||
if (map.TryGetValue("next_options", out var optObj) && optObj is List<Dictionary<string, object?>> options && options.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Choose Next Node");
|
||||
foreach (var opt in options)
|
||||
{
|
||||
string lookahead = "";
|
||||
if (opt.TryGetValue("leads_to", out var leadsObj) && leadsObj is List<Dictionary<string, object?>> leads && leads.Count > 0)
|
||||
lookahead = " → leads to: " + string.Join(", ", leads.Select(l => $"{l["type"]}({l["col"]},{l["row"]})"));
|
||||
sb.AppendLine($"- [{opt["index"]}] **{opt["type"]}** ({opt["col"]},{opt["row"]}){lookahead}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("## Map");
|
||||
sb.AppendLine("No travelable nodes available.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Full map overview — compact row-by-row
|
||||
if (map.TryGetValue("nodes", out var nodesObj) && nodesObj is List<Dictionary<string, object?>> nodes && nodes.Count > 0)
|
||||
{
|
||||
// Collect visited and travelable coords for markers
|
||||
var visitedSet = new HashSet<string>();
|
||||
if (map.TryGetValue("visited", out var v2) && v2 is List<Dictionary<string, object?>> vList)
|
||||
foreach (var vn in vList)
|
||||
visitedSet.Add($"{vn["col"]},{vn["row"]}");
|
||||
|
||||
var travelableSet = new HashSet<string>();
|
||||
if (map.TryGetValue("next_options", out var o2) && o2 is List<Dictionary<string, object?>> oList)
|
||||
foreach (var on in oList)
|
||||
travelableSet.Add($"{on["col"]},{on["row"]}");
|
||||
|
||||
string? currentKey = null;
|
||||
if (map.TryGetValue("current_position", out var cpObj) && cpObj is Dictionary<string, object?> cp)
|
||||
currentKey = $"{cp["col"]},{cp["row"]}";
|
||||
|
||||
// Group nodes by row
|
||||
var byRow = new SortedDictionary<int, List<Dictionary<string, object?>>>();
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
int row = node["row"] is int r ? r : Convert.ToInt32(node["row"]);
|
||||
if (!byRow.TryGetValue(row, out var rowList))
|
||||
byRow[row] = rowList = new List<Dictionary<string, object?>>();
|
||||
rowList.Add(node);
|
||||
}
|
||||
|
||||
sb.AppendLine("## Map Overview");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine("Legend: · = visited, * = current, → = next option");
|
||||
sb.AppendLine();
|
||||
foreach (var (row, rowNodes) in byRow)
|
||||
{
|
||||
var sorted = rowNodes.OrderBy(n => n["col"] is int c ? c : Convert.ToInt32(n["col"])).ToList();
|
||||
var labels = new List<string>();
|
||||
foreach (var node in sorted)
|
||||
{
|
||||
string type = node["type"]?.ToString() ?? "Unknown";
|
||||
string key = $"{node["col"]},{node["row"]}";
|
||||
|
||||
string marker = "";
|
||||
if (key == currentKey) marker = "*";
|
||||
else if (travelableSet.Contains(key)) marker = "→";
|
||||
else if (visitedSet.Contains(key)) marker = "·";
|
||||
|
||||
labels.Add($"{marker}{type}({node["col"]},{node["row"]})");
|
||||
}
|
||||
sb.AppendLine($" Row {row,2}: {string.Join(" ", labels)}");
|
||||
}
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void FormatRewardsMarkdown(StringBuilder sb, Dictionary<string, object?> rewards)
|
||||
{
|
||||
if (rewards.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (rewards.TryGetValue("items", out var itemsObj) && itemsObj is List<Dictionary<string, object?>> items && items.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Rewards");
|
||||
foreach (var item in items)
|
||||
{
|
||||
string extra = "";
|
||||
if (item.TryGetValue("gold_amount", out var gold) && gold != null)
|
||||
extra = $" ({gold} gold)";
|
||||
else if (item.TryGetValue("potion_name", out var pName) && pName != null)
|
||||
extra = $" ({pName})";
|
||||
sb.AppendLine($"- [{item["index"]}] **{item["type"]}**: {item["description"]}{extra}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("## Rewards");
|
||||
sb.AppendLine("No rewards available.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canProceed = rewards.TryGetValue("can_proceed", out var cp) && cp is true;
|
||||
sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatCardRewardMarkdown(StringBuilder sb, Dictionary<string, object?> cardReward)
|
||||
{
|
||||
sb.AppendLine("## Card Reward Selection");
|
||||
sb.AppendLine("Choose a card to add to your deck:");
|
||||
sb.AppendLine();
|
||||
|
||||
if (cardReward.TryGetValue("cards", out var cardsObj) && cardsObj is List<Dictionary<string, object?>> cards)
|
||||
{
|
||||
foreach (var card in cards)
|
||||
{
|
||||
string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : "";
|
||||
string keywords = card.TryGetValue("keywords", out var kw) && kw is List<string> kwList && kwList.Count > 0
|
||||
? $" [{string.Join(", ", kwList)}]" : "";
|
||||
sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} — {card["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canSkip = cardReward.TryGetValue("can_skip", out var cs) && cs is true;
|
||||
sb.AppendLine($"**Can skip:** {(canSkip ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatRelicSelectMarkdown(StringBuilder sb, Dictionary<string, object?> relicSelect)
|
||||
{
|
||||
sb.AppendLine("## Relic Selection");
|
||||
if (relicSelect.TryGetValue("prompt", out var p) && p != null)
|
||||
sb.AppendLine($"*{p}*");
|
||||
sb.AppendLine();
|
||||
|
||||
if (relicSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (relicSelect.TryGetValue("relics", out var relicsObj) && relicsObj is List<Dictionary<string, object?>> relics)
|
||||
{
|
||||
foreach (var relic in relics)
|
||||
sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}** — {relic["description"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canSkip = relicSelect.TryGetValue("can_skip", out var cs) && cs is true;
|
||||
sb.AppendLine($"Use `select_relic(index)` to choose. Can skip: {(canSkip ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatHandSelectMarkdown(StringBuilder sb, Dictionary<string, object?> handSelect)
|
||||
{
|
||||
sb.AppendLine("## In-Combat Card Selection");
|
||||
|
||||
if (handSelect.TryGetValue("prompt", out var promptObj) && promptObj != null)
|
||||
sb.AppendLine($"*{promptObj}*");
|
||||
sb.AppendLine();
|
||||
|
||||
string mode = handSelect.TryGetValue("mode", out var m) ? m?.ToString() ?? "simple_select" : "simple_select";
|
||||
if (mode == "upgrade_select")
|
||||
sb.AppendLine("**Mode:** Upgrade selection");
|
||||
sb.AppendLine();
|
||||
|
||||
if (handSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List<Dictionary<string, object?>> cards && cards.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Selectable Cards");
|
||||
foreach (var card in cards)
|
||||
{
|
||||
sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] — {card["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (handSelect.TryGetValue("selected_cards", out var selObj) && selObj is List<Dictionary<string, object?>> selected && selected.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Already Selected");
|
||||
foreach (var card in selected)
|
||||
sb.AppendLine($"- {card["name"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool canConfirm = handSelect.TryGetValue("can_confirm", out var cc) && cc is true;
|
||||
sb.AppendLine($"Use `combat_select_card(card_index)` to select. Can confirm: {(canConfirm ? "Yes — use `combat_confirm_selection`" : "No — select more cards")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatCardSelectMarkdown(StringBuilder sb, Dictionary<string, object?> cardSelect)
|
||||
{
|
||||
string screenType = cardSelect.TryGetValue("screen_type", out var st) ? st?.ToString() ?? "select" : "select";
|
||||
string screenLabel = screenType switch
|
||||
{
|
||||
"transform" => "Transform",
|
||||
"upgrade" => "Upgrade",
|
||||
"select" => "Select",
|
||||
"simple_select" => "Select",
|
||||
_ => screenType
|
||||
};
|
||||
sb.AppendLine($"## Card Selection: {screenLabel}");
|
||||
|
||||
if (cardSelect.TryGetValue("prompt", out var promptObj) && promptObj != null)
|
||||
{
|
||||
sb.AppendLine($"*{promptObj}*");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
if (cardSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (cardSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List<Dictionary<string, object?>> cards)
|
||||
{
|
||||
sb.AppendLine("### Cards");
|
||||
foreach (var card in cards)
|
||||
{
|
||||
sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] {card["rarity"]} — {card["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
bool preview = cardSelect.TryGetValue("preview_showing", out var pv) && pv is true;
|
||||
bool canConfirm = cardSelect.TryGetValue("can_confirm", out var cc) && cc is true;
|
||||
bool canCancel = cardSelect.TryGetValue("can_cancel", out var cn) && cn is true;
|
||||
|
||||
if (preview)
|
||||
sb.AppendLine("**Preview is showing** — use `confirm_selection` to confirm or `cancel_selection` to go back.");
|
||||
else
|
||||
sb.AppendLine($"**Select cards** using `select_card(index)`. Can confirm: {(canConfirm ? "Yes" : "No")} | Can cancel: {(canCancel ? "Yes" : "No")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatTreasureMarkdown(StringBuilder sb, Dictionary<string, object?> treasure)
|
||||
{
|
||||
if (treasure.TryGetValue("player", out var playerObj) && playerObj is Dictionary<string, object?> player)
|
||||
{
|
||||
sb.AppendLine("## Player (You)");
|
||||
sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (treasure.TryGetValue("relics", out var relicsObj) && relicsObj is List<Dictionary<string, object?>> relics && relics.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Treasure Relics");
|
||||
foreach (var relic in relics)
|
||||
{
|
||||
string rarity = relic.TryGetValue("rarity", out var r) && r != null ? $" ({r})" : "";
|
||||
sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}**{rarity} — {relic["description"]}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Use `treasure_claim_relic(relic_index)` to claim a relic.");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("Chest is opening...");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
bool canProceed = treasure.TryGetValue("can_proceed", out var cp) && cp is true;
|
||||
if (canProceed)
|
||||
sb.AppendLine("**Can proceed:** Yes");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static string FormatStatusAmount(object? amount)
|
||||
{
|
||||
if (amount is int i && i == -1) return "indefinite";
|
||||
return amount?.ToString() ?? "0";
|
||||
}
|
||||
|
||||
private static void FormatListSection(StringBuilder sb, string title, Dictionary<string, object?> parent, string key,
|
||||
Func<Dictionary<string, object?>, string> formatter)
|
||||
{
|
||||
if (parent.TryGetValue(key, out var listObj) && listObj is List<Dictionary<string, object?>> list && list.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"### {title}");
|
||||
foreach (var item in list)
|
||||
sb.AppendLine(formatter(item));
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void FormatMultiplayerBattleMarkdown(StringBuilder sb, Dictionary<string, object?> battle)
|
||||
{
|
||||
if (battle.TryGetValue("error", out var err) && err != null)
|
||||
{
|
||||
sb.AppendLine($"**Combat Error:** {err}");
|
||||
sb.AppendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
bool allReady = battle.TryGetValue("all_players_ready", out var ar) && ar is true;
|
||||
sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]} | All Ready: {allReady}");
|
||||
sb.AppendLine();
|
||||
|
||||
// All players
|
||||
if (battle.TryGetValue("players", out var playersObj) && playersObj is List<Dictionary<string, object?>> players)
|
||||
{
|
||||
foreach (var player in players)
|
||||
{
|
||||
string youTag = player["is_local"] is true ? " **(YOU)**" : "";
|
||||
string aliveTag = player["is_alive"] is false ? " [DEAD]" : "";
|
||||
string readyTag = player["is_ready_to_end_turn"] is true ? " [READY]" : "";
|
||||
string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : "";
|
||||
|
||||
sb.AppendLine($"## Player: {player["character"]}{youTag}{aliveTag}{readyTag}");
|
||||
string energyStr = player.TryGetValue("energy", out var en) && player.TryGetValue("max_energy", out var men)
|
||||
? $" | Energy: {en}/{men}" : "";
|
||||
sb.AppendLine($"HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]}{energyStr}{stars} | Gold: {player["gold"]}");
|
||||
sb.AppendLine();
|
||||
|
||||
FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}");
|
||||
FormatListSection(sb, "Relics", player, "relics", r =>
|
||||
{
|
||||
string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : "";
|
||||
return $"- **{r["name"]}**{counter}: {r["description"]}";
|
||||
});
|
||||
FormatListSection(sb, "Potions", player, "potions", p =>
|
||||
{
|
||||
string desc = p.TryGetValue("description", out var d) && d != null ? $": {d}" : "";
|
||||
return $"- [{p["slot"]}] **{p["name"]}**{desc}";
|
||||
});
|
||||
|
||||
if (player["is_local"] is true)
|
||||
{
|
||||
if (player.TryGetValue("hand", out var handObj) && handObj is List<Dictionary<string, object?>> hand && hand.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Hand");
|
||||
foreach (var card in hand)
|
||||
{
|
||||
string playable = card["can_play"] is true ? "\u2713" : "\u2717";
|
||||
string keywords = card.TryGetValue("keywords", out var kw) && kw is List<string> kwList && kwList.Count > 0
|
||||
? $" [{string.Join(", ", kwList)}]" : "";
|
||||
string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : "";
|
||||
sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} — {card["description"]} (target: {card["target_type"]})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
FormatDeckPilesMarkdown(sb, player);
|
||||
|
||||
if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List<Dictionary<string, object?>> orbs && orbs.Count > 0)
|
||||
{
|
||||
int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count;
|
||||
int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0;
|
||||
sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)");
|
||||
foreach (var orb in orbs)
|
||||
{
|
||||
string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : "";
|
||||
sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}");
|
||||
}
|
||||
if (empty > 0)
|
||||
sb.AppendLine($"- *{empty} empty slot(s)*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List<Dictionary<string, object?>> enemies && enemies.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Enemies");
|
||||
foreach (var enemy in enemies)
|
||||
{
|
||||
sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)");
|
||||
sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}");
|
||||
|
||||
if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List<Dictionary<string, object?>> intents && intents.Count > 0)
|
||||
{
|
||||
sb.Append("**Intent:** ");
|
||||
sb.AppendLine(string.Join(", ", intents.Select(i =>
|
||||
{
|
||||
string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!;
|
||||
string typeTag = $" ({i["type"]})";
|
||||
string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : "";
|
||||
string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : "";
|
||||
return $"{title}{typeTag}{label}{desc}";
|
||||
})));
|
||||
}
|
||||
|
||||
FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void FormatMapVotesMarkdown(StringBuilder sb, Dictionary<string, object?> mapData)
|
||||
{
|
||||
if (!mapData.TryGetValue("votes", out var votesObj) || votesObj is not List<Dictionary<string, object?>> votes || votes.Count == 0)
|
||||
return;
|
||||
|
||||
sb.AppendLine("## Map Votes");
|
||||
foreach (var vote in votes)
|
||||
{
|
||||
string youTag = vote["is_local"] is true ? " (YOU)" : "";
|
||||
if (vote["voted"] is true)
|
||||
sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for ({vote["vote_col"]},{vote["vote_row"]})");
|
||||
else
|
||||
sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*");
|
||||
}
|
||||
bool allVoted = mapData.TryGetValue("all_voted", out var av) && av is true;
|
||||
if (allVoted)
|
||||
sb.AppendLine("**All players have voted!**");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatEventVotesMarkdown(StringBuilder sb, Dictionary<string, object?> eventData)
|
||||
{
|
||||
bool isShared = eventData.TryGetValue("is_shared", out var sh) && sh is true;
|
||||
if (!isShared) return;
|
||||
|
||||
if (!eventData.TryGetValue("votes", out var votesObj) || votesObj is not List<Dictionary<string, object?>> votes || votes.Count == 0)
|
||||
return;
|
||||
|
||||
sb.AppendLine("## Event Votes (Shared Event)");
|
||||
foreach (var vote in votes)
|
||||
{
|
||||
string youTag = vote["is_local"] is true ? " (YOU)" : "";
|
||||
if (vote["voted"] is true)
|
||||
sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for option {vote["vote_option"]}");
|
||||
else
|
||||
sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*");
|
||||
}
|
||||
bool allVoted = eventData.TryGetValue("all_voted", out var av) && av is true;
|
||||
if (allVoted)
|
||||
sb.AppendLine("**All players have voted!**");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatTreasureBidsMarkdown(StringBuilder sb, Dictionary<string, object?> treasureData)
|
||||
{
|
||||
if (treasureData.TryGetValue("is_bidding_phase", out var bp) && bp is not true)
|
||||
return;
|
||||
|
||||
if (!treasureData.TryGetValue("bids", out var bidsObj) || bidsObj is not List<Dictionary<string, object?>> bids || bids.Count == 0)
|
||||
return;
|
||||
|
||||
sb.AppendLine("## Treasure Bids");
|
||||
foreach (var bid in bids)
|
||||
{
|
||||
string youTag = bid["is_local"] is true ? " (YOU)" : "";
|
||||
if (bid["voted"] is true)
|
||||
sb.AppendLine($"- **{bid["player"]}**{youTag}: bid on relic #{bid["vote_relic_index"]}");
|
||||
else
|
||||
sb.AppendLine($"- **{bid["player"]}**{youTag}: *waiting...*");
|
||||
}
|
||||
bool allBid = treasureData.TryGetValue("all_bid", out var ab) && ab is true;
|
||||
if (allBid)
|
||||
sb.AppendLine("**All players have bid!**");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void CollectKeywordsFromState(object? obj, Dictionary<string, string> glossary)
|
||||
{
|
||||
if (obj is Dictionary<string, object?> dict)
|
||||
{
|
||||
if (dict.TryGetValue("keywords", out var kw) && kw is List<Dictionary<string, object?>> keywords)
|
||||
{
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
string? name = keyword.GetValueOrDefault("name")?.ToString();
|
||||
string? desc = keyword.GetValueOrDefault("description")?.ToString();
|
||||
if (name != null && desc != null)
|
||||
glossary.TryAdd(name, desc);
|
||||
}
|
||||
}
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
if (key != "keywords")
|
||||
CollectKeywordsFromState(value, glossary);
|
||||
}
|
||||
}
|
||||
else if (obj is List<Dictionary<string, object?>> list)
|
||||
{
|
||||
foreach (var item in list)
|
||||
CollectKeywordsFromState(item, glossary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Godot;
|
||||
using MegaCrit.Sts2.Core.Entities.Cards;
|
||||
using MegaCrit.Sts2.Core.HoverTips;
|
||||
using MegaCrit.Sts2.Core.Models;
|
||||
|
||||
namespace STS2_Bridge;
|
||||
|
||||
public static partial class BridgeMod
|
||||
{
|
||||
private static string? SafeGetCardDescription(CardModel card, PileType pile = PileType.Hand)
|
||||
{
|
||||
try { return StripRichTextTags(card.GetDescriptionForPile(pile)).Replace("\n", " "); }
|
||||
catch { return SafeGetText(() => card.Description)?.Replace("\n", " "); }
|
||||
}
|
||||
|
||||
internal static string? SafeGetText(Func<object?> getter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = getter();
|
||||
if (result == null) return null;
|
||||
// If it's a LocString, call GetFormattedText
|
||||
if (result is MegaCrit.Sts2.Core.Localization.LocString locString)
|
||||
return StripRichTextTags(locString.GetFormattedText());
|
||||
return result.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
internal static string StripRichTextTags(string text)
|
||||
{
|
||||
// Remove BBCode-style tags like [color=red], [/color], etc.
|
||||
// Special case: [img]res://path/to/file.png[/img] → [file.png]
|
||||
var sb = new StringBuilder();
|
||||
int i = 0;
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (text[i] == '[')
|
||||
{
|
||||
// Check for [img]...[/img] pattern
|
||||
if (text.AsSpan(i).StartsWith("[img]"))
|
||||
{
|
||||
int contentStart = i + 5; // length of "[img]"
|
||||
int closeTag = text.IndexOf("[/img]", contentStart, StringComparison.Ordinal);
|
||||
if (closeTag >= 0)
|
||||
{
|
||||
string path = text[contentStart..closeTag];
|
||||
int lastSlash = path.LastIndexOf('/');
|
||||
string filename = lastSlash >= 0 ? path[(lastSlash + 1)..] : path;
|
||||
sb.Append('[').Append(filename).Append(']');
|
||||
i = closeTag + 6; // length of "[/img]"
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
int end = text.IndexOf(']', i);
|
||||
if (end >= 0) { i = end + 1; continue; }
|
||||
}
|
||||
sb.Append(text[i]);
|
||||
i++;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static void SendJson(HttpListenerResponse response, object data)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(data, _jsonOptions);
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(json);
|
||||
response.ContentType = "application/json; charset=utf-8";
|
||||
response.ContentLength64 = buffer.Length;
|
||||
response.OutputStream.Write(buffer, 0, buffer.Length);
|
||||
response.Close();
|
||||
}
|
||||
|
||||
internal static void SendText(HttpListenerResponse response, string text, string contentType = "text/plain")
|
||||
{
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(text);
|
||||
response.ContentType = $"{contentType}; charset=utf-8";
|
||||
response.ContentLength64 = buffer.Length;
|
||||
response.OutputStream.Write(buffer, 0, buffer.Length);
|
||||
response.Close();
|
||||
}
|
||||
|
||||
internal static void SendError(HttpListenerResponse response, int statusCode, string message)
|
||||
{
|
||||
response.StatusCode = statusCode;
|
||||
SendJson(response, new Dictionary<string, object?> { ["error"] = message });
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Error(string message)
|
||||
{
|
||||
return new Dictionary<string, object?> { ["status"] = "error", ["error"] = message };
|
||||
}
|
||||
|
||||
internal static List<T> FindAll<T>(Node start) where T : Node
|
||||
{
|
||||
var list = new List<T>();
|
||||
if (GodotObject.IsInstanceValid(start))
|
||||
FindAllRecursive(start, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FindAll variant that sorts results by visual position (row-major: top-to-bottom, left-to-right).
|
||||
/// NGridCardHolder.OnFocus() calls MoveToFront() which scrambles child order for z-rendering.
|
||||
/// Sorting by GlobalPosition restores the correct visual order for both single-row (card rewards,
|
||||
/// choose-a-card) and multi-row (deck selection grids) layouts.
|
||||
/// </summary>
|
||||
internal static List<T> FindAllSortedByPosition<T>(Node start) where T : Control
|
||||
{
|
||||
var list = FindAll<T>(start);
|
||||
list.Sort((a, b) =>
|
||||
{
|
||||
int cmp = a.GlobalPosition.Y.CompareTo(b.GlobalPosition.Y);
|
||||
return cmp != 0 ? cmp : a.GlobalPosition.X.CompareTo(b.GlobalPosition.X);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void FindAllRecursive<T>(Node node, List<T> found) where T : Node
|
||||
{
|
||||
if (!GodotObject.IsInstanceValid(node))
|
||||
return;
|
||||
if (node is T item)
|
||||
found.Add(item);
|
||||
foreach (var child in node.GetChildren())
|
||||
FindAllRecursive(child, found);
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> BuildHoverTips(IEnumerable<IHoverTip> tips)
|
||||
{
|
||||
var result = new List<Dictionary<string, object?>>();
|
||||
try
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var tip in IHoverTip.RemoveDupes(tips))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? title = null;
|
||||
string? description = null;
|
||||
|
||||
if (tip is HoverTip ht)
|
||||
{
|
||||
title = ht.Title != null ? StripRichTextTags(ht.Title) : null;
|
||||
description = StripRichTextTags(ht.Description);
|
||||
}
|
||||
else if (tip is CardHoverTip cardTip)
|
||||
{
|
||||
title = SafeGetText(() => cardTip.Card.Title);
|
||||
description = SafeGetCardDescription(cardTip.Card);
|
||||
}
|
||||
|
||||
if (title == null && description == null) continue;
|
||||
|
||||
string key = title ?? description!;
|
||||
if (!seen.Add(key)) continue;
|
||||
|
||||
result.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = title,
|
||||
["description"] = description
|
||||
});
|
||||
}
|
||||
catch { /* skip individual tip on error */ }
|
||||
}
|
||||
}
|
||||
catch { /* return partial results */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static T? FindFirst<T>(Node start) where T : Node
|
||||
{
|
||||
if (!GodotObject.IsInstanceValid(start))
|
||||
return null;
|
||||
if (start is T result)
|
||||
return result;
|
||||
foreach (var child in start.GetChildren())
|
||||
{
|
||||
var val = FindFirst<T>(child);
|
||||
if (val != null) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using MegaCrit.Sts2.Core.Combat;
|
||||
using MegaCrit.Sts2.Core.Context;
|
||||
using MegaCrit.Sts2.Core.Entities.Players;
|
||||
using MegaCrit.Sts2.Core.GameActions;
|
||||
using MegaCrit.Sts2.Core.Multiplayer.Game;
|
||||
using MegaCrit.Sts2.Core.Nodes.Combat;
|
||||
using MegaCrit.Sts2.Core.Nodes.Rooms;
|
||||
using MegaCrit.Sts2.Core.Runs;
|
||||
|
||||
namespace STS2_Bridge;
|
||||
|
||||
public static partial class BridgeMod
|
||||
{
|
||||
private static Dictionary<string, object?> ExecuteMultiplayerAction(string action, Dictionary<string, JsonElement> data)
|
||||
{
|
||||
if (!RunManager.Instance.IsInProgress)
|
||||
return Error("No run in progress");
|
||||
|
||||
if (!RunManager.Instance.NetService.Type.IsMultiplayer())
|
||||
return Error("Not in a multiplayer run. Use /api/v1/singleplayer instead.");
|
||||
|
||||
var runState = RunManager.Instance.DebugOnlyGetState()!;
|
||||
var player = LocalContext.GetMe(runState);
|
||||
if (player == null)
|
||||
return Error("Could not find local player");
|
||||
|
||||
return action switch
|
||||
{
|
||||
// Delegated to existing sync-safe handlers
|
||||
"play_card" => ExecutePlayCard(player, data),
|
||||
"use_potion" => ExecuteUsePotion(player, data),
|
||||
"choose_map_node" => ExecuteChooseMapNode(data),
|
||||
"choose_event_option" => ExecuteChooseEventOption(data),
|
||||
"advance_dialogue" => ExecuteAdvanceDialogue(),
|
||||
"choose_rest_option" => ExecuteChooseRestOption(data),
|
||||
"shop_purchase" => ExecuteShopPurchase(player, data),
|
||||
"claim_reward" => ExecuteClaimReward(data),
|
||||
"select_card_reward" => ExecuteSelectCardReward(data),
|
||||
"skip_card_reward" => ExecuteSkipCardReward(),
|
||||
"proceed" => ExecuteProceed(),
|
||||
"select_card" => ExecuteSelectCard(data),
|
||||
"confirm_selection" => ExecuteConfirmSelection(),
|
||||
"cancel_selection" => ExecuteCancelSelection(),
|
||||
"combat_select_card" => ExecuteCombatSelectCard(data),
|
||||
"combat_confirm_selection" => ExecuteCombatConfirmSelection(),
|
||||
"select_relic" => ExecuteSelectRelic(data),
|
||||
"skip_relic_selection" => ExecuteSkipRelicSelection(),
|
||||
"claim_treasure_relic" => ExecuteClaimTreasureRelic(data),
|
||||
|
||||
// Multiplayer-specific actions
|
||||
"end_turn" => ExecuteMultiplayerEndTurn(player),
|
||||
"undo_end_turn" => ExecuteUndoEndTurn(player),
|
||||
|
||||
_ => Error($"Unknown multiplayer action: {action}")
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ExecuteMultiplayerEndTurn(Player player)
|
||||
{
|
||||
if (!CombatManager.Instance.IsInProgress)
|
||||
return Error("Not in combat");
|
||||
if (!CombatManager.Instance.IsPlayPhase)
|
||||
return Error("Not in play phase — cannot act during enemy turn");
|
||||
if (CombatManager.Instance.PlayerActionsDisabled)
|
||||
return Error("Player actions are currently disabled");
|
||||
if (!player.Creature.IsAlive)
|
||||
return Error("Player creature is dead — cannot end turn");
|
||||
if (CombatManager.Instance.IsPlayerReadyToEndTurn(player))
|
||||
return Error("Already submitted end turn — use 'undo_end_turn' to retract");
|
||||
|
||||
// Match the game's own CanTurnBeEnded guard (NEndTurnButton.cs:114-123)
|
||||
var hand = NCombatRoom.Instance?.Ui?.Hand;
|
||||
if (hand != null && (hand.InCardPlay || hand.CurrentMode != NPlayerHand.Mode.Play))
|
||||
return Error("Cannot end turn while a card is being played or hand is in selection mode");
|
||||
|
||||
var combatState = player.Creature.CombatState;
|
||||
if (combatState == null)
|
||||
return Error("No combat state");
|
||||
|
||||
int roundNumber = combatState.RoundNumber;
|
||||
RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(
|
||||
new EndPlayerTurnAction(player, roundNumber));
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = "ok",
|
||||
["message"] = "Submitted end turn (waiting for other players)"
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ExecuteUndoEndTurn(Player player)
|
||||
{
|
||||
if (!CombatManager.Instance.IsInProgress)
|
||||
return Error("Not in combat");
|
||||
if (!CombatManager.Instance.IsPlayPhase)
|
||||
return Error("Not in play phase — cannot act during enemy turn");
|
||||
if (CombatManager.Instance.PlayerActionsDisabled)
|
||||
return Error("Player actions are currently disabled");
|
||||
if (!player.Creature.IsAlive)
|
||||
return Error("Player creature is dead");
|
||||
if (!CombatManager.Instance.IsPlayerReadyToEndTurn(player))
|
||||
return Error("Not ready to end turn — nothing to undo");
|
||||
|
||||
var combatState = player.Creature.CombatState;
|
||||
if (combatState == null)
|
||||
return Error("No combat state");
|
||||
|
||||
int roundNumber = combatState.RoundNumber;
|
||||
RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(
|
||||
new UndoEndPlayerTurnAction(player, roundNumber));
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = "ok",
|
||||
["message"] = "Undid end turn — continue playing cards"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MegaCrit.Sts2.Core.Combat;
|
||||
using MegaCrit.Sts2.Core.Context;
|
||||
using MegaCrit.Sts2.Core.Entities.Players;
|
||||
using MegaCrit.Sts2.Core.Multiplayer.Game;
|
||||
using MegaCrit.Sts2.Core.Nodes.Combat;
|
||||
using MegaCrit.Sts2.Core.Nodes.Events;
|
||||
using MegaCrit.Sts2.Core.Nodes.GodotExtensions;
|
||||
using MegaCrit.Sts2.Core.Nodes.Rooms;
|
||||
using MegaCrit.Sts2.Core.Nodes.Screens;
|
||||
using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection;
|
||||
using MegaCrit.Sts2.Core.Nodes.Screens.Map;
|
||||
using MegaCrit.Sts2.Core.Nodes.Screens.Overlays;
|
||||
using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic;
|
||||
using MegaCrit.Sts2.Core.Rooms;
|
||||
using MegaCrit.Sts2.Core.Runs;
|
||||
|
||||
namespace STS2_Bridge;
|
||||
|
||||
public static partial class BridgeMod
|
||||
{
|
||||
private static Dictionary<string, object?> BuildMultiplayerGameState()
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
|
||||
if (!RunManager.Instance.IsInProgress)
|
||||
{
|
||||
result["state_type"] = "menu";
|
||||
result["message"] = "No run in progress. Player is in the main menu.";
|
||||
return result;
|
||||
}
|
||||
|
||||
var runState = RunManager.Instance.DebugOnlyGetState();
|
||||
if (runState == null)
|
||||
{
|
||||
result["state_type"] = "unknown";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!RunManager.Instance.NetService.Type.IsMultiplayer())
|
||||
{
|
||||
result["state_type"] = "error";
|
||||
result["message"] = "Not in a multiplayer run. Use /api/v1/singleplayer instead.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Multiplayer metadata
|
||||
result["game_mode"] = "multiplayer";
|
||||
result["net_type"] = RunManager.Instance.NetService.Type.ToString();
|
||||
result["player_count"] = runState.Players.Count;
|
||||
var localPlayer = LocalContext.GetMe(runState);
|
||||
if (localPlayer != null)
|
||||
{
|
||||
for (int i = 0; i < runState.Players.Count; i++)
|
||||
{
|
||||
if (runState.Players[i] == localPlayer)
|
||||
{
|
||||
result["local_player_slot"] = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same overlay-first detection logic as singleplayer
|
||||
var topOverlay = NOverlayStack.Instance?.Peek();
|
||||
var currentRoom = runState.CurrentRoom;
|
||||
|
||||
if (topOverlay is NCardGridSelectionScreen cardSelectScreen)
|
||||
{
|
||||
result["state_type"] = "card_select";
|
||||
result["card_select"] = BuildCardSelectState(cardSelectScreen, runState);
|
||||
}
|
||||
else if (topOverlay is NChooseACardSelectionScreen chooseCardScreen)
|
||||
{
|
||||
result["state_type"] = "card_select";
|
||||
result["card_select"] = BuildChooseCardState(chooseCardScreen, runState);
|
||||
}
|
||||
else if (topOverlay is NChooseARelicSelection relicSelectScreen)
|
||||
{
|
||||
result["state_type"] = "relic_select";
|
||||
result["relic_select"] = BuildRelicSelectState(relicSelectScreen, runState);
|
||||
}
|
||||
else if (topOverlay is IOverlayScreen
|
||||
&& topOverlay is not NRewardsScreen
|
||||
&& topOverlay is not NCardRewardSelectionScreen)
|
||||
{
|
||||
result["state_type"] = "overlay";
|
||||
result["overlay"] = new Dictionary<string, object?>
|
||||
{
|
||||
["screen_type"] = topOverlay.GetType().Name,
|
||||
["message"] = $"An overlay ({topOverlay.GetType().Name}) is active. It may require manual interaction in-game."
|
||||
};
|
||||
}
|
||||
else if (currentRoom is CombatRoom combatRoom)
|
||||
{
|
||||
if (CombatManager.Instance.IsInProgress)
|
||||
{
|
||||
var playerHand = NPlayerHand.Instance;
|
||||
if (playerHand != null && playerHand.IsInCardSelection)
|
||||
{
|
||||
result["state_type"] = "hand_select";
|
||||
result["hand_select"] = BuildHandSelectState(playerHand, runState);
|
||||
result["battle"] = BuildMultiplayerBattleState(runState, combatRoom);
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = combatRoom.RoomType.ToString().ToLower();
|
||||
result["battle"] = BuildMultiplayerBattleState(runState, combatRoom);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (NMapScreen.Instance is { IsOpen: true })
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
var overlay = NOverlayStack.Instance?.Peek();
|
||||
if (overlay is NCardRewardSelectionScreen cardScreen)
|
||||
{
|
||||
result["state_type"] = "card_reward";
|
||||
result["card_reward"] = BuildCardRewardState(cardScreen);
|
||||
}
|
||||
else if (overlay is NRewardsScreen rewardsScreen)
|
||||
{
|
||||
result["state_type"] = "combat_rewards";
|
||||
result["rewards"] = BuildRewardsState(rewardsScreen, runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = combatRoom.RoomType.ToString().ToLower();
|
||||
result["message"] = "Combat ended. Waiting for rewards...";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (currentRoom is EventRoom eventRoom)
|
||||
{
|
||||
if (NMapScreen.Instance is { IsOpen: true })
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = "event";
|
||||
result["event"] = BuildMultiplayerEventState(eventRoom, runState);
|
||||
}
|
||||
}
|
||||
else if (currentRoom is MapRoom)
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else if (currentRoom is MerchantRoom merchantRoom)
|
||||
{
|
||||
if (NMapScreen.Instance is { IsOpen: true })
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
var merchUI = NMerchantRoom.Instance;
|
||||
if (merchUI != null && !merchUI.Inventory.IsOpen)
|
||||
merchUI.OpenInventory();
|
||||
|
||||
result["state_type"] = "shop";
|
||||
result["shop"] = BuildShopState(merchantRoom, runState);
|
||||
}
|
||||
}
|
||||
else if (currentRoom is RestSiteRoom restSiteRoom)
|
||||
{
|
||||
if (NMapScreen.Instance is { IsOpen: true })
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = "rest_site";
|
||||
result["rest_site"] = BuildRestSiteState(restSiteRoom, runState);
|
||||
}
|
||||
}
|
||||
else if (currentRoom is TreasureRoom treasureRoom)
|
||||
{
|
||||
if (NMapScreen.Instance is { IsOpen: true })
|
||||
{
|
||||
result["state_type"] = "map";
|
||||
result["map"] = BuildMultiplayerMapState(runState);
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = "treasure";
|
||||
result["treasure"] = BuildMultiplayerTreasureState(treasureRoom, runState);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result["state_type"] = "unknown";
|
||||
result["room_type"] = currentRoom?.GetType().Name;
|
||||
}
|
||||
|
||||
// Common run info
|
||||
result["run"] = new Dictionary<string, object?>
|
||||
{
|
||||
["act"] = runState.CurrentActIndex + 1,
|
||||
["floor"] = runState.TotalFloor,
|
||||
["ascension"] = runState.AscensionLevel
|
||||
};
|
||||
|
||||
// All players summary (always included for multiplayer)
|
||||
result["players"] = BuildAllPlayersState(runState);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildMultiplayerBattleState(RunState runState, CombatRoom combatRoom)
|
||||
{
|
||||
var combatState = CombatManager.Instance.DebugOnlyGetState();
|
||||
var battle = new Dictionary<string, object?>();
|
||||
|
||||
if (combatState == null)
|
||||
{
|
||||
battle["error"] = "Combat state unavailable";
|
||||
return battle;
|
||||
}
|
||||
|
||||
battle["round"] = combatState.RoundNumber;
|
||||
battle["turn"] = combatState.CurrentSide.ToString().ToLower();
|
||||
battle["is_play_phase"] = CombatManager.Instance.IsPlayPhase;
|
||||
battle["all_players_ready"] = CombatManager.Instance.AllPlayersReadyToEndTurn();
|
||||
|
||||
// All players in combat — full state for local player, summary for others
|
||||
var players = new List<Dictionary<string, object?>>();
|
||||
Dictionary<string, object?>? localPlayerState = null;
|
||||
foreach (var player in runState.Players)
|
||||
{
|
||||
bool isLocal = LocalContext.IsMe(player);
|
||||
// Full hand/piles/orbs only for local player; others get summary only
|
||||
var playerState = isLocal ? BuildPlayerState(player) : BuildPlayerStateSummary(player);
|
||||
playerState["is_local"] = isLocal;
|
||||
playerState["is_alive"] = player.Creature.IsAlive;
|
||||
playerState["is_ready_to_end_turn"] = CombatManager.Instance.IsPlayerReadyToEndTurn(player);
|
||||
players.Add(playerState);
|
||||
if (isLocal)
|
||||
localPlayerState = playerState;
|
||||
}
|
||||
battle["players"] = players;
|
||||
|
||||
// Local player shortcut (same dict as the is_local=true entry in players)
|
||||
if (localPlayerState != null)
|
||||
battle["player"] = localPlayerState;
|
||||
|
||||
// Enemies
|
||||
var enemies = new List<Dictionary<string, object?>>();
|
||||
var entityCounts = new Dictionary<string, int>();
|
||||
foreach (var creature in combatState.Enemies)
|
||||
{
|
||||
if (creature.IsAlive)
|
||||
enemies.Add(BuildEnemyState(creature, entityCounts));
|
||||
}
|
||||
battle["enemies"] = enemies;
|
||||
|
||||
return battle;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildMultiplayerMapState(RunState runState)
|
||||
{
|
||||
// Start with the standard map state
|
||||
var state = BuildMapState(runState);
|
||||
|
||||
// Add per-player vote data
|
||||
try
|
||||
{
|
||||
var mapSync = RunManager.Instance.MapSelectionSynchronizer;
|
||||
var votes = new List<Dictionary<string, object?>>();
|
||||
|
||||
foreach (var player in runState.Players)
|
||||
{
|
||||
var vote = mapSync.GetVote(player);
|
||||
votes.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["player"] = SafeGetText(() => player.Character.Title),
|
||||
["is_local"] = LocalContext.IsMe(player),
|
||||
["voted"] = vote != null,
|
||||
["vote_col"] = vote?.coord.col,
|
||||
["vote_row"] = vote?.coord.row
|
||||
});
|
||||
}
|
||||
|
||||
state["votes"] = votes;
|
||||
state["all_voted"] = votes.All(v => v["voted"] is true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// MapSelectionSynchronizer may not be available in all contexts
|
||||
}
|
||||
|
||||
// All players summary
|
||||
state["players"] = BuildAllPlayersState(runState);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildMultiplayerEventState(EventRoom eventRoom, RunState runState)
|
||||
{
|
||||
// Start with the standard event state
|
||||
var state = BuildEventState(eventRoom, runState);
|
||||
|
||||
// Add multiplayer-specific event data
|
||||
try
|
||||
{
|
||||
var eventSync = RunManager.Instance.EventSynchronizer;
|
||||
bool isShared = false;
|
||||
try { isShared = eventSync.IsShared; } catch { /* throws if no event in progress */ }
|
||||
state["is_shared"] = isShared;
|
||||
|
||||
if (isShared)
|
||||
{
|
||||
var votes = new List<Dictionary<string, object?>>();
|
||||
foreach (var player in runState.Players)
|
||||
{
|
||||
var vote = eventSync.GetPlayerVote(player);
|
||||
votes.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["player"] = SafeGetText(() => player.Character.Title),
|
||||
["is_local"] = LocalContext.IsMe(player),
|
||||
["voted"] = vote != null,
|
||||
["vote_option"] = vote
|
||||
});
|
||||
}
|
||||
state["votes"] = votes;
|
||||
state["all_voted"] = votes.All(v => v["voted"] is true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// EventSynchronizer may not be available
|
||||
}
|
||||
|
||||
// All players summary
|
||||
state["players"] = BuildAllPlayersState(runState);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildMultiplayerTreasureState(TreasureRoom treasureRoom, RunState runState)
|
||||
{
|
||||
// Auto-open chest same as singleplayer. BeginRelicPicking() runs during
|
||||
// TreasureRoom.Enter(), so relics are already generated. The chest click
|
||||
// just triggers the UI animation + gold via OneOffSynchronizer — same path
|
||||
// as a human click or the game's own AutoSlay handler.
|
||||
var state = BuildTreasureState(treasureRoom, runState);
|
||||
|
||||
// Add per-player bid data
|
||||
try
|
||||
{
|
||||
var treasureSync = RunManager.Instance.TreasureRoomRelicSynchronizer;
|
||||
var currentRelics = treasureSync.CurrentRelics;
|
||||
|
||||
state["is_bidding_phase"] = currentRelics != null;
|
||||
|
||||
if (currentRelics != null)
|
||||
{
|
||||
var bids = new List<Dictionary<string, object?>>();
|
||||
foreach (var player in runState.Players)
|
||||
{
|
||||
var vote = treasureSync.GetPlayerVote(player);
|
||||
bids.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["player"] = SafeGetText(() => player.Character.Title),
|
||||
["is_local"] = LocalContext.IsMe(player),
|
||||
["voted"] = vote != null,
|
||||
["vote_relic_index"] = vote
|
||||
});
|
||||
}
|
||||
state["bids"] = bids;
|
||||
state["all_bid"] = bids.All(b => b["voted"] is true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// TreasureRoomRelicSynchronizer may not be available
|
||||
}
|
||||
|
||||
// All players summary
|
||||
state["players"] = BuildAllPlayersState(runState);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds player combat state without private info (hand, draw/discard/exhaust piles, orbs).
|
||||
/// Used for non-local players in multiplayer — shows HP, block, energy, powers, relics, potions.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> BuildPlayerStateSummary(Player player)
|
||||
{
|
||||
var state = new Dictionary<string, object?>();
|
||||
var creature = player.Creature;
|
||||
var combatState = player.PlayerCombatState;
|
||||
|
||||
state["character"] = SafeGetText(() => player.Character.Title);
|
||||
state["hp"] = creature.CurrentHp;
|
||||
state["max_hp"] = creature.MaxHp;
|
||||
state["block"] = creature.Block;
|
||||
|
||||
if (combatState != null)
|
||||
{
|
||||
state["energy"] = combatState.Energy;
|
||||
state["max_energy"] = combatState.MaxEnergy;
|
||||
|
||||
if (player.Character.ShouldAlwaysShowStarCounter || combatState.Stars > 0)
|
||||
state["stars"] = combatState.Stars;
|
||||
}
|
||||
|
||||
state["gold"] = player.Gold;
|
||||
state["status"] = BuildPowersState(creature);
|
||||
|
||||
var relics = new List<Dictionary<string, object?>>();
|
||||
foreach (var relic in player.Relics)
|
||||
{
|
||||
relics.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = relic.Id.Entry,
|
||||
["name"] = SafeGetText(() => relic.Title),
|
||||
["description"] = SafeGetText(() => relic.DynamicDescription),
|
||||
["counter"] = relic.ShowCounter ? relic.DisplayAmount : null,
|
||||
["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic)
|
||||
});
|
||||
}
|
||||
state["relics"] = relics;
|
||||
|
||||
var potions = new List<Dictionary<string, object?>>();
|
||||
int slotIndex = 0;
|
||||
foreach (var potion in player.PotionSlots)
|
||||
{
|
||||
if (potion != null)
|
||||
{
|
||||
potions.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = potion.Id.Entry,
|
||||
["name"] = SafeGetText(() => potion.Title),
|
||||
["slot"] = slotIndex
|
||||
});
|
||||
}
|
||||
slotIndex++;
|
||||
}
|
||||
state["potions"] = potions;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> BuildAllPlayersState(RunState runState)
|
||||
{
|
||||
var players = new List<Dictionary<string, object?>>();
|
||||
foreach (var player in runState.Players)
|
||||
{
|
||||
players.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["character"] = SafeGetText(() => player.Character.Title),
|
||||
["is_local"] = LocalContext.IsMe(player),
|
||||
["hp"] = player.Creature.CurrentHp,
|
||||
["max_hp"] = player.Creature.MaxHp,
|
||||
["gold"] = player.Gold,
|
||||
["is_alive"] = player.Creature.IsAlive
|
||||
});
|
||||
}
|
||||
return players;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
319
slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs
Normal file
319
slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
72
slay_the_spire_ii/agent-harness/bridge/plugin/README.md
Normal file
72
slay_the_spire_ii/agent-harness/bridge/plugin/README.md
Normal 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>12.0</LangVersion>
|
||||
<AssemblyName>STS2_Bridge</AssemblyName>
|
||||
<RootNamespace>STS2_Bridge</RootNamespace>
|
||||
<STS2GameDataDir Condition="'$(STS2GameDataDir)' == ''">/path/to/sts2/data_dir</STS2GameDataDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="ValidateGameDir" BeforeTargets="ResolveReferences">
|
||||
<Error Condition="!Exists('$(STS2GameDataDir)/sts2.dll')" Text="STS2GameDataDir is invalid. Expected sts2.dll at $(STS2GameDataDir)/sts2.dll" />
|
||||
<Error Condition="!Exists('$(STS2GameDataDir)/GodotSharp.dll')" Text="STS2GameDataDir is invalid. Expected GodotSharp.dll at $(STS2GameDataDir)/GodotSharp.dll" />
|
||||
<Error Condition="!Exists('$(STS2GameDataDir)/0Harmony.dll')" Text="STS2GameDataDir is invalid. Expected 0Harmony.dll at $(STS2GameDataDir)/0Harmony.dll" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="sts2">
|
||||
<HintPath>$(STS2GameDataDir)/sts2.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="GodotSharp">
|
||||
<HintPath>$(STS2GameDataDir)/GodotSharp.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(STS2GameDataDir)/0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "STS2_Bridge",
|
||||
"name": "STS2 Bridge",
|
||||
"author": "kunology",
|
||||
"description": "Local bridge plugin for Slay the Spire 2",
|
||||
"version": "0.3.0",
|
||||
"has_pck": false,
|
||||
"has_dll": true,
|
||||
"affects_gameplay": false
|
||||
}
|
||||
95
slay_the_spire_ii/agent-harness/bridge/plugin/build.sh
Executable file
95
slay_the_spire_ii/agent-harness/bridge/plugin/build.sh
Executable 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"
|
||||
318
slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md
Normal file
318
slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Slay the Spire II CLI Harness - Test Documentation
|
||||
|
||||
## Test Inventory
|
||||
|
||||
| File | Test Classes | Test Count | Focus |
|
||||
|------|-------------|------------|-------|
|
||||
| `test_core.py` | 2 | 9 | Unit tests for action payload factories and normalized state mapping |
|
||||
| `test_full_e2e.py` | 2 | 5 | CLI subprocess tests against a mocked local bridge server |
|
||||
| **Total** | **4** | **14** | |
|
||||
|
||||
## Unit Tests (`test_core.py`)
|
||||
|
||||
All unit tests use synthetic state payloads and direct function calls. No game
|
||||
process or network access is required.
|
||||
|
||||
### `TestActionAdapter` (4 tests)
|
||||
|
||||
- `play_card()` includes `target` only when provided
|
||||
- `start_new_game()` preserves character and ascension
|
||||
- `from_name()` dispatches to the correct factory
|
||||
- `from_name()` rejects unknown action names
|
||||
|
||||
### `TestStateAdapter` (5 tests)
|
||||
|
||||
- Combat state normalizes to `combat_play`
|
||||
- Shop state splits items into cards, relics, potions, and card removal
|
||||
- Menu state exposes launcher capabilities
|
||||
- Overlay state preserves overlay payload
|
||||
- Unknown state falls back to `decision="unknown"` and includes raw payload
|
||||
|
||||
## E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
These tests start a local fake HTTP server that mimics the bridge plugin API, so
|
||||
they run without Slay the Spire II installed.
|
||||
|
||||
### `TestBridgeSubprocess` (4 tests)
|
||||
|
||||
- `--help` exits 0 and shows the CLI name
|
||||
- `raw-state` returns the raw bridge JSON object
|
||||
- `state` returns normalized JSON with the expected `decision`
|
||||
- `action <name> --kv key=value` posts the expected body to the fake bridge
|
||||
|
||||
### `TestCommandSubprocess` (1 test)
|
||||
|
||||
- `continue-game` posts the action produced by `action_adapter.continue_game()`
|
||||
|
||||
## Realistic Workflow Scenarios
|
||||
|
||||
### Scenario 1: Inspect game state before acting
|
||||
|
||||
- **Simulates**: An agent polling the live game to decide its next move
|
||||
- **Operations**: `raw-state` -> `state`
|
||||
- **Verified**: Raw JSON is preserved, normalized JSON contains the expected decision
|
||||
|
||||
### Scenario 2: Send a bridge action from the CLI
|
||||
|
||||
- **Simulates**: An agent issuing a one-shot command during a run
|
||||
- **Operations**: `action custom --kv floor=12 --kv urgent=true`
|
||||
- **Verified**: The fake bridge receives the exact action payload and CLI exits 0
|
||||
|
||||
### Scenario 3: Trigger a typed action factory
|
||||
|
||||
- **Simulates**: An agent using a higher-level command rather than raw JSON
|
||||
- **Operations**: `continue-game`
|
||||
- **Verified**: The fake bridge receives `{"action": "continue_game"}`
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Slay the Spire II CLI harness."""
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Unit tests for the Slay the Spire II CLI harness core modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from cli_anything.slay_the_spire_ii.core import action_adapter
|
||||
from cli_anything.slay_the_spire_ii.core.state_adapter import normalize_state
|
||||
|
||||
|
||||
class TestActionAdapter(unittest.TestCase):
|
||||
def test_play_card_without_target_omits_target_field(self) -> None:
|
||||
payload = action_adapter.play_card(2)
|
||||
self.assertEqual(payload, {"action": "play_card", "card_index": 2})
|
||||
|
||||
def test_play_card_with_target_includes_target_field(self) -> None:
|
||||
payload = action_adapter.play_card(1, target="slime_0")
|
||||
self.assertEqual(
|
||||
payload,
|
||||
{"action": "play_card", "card_index": 1, "target": "slime_0"},
|
||||
)
|
||||
|
||||
def test_start_new_game_preserves_parameters(self) -> None:
|
||||
payload = action_adapter.start_new_game("REGENT", 12)
|
||||
self.assertEqual(
|
||||
payload,
|
||||
{"action": "start_new_game", "character": "REGENT", "ascension": 12},
|
||||
)
|
||||
|
||||
def test_from_name_dispatches_and_rejects_unknown_actions(self) -> None:
|
||||
payload = action_adapter.from_name("choose_rest_option", index=1)
|
||||
self.assertEqual(payload, {"action": "choose_rest_option", "index": 1})
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Unknown action name"):
|
||||
action_adapter.from_name("missing_action")
|
||||
|
||||
|
||||
class TestStateAdapter(unittest.TestCase):
|
||||
def test_normalize_combat_state(self) -> None:
|
||||
raw_state = {
|
||||
"state_type": "monster",
|
||||
"run": {"act": 1, "floor": 3, "ascension": 7},
|
||||
"battle": {
|
||||
"round": 2,
|
||||
"turn": 1,
|
||||
"is_play_phase": True,
|
||||
"player": {
|
||||
"energy": 3,
|
||||
"max_energy": 3,
|
||||
"hand": [{"name": "Strike", "cost": 1}],
|
||||
"draw_pile_count": 10,
|
||||
"discard_pile_count": 2,
|
||||
"exhaust_pile_count": 0,
|
||||
},
|
||||
"enemies": [{"id": "slime_0", "hp": 12}],
|
||||
},
|
||||
}
|
||||
|
||||
normalized = normalize_state(raw_state)
|
||||
|
||||
self.assertEqual(normalized["decision"], "combat_play")
|
||||
self.assertEqual(normalized["room_type"], "monster")
|
||||
self.assertEqual(normalized["context"], {"act": 1, "floor": 3, "ascension": 7})
|
||||
self.assertEqual(normalized["energy"], 3)
|
||||
self.assertEqual(normalized["hand"][0]["name"], "Strike")
|
||||
self.assertEqual(normalized["enemies"][0]["id"], "slime_0")
|
||||
|
||||
def test_normalize_shop_state_groups_items(self) -> None:
|
||||
raw_state = {
|
||||
"state_type": "shop",
|
||||
"run": {"act": 2, "floor": 20, "ascension": 5},
|
||||
"shop": {
|
||||
"items": [
|
||||
{"name": "Bash", "category": "card"},
|
||||
{"name": "Anchor", "category": "relic"},
|
||||
{"name": "Dexterity Potion", "category": "potion"},
|
||||
{"name": "Remove a card", "category": "card_removal"},
|
||||
],
|
||||
"player": {"gold": 222},
|
||||
"can_proceed": True,
|
||||
},
|
||||
}
|
||||
|
||||
normalized = normalize_state(raw_state)
|
||||
|
||||
self.assertEqual(normalized["decision"], "shop")
|
||||
self.assertEqual(len(normalized["cards"]), 1)
|
||||
self.assertEqual(len(normalized["relics"]), 1)
|
||||
self.assertEqual(len(normalized["potions"]), 1)
|
||||
self.assertEqual(normalized["card_removal"]["category"], "card_removal")
|
||||
self.assertTrue(normalized["can_proceed"])
|
||||
|
||||
def test_normalize_menu_state(self) -> None:
|
||||
raw_state = {
|
||||
"state_type": "menu",
|
||||
"run": {"act": None, "floor": None, "ascension": None},
|
||||
"menu": {
|
||||
"screen": "main_menu",
|
||||
"can_continue_game": True,
|
||||
"can_start_new_game": True,
|
||||
"can_abandon_game": False,
|
||||
"characters": ["IRONCLAD", "SILENT"],
|
||||
"ascension": 10,
|
||||
},
|
||||
}
|
||||
|
||||
normalized = normalize_state(raw_state)
|
||||
|
||||
self.assertEqual(normalized["decision"], "menu")
|
||||
self.assertTrue(normalized["can_continue_game"])
|
||||
self.assertTrue(normalized["can_start_new_game"])
|
||||
self.assertEqual(normalized["characters"], ["IRONCLAD", "SILENT"])
|
||||
|
||||
def test_normalize_overlay_state(self) -> None:
|
||||
raw_state = {
|
||||
"state_type": "overlay",
|
||||
"run": {"act": 1, "floor": 5, "ascension": 0},
|
||||
"overlay": {"screen_type": "confirm", "message": "Choose one"},
|
||||
}
|
||||
|
||||
normalized = normalize_state(raw_state)
|
||||
|
||||
self.assertEqual(normalized["decision"], "overlay")
|
||||
self.assertEqual(normalized["overlay"]["screen_type"], "confirm")
|
||||
|
||||
def test_normalize_unknown_state_preserves_raw_payload(self) -> None:
|
||||
raw_state = {
|
||||
"state_type": "mystery_screen",
|
||||
"message": "Unexpected",
|
||||
"run": {"act": 3, "floor": 42, "ascension": 20},
|
||||
}
|
||||
|
||||
normalized = normalize_state(raw_state)
|
||||
|
||||
self.assertEqual(normalized["decision"], "unknown")
|
||||
self.assertEqual(normalized["raw_state_type"], "mystery_screen")
|
||||
self.assertEqual(normalized["message"], "Unexpected")
|
||||
self.assertEqual(normalized["raw"], raw_state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""CLI subprocess tests for the Slay the Spire II harness using a fake bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _resolve_cli(name: str) -> list[str]:
|
||||
"""Resolve installed CLI command; falls back to python -m for dev."""
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
module = "cli_anything.slay_the_spire_ii"
|
||||
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||
return [sys.executable, "-m", module]
|
||||
|
||||
|
||||
class _BridgeHandler(BaseHTTPRequestHandler):
|
||||
raw_state = {
|
||||
"state_type": "menu",
|
||||
"run": {"act": None, "floor": None, "ascension": None},
|
||||
"menu": {
|
||||
"screen": "main_menu",
|
||||
"can_continue_game": True,
|
||||
"can_start_new_game": True,
|
||||
"can_abandon_game": False,
|
||||
"characters": ["IRONCLAD", "SILENT", "DEFECT"],
|
||||
"ascension": 4,
|
||||
},
|
||||
"message": "Ready",
|
||||
}
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path != "/api/v1/singleplayer?format=json":
|
||||
self.send_error(404)
|
||||
return
|
||||
body = json.dumps(type(self).raw_state).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/api/v1/singleplayer":
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
type(self).requests.append(payload)
|
||||
|
||||
body = json.dumps({"ok": True, "received": payload}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
||||
return
|
||||
|
||||
|
||||
class BridgeServerTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), _BridgeHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
cls.agent_harness_dir = Path(__file__).resolve().parents[3]
|
||||
cls.cli_base = _resolve_cli("cli-anything-sts2")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.thread.join(timeout=5)
|
||||
cls.httpd.server_close()
|
||||
|
||||
def setUp(self) -> None:
|
||||
_BridgeHandler.requests.clear()
|
||||
|
||||
def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(self.agent_harness_dir)
|
||||
return subprocess.run(
|
||||
self.cli_base + ["--base-url", f"http://127.0.0.1:{self.port}", *args],
|
||||
cwd=self.agent_harness_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
class TestBridgeSubprocess(BridgeServerTestCase):
|
||||
def test_help(self) -> None:
|
||||
result = self._run(["--help"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("cli-anything-sts2", result.stdout)
|
||||
self.assertIn("Run without a subcommand to enter interactive REPL mode.", result.stdout)
|
||||
|
||||
def test_raw_state_returns_server_payload(self) -> None:
|
||||
result = self._run(["raw-state"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["state_type"], "menu")
|
||||
self.assertEqual(data["menu"]["screen"], "main_menu")
|
||||
|
||||
def test_state_returns_normalized_decision(self) -> None:
|
||||
result = self._run(["state"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["decision"], "menu")
|
||||
self.assertTrue(data["can_continue_game"])
|
||||
self.assertEqual(data["characters"], ["IRONCLAD", "SILENT", "DEFECT"])
|
||||
|
||||
def test_action_posts_kv_payload(self) -> None:
|
||||
result = self._run(["action", "custom_ping", "--kv", "floor=12", "--kv", "urgent=true"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(data["received"]["action"], "custom_ping")
|
||||
self.assertEqual(data["received"]["floor"], 12)
|
||||
self.assertEqual(data["received"]["urgent"], True)
|
||||
self.assertEqual(_BridgeHandler.requests[-1]["action"], "custom_ping")
|
||||
|
||||
|
||||
class TestCommandSubprocess(BridgeServerTestCase):
|
||||
def test_continue_game_uses_action_adapter_payload(self) -> None:
|
||||
result = self._run(["continue-game"])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["received"], {"action": "continue_game"})
|
||||
self.assertEqual(_BridgeHandler.requests[-1], {"action": "continue_game"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user