diff --git a/slay_the_spire_ii/README.md b/slay_the_spire_ii/README.md index 9f66b3091..b7cd36fa9 100644 --- a/slay_the_spire_ii/README.md +++ b/slay_the_spire_ii/README.md @@ -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 diff --git a/slay_the_spire_ii/agent-harness/STS2.md b/slay_the_spire_ii/agent-harness/STS2.md index f58cbf1b3..f104131b0 100644 --- a/slay_the_spire_ii/agent-harness/STS2.md +++ b/slay_the_spire_ii/agent-harness/STS2.md @@ -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` · diff --git a/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll new file mode 100644 index 000000000..7f629ac1d Binary files /dev/null and b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.dll differ diff --git a/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json new file mode 100644 index 000000000..5ff39c5a8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/install/bridge_plugin/STS2_Bridge.json @@ -0,0 +1,10 @@ +{ + "id": "STS2_Bridge", + "name": "STS2 Bridge", + "author": "kunology", + "description": "Local bridge plugin for Slay the Spire 2", + "version": "0.3.0", + "has_pck": false, + "has_dll": true, + "affects_gameplay": false +} diff --git a/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh b/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh new file mode 100755 index 000000000..c1ef3f7ce --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/install/install_bridge.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUNDLE_DIR="$SCRIPT_DIR/bridge_plugin" +DLL="$BUNDLE_DIR/STS2_Bridge.dll" +JSON="$BUNDLE_DIR/STS2_Bridge.json" + +if [ ! -f "$DLL" ] || [ ! -f "$JSON" ]; then + echo "ERROR: bridge plugin files not found in $BUNDLE_DIR" >&2 + echo "Build the bridge first: ../plugin/build.sh" >&2 + exit 1 +fi + +GAME_ROOT="${1:-$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2}" +MOD_DIR="$GAME_ROOT/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge" + +mkdir -p "$MOD_DIR" +cp "$DLL" "$MOD_DIR/STS2_Bridge.dll" +cp "$JSON" "$MOD_DIR/STS2_Bridge.json" + +echo "Installed bridge plugin to:" +echo " $MOD_DIR/STS2_Bridge.dll" +echo " $MOD_DIR/STS2_Bridge.json" diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs new file mode 100644 index 000000000..be7e09696 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Actions.cs @@ -0,0 +1,1039 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using MegaCrit.Sts2.Core.Nodes.Cards; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Nodes.Screens; +using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; +using MegaCrit.Sts2.Core.Nodes.Screens.Map; +using MegaCrit.Sts2.Core.Nodes.Relics; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; +using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Entities.Merchant; +using MegaCrit.Sts2.Core.Nodes.Events; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Map; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Nodes.RestSite; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Rewards; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Entities.Potions; +using MegaCrit.Sts2.Core.GameActions; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary ExecuteAction(string action, Dictionary data) + { + if (!RunManager.Instance.IsInProgress) + return ExecuteMenuAction(action, data); + + var runState = RunManager.Instance.DebugOnlyGetState()!; + var player = LocalContext.GetMe(runState); + if (player == null) + return Error("Could not find local player"); + + return action switch + { + "play_card" => ExecutePlayCard(player, data), + "use_potion" => ExecuteUsePotion(player, data), + "end_turn" => ExecuteEndTurn(player), + "choose_map_node" => ExecuteChooseMapNode(data), + "choose_event_option" => ExecuteChooseEventOption(data), + "advance_dialogue" => ExecuteAdvanceDialogue(), + "choose_rest_option" => ExecuteChooseRestOption(data), + "shop_purchase" => ExecuteShopPurchase(player, data), + "claim_reward" => ExecuteClaimReward(data), + "select_card_reward" => ExecuteSelectCardReward(data), + "skip_card_reward" => ExecuteSkipCardReward(), + "proceed" => ExecuteProceed(), + "select_card" => ExecuteSelectCard(data), + "confirm_selection" => ExecuteConfirmSelection(), + "cancel_selection" => ExecuteCancelSelection(), + "combat_select_card" => ExecuteCombatSelectCard(data), + "combat_confirm_selection" => ExecuteCombatConfirmSelection(), + "select_relic" => ExecuteSelectRelic(data), + "skip_relic_selection" => ExecuteSkipRelicSelection(), + "claim_treasure_relic" => ExecuteClaimTreasureRelic(data), + "return_to_main_menu" => ExecuteReturnToMainMenu(), + _ => Error($"Unknown action: {action}") + }; + } + + private static Dictionary ExecuteMenuAction(string action, Dictionary data) + { + return action switch + { + "continue_game" => ExecuteContinueGame(), + "start_new_game" => ExecuteStartNewGame(data), + "abandon_game" => ExecuteAbandonGame(), + "return_to_main_menu" => ExecuteReturnToMainMenu(), + _ => Error("No run in progress") + }; + } + + private static Dictionary ExecuteContinueGame() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var continueInfo = mainMenu.ContinueRunInfo; + if (continueInfo == null || !continueInfo.IsVisibleInTree()) + return Error("No continueable run found"); + + var continueButton = GetFieldValue(mainMenu, "_continueButton"); + if (continueButton == null || !continueButton.IsVisibleInTree()) + return Error("Continue button is not available on this game build"); + + continueButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing saved run" + }; + } + + private static Dictionary ExecuteAbandonGame() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var continueInfo = mainMenu.ContinueRunInfo; + if (continueInfo == null || !continueInfo.IsVisibleInTree()) + return Error("No continueable run found"); + + var abandonButton = GetFieldValue(mainMenu, "_abandonRunButton"); + if (abandonButton == null || !abandonButton.IsVisibleInTree()) + return Error("Abandon button is not available on this game build"); + + abandonButton.ForceClick(); + + var popup = FindFirst(root); + if (popup != null && popup.IsVisibleInTree()) + { + var yesButton = FindAll(popup) + .FirstOrDefault(button => button.IsVisibleInTree()); + yesButton?.ForceClick(); + } + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Abandoning saved run" + }; + } + + private static Dictionary ExecuteStartNewGame(Dictionary data) + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var characterSelect = FindFirst(root); + + if (characterSelect == null || !characterSelect.IsVisibleInTree()) + { + var mainMenu = FindFirst(root); + if (mainMenu == null || !mainMenu.IsVisibleInTree()) + return Error("Main menu is not open"); + + var singleplayerButton = GetFieldValue(mainMenu, "_singleplayerButton"); + if (singleplayerButton == null || !singleplayerButton.IsVisibleInTree()) + return Error("Singleplayer button is not available"); + singleplayerButton.ForceClick(); + + var submenu = mainMenu.OpenSingleplayerSubmenu(); + if (submenu == null || !submenu.IsVisibleInTree()) + return Error("Singleplayer submenu could not be opened"); + + var standardButton = GetFieldValue(submenu, "_standardButton"); + if (standardButton == null || !standardButton.IsVisibleInTree()) + return Error("Standard new game button is not available"); + standardButton.ForceClick(); + + characterSelect = FindFirst(root); + if (characterSelect == null || !characterSelect.IsVisibleInTree()) + return Error("Character select could not be opened"); + } + + string characterId = NormalizeCharacterId( + data.TryGetValue("character", out var charElem) ? charElem.GetString() : null + ); + + int ascension = 0; + if (data.TryGetValue("ascension", out var ascElem)) + ascension = ascElem.GetInt32(); + + var button = FindAll(characterSelect) + .FirstOrDefault(b => b.Character?.Id.Entry == characterId); + if (button == null || button.Character == null) + return Error($"Character '{characterId}' is not available on the character select screen"); + + characterSelect.SelectCharacter(button, button.Character); + + var ascensionPanel = FindFirst(characterSelect); + ascensionPanel?.SetAscensionLevel(ascension); + + var embarkButton = GetFieldValue(characterSelect, "_embarkButton"); + if (embarkButton == null || !embarkButton.IsVisibleInTree() || !embarkButton.IsEnabled) + return Error("Embark button is not available on this game build"); + embarkButton.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Starting new singleplayer run as {characterId} on Ascension {ascension}" + }; + } + + private static T? GetFieldValue(object target, string fieldName) where T : class + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return field?.GetValue(target) as T; + } + + private static string NormalizeCharacterId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "IRONCLAD"; + + return value.Trim().ToUpperInvariant() switch + { + "IRONCLAD" or "铁甲战士" => "IRONCLAD", + "SILENT" or "静默猎手" => "SILENT", + "DEFECT" or "故障机器人" => "DEFECT", + "NECROBINDER" or "亡灵契约师" => "NECROBINDER", + "REGENT" or "储君" => "REGENT", + var other => other + }; + } + + private static Dictionary ExecuteReturnToMainMenu() + { + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var gameOverScreen = FindFirst(root); + if (gameOverScreen != null && gameOverScreen.IsVisibleInTree()) + { + var returnButton = FindFirst(gameOverScreen); + if (returnButton != null && returnButton.IsVisibleInTree() && returnButton.IsEnabled) + { + returnButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Leaving game over screen and returning to main menu" + }; + } + + var continueButton = FindFirst(gameOverScreen); + if (continueButton != null && continueButton.IsVisibleInTree() && continueButton.IsEnabled) + { + continueButton.ForceClick(); + + returnButton = FindFirst(gameOverScreen); + if (returnButton != null && returnButton.IsVisibleInTree() && returnButton.IsEnabled) + { + returnButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing past game over screen and returning to main menu" + }; + } + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Continuing past game over screen" + }; + } + + return Error("Game over screen is open but no usable main-menu button is available"); + } + + var game = FindFirst(((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (game == null) + return Error("Game node is not available"); + + game.ReturnToMainMenu().GetAwaiter().GetResult(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Returning to main menu" + }; + } + + private static Dictionary ExecutePlayCard(Player player, Dictionary data) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + if (!player.Creature.IsAlive) + return Error("Player creature is dead — cannot play cards"); + + var combatState = player.Creature.CombatState; + if (combatState == null) + return Error("No combat state"); + + // Get card by index in hand + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index'"); + + int cardIndex = indexElem.GetInt32(); + var hand = player.PlayerCombatState?.Hand; + if (hand == null) + return Error("No hand available"); + + if (cardIndex < 0 || cardIndex >= hand.Cards.Count) + return Error($"card_index {cardIndex} out of range (hand has {hand.Cards.Count} cards)"); + + var card = hand.Cards[cardIndex]; + + if (!card.CanPlay(out var reason, out _)) + return Error($"Card '{card.Title}' cannot be played: {reason}"); + + // Resolve target + Creature? target = null; + if (card.TargetType == TargetType.AnyEnemy) + { + if (!data.TryGetValue("target", out var targetElem)) + return Error("Card requires a target. Provide 'target' with an entity_id."); + + string targetId = targetElem.GetString() ?? ""; + target = ResolveTarget(combatState, targetId); + if (target == null) + return Error($"Target '{targetId}' not found among alive enemies"); + } + + // Play the card via the action queue (same path as the game UI) + RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(new PlayCardAction(card, target)); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Playing '{card.Title}'" + (target != null ? $" targeting {SafeGetText(() => target.Monster?.Title) ?? "target"}" : "") + }; + } + + private static Dictionary ExecuteEndTurn(Player player) + { + if (!CombatManager.Instance.IsInProgress) + return Error("Not in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Not in play phase — cannot act during enemy turn"); + if (CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled (turn may already be ending)"); + + // Match the game's own CanTurnBeEnded guard (NEndTurnButton.cs:114-123) + var hand = NCombatRoom.Instance?.Ui?.Hand; + if (hand != null && (hand.InCardPlay || hand.CurrentMode != NPlayerHand.Mode.Play)) + return Error("Cannot end turn while a card is being played or hand is in selection mode"); + + PlayerCmd.EndTurn(player, canBackOut: false); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Ending turn" + }; + } + + private static Dictionary ExecuteUsePotion(Player player, Dictionary data) + { + if (!data.TryGetValue("slot", out var slotElem)) + return Error("Missing 'slot' (potion slot index)"); + + int slot = slotElem.GetInt32(); + if (slot < 0 || slot >= player.PotionSlots.Count) + return Error($"Potion slot {slot} out of range (player has {player.PotionSlots.Count} slots)"); + + var potion = player.GetPotionAtSlotIndex(slot); + if (potion == null) + return Error($"No potion in slot {slot}"); + if (potion.IsQueued) + return Error($"Potion '{SafeGetText(() => potion.Title)}' is already queued for use"); + if (potion.Owner.Creature.IsDead) + return Error("Cannot use potion — player creature is dead"); + if (!potion.PassesCustomUsabilityCheck) + return Error($"Potion '{SafeGetText(() => potion.Title)}' cannot be used right now"); + + bool inCombat = CombatManager.Instance.IsInProgress; + if (potion.Usage == PotionUsage.CombatOnly) + { + if (!inCombat) + return Error($"Potion '{SafeGetText(() => potion.Title)}' can only be used in combat"); + if (!CombatManager.Instance.IsPlayPhase) + return Error("Cannot use potions outside of play phase"); + } + else if (potion.Usage == PotionUsage.Automatic) + return Error($"Potion '{SafeGetText(() => potion.Title)}' is automatic and cannot be manually used"); + + if (inCombat && CombatManager.Instance.PlayerActionsDisabled) + return Error("Player actions are currently disabled"); + + // Resolve target + Creature? target = null; + var combatState = player.Creature.CombatState; + + switch (potion.TargetType) + { + case TargetType.AnyEnemy: + if (!data.TryGetValue("target", out var targetElem)) + return Error("Potion requires a target enemy. Provide 'target' with an entity_id."); + string targetId = targetElem.GetString() ?? ""; + if (combatState == null) + return Error("No combat state for target resolution"); + target = ResolveTarget(combatState, targetId); + if (target == null) + return Error($"Target '{targetId}' not found among alive enemies"); + break; + case TargetType.Self: + case TargetType.AnyAlly: + case TargetType.AnyPlayer: + target = player.Creature; + break; + default: + target = null; + break; + } + + potion.EnqueueManualUse(target); + + string targetMsg = potion.TargetType switch + { + TargetType.AnyEnemy => $" targeting {SafeGetText(() => target?.Monster?.Title) ?? "enemy"}", + TargetType.Self or TargetType.AnyPlayer or TargetType.AnyAlly => " on self", + _ => "" + }; + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Using potion '{SafeGetText(() => potion.Title)}' from slot {slot}{targetMsg}" + }; + } + + private static Dictionary ExecuteChooseEventOption(Dictionary data) + { + var uiRoom = NEventRoom.Instance; + if (uiRoom == null) + return Error("Event room is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (event option index)"); + + int index = indexElem.GetInt32(); + + var buttons = FindAll(uiRoom) + .Where(b => !b.Option.IsLocked) + .ToList(); + + if (buttons.Count == 0) + return Error("No unlocked event options available"); + if (index < 0 || index >= buttons.Count) + return Error($"Event option index {index} out of range ({buttons.Count} unlocked options)"); + + var button = buttons[index]; + string title = SafeGetText(() => button.Option.Title) ?? "option"; + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Choosing event option: {title}" + }; + } + + private static Dictionary ExecuteAdvanceDialogue() + { + var uiRoom = NEventRoom.Instance; + if (uiRoom == null) + return Error("Event room is not open"); + + var ancientLayout = FindFirst(uiRoom); + if (ancientLayout == null) + return Error("No ancient dialogue active"); + + var hitbox = ancientLayout.GetNodeOrNull("%DialogueHitbox"); + if (hitbox == null || !hitbox.Visible || !hitbox.IsEnabled) + return Error("Dialogue hitbox not available — dialogue may have ended"); + + hitbox.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Advancing dialogue" + }; + } + + private static Dictionary ExecuteChooseRestOption(Dictionary data) + { + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (rest site option index)"); + + int index = indexElem.GetInt32(); + + var restRoom = NRestSiteRoom.Instance; + if (restRoom == null) + return Error("Rest site room is not open"); + + var buttons = FindAll(restRoom) + .Where(b => b.Option.IsEnabled) + .ToList(); + + if (index < 0 || index >= buttons.Count) + return Error($"Rest option index {index} out of range ({buttons.Count} enabled options)"); + + var button = buttons[index]; + string optionName = SafeGetText(() => button.Option.Title) ?? button.Option.OptionId; + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting rest site option: {optionName}" + }; + } + + private static Dictionary ExecuteShopPurchase(Player player, Dictionary data) + { + if (player.RunState.CurrentRoom is not MerchantRoom merchantRoom) + return Error("Not in a shop"); + + // Auto-open inventory if needed + var merchUI = NMerchantRoom.Instance; + if (merchUI != null && !merchUI.Inventory.IsOpen) + merchUI.OpenInventory(); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (shop item index)"); + + int index = indexElem.GetInt32(); + + var allEntries = merchantRoom.Inventory.AllEntries.ToList(); + if (index < 0 || index >= allEntries.Count) + return Error($"Shop item index {index} out of range ({allEntries.Count} items)"); + + var entry = allEntries[index]; + if (!entry.IsStocked) + return Error("Item is sold out"); + if (!entry.EnoughGold) + return Error($"Not enough gold (need {entry.Cost}, have {player.Gold})"); + + // Fire-and-forget purchase (same path as AutoSlay) + _ = entry.OnTryPurchaseWrapper(merchantRoom.Inventory); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Purchasing item for {entry.Cost} gold" + }; + } + + private static Dictionary ExecuteChooseMapNode(Dictionary data) + { + var mapScreen = NMapScreen.Instance; + if (mapScreen == null || !mapScreen.IsOpen) + return Error("Map screen is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (map node index from next_options)"); + + int index = indexElem.GetInt32(); + + var travelable = FindAll(mapScreen) + .Where(mp => mp.State == MapPointState.Travelable) + .OrderBy(mp => mp.Point.coord.col) + .ToList(); + + if (travelable.Count == 0) + return Error("No travelable map nodes available"); + if (index < 0 || index >= travelable.Count) + return Error($"Map node index {index} out of range ({travelable.Count} options available)"); + + var target = travelable[index]; + mapScreen.OnMapPointSelectedLocally(target); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Traveling to {target.Point.PointType} at ({target.Point.coord.col},{target.Point.coord.row})" + }; + } + + private static Dictionary ExecuteClaimReward(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NRewardsScreen rewardsScreen) + return Error("Rewards screen is not open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (reward index)"); + + int index = indexElem.GetInt32(); + + var enabledButtons = FindAll(rewardsScreen) + .Where(b => b.IsEnabled && b.Reward != null) + .ToList(); + + if (index < 0 || index >= enabledButtons.Count) + return Error($"Reward index {index} out of range (screen has {enabledButtons.Count} claimable rewards)"); + + var button = enabledButtons[index]; + var reward = button.Reward!; + string rewardDesc = GetRewardTypeName(reward); + if (reward is GoldReward g) + rewardDesc = $"gold ({g.Amount})"; + else if (reward is PotionReward p) + rewardDesc = $"potion ({SafeGetText(() => p.Potion?.Title)})"; + else if (reward is CardReward) + rewardDesc = "card (opens card selection)"; + + button.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Claiming reward: {rewardDesc}" + }; + } + + private static Dictionary ExecuteSelectCardReward(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NCardRewardSelectionScreen cardScreen) + return Error("Card reward selection screen is not open"); + + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index'"); + + int cardIndex = indexElem.GetInt32(); + + var cardHolders = FindAllSortedByPosition(cardScreen); + if (cardIndex < 0 || cardIndex >= cardHolders.Count) + return Error($"Card index {cardIndex} out of range (screen has {cardHolders.Count} cards)"); + + var holder = cardHolders[cardIndex]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting card: {cardName}" + }; + } + + private static Dictionary ExecuteSkipCardReward() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NCardRewardSelectionScreen cardScreen) + return Error("Card reward selection screen is not open"); + + var altButtons = FindAll(cardScreen); + if (altButtons.Count == 0) + return Error("No skip option available on this card reward"); + + altButtons[0].ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping card reward" + }; + } + + private static Dictionary ExecuteProceed() + { + // Try rewards overlay + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NRewardsScreen rewardsScreen) + { + var btn = FindFirst(rewardsScreen); + if (btn is { IsEnabled: true }) + { + btn.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rewards" }; + } + } + + // Try rest site + if (NRestSiteRoom.Instance is { } restRoom && restRoom.ProceedButton.IsEnabled) + { + restRoom.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from rest site" }; + } + + // Try merchant — close inventory first if open, then proceed + if (NMerchantRoom.Instance is { } merchRoom) + { + if (merchRoom.Inventory.IsOpen) + { + var backBtn = FindFirst(merchRoom); + if (backBtn is { IsEnabled: true }) + backBtn.ForceClick(); + } + if (merchRoom.ProceedButton.IsEnabled) + { + merchRoom.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from shop" }; + } + } + + // Try treasure room + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (treasureUI != null && treasureUI.ProceedButton.IsEnabled) + { + treasureUI.ProceedButton.ForceClick(); + return new Dictionary { ["status"] = "ok", ["message"] = "Proceeding from treasure room" }; + } + + return Error("No proceed button available or enabled"); + } + + private static Dictionary ExecuteSelectCard(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (card index in the grid)"); + + int index = indexElem.GetInt32(); + + if (overlay is NCardGridSelectionScreen gridScreen) + { + var grid = FindFirst(gridScreen); + if (grid == null) + return Error("Card grid not found in selection screen"); + + var holders = FindAllSortedByPosition(gridScreen); + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} cards available)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + grid.EmitSignal(NCardGrid.SignalName.HolderPressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Toggling card selection: {cardName}" + }; + } + else if (overlay is NChooseACardSelectionScreen chooseScreen) + { + var holders = FindAllSortedByPosition(chooseScreen); + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} cards available)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Choosing card: {cardName}" + }; + } + + return Error("No card selection screen is open"); + } + + private static Dictionary ExecuteConfirmSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is NChooseACardSelectionScreen) + return Error("Choose-a-card screen requires no confirmation — use select_card(index) to pick directly"); + if (overlay is not NCardGridSelectionScreen screen) + return Error("No card selection screen is open"); + + // Check all preview containers (upgrade uses UpgradeSinglePreviewContainer / UpgradeMultiPreviewContainer, + // NDeckCardSelectScreen uses PreviewContainer with %PreviewConfirm) + foreach (var containerName in new[] { "%UpgradeSinglePreviewContainer", "%UpgradeMultiPreviewContainer", "%PreviewContainer" }) + { + var container = screen.GetNodeOrNull(containerName); + if (container?.Visible == true) + { + var confirm = container.GetNodeOrNull("Confirm") + ?? container.GetNodeOrNull("%PreviewConfirm"); + if (confirm is { IsEnabled: true }) + { + confirm.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection from preview" + }; + } + } + } + + // Try main confirm button + var mainConfirm = screen.GetNodeOrNull("Confirm") + ?? screen.GetNodeOrNull("%Confirm"); + if (mainConfirm is { IsEnabled: true }) + { + mainConfirm.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection" + }; + } + + // Fallback: find ANY enabled NConfirmButton in the screen tree. + // Covers NCardGridSelectionScreen subclasses (like NDeckEnchantSelectScreen) + // whose confirm button isn't in any of the known container paths above. + var allConfirmButtons = FindAll(screen); + foreach (var btn in allConfirmButtons) + { + if (btn.IsEnabled && btn.IsVisibleInTree()) + { + btn.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming selection" + }; + } + } + + return Error("No confirm button is currently enabled — select more cards first"); + } + + private static Dictionary ExecuteCancelSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + + // Handle choose-a-card screen (skip button) + if (overlay is NChooseACardSelectionScreen chooseScreen) + { + var skipButton = chooseScreen.GetNodeOrNull("SkipButton"); + if (skipButton is { IsEnabled: true }) + { + skipButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping card choice" + }; + } + return Error("No skip option available — a card must be chosen"); + } + + if (overlay is not NCardGridSelectionScreen screen) + return Error("No card selection screen is open"); + + // If preview is showing, cancel back to selection + foreach (var containerName in new[] { "%UpgradeSinglePreviewContainer", "%UpgradeMultiPreviewContainer", "%PreviewContainer" }) + { + var container = screen.GetNodeOrNull(containerName); + if (container?.Visible == true) + { + var cancelBtn = container.GetNodeOrNull("Cancel") + ?? container.GetNodeOrNull("%PreviewCancel"); + if (cancelBtn is { IsEnabled: true }) + { + cancelBtn.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Cancelling preview — returning to card selection" + }; + } + } + } + + // Close the screen entirely + var closeButton = screen.GetNodeOrNull("%Close"); + if (closeButton is { IsEnabled: true }) + { + closeButton.ForceClick(); + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Closing card selection screen" + }; + } + + return Error("No cancel/close button is currently enabled — selection may be mandatory"); + } + + private static Dictionary ExecuteCombatSelectCard(Dictionary data) + { + var hand = NPlayerHand.Instance; + if (hand == null || !hand.IsInCardSelection) + return Error("No in-combat card selection is active"); + + if (!data.TryGetValue("card_index", out var indexElem)) + return Error("Missing 'card_index' (index of the card in hand)"); + + int index = indexElem.GetInt32(); + var holders = hand.ActiveHolders; + if (index < 0 || index >= holders.Count) + return Error($"Card index {index} out of range ({holders.Count} selectable cards)"); + + var holder = holders[index]; + string cardName = SafeGetText(() => holder.CardModel?.Title) ?? "unknown"; + + // Emit the Pressed signal — same path the game UI uses + holder.EmitSignal(NCardHolder.SignalName.Pressed, holder); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting card from hand: {cardName}" + }; + } + + private static Dictionary ExecuteCombatConfirmSelection() + { + var hand = NPlayerHand.Instance; + if (hand == null || !hand.IsInCardSelection) + return Error("No in-combat card selection is active"); + + var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); + if (confirmBtn == null || !confirmBtn.IsEnabled) + return Error("Confirm button is not enabled — select more cards first"); + + confirmBtn.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Confirming combat card selection" + }; + } + + private static Dictionary ExecuteSelectRelic(Dictionary data) + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NChooseARelicSelection screen) + return Error("No relic selection screen is open"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (relic index)"); + + int index = indexElem.GetInt32(); + + var holders = FindAll(screen); + if (index < 0 || index >= holders.Count) + return Error($"Relic index {index} out of range ({holders.Count} relics available)"); + + var holder = holders[index]; + string relicName = SafeGetText(() => holder.Relic?.Model?.Title) ?? "unknown"; + holder.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Selecting relic: {relicName}" + }; + } + + private static Dictionary ExecuteSkipRelicSelection() + { + var overlay = NOverlayStack.Instance?.Peek(); + if (overlay is not NChooseARelicSelection screen) + return Error("No relic selection screen is open"); + + var skipButton = screen.GetNodeOrNull("SkipButton"); + if (skipButton is not { IsEnabled: true }) + return Error("No skip option available"); + + skipButton.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = "Skipping relic selection" + }; + } + + private static Dictionary ExecuteClaimTreasureRelic(Dictionary data) + { + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + if (treasureUI == null) + return Error("Treasure room is not open"); + + var relicCollection = treasureUI.GetNodeOrNull("%RelicCollection"); + if (relicCollection?.Visible != true) + return Error("Relic collection is not visible — chest may not be opened yet"); + + if (!data.TryGetValue("index", out var indexElem)) + return Error("Missing 'index' (relic index)"); + + int index = indexElem.GetInt32(); + + var holders = FindAll(relicCollection) + .Where(h => h.IsEnabled && h.Visible) + .ToList(); + + if (index < 0 || index >= holders.Count) + return Error($"Relic index {index} out of range ({holders.Count} relics available)"); + + var holder = holders[index]; + string relicName = SafeGetText(() => holder.Relic?.Model?.Title) ?? "unknown"; + holder.ForceClick(); + + return new Dictionary + { + ["status"] = "ok", + ["message"] = $"Claiming treasure relic: {relicName}" + }; + } + + private static Creature? ResolveTarget(CombatState combatState, string entityId) + { + // Try to match by entity_id pattern: "model_entry_N" + // First try matching by combat_id if it's a pure number + if (uint.TryParse(entityId, out uint combatId)) + return combatState.GetCreature(combatId); + + // Match by entity_id pattern (e.g., "jaw_worm_0") + // We rebuild the entity IDs the same way as BuildEnemyState + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (!creature.IsAlive) continue; + string baseId = creature.Monster?.Id.Entry ?? "unknown"; + if (!entityCounts.TryGetValue(baseId, out int count)) + count = 0; + entityCounts[baseId] = count + 1; + string generatedId = $"{baseId}_{count}"; + + if (generatedId == entityId) + return creature; + } + + return null; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs new file mode 100644 index 000000000..35fa93900 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Formatting.cs @@ -0,0 +1,868 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static string FormatAsMarkdown(Dictionary state) + { + var sb = new StringBuilder(); + string stateType = state.TryGetValue("state_type", out var st) ? st?.ToString() ?? "unknown" : "unknown"; + bool isMultiplayer = state.TryGetValue("game_mode", out var gm) && gm?.ToString() == "multiplayer"; + + if (isMultiplayer) + sb.AppendLine($"# Multiplayer Game State: {stateType}"); + else + sb.AppendLine($"# Game State: {stateType}"); + sb.AppendLine(); + + if (state.TryGetValue("run", out var runObj) && runObj is Dictionary run) + { + sb.AppendLine($"**Act {run["act"]}** | Floor {run["floor"]} | Ascension {run["ascension"]}"); + sb.AppendLine(); + } + + if (state.TryGetValue("message", out var msg) && msg != null) + { + sb.AppendLine(msg.ToString()); + return sb.ToString(); + } + + // Multiplayer players summary (top-level) + if (isMultiplayer && state.TryGetValue("players", out var playersListObj) + && playersListObj is List> playersList && playersList.Count > 0) + { + sb.AppendLine("## Party"); + foreach (var p in playersList) + { + string youTag = p["is_local"] is true ? " **(YOU)**" : ""; + string aliveTag = p["is_alive"] is false ? " [DEAD]" : ""; + sb.AppendLine($"- **{p["character"]}**{youTag}{aliveTag} — HP: {p["hp"]}/{p["max_hp"]} | Gold: {p["gold"]}"); + } + sb.AppendLine(); + } + + if (state.TryGetValue("battle", out var battleObj) && battleObj is Dictionary battle) + { + if (isMultiplayer) + FormatMultiplayerBattleMarkdown(sb, battle); + else + FormatBattleMarkdown(sb, battle); + } + + if (state.TryGetValue("event", out var eventObj) && eventObj is Dictionary eventData) + { + FormatEventMarkdown(sb, eventData); + if (isMultiplayer) + FormatEventVotesMarkdown(sb, eventData); + } + + if (state.TryGetValue("rest_site", out var restObj) && restObj is Dictionary restData) + { + FormatRestSiteMarkdown(sb, restData); + } + + if (state.TryGetValue("shop", out var shopObj) && shopObj is Dictionary shopData) + { + FormatShopMarkdown(sb, shopData); + } + + if (state.TryGetValue("map", out var mapObj) && mapObj is Dictionary mapData) + { + FormatMapMarkdown(sb, mapData); + if (isMultiplayer) + FormatMapVotesMarkdown(sb, mapData); + } + + if (state.TryGetValue("rewards", out var rewardsObj) && rewardsObj is Dictionary rewards) + { + FormatRewardsMarkdown(sb, rewards); + } + + if (state.TryGetValue("card_reward", out var cardRewardObj) && cardRewardObj is Dictionary cardReward) + { + FormatCardRewardMarkdown(sb, cardReward); + } + + if (state.TryGetValue("hand_select", out var handSelectObj) && handSelectObj is Dictionary handSelect) + { + FormatHandSelectMarkdown(sb, handSelect); + } + + if (state.TryGetValue("card_select", out var cardSelectObj) && cardSelectObj is Dictionary cardSelect) + { + FormatCardSelectMarkdown(sb, cardSelect); + } + + if (state.TryGetValue("relic_select", out var relicSelectObj) && relicSelectObj is Dictionary relicSelect) + { + FormatRelicSelectMarkdown(sb, relicSelect); + } + + if (state.TryGetValue("treasure", out var treasureObj) && treasureObj is Dictionary treasureData) + { + FormatTreasureMarkdown(sb, treasureData); + if (isMultiplayer) + FormatTreasureBidsMarkdown(sb, treasureData); + } + + if (state.TryGetValue("overlay", out var overlayObj) && overlayObj is Dictionary overlayData) + { + sb.AppendLine($"## Overlay: {overlayData.GetValueOrDefault("screen_type")}"); + sb.AppendLine(overlayData.GetValueOrDefault("message")?.ToString()); + sb.AppendLine(); + } + + // Keyword glossary — collect all unique keyword definitions + var glossary = new Dictionary(); + CollectKeywordsFromState(state, glossary); + if (glossary.Count > 0) + { + sb.AppendLine("## Keyword Glossary"); + foreach (var (name, description) in glossary.OrderBy(kv => kv.Key)) + sb.AppendLine($"- **{name}**: {description}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void FormatBattleMarkdown(StringBuilder sb, Dictionary battle) + { + sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]}"); + sb.AppendLine(); + + if (battle.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : ""; + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]} | Energy: {player["energy"]}/{player["max_energy"]}{stars} | Gold: {player["gold"]}"); + sb.AppendLine(); + + FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + FormatListSection(sb, "Relics", player, "relics", r => + { + string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : ""; + return $"- **{r["name"]}**{counter}: {r["description"]}"; + }); + FormatListSection(sb, "Potions", player, "potions", p => $"- [{p["slot"]}] **{p["name"]}**: {p["description"]}"); + + if (player.TryGetValue("hand", out var handObj) && handObj is List> hand && hand.Count > 0) + { + sb.AppendLine("### Hand"); + foreach (var card in hand) + { + string playable = card["can_play"] is true ? "✓" : "✗"; + string keywords = card.TryGetValue("keywords", out var kw) && kw is List kwList && kwList.Count > 0 + ? $" [{string.Join(", ", kwList)}]" : ""; + string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} — {card["description"]} (target: {card["target_type"]})"); + } + sb.AppendLine(); + } + + FormatDeckPilesMarkdown(sb, player); + + if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List> orbs && orbs.Count > 0) + { + int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count; + int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0; + sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)"); + foreach (var orb in orbs) + { + string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : ""; + sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}"); + } + if (empty > 0) + sb.AppendLine($"- *{empty} empty slot(s)*"); + sb.AppendLine(); + } + } + + if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List> enemies && enemies.Count > 0) + { + sb.AppendLine("## Enemies"); + foreach (var enemy in enemies) + { + sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)"); + sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}"); + + if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List> intents && intents.Count > 0) + { + sb.Append("**Intent:** "); + sb.AppendLine(string.Join(", ", intents.Select(i => + { + string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!; + string typeTag = $" ({i["type"]})"; + string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : ""; + string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : ""; + return $"{title}{typeTag}{label}{desc}"; + }))); + } + + FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + sb.AppendLine(); + } + } + } + + private static void FormatDeckPilesMarkdown(StringBuilder sb, Dictionary player) + { + sb.AppendLine("### Deck Information"); + sb.AppendLine(); + + sb.AppendLine($"#### Draw Pile ({player["draw_pile_count"]} cards, in random order)"); + if (player.TryGetValue("draw_pile", out var drawObj) && drawObj is List> drawPile && drawPile.Count > 0) + { + foreach (var card in drawPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + + sb.AppendLine($"#### Discard Pile ({player["discard_pile_count"]} cards)"); + if (player.TryGetValue("discard_pile", out var discardObj) && discardObj is List> discardPile && discardPile.Count > 0) + { + foreach (var card in discardPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + + sb.AppendLine($"#### Exhaust Pile ({player["exhaust_pile_count"]} cards)"); + if (player.TryGetValue("exhaust_pile", out var exhaustObj) && exhaustObj is List> exhaustPile && exhaustPile.Count > 0) + { + foreach (var card in exhaustPile) + sb.AppendLine($"- {card["name"]}: {card["description"]}"); + } + else + sb.AppendLine("- *(empty)*"); + sb.AppendLine(); + } + + private static void FormatEventMarkdown(StringBuilder sb, Dictionary evt) + { + string name = evt.TryGetValue("event_name", out var n) && n != null ? n.ToString()! : "Unknown Event"; + bool isAncient = evt.TryGetValue("is_ancient", out var a) && a is true; + sb.AppendLine($"## {(isAncient ? "Ancient" : "Event")}: {name}"); + sb.AppendLine(); + + if (evt.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + bool inDialogue = evt.TryGetValue("in_dialogue", out var d) && d is true; + if (inDialogue) + { + sb.AppendLine("*Ancient dialogue in progress — use `advance_dialogue` to continue.*"); + sb.AppendLine(); + return; + } + + if (evt.TryGetValue("options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("### Options"); + foreach (var opt in options) + { + bool locked = opt["is_locked"] is true; + bool proceed = opt["is_proceed"] is true; + bool chosen = opt["was_chosen"] is true; + + string tag = locked ? " (LOCKED)" : chosen ? " (CHOSEN)" : proceed ? " (PROCEED)" : ""; + string relic = opt.TryGetValue("relic_name", out var rn) && rn != null ? $" [Relic: {rn}]" : ""; + sb.AppendLine($"- [{opt["index"]}] **{opt["title"]}**{tag}{relic} — {opt["description"]}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("No options available."); + sb.AppendLine(); + } + } + + private static void FormatRestSiteMarkdown(StringBuilder sb, Dictionary restSite) + { + if (restSite.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (restSite.TryGetValue("options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("## Rest Site Options"); + foreach (var opt in options) + { + string enabled = opt["is_enabled"] is true ? "" : " (DISABLED)"; + sb.AppendLine($"- [{opt["index"]}] **{opt["name"]}**{enabled} — {opt["description"]}"); + } + sb.AppendLine(); + } + + bool canProceed = restSite.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatShopMarkdown(StringBuilder sb, Dictionary shop) + { + if (shop.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + if (shop.TryGetValue("items", out var itemsObj) && itemsObj is List> items) + { + sb.AppendLine("## Shop Inventory"); + string? lastCategory = null; + foreach (var item in items) + { + string category = item["category"]?.ToString() ?? ""; + if (category != lastCategory) + { + string header = category switch { "card" => "Cards", "relic" => "Relics", "potion" => "Potions", "card_removal" => "Services", _ => category }; + sb.AppendLine($"### {header}"); + lastCategory = category; + } + + bool stocked = item["is_stocked"] is true; + bool afford = item["can_afford"] is true; + string costTag = stocked ? $"{item["cost"]}g" : "SOLD"; + string affordTag = stocked && !afford ? " (can't afford)" : ""; + string saleTag = item.TryGetValue("on_sale", out var os) && os is true ? " **SALE**" : ""; + + string desc = category switch + { + "card" => $"**{item.GetValueOrDefault("card_name")}** [{item.GetValueOrDefault("card_type")}] {item.GetValueOrDefault("card_rarity")} — {item.GetValueOrDefault("card_description")}", + "relic" => $"**{item.GetValueOrDefault("relic_name")}** — {item.GetValueOrDefault("relic_description")}", + "potion" => $"**{item.GetValueOrDefault("potion_name")}** — {item.GetValueOrDefault("potion_description")}", + "card_removal" => "**Remove a card** from your deck", + _ => "Unknown item" + }; + sb.AppendLine($"- [{item["index"]}] {desc} — {costTag}{saleTag}{affordTag}"); + } + sb.AppendLine(); + } + + bool canProceed = shop.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatMapMarkdown(StringBuilder sb, Dictionary map) + { + // Player summary + if (map.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + // Path taken + if (map.TryGetValue("visited", out var visitedObj) && visitedObj is List> visited && visited.Count > 0) + { + sb.AppendLine("## Path Taken"); + var parts = visited.Select((v, i) => $"{i + 1}. {v["type"]} ({v["col"]},{v["row"]})"); + sb.AppendLine(string.Join(" → ", parts) + " ← current"); + sb.AppendLine(); + } + + // Next options — the key decision section + if (map.TryGetValue("next_options", out var optObj) && optObj is List> options && options.Count > 0) + { + sb.AppendLine("## Choose Next Node"); + foreach (var opt in options) + { + string lookahead = ""; + if (opt.TryGetValue("leads_to", out var leadsObj) && leadsObj is List> leads && leads.Count > 0) + lookahead = " → leads to: " + string.Join(", ", leads.Select(l => $"{l["type"]}({l["col"]},{l["row"]})")); + sb.AppendLine($"- [{opt["index"]}] **{opt["type"]}** ({opt["col"]},{opt["row"]}){lookahead}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("## Map"); + sb.AppendLine("No travelable nodes available."); + sb.AppendLine(); + } + + // Full map overview — compact row-by-row + if (map.TryGetValue("nodes", out var nodesObj) && nodesObj is List> nodes && nodes.Count > 0) + { + // Collect visited and travelable coords for markers + var visitedSet = new HashSet(); + if (map.TryGetValue("visited", out var v2) && v2 is List> vList) + foreach (var vn in vList) + visitedSet.Add($"{vn["col"]},{vn["row"]}"); + + var travelableSet = new HashSet(); + if (map.TryGetValue("next_options", out var o2) && o2 is List> oList) + foreach (var on in oList) + travelableSet.Add($"{on["col"]},{on["row"]}"); + + string? currentKey = null; + if (map.TryGetValue("current_position", out var cpObj) && cpObj is Dictionary cp) + currentKey = $"{cp["col"]},{cp["row"]}"; + + // Group nodes by row + var byRow = new SortedDictionary>>(); + foreach (var node in nodes) + { + int row = node["row"] is int r ? r : Convert.ToInt32(node["row"]); + if (!byRow.TryGetValue(row, out var rowList)) + byRow[row] = rowList = new List>(); + rowList.Add(node); + } + + sb.AppendLine("## Map Overview"); + sb.AppendLine("```"); + sb.AppendLine("Legend: · = visited, * = current, → = next option"); + sb.AppendLine(); + foreach (var (row, rowNodes) in byRow) + { + var sorted = rowNodes.OrderBy(n => n["col"] is int c ? c : Convert.ToInt32(n["col"])).ToList(); + var labels = new List(); + foreach (var node in sorted) + { + string type = node["type"]?.ToString() ?? "Unknown"; + string key = $"{node["col"]},{node["row"]}"; + + string marker = ""; + if (key == currentKey) marker = "*"; + else if (travelableSet.Contains(key)) marker = "→"; + else if (visitedSet.Contains(key)) marker = "·"; + + labels.Add($"{marker}{type}({node["col"]},{node["row"]})"); + } + sb.AppendLine($" Row {row,2}: {string.Join(" ", labels)}"); + } + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + private static void FormatRewardsMarkdown(StringBuilder sb, Dictionary rewards) + { + if (rewards.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]} | Potion slots: {player["open_potion_slots"]}/{player["potion_slots"]} open"); + sb.AppendLine(); + } + + if (rewards.TryGetValue("items", out var itemsObj) && itemsObj is List> items && items.Count > 0) + { + sb.AppendLine("## Rewards"); + foreach (var item in items) + { + string extra = ""; + if (item.TryGetValue("gold_amount", out var gold) && gold != null) + extra = $" ({gold} gold)"; + else if (item.TryGetValue("potion_name", out var pName) && pName != null) + extra = $" ({pName})"; + sb.AppendLine($"- [{item["index"]}] **{item["type"]}**: {item["description"]}{extra}"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine("## Rewards"); + sb.AppendLine("No rewards available."); + sb.AppendLine(); + } + + bool canProceed = rewards.TryGetValue("can_proceed", out var cp) && cp is true; + sb.AppendLine($"**Can proceed:** {(canProceed ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatCardRewardMarkdown(StringBuilder sb, Dictionary cardReward) + { + sb.AppendLine("## Card Reward Selection"); + sb.AppendLine("Choose a card to add to your deck:"); + sb.AppendLine(); + + if (cardReward.TryGetValue("cards", out var cardsObj) && cardsObj is List> cards) + { + foreach (var card in cards) + { + string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; + string keywords = card.TryGetValue("keywords", out var kw) && kw is List kwList && kwList.Count > 0 + ? $" [{string.Join(", ", kwList)}]" : ""; + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {card["rarity"]}{keywords} — {card["description"]}"); + } + sb.AppendLine(); + } + + bool canSkip = cardReward.TryGetValue("can_skip", out var cs) && cs is true; + sb.AppendLine($"**Can skip:** {(canSkip ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatRelicSelectMarkdown(StringBuilder sb, Dictionary relicSelect) + { + sb.AppendLine("## Relic Selection"); + if (relicSelect.TryGetValue("prompt", out var p) && p != null) + sb.AppendLine($"*{p}*"); + sb.AppendLine(); + + if (relicSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (relicSelect.TryGetValue("relics", out var relicsObj) && relicsObj is List> relics) + { + foreach (var relic in relics) + sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}** — {relic["description"]}"); + sb.AppendLine(); + } + + bool canSkip = relicSelect.TryGetValue("can_skip", out var cs) && cs is true; + sb.AppendLine($"Use `select_relic(index)` to choose. Can skip: {(canSkip ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatHandSelectMarkdown(StringBuilder sb, Dictionary handSelect) + { + sb.AppendLine("## In-Combat Card Selection"); + + if (handSelect.TryGetValue("prompt", out var promptObj) && promptObj != null) + sb.AppendLine($"*{promptObj}*"); + sb.AppendLine(); + + string mode = handSelect.TryGetValue("mode", out var m) ? m?.ToString() ?? "simple_select" : "simple_select"; + if (mode == "upgrade_select") + sb.AppendLine("**Mode:** Upgrade selection"); + sb.AppendLine(); + + if (handSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List> cards && cards.Count > 0) + { + sb.AppendLine("### Selectable Cards"); + foreach (var card in cards) + { + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] — {card["description"]}"); + } + sb.AppendLine(); + } + + if (handSelect.TryGetValue("selected_cards", out var selObj) && selObj is List> selected && selected.Count > 0) + { + sb.AppendLine("### Already Selected"); + foreach (var card in selected) + sb.AppendLine($"- {card["name"]}"); + sb.AppendLine(); + } + + bool canConfirm = handSelect.TryGetValue("can_confirm", out var cc) && cc is true; + sb.AppendLine($"Use `combat_select_card(card_index)` to select. Can confirm: {(canConfirm ? "Yes — use `combat_confirm_selection`" : "No — select more cards")}"); + sb.AppendLine(); + } + + private static void FormatCardSelectMarkdown(StringBuilder sb, Dictionary cardSelect) + { + string screenType = cardSelect.TryGetValue("screen_type", out var st) ? st?.ToString() ?? "select" : "select"; + string screenLabel = screenType switch + { + "transform" => "Transform", + "upgrade" => "Upgrade", + "select" => "Select", + "simple_select" => "Select", + _ => screenType + }; + sb.AppendLine($"## Card Selection: {screenLabel}"); + + if (cardSelect.TryGetValue("prompt", out var promptObj) && promptObj != null) + { + sb.AppendLine($"*{promptObj}*"); + } + sb.AppendLine(); + + if (cardSelect.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (cardSelect.TryGetValue("cards", out var cardsObj) && cardsObj is List> cards) + { + sb.AppendLine("### Cards"); + foreach (var card in cards) + { + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy) [{card["type"]}] {card["rarity"]} — {card["description"]}"); + } + sb.AppendLine(); + } + + bool preview = cardSelect.TryGetValue("preview_showing", out var pv) && pv is true; + bool canConfirm = cardSelect.TryGetValue("can_confirm", out var cc) && cc is true; + bool canCancel = cardSelect.TryGetValue("can_cancel", out var cn) && cn is true; + + if (preview) + sb.AppendLine("**Preview is showing** — use `confirm_selection` to confirm or `cancel_selection` to go back."); + else + sb.AppendLine($"**Select cards** using `select_card(index)`. Can confirm: {(canConfirm ? "Yes" : "No")} | Can cancel: {(canCancel ? "Yes" : "No")}"); + sb.AppendLine(); + } + + private static void FormatTreasureMarkdown(StringBuilder sb, Dictionary treasure) + { + if (treasure.TryGetValue("player", out var playerObj) && playerObj is Dictionary player) + { + sb.AppendLine("## Player (You)"); + sb.AppendLine($"**{player["character"]}** — HP: {player["hp"]}/{player["max_hp"]} | Gold: {player["gold"]}"); + sb.AppendLine(); + } + + if (treasure.TryGetValue("relics", out var relicsObj) && relicsObj is List> relics && relics.Count > 0) + { + sb.AppendLine("## Treasure Relics"); + foreach (var relic in relics) + { + string rarity = relic.TryGetValue("rarity", out var r) && r != null ? $" ({r})" : ""; + sb.AppendLine($"- [{relic["index"]}] **{relic["name"]}**{rarity} — {relic["description"]}"); + } + sb.AppendLine(); + sb.AppendLine("Use `treasure_claim_relic(relic_index)` to claim a relic."); + } + else + { + sb.AppendLine("Chest is opening..."); + } + sb.AppendLine(); + + bool canProceed = treasure.TryGetValue("can_proceed", out var cp) && cp is true; + if (canProceed) + sb.AppendLine("**Can proceed:** Yes"); + sb.AppendLine(); + } + + private static string FormatStatusAmount(object? amount) + { + if (amount is int i && i == -1) return "indefinite"; + return amount?.ToString() ?? "0"; + } + + private static void FormatListSection(StringBuilder sb, string title, Dictionary parent, string key, + Func, string> formatter) + { + if (parent.TryGetValue(key, out var listObj) && listObj is List> list && list.Count > 0) + { + sb.AppendLine($"### {title}"); + foreach (var item in list) + sb.AppendLine(formatter(item)); + sb.AppendLine(); + } + } + + private static void FormatMultiplayerBattleMarkdown(StringBuilder sb, Dictionary battle) + { + if (battle.TryGetValue("error", out var err) && err != null) + { + sb.AppendLine($"**Combat Error:** {err}"); + sb.AppendLine(); + return; + } + + bool allReady = battle.TryGetValue("all_players_ready", out var ar) && ar is true; + sb.AppendLine($"**Round {battle["round"]}** | Turn: {battle["turn"]} | Play Phase: {battle["is_play_phase"]} | All Ready: {allReady}"); + sb.AppendLine(); + + // All players + if (battle.TryGetValue("players", out var playersObj) && playersObj is List> players) + { + foreach (var player in players) + { + string youTag = player["is_local"] is true ? " **(YOU)**" : ""; + string aliveTag = player["is_alive"] is false ? " [DEAD]" : ""; + string readyTag = player["is_ready_to_end_turn"] is true ? " [READY]" : ""; + string stars = player.TryGetValue("stars", out var s) && s != null ? $" | Stars: {s}" : ""; + + sb.AppendLine($"## Player: {player["character"]}{youTag}{aliveTag}{readyTag}"); + string energyStr = player.TryGetValue("energy", out var en) && player.TryGetValue("max_energy", out var men) + ? $" | Energy: {en}/{men}" : ""; + sb.AppendLine($"HP: {player["hp"]}/{player["max_hp"]} | Block: {player["block"]}{energyStr}{stars} | Gold: {player["gold"]}"); + sb.AppendLine(); + + FormatListSection(sb, "Status", player, "status", p => $"- **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + FormatListSection(sb, "Relics", player, "relics", r => + { + string counter = r.TryGetValue("counter", out var c) && c != null ? $" [{c}]" : ""; + return $"- **{r["name"]}**{counter}: {r["description"]}"; + }); + FormatListSection(sb, "Potions", player, "potions", p => + { + string desc = p.TryGetValue("description", out var d) && d != null ? $": {d}" : ""; + return $"- [{p["slot"]}] **{p["name"]}**{desc}"; + }); + + if (player["is_local"] is true) + { + if (player.TryGetValue("hand", out var handObj) && handObj is List> hand && hand.Count > 0) + { + sb.AppendLine("### Hand"); + foreach (var card in hand) + { + string playable = card["can_play"] is true ? "\u2713" : "\u2717"; + string keywords = card.TryGetValue("keywords", out var kw) && kw is List kwList && kwList.Count > 0 + ? $" [{string.Join(", ", kwList)}]" : ""; + string starCost = card.TryGetValue("star_cost", out var sc) && sc != null ? $" + {sc} star" : ""; + sb.AppendLine($"- [{card["index"]}] **{card["name"]}** ({card["cost"]} energy{starCost}) [{card["type"]}] {playable}{keywords} — {card["description"]} (target: {card["target_type"]})"); + } + sb.AppendLine(); + } + + FormatDeckPilesMarkdown(sb, player); + + if (player.TryGetValue("orbs", out var orbsObj) && orbsObj is List> orbs && orbs.Count > 0) + { + int slots = player.TryGetValue("orb_slots", out var osVal) && osVal is int sv ? sv : orbs.Count; + int empty = player.TryGetValue("orb_empty_slots", out var esVal) && esVal is int ev ? ev : 0; + sb.AppendLine($"### Orbs ({orbs.Count}/{slots} slots)"); + foreach (var orb in orbs) + { + string desc = orb.TryGetValue("description", out var d) && d != null ? $" — {d}" : ""; + sb.AppendLine($"- **{orb["name"]}** (passive: {orb["passive_val"]}, evoke: {orb["evoke_val"]}){desc}"); + } + if (empty > 0) + sb.AppendLine($"- *{empty} empty slot(s)*"); + sb.AppendLine(); + } + } + } + } + + if (battle.TryGetValue("enemies", out var enemiesObj) && enemiesObj is List> enemies && enemies.Count > 0) + { + sb.AppendLine("## Enemies"); + foreach (var enemy in enemies) + { + sb.AppendLine($"### {enemy["name"]} (`{enemy["entity_id"]}`)"); + sb.AppendLine($"HP: {enemy["hp"]}/{enemy["max_hp"]} | Block: {enemy["block"]}"); + + if (enemy.TryGetValue("intents", out var intentsObj) && intentsObj is List> intents && intents.Count > 0) + { + sb.Append("**Intent:** "); + sb.AppendLine(string.Join(", ", intents.Select(i => + { + string title = i.TryGetValue("title", out var t) && t != null ? t.ToString()! : i["type"]!.ToString()!; + string typeTag = $" ({i["type"]})"; + string label = i.TryGetValue("label", out var l) && l is string ls && ls.Length > 0 ? $" {ls}" : ""; + string desc = i.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 ? $" - {ds}" : ""; + return $"{title}{typeTag}{label}{desc}"; + }))); + } + + FormatListSection(sb, "Status", enemy, "status", p => $" - **{p["name"]}** ({FormatStatusAmount(p["amount"])}): {p["description"]}"); + sb.AppendLine(); + } + } + } + + private static void FormatMapVotesMarkdown(StringBuilder sb, Dictionary mapData) + { + if (!mapData.TryGetValue("votes", out var votesObj) || votesObj is not List> votes || votes.Count == 0) + return; + + sb.AppendLine("## Map Votes"); + foreach (var vote in votes) + { + string youTag = vote["is_local"] is true ? " (YOU)" : ""; + if (vote["voted"] is true) + sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for ({vote["vote_col"]},{vote["vote_row"]})"); + else + sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*"); + } + bool allVoted = mapData.TryGetValue("all_voted", out var av) && av is true; + if (allVoted) + sb.AppendLine("**All players have voted!**"); + sb.AppendLine(); + } + + private static void FormatEventVotesMarkdown(StringBuilder sb, Dictionary eventData) + { + bool isShared = eventData.TryGetValue("is_shared", out var sh) && sh is true; + if (!isShared) return; + + if (!eventData.TryGetValue("votes", out var votesObj) || votesObj is not List> votes || votes.Count == 0) + return; + + sb.AppendLine("## Event Votes (Shared Event)"); + foreach (var vote in votes) + { + string youTag = vote["is_local"] is true ? " (YOU)" : ""; + if (vote["voted"] is true) + sb.AppendLine($"- **{vote["player"]}**{youTag}: voted for option {vote["vote_option"]}"); + else + sb.AppendLine($"- **{vote["player"]}**{youTag}: *waiting...*"); + } + bool allVoted = eventData.TryGetValue("all_voted", out var av) && av is true; + if (allVoted) + sb.AppendLine("**All players have voted!**"); + sb.AppendLine(); + } + + private static void FormatTreasureBidsMarkdown(StringBuilder sb, Dictionary treasureData) + { + if (treasureData.TryGetValue("is_bidding_phase", out var bp) && bp is not true) + return; + + if (!treasureData.TryGetValue("bids", out var bidsObj) || bidsObj is not List> bids || bids.Count == 0) + return; + + sb.AppendLine("## Treasure Bids"); + foreach (var bid in bids) + { + string youTag = bid["is_local"] is true ? " (YOU)" : ""; + if (bid["voted"] is true) + sb.AppendLine($"- **{bid["player"]}**{youTag}: bid on relic #{bid["vote_relic_index"]}"); + else + sb.AppendLine($"- **{bid["player"]}**{youTag}: *waiting...*"); + } + bool allBid = treasureData.TryGetValue("all_bid", out var ab) && ab is true; + if (allBid) + sb.AppendLine("**All players have bid!**"); + sb.AppendLine(); + } + + private static void CollectKeywordsFromState(object? obj, Dictionary glossary) + { + if (obj is Dictionary dict) + { + if (dict.TryGetValue("keywords", out var kw) && kw is List> keywords) + { + foreach (var keyword in keywords) + { + string? name = keyword.GetValueOrDefault("name")?.ToString(); + string? desc = keyword.GetValueOrDefault("description")?.ToString(); + if (name != null && desc != null) + glossary.TryAdd(name, desc); + } + } + foreach (var (key, value) in dict) + { + if (key != "keywords") + CollectKeywordsFromState(value, glossary); + } + } + else if (obj is List> list) + { + foreach (var item in list) + CollectKeywordsFromState(item, glossary); + } + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs new file mode 100644 index 000000000..c50a9523c --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.Helpers.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using System.Text.Json; +using Godot; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Models; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static string? SafeGetCardDescription(CardModel card, PileType pile = PileType.Hand) + { + try { return StripRichTextTags(card.GetDescriptionForPile(pile)).Replace("\n", " "); } + catch { return SafeGetText(() => card.Description)?.Replace("\n", " "); } + } + + internal static string? SafeGetText(Func 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 { ["error"] = message }); + } + + private static Dictionary Error(string message) + { + return new Dictionary { ["status"] = "error", ["error"] = message }; + } + + internal static List FindAll(Node start) where T : Node + { + var list = new List(); + if (GodotObject.IsInstanceValid(start)) + FindAllRecursive(start, list); + return list; + } + + /// + /// 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. + /// + internal static List FindAllSortedByPosition(Node start) where T : Control + { + var list = FindAll(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(Node node, List 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> BuildHoverTips(IEnumerable tips) + { + var result = new List>(); + try + { + var seen = new HashSet(); + 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 + { + ["name"] = title, + ["description"] = description + }); + } + catch { /* skip individual tip on error */ } + } + } + catch { /* return partial results */ } + return result; + } + + internal static T? FindFirst(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(child); + if (val != null) return val; + } + return null; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs new file mode 100644 index 000000000..000632f37 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerActions.cs @@ -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 ExecuteMultiplayerAction(string action, Dictionary 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 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 + { + ["status"] = "ok", + ["message"] = "Submitted end turn (waiting for other players)" + }; + } + + private static Dictionary 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 + { + ["status"] = "ok", + ["message"] = "Undid end turn — continue playing cards" + }; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs new file mode 100644 index 000000000..c23111c37 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.MultiplayerState.cs @@ -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 BuildMultiplayerGameState() + { + var result = new Dictionary(); + + 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 + { + ["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 + { + ["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 BuildMultiplayerBattleState(RunState runState, CombatRoom combatRoom) + { + var combatState = CombatManager.Instance.DebugOnlyGetState(); + var battle = new Dictionary(); + + 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? 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>(); + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (creature.IsAlive) + enemies.Add(BuildEnemyState(creature, entityCounts)); + } + battle["enemies"] = enemies; + + return battle; + } + + private static Dictionary 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>(); + + foreach (var player in runState.Players) + { + var vote = mapSync.GetVote(player); + votes.Add(new Dictionary + { + ["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 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>(); + foreach (var player in runState.Players) + { + var vote = eventSync.GetPlayerVote(player); + votes.Add(new Dictionary + { + ["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 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>(); + foreach (var player in runState.Players) + { + var vote = treasureSync.GetPlayerVote(player); + bids.Add(new Dictionary + { + ["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; + } + + /// + /// 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. + /// + private static Dictionary BuildPlayerStateSummary(Player player) + { + var state = new Dictionary(); + 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>(); + foreach (var relic in player.Relics) + { + relics.Add(new Dictionary + { + ["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>(); + int slotIndex = 0; + foreach (var potion in player.PotionSlots) + { + if (potion != null) + { + potions.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["slot"] = slotIndex + }); + } + slotIndex++; + } + state["potions"] = potions; + + return state; + } + + private static List> BuildAllPlayersState(RunState runState) + { + var players = new List>(); + foreach (var player in runState.Players) + { + players.Add(new Dictionary + { + ["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; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs new file mode 100644 index 000000000..965ca64cf --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.StateBuilder.cs @@ -0,0 +1,1446 @@ +using System.Collections.Generic; +using System.Linq; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Context; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Entities.Potions; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.MonsterMoves.Intents; +using MegaCrit.Sts2.Core.MonsterMoves.MonsterMoveStateMachine; +using MegaCrit.Sts2.Core.Entities.Merchant; +using MegaCrit.Sts2.Core.Entities.RestSite; +using MegaCrit.Sts2.Core.Events; +using MegaCrit.Sts2.Core.Nodes.Events; +using MegaCrit.Sts2.Core.Nodes.GodotExtensions; +using MegaCrit.Sts2.Core.Map; +using MegaCrit.Sts2.Core.Nodes.Cards; +using MegaCrit.Sts2.Core.Nodes.Cards.Holders; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.CommonUi; +using MegaCrit.Sts2.Core.Nodes.Rooms; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Nodes.Screens; +using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection; +using MegaCrit.Sts2.Core.Nodes.Screens.Map; +using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen; +using MegaCrit.Sts2.Core.Nodes.Relics; +using MegaCrit.Sts2.Core.Nodes.Screens.Overlays; +using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic; +using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu; +using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect; +using MegaCrit.Sts2.Core.Rewards; +using MegaCrit.Sts2.Core.Rooms; +using MegaCrit.Sts2.Core.Runs; + +namespace STS2_Bridge; + +public static partial class BridgeMod +{ + private static Dictionary BuildGameState() + { + var result = new Dictionary(); + + if (!RunManager.Instance.IsInProgress) + { + result["state_type"] = "menu"; + result["message"] = "No run in progress. Player is in the main menu."; + result["menu"] = BuildMenuState(); + return result; + } + + var runState = RunManager.Instance.DebugOnlyGetState(); + if (runState == null) + { + result["state_type"] = "unknown"; + return result; + } + + // Card selection overlays can appear on top of any room (events, rest sites, combat) + 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 NGameOverScreen gameOverScreen) + { + result["state_type"] = "game_over"; + result["game_over"] = BuildGameOverState(gameOverScreen, runState); + } + else if (topOverlay is IOverlayScreen + && topOverlay is not NRewardsScreen + && topOverlay is not NCardRewardSelectionScreen) + { + // Catch-all for unhandled overlays — prevents soft-locks + result["state_type"] = "overlay"; + result["overlay"] = new Dictionary + { + ["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) + { + // Check for in-combat hand card selection (e.g., "Select a card to exhaust") + var playerHand = NPlayerHand.Instance; + if (playerHand != null && playerHand.IsInCardSelection) + { + result["state_type"] = "hand_select"; + result["hand_select"] = BuildHandSelectState(playerHand, runState); + result["battle"] = BuildBattleState(runState, combatRoom); + } + else + { + result["state_type"] = combatRoom.RoomType.ToString().ToLower(); // monster, elite, boss + result["battle"] = BuildBattleState(runState, combatRoom); + } + } + else + { + // After combat ends, check: map open (post-rewards) > overlays > fallback + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(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"] = BuildMapState(runState); + } + else + { + result["state_type"] = "event"; + result["event"] = BuildEventState(eventRoom, runState); + } + } + else if (currentRoom is MapRoom) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else if (currentRoom is MerchantRoom merchantRoom) + { + if (NMapScreen.Instance is { IsOpen: true }) + { + result["state_type"] = "map"; + result["map"] = BuildMapState(runState); + } + else + { + // Auto-open the shopkeeper's inventory if not already open + 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"] = BuildMapState(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"] = BuildMapState(runState); + } + else + { + result["state_type"] = "treasure"; + result["treasure"] = BuildTreasureState(treasureRoom, runState); + } + } + else + { + result["state_type"] = "unknown"; + result["room_type"] = currentRoom?.GetType().Name; + } + + // Common run info + result["run"] = new Dictionary + { + ["act"] = runState.CurrentActIndex + 1, + ["floor"] = runState.TotalFloor, + ["ascension"] = runState.AscensionLevel + }; + + return result; + } + + private static Dictionary BuildGameOverState(NGameOverScreen screen, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + }; + } + + state["screen_type"] = nameof(NGameOverScreen); + + var returnButton = FindFirst(screen); + var continueButton = FindFirst(screen); + var viewRunButton = FindFirst(screen); + + state["can_return_to_main_menu"] = returnButton?.IsVisibleInTree() == true && returnButton.IsEnabled; + state["can_continue"] = continueButton?.IsVisibleInTree() == true && continueButton.IsEnabled; + state["can_view_run"] = viewRunButton?.IsVisibleInTree() == true && viewRunButton.IsEnabled; + + var options = new List>(); + if (returnButton != null && returnButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "return_to_main_menu", + ["title"] = "Return To Main Menu", + ["is_enabled"] = returnButton.IsEnabled, + }); + } + if (continueButton != null && continueButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "continue", + ["title"] = "Continue", + ["is_enabled"] = continueButton.IsEnabled, + }); + } + if (viewRunButton != null && viewRunButton.IsVisibleInTree()) + { + options.Add(new Dictionary + { + ["id"] = "view_run", + ["title"] = "View Run", + ["is_enabled"] = viewRunButton.IsEnabled, + }); + } + state["options"] = options; + + return state; + } + + private static Dictionary BuildMenuState() + { + var state = new Dictionary(); + + var root = ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root; + var characterSelect = FindFirst(root); + if (characterSelect != null && characterSelect.IsVisibleInTree()) + { + state["screen"] = "character_select"; + + var characters = new List>(); + foreach (var button in FindAll(characterSelect)) + { + var character = button.Character; + if (character == null) continue; + + characters.Add(new Dictionary + { + ["id"] = character.Id.Entry, + ["name"] = SafeGetText(() => character.Title), + }); + } + state["characters"] = characters; + + var ascensionPanel = FindFirst(characterSelect); + if (ascensionPanel != null) + state["ascension"] = ascensionPanel.Ascension; + + var embarkButton = FindAll(characterSelect) + .FirstOrDefault(button => button.IsVisibleInTree()); + state["can_start_new_game"] = embarkButton?.IsEnabled ?? false; + state["can_continue_game"] = false; + state["can_abandon_game"] = false; + + return state; + } + + var mainMenu = FindFirst(root); + if (mainMenu != null && mainMenu.IsVisibleInTree()) + { + state["screen"] = "main_menu"; + + var continueInfo = mainMenu.ContinueRunInfo; + bool canContinue = continueInfo != null && continueInfo.IsVisibleInTree(); + + state["can_continue_game"] = canContinue; + state["can_abandon_game"] = canContinue; + state["can_start_new_game"] = true; + return state; + } + + state["screen"] = "unknown"; + state["can_continue_game"] = false; + state["can_abandon_game"] = false; + state["can_start_new_game"] = false; + return state; + } + + private static Dictionary BuildBattleState(RunState runState, CombatRoom combatRoom) + { + var combatState = CombatManager.Instance.DebugOnlyGetState(); + var battle = new Dictionary(); + + 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; + + // Player state + var player = LocalContext.GetMe(runState); + if (player != null) + { + battle["player"] = BuildPlayerState(player); + } + + // Enemies + var enemies = new List>(); + var entityCounts = new Dictionary(); + foreach (var creature in combatState.Enemies) + { + if (creature.IsAlive) + { + enemies.Add(BuildEnemyState(creature, entityCounts)); + } + } + battle["enemies"] = enemies; + + return battle; + } + + private static Dictionary BuildPlayerState(Player player) + { + var state = new Dictionary(); + 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; + + // Stars (The Regent's resource, conditionally shown) + if (player.Character.ShouldAlwaysShowStarCounter || combatState.Stars > 0) + { + state["stars"] = combatState.Stars; + } + + // Hand + var hand = new List>(); + int cardIndex = 0; + foreach (var card in combatState.Hand.Cards) + { + hand.Add(BuildCardState(card, cardIndex)); + cardIndex++; + } + state["hand"] = hand; + + // Pile counts + state["draw_pile_count"] = combatState.DrawPile.Cards.Count; + state["discard_pile_count"] = combatState.DiscardPile.Cards.Count; + state["exhaust_pile_count"] = combatState.ExhaustPile.Cards.Count; + + // Pile contents + state["draw_pile"] = BuildPileCardList(combatState.DrawPile.Cards, PileType.Draw); + state["discard_pile"] = BuildPileCardList(combatState.DiscardPile.Cards, PileType.Discard); + state["exhaust_pile"] = BuildPileCardList(combatState.ExhaustPile.Cards, PileType.Exhaust); + + // Orbs + if (combatState.OrbQueue.Capacity > 0) + { + var orbs = new List>(); + foreach (var orb in combatState.OrbQueue.Orbs) + { + // Populate SmartDescription placeholders with Focus-modified values, + // mirroring OrbModel.HoverTips getter (OrbModel.cs:92-94) + string? description = SafeGetText(() => + { + var desc = orb.SmartDescription; + desc.Add("energyPrefix", orb.Owner.Character.CardPool.Title); + desc.Add("Passive", orb.PassiveVal); + desc.Add("Evoke", orb.EvokeVal); + return desc; + }); + orbs.Add(new Dictionary + { + ["id"] = orb.Id.Entry, + ["name"] = SafeGetText(() => orb.Title), + ["description"] = description, + ["passive_val"] = orb.PassiveVal, + ["evoke_val"] = orb.EvokeVal, + ["keywords"] = BuildHoverTips(orb.HoverTips) + }); + } + state["orbs"] = orbs; + state["orb_slots"] = combatState.OrbQueue.Capacity; + state["orb_empty_slots"] = combatState.OrbQueue.Capacity - combatState.OrbQueue.Orbs.Count; + } + } + + state["gold"] = player.Gold; + + // Powers (status effects) + state["status"] = BuildPowersState(creature); + + // Relics + var relics = new List>(); + foreach (var relic in player.Relics) + { + relics.Add(new Dictionary + { + ["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; + + // Potions + var potions = new List>(); + int slotIndex = 0; + foreach (var potion in player.PotionSlots) + { + if (potion != null) + { + potions.Add(new Dictionary + { + ["id"] = potion.Id.Entry, + ["name"] = SafeGetText(() => potion.Title), + ["description"] = SafeGetText(() => potion.DynamicDescription), + ["slot"] = slotIndex, + ["can_use_in_combat"] = potion.Usage == PotionUsage.CombatOnly || potion.Usage == PotionUsage.AnyTime, + ["target_type"] = potion.TargetType.ToString(), + ["keywords"] = BuildHoverTips(potion.ExtraHoverTips) + }); + } + slotIndex++; + } + state["potions"] = potions; + + return state; + } + + private static Dictionary BuildCardState(CardModel card, int index) + { + string costDisplay; + if (card.EnergyCost.CostsX) + costDisplay = "X"; + else + { + int cost = card.EnergyCost.GetAmountToSpend(); + costDisplay = cost.ToString(); + } + + card.CanPlay(out var unplayableReason, out _); + + // Star cost (The Regent's cards; CanonicalStarCost >= 0 means card has a star cost) + string? starCostDisplay = null; + if (card.HasStarCostX) + starCostDisplay = "X"; + else if (card.CurrentStarCost >= 0) + starCostDisplay = card.GetStarCostWithModifiers().ToString(); + + return new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = card.Title, + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["star_cost"] = starCostDisplay, + ["description"] = SafeGetCardDescription(card), + ["target_type"] = card.TargetType.ToString(), + ["can_play"] = unplayableReason == UnplayableReason.None, + ["unplayable_reason"] = unplayableReason != UnplayableReason.None ? unplayableReason.ToString() : null, + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }; + } + + private static List> BuildPileCardList(IEnumerable cards, PileType pile) + { + var list = new List>(); + foreach (var card in cards) + { + list.Add(new Dictionary + { + ["name"] = SafeGetText(() => card.Title), + ["description"] = SafeGetCardDescription(card, pile) + }); + } + return list; + } + + private static Dictionary BuildEnemyState(Creature creature, Dictionary entityCounts) + { + var monster = creature.Monster; + string baseId = monster?.Id.Entry ?? "unknown"; + + // Generate entity_id like "jaw_worm_0" + if (!entityCounts.TryGetValue(baseId, out int count)) + count = 0; + entityCounts[baseId] = count + 1; + string entityId = $"{baseId}_{count}"; + + var state = new Dictionary + { + ["entity_id"] = entityId, + ["combat_id"] = creature.CombatId, + ["name"] = SafeGetText(() => monster?.Title), + ["hp"] = creature.CurrentHp, + ["max_hp"] = creature.MaxHp, + ["block"] = creature.Block, + ["status"] = BuildPowersState(creature) + }; + + // Intents + if (monster?.NextMove is MoveState moveState) + { + var intents = new List>(); + foreach (var intent in moveState.Intents) + { + var intentData = new Dictionary + { + ["type"] = intent.IntentType.ToString() + }; + try + { + var targets = creature.CombatState?.PlayerCreatures; + if (targets != null) + { + string label = intent.GetIntentLabel(targets, creature).GetFormattedText(); + intentData["label"] = StripRichTextTags(label); + + var hoverTip = intent.GetHoverTip(targets, creature); + if (hoverTip.Title != null) + intentData["title"] = StripRichTextTags(hoverTip.Title); + if (hoverTip.Description != null) + intentData["description"] = StripRichTextTags(hoverTip.Description); + } + } + catch { /* intent label may fail for some types */ } + intents.Add(intentData); + } + state["intents"] = intents; + } + + return state; + } + + private static Dictionary BuildEventState(EventRoom eventRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var eventModel = eventRoom.CanonicalEvent; + bool isAncient = eventModel is AncientEventModel; + state["event_id"] = eventModel.Id.Entry; + state["event_name"] = SafeGetText(() => eventModel.Title); + state["is_ancient"] = isAncient; + + // Check dialogue state for ancients + bool inDialogue = false; + var uiRoom = NEventRoom.Instance; + if (isAncient && uiRoom != null) + { + var ancientLayout = FindFirst(uiRoom); + if (ancientLayout != null) + { + var hitbox = ancientLayout.GetNodeOrNull("%DialogueHitbox"); + inDialogue = hitbox != null && hitbox.Visible && hitbox.IsEnabled; + } + } + state["in_dialogue"] = inDialogue; + + // Event body text + state["body"] = SafeGetText(() => eventModel.Description); + + // Options from UI + var options = new List>(); + if (uiRoom != null) + { + var buttons = FindAll(uiRoom); + int index = 0; + foreach (var button in buttons) + { + var opt = button.Option; + var optData = new Dictionary + { + ["index"] = index, + ["title"] = SafeGetText(() => opt.Title), + ["description"] = SafeGetText(() => opt.Description), + ["is_locked"] = opt.IsLocked, + ["is_proceed"] = opt.IsProceed, + ["was_chosen"] = opt.WasChosen + }; + if (opt.Relic != null) + { + optData["relic_name"] = SafeGetText(() => opt.Relic.Title); + optData["relic_description"] = SafeGetText(() => opt.Relic.DynamicDescription); + } + optData["keywords"] = BuildHoverTips(opt.HoverTips); + options.Add(optData); + index++; + } + } + state["options"] = options; + + return state; + } + + private static Dictionary BuildRestSiteState(RestSiteRoom restSiteRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var options = new List>(); + int index = 0; + foreach (var opt in restSiteRoom.Options) + { + options.Add(new Dictionary + { + ["index"] = index, + ["id"] = opt.OptionId, + ["name"] = SafeGetText(() => opt.Title), + ["description"] = SafeGetText(() => opt.Description), + ["is_enabled"] = opt.IsEnabled + }); + index++; + } + state["options"] = options; + + var proceedButton = NRestSiteRoom.Instance?.ProceedButton; + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildShopState(MerchantRoom merchantRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = player.PotionSlots.Count, + ["open_potion_slots"] = player.PotionSlots.Count(s => s == null) + }; + } + + var inventory = merchantRoom.Inventory; + var items = new List>(); + int index = 0; + + // Cards + foreach (var entry in inventory.CardEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "card", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = entry.EnoughGold, + ["on_sale"] = entry.IsOnSale + }; + if (entry.CreationResult?.Card is { } card) + { + item["card_id"] = card.Id.Entry; + item["card_name"] = SafeGetText(() => card.Title); + item["card_type"] = card.Type.ToString(); + item["card_rarity"] = card.Rarity.ToString(); + item["card_description"] = SafeGetCardDescription(card, PileType.None); + item["keywords"] = BuildHoverTips(card.HoverTips); + } + items.Add(item); + index++; + } + + // Relics + foreach (var entry in inventory.RelicEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "relic", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = entry.EnoughGold + }; + if (entry.Model is { } relic) + { + item["relic_id"] = relic.Id.Entry; + item["relic_name"] = SafeGetText(() => relic.Title); + item["relic_description"] = SafeGetText(() => relic.DynamicDescription); + item["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic); + } + items.Add(item); + index++; + } + + // Potions + foreach (var entry in inventory.PotionEntries) + { + var item = new Dictionary + { + ["index"] = index, + ["category"] = "potion", + ["cost"] = entry.Cost, + ["is_stocked"] = entry.IsStocked, + ["can_afford"] = entry.EnoughGold + }; + if (entry.Model is { } potion) + { + item["potion_id"] = potion.Id.Entry; + item["potion_name"] = SafeGetText(() => potion.Title); + item["potion_description"] = SafeGetText(() => potion.DynamicDescription); + item["keywords"] = BuildHoverTips(potion.ExtraHoverTips); + } + items.Add(item); + index++; + } + + // Card removal + if (inventory.CardRemovalEntry is { } removal) + { + items.Add(new Dictionary + { + ["index"] = index, + ["category"] = "card_removal", + ["cost"] = removal.Cost, + ["is_stocked"] = removal.IsStocked, + ["can_afford"] = removal.EnoughGold + }); + } + + state["items"] = items; + + var proceedButton = NMerchantRoom.Instance?.ProceedButton; + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildMapState(RunState runState) + { + var state = new Dictionary(); + + // Player summary + var player = LocalContext.GetMe(runState); + if (player != null) + { + int totalSlots = player.PotionSlots.Count; + int openSlots = player.PotionSlots.Count(s => s == null); + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = totalSlots, + ["open_potion_slots"] = openSlots + }; + } + + var map = runState.Map; + var visitedCoords = runState.VisitedMapCoords; + + // Current position + if (visitedCoords.Count > 0) + { + var cur = visitedCoords[visitedCoords.Count - 1]; + state["current_position"] = new Dictionary + { + ["col"] = cur.col, ["row"] = cur.row, + ["type"] = map.GetPoint(cur)?.PointType.ToString() + }; + } + + // Visited path + var visited = new List>(); + foreach (var coord in visitedCoords) + { + visited.Add(new Dictionary + { + ["col"] = coord.col, ["row"] = coord.row, + ["type"] = map.GetPoint(coord)?.PointType.ToString() + }); + } + state["visited"] = visited; + + // Next options — read travelable state from UI nodes + var nextOptions = new List>(); + var mapScreen = NMapScreen.Instance; + if (mapScreen != null) + { + var travelable = FindAll(mapScreen) + .Where(mp => mp.State == MapPointState.Travelable) + .OrderBy(mp => mp.Point.coord.col) + .ToList(); + + int index = 0; + foreach (var nmp in travelable) + { + var pt = nmp.Point; + var option = new Dictionary + { + ["index"] = index, + ["col"] = pt.coord.col, + ["row"] = pt.coord.row, + ["type"] = pt.PointType.ToString() + }; + + // 1-level lookahead + var children = pt.Children + .OrderBy(c => c.coord.col) + .Select(c => new Dictionary + { + ["col"] = c.coord.col, ["row"] = c.coord.row, + ["type"] = c.PointType.ToString() + }).ToList(); + if (children.Count > 0) + option["leads_to"] = children; + + nextOptions.Add(option); + index++; + } + } + state["next_options"] = nextOptions; + + // Full map — all nodes organized for planning + var nodes = new List>(); + + // Starting point + var start = map.StartingMapPoint; + nodes.Add(BuildMapNode(start)); + + // Grid nodes + foreach (var pt in map.GetAllMapPoints()) + nodes.Add(BuildMapNode(pt)); + + // Boss + nodes.Add(BuildMapNode(map.BossMapPoint)); + if (map.SecondBossMapPoint != null) + nodes.Add(BuildMapNode(map.SecondBossMapPoint)); + + state["nodes"] = nodes; + state["boss"] = new Dictionary + { + ["col"] = map.BossMapPoint.coord.col, + ["row"] = map.BossMapPoint.coord.row + }; + + return state; + } + + private static Dictionary BuildMapNode(MapPoint pt) + { + return new Dictionary + { + ["col"] = pt.coord.col, + ["row"] = pt.coord.row, + ["type"] = pt.PointType.ToString(), + ["children"] = pt.Children + .OrderBy(c => c.coord.col) + .Select(c => new List { c.coord.col, c.coord.row }) + .ToList() + }; + } + + private static Dictionary BuildRewardsState(NRewardsScreen rewardsScreen, RunState runState) + { + var state = new Dictionary(); + + // Player summary for decision-making context + var player = LocalContext.GetMe(runState); + if (player != null) + { + int totalSlots = player.PotionSlots.Count; + int openSlots = player.PotionSlots.Count(s => s == null); + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold, + ["potion_slots"] = totalSlots, + ["open_potion_slots"] = openSlots + }; + } + + // Reward items + var rewardButtons = FindAll(rewardsScreen); + var items = new List>(); + int index = 0; + foreach (var button in rewardButtons) + { + if (button.Reward == null || !button.IsEnabled) continue; + var reward = button.Reward; + + var item = new Dictionary + { + ["index"] = index, + ["type"] = GetRewardTypeName(reward), + ["description"] = SafeGetText(() => reward.Description) + }; + + // Type-specific details + if (reward is GoldReward goldReward) + item["gold_amount"] = goldReward.Amount; + else if (reward is PotionReward potionReward && potionReward.Potion != null) + { + item["potion_id"] = potionReward.Potion.Id.Entry; + item["potion_name"] = SafeGetText(() => potionReward.Potion.Title); + } + + items.Add(item); + index++; + } + state["items"] = items; + + // Proceed button + var proceedButton = FindFirst(rewardsScreen); + state["can_proceed"] = proceedButton?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildCardRewardState(NCardRewardSelectionScreen cardScreen) + { + var state = new Dictionary(); + + var cardHolders = FindAllSortedByPosition(cardScreen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + string costDisplay = card.EnergyCost.CostsX + ? "X" + : card.EnergyCost.GetAmountToSpend().ToString(); + + string? starCostDisplay = null; + if (card.HasStarCostX) + starCostDisplay = "X"; + else if (card.CurrentStarCost >= 0) + starCostDisplay = card.GetStarCostWithModifiers().ToString(); + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = costDisplay, + ["star_cost"] = starCostDisplay, + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + var altButtons = FindAll(cardScreen); + state["can_skip"] = altButtons.Count > 0; + + return state; + } + + private static Dictionary BuildCardSelectState(NCardGridSelectionScreen screen, RunState runState) + { + var state = new Dictionary(); + + // Screen type + state["screen_type"] = screen switch + { + NDeckTransformSelectScreen => "transform", + NDeckUpgradeSelectScreen => "upgrade", + NDeckCardSelectScreen => "select", + NSimpleCardSelectScreen => "simple_select", + _ => screen.GetType().Name + }; + + // Player summary + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + // Prompt text from UI label + var bottomLabel = screen.GetNodeOrNull("%BottomLabel"); + if (bottomLabel != null) + { + var textVariant = bottomLabel.Get("text"); + string? prompt = textVariant.VariantType != Godot.Variant.Type.Nil ? StripRichTextTags(textVariant.AsString()) : null; + state["prompt"] = prompt; + } + + // Cards in the grid (sorted by visual position — MoveToFront can reorder children) + var cardHolders = FindAllSortedByPosition(screen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + // Preview container showing? (selection complete, awaiting confirm) + // Upgrade screens use UpgradeSinglePreviewContainer / UpgradeMultiPreviewContainer + var previewSingle = screen.GetNodeOrNull("%UpgradeSinglePreviewContainer"); + var previewMulti = screen.GetNodeOrNull("%UpgradeMultiPreviewContainer"); + var previewGeneric = screen.GetNodeOrNull("%PreviewContainer"); + bool previewShowing = (previewSingle?.Visible ?? false) + || (previewMulti?.Visible ?? false) + || (previewGeneric?.Visible ?? false); + state["preview_showing"] = previewShowing; + + // Button states + var closeButton = screen.GetNodeOrNull("%Close"); + state["can_cancel"] = closeButton?.IsEnabled ?? false; + + // Confirm button — search all preview containers and main screen + bool canConfirm = false; + foreach (var container in new[] { previewSingle, previewMulti, previewGeneric }) + { + if (container?.Visible == true) + { + var confirm = container.GetNodeOrNull("Confirm") + ?? container.GetNodeOrNull("%PreviewConfirm"); + if (confirm?.IsEnabled == true) { canConfirm = true; break; } + } + } + if (!canConfirm) + { + var mainConfirm = screen.GetNodeOrNull("Confirm") + ?? screen.GetNodeOrNull("%Confirm"); + if (mainConfirm?.IsEnabled == true) canConfirm = true; + } + // Fallback: search entire screen tree for any enabled confirm button + // (covers subclasses like NDeckEnchantSelectScreen) + if (!canConfirm) + { + canConfirm = FindAll(screen).Any(b => b.IsEnabled && b.IsVisibleInTree()); + } + state["can_confirm"] = canConfirm; + + return state; + } + + private static Dictionary BuildChooseCardState(NChooseACardSelectionScreen screen, RunState runState) + { + var state = new Dictionary(); + state["screen_type"] = "choose"; + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + state["prompt"] = "Choose a card."; + + var cardHolders = FindAllSortedByPosition(screen); + var cards = new List>(); + int index = 0; + foreach (var holder in cardHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + cards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card, PileType.None), + ["rarity"] = card.Rarity.ToString(), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = cards; + + var skipButton = screen.GetNodeOrNull("SkipButton"); + state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + state["preview_showing"] = false; + state["can_confirm"] = false; + state["can_cancel"] = state["can_skip"]; + + return state; + } + + private static Dictionary BuildHandSelectState(NPlayerHand hand, RunState runState) + { + var state = new Dictionary(); + + // Mode + state["mode"] = hand.CurrentMode switch + { + NPlayerHand.Mode.SimpleSelect => "simple_select", + NPlayerHand.Mode.UpgradeSelect => "upgrade_select", + _ => hand.CurrentMode.ToString() + }; + + // Prompt text from %SelectionHeader + var headerLabel = hand.GetNodeOrNull("%SelectionHeader"); + if (headerLabel != null) + { + var textVariant = headerLabel.Get("text"); + string? prompt = textVariant.VariantType != Godot.Variant.Type.Nil + ? StripRichTextTags(textVariant.AsString()) + : null; + state["prompt"] = prompt; + } + + // Selectable cards (visible holders in the hand) + var selectableCards = new List>(); + int index = 0; + foreach (var holder in hand.ActiveHolders) + { + var card = holder.CardModel; + if (card == null) continue; + + selectableCards.Add(new Dictionary + { + ["index"] = index, + ["id"] = card.Id.Entry, + ["name"] = SafeGetText(() => card.Title), + ["type"] = card.Type.ToString(), + ["cost"] = card.EnergyCost.CostsX ? "X" : card.EnergyCost.GetAmountToSpend().ToString(), + ["description"] = SafeGetCardDescription(card), + ["is_upgraded"] = card.IsUpgraded, + ["keywords"] = BuildHoverTips(card.HoverTips) + }); + index++; + } + state["cards"] = selectableCards; + + // Already-selected cards (in the SelectedHandCardContainer) + var selectedContainer = hand.GetNodeOrNull("%SelectedHandCardContainer"); + if (selectedContainer != null) + { + var selectedCards = new List>(); + var selectedHolders = FindAll(selectedContainer); + int selIdx = 0; + foreach (var holder in selectedHolders) + { + var card = holder.CardModel; + if (card == null) continue; + selectedCards.Add(new Dictionary + { + ["index"] = selIdx, + ["name"] = SafeGetText(() => card.Title) + }); + selIdx++; + } + if (selectedCards.Count > 0) + state["selected_cards"] = selectedCards; + } + + // Confirm button state + var confirmBtn = hand.GetNodeOrNull("%SelectModeConfirmButton"); + state["can_confirm"] = confirmBtn?.IsEnabled ?? false; + + return state; + } + + private static Dictionary BuildRelicSelectState(NChooseARelicSelection screen, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + state["prompt"] = "Choose a relic."; + + var relicHolders = FindAll(screen); + var relics = new List>(); + int index = 0; + foreach (var holder in relicHolders) + { + var relic = holder.Relic?.Model; + if (relic == null) continue; + + relics.Add(new Dictionary + { + ["index"] = index, + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + index++; + } + state["relics"] = relics; + + var skipButton = screen.GetNodeOrNull("SkipButton"); + state["can_skip"] = skipButton?.IsEnabled == true && skipButton.Visible; + + return state; + } + + private static Dictionary BuildTreasureState(TreasureRoom treasureRoom, RunState runState) + { + var state = new Dictionary(); + + var player = LocalContext.GetMe(runState); + if (player != null) + { + state["player"] = new Dictionary + { + ["character"] = SafeGetText(() => player.Character.Title), + ["hp"] = player.Creature.CurrentHp, + ["max_hp"] = player.Creature.MaxHp, + ["gold"] = player.Gold + }; + } + + var treasureUI = FindFirst( + ((Godot.SceneTree)Godot.Engine.GetMainLoop()).Root); + + if (treasureUI == null) + { + state["message"] = "Treasure room loading..."; + return state; + } + + // Auto-open chest if not yet opened + var chestButton = treasureUI.GetNodeOrNull("Chest"); + if (chestButton is { IsEnabled: true }) + { + chestButton.ForceClick(); + state["message"] = "Opening chest..."; + return state; + } + + // Show relics available for picking + var relicCollection = treasureUI.GetNodeOrNull("%RelicCollection"); + if (relicCollection?.Visible == true) + { + var holders = FindAll(relicCollection) + .Where(h => h.IsEnabled && h.Visible) + .ToList(); + + var relics = new List>(); + int index = 0; + foreach (var holder in holders) + { + var relic = holder.Relic?.Model; + if (relic == null) continue; + relics.Add(new Dictionary + { + ["index"] = index, + ["id"] = relic.Id.Entry, + ["name"] = SafeGetText(() => relic.Title), + ["description"] = SafeGetText(() => relic.DynamicDescription), + ["rarity"] = relic.Rarity.ToString(), + ["keywords"] = BuildHoverTips(relic.HoverTipsExcludingRelic) + }); + index++; + } + state["relics"] = relics; + } + + state["can_proceed"] = treasureUI.ProceedButton?.IsEnabled ?? false; + + return state; + } + + private static string GetRewardTypeName(Reward reward) => reward switch + { + GoldReward => "gold", + PotionReward => "potion", + RelicReward => "relic", + CardReward => "card", + SpecialCardReward => "special_card", + CardRemovalReward => "card_removal", + _ => reward.GetType().Name.ToLower() + }; + + private static List> BuildPowersState(Creature creature) + { + var powers = new List>(); + foreach (var power in creature.Powers) + { + if (!power.IsVisible) continue; + + // HoverTips resolves all dynamic vars (Amount, DynamicVars, etc.) + // The first tip is the power's own description; the rest are extra keywords + var allTips = power.HoverTips.ToList(); + string? resolvedDesc = null; + var extraTips = new List(); + foreach (var tip in allTips) + { + if (tip.Id == power.Id.ToString()) + { + // This is the power's own hover tip — extract its resolved description + if (tip is HoverTip ht) + resolvedDesc = StripRichTextTags(ht.Description); + } + else + { + extraTips.Add(tip); + } + } + // Fallback to raw SmartDescription if HoverTips extraction failed + resolvedDesc ??= SafeGetText(() => power.SmartDescription); + + powers.Add(new Dictionary + { + ["id"] = power.Id.Entry, + ["name"] = SafeGetText(() => power.Title), + ["amount"] = power.DisplayAmount, + ["type"] = power.Type.ToString(), + ["description"] = resolvedDesc, + ["keywords"] = BuildHoverTips(extraTips) + }); + } + return powers; + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs new file mode 100644 index 000000000..662412f8e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/BridgeMod.cs @@ -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 _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 RunOnMainThread(Func func) + { + var tcs = new TaskCompletionSource(); + _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(); + _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? parsed; + try + { + parsed = JsonSerializer.Deserialize>(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? parsed; + try + { + parsed = JsonSerializer.Deserialize>(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}"); + } + } +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/README.md b/slay_the_spire_ii/agent-harness/bridge/plugin/README.md new file mode 100644 index 000000000..6cfa65d4e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/README.md @@ -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 +/SlayTheSpire2.app/Contents/MacOS/mods/STS2_Bridge/ +``` + +After that, launch the game and enable the `STS2_Bridge` mod. diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj b/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj new file mode 100644 index 000000000..e150c74be --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/STS2_Bridge.csproj @@ -0,0 +1,32 @@ + + + net9.0 + Library + enable + 12.0 + STS2_Bridge + STS2_Bridge + /path/to/sts2/data_dir + + + + + + + + + + + $(STS2GameDataDir)/sts2.dll + false + + + $(STS2GameDataDir)/GodotSharp.dll + false + + + $(STS2GameDataDir)/0Harmony.dll + false + + + diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json b/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json new file mode 100644 index 000000000..5ff39c5a8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/bridge_manifest.json @@ -0,0 +1,10 @@ +{ + "id": "STS2_Bridge", + "name": "STS2 Bridge", + "author": "kunology", + "description": "Local bridge plugin for Slay the Spire 2", + "version": "0.3.0", + "has_pck": false, + "has_dll": true, + "affects_gameplay": false +} diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh b/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh new file mode 100755 index 000000000..b41ad1e3d --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/build.sh @@ -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 /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" diff --git a/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md b/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md new file mode 100644 index 000000000..5f2b73593 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/bridge/plugin/docs/raw_api.md @@ -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. diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md index 805d7c0d0..b97a6916e 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/README.md @@ -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 diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md index b675a727b..a9677c39d 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md @@ -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 diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py index 62b1098cd..e432c1a70 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/slay_the_spire_ii_cli.py @@ -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__": diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md new file mode 100644 index 000000000..b919a9315 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/TEST.md @@ -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 --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"}` diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py new file mode 100644 index 000000000..33078d736 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Slay the Spire II CLI harness.""" diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py new file mode 100644 index 000000000..2ca5a088e --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_core.py @@ -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() diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py new file mode 100644 index 000000000..09d42c6c8 --- /dev/null +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/tests/test_full_e2e.py @@ -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() diff --git a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py index b79768e9e..965bcb78c 100644 --- a/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py +++ b/slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/utils/sts2_backend.py @@ -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: diff --git a/slay_the_spire_ii/agent-harness/setup.py b/slay_the_spire_ii/agent-harness/setup.py index 0e7cee475..914f40a17 100644 --- a/slay_the_spire_ii/agent-harness/setup.py +++ b/slay_the_spire_ii/agent-harness/setup.py @@ -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",