# 04 — State shapes Every shape below was captured from a **live game**, not read from docs. Keys are exact. Where a field is missing in some states, that is called out, because each absence caused a real bug. Captured from `GET /api/v1/singleplayer?format=json` on game `v0.107.1`. --- ## `menu` ```json { "state_type": "menu", "menu_screen": "main", "message": "Main menu.", "options": ["singleplayer", "multiplayer", "compendium", "timeline", "settings", "quit"], "blocked_options": [ {"name": "timeline", "enabled": false, "reason": "manual_epoch_reveal_required", "pending_epoch_ids": ["NEOW_EPOCH"]} ] } ``` `options` entries are **plain strings** here. On other screens they are objects `{"name": ..., "enabled": ...}`. Handle both. Observed `menu_screen` values: `main`, `character_select`, `tutorial_prompt`. Mode selection (after the first epoch unlock) offers `standard`, `daily`, `custom`, `back`. `daily` and `custom` are the seeded modes; standard singleplayer exposes no seed. ## `character_select` ```json { "state_type": "menu", "menu_screen": "character_select", "message": "Select a character.", "characters": [{ "name": "The Ironclad", "id": "IRONCLAD", "locked": false, "hp": 80, "gold": 99, "energy": 3, "description": "Ironclad cards will now appear in rewards and shops.", "starting_relics": [{"name": "Burning Blood", "description": "At the end of combat, heal 6 HP."}], "starting_deck": ["Strike","Strike","Strike","Strike","Strike", "Defend","Defend","Defend","Defend","Bash"], "total_cards": 87, "total_relics": 8, "total_potions": 3 }], "options": [{"name": "IRONCLAD", "enabled": true}, {"name": "SILENT", "enabled": false}, {"name": "confirm", "enabled": true}, {"name": "embark", "enabled": true}, {"name": "back", "enabled": true}] } ``` All five characters are listed, with `locked` set per profile. `starting_deck` is the cheapest reliable source of the initial deck for a deck tracker. ## `monster` / `elite` / `boss` ```json { "state_type": "monster", "battle": { "round": 1, "turn": "player", "is_play_phase": true, "enemies": [{ "entity_id": "NIBBIT_0", "combat_id": 1, "name": "Nibbit", "hp": 44, "max_hp": 44, "block": 0, "status": [{"id": "TERRITORIAL_POWER", "name": "Territorial", "amount": 1, "type": "Buff", "description": "At the end of Byrdonis's turn, it gains 1 Strength.", "keywords": [{"name": "Strength", "description": "..."}]}], "intents": [{"type": "Attack", "label": "12", "title": "Aggressive", "description": "This enemy intends to Attack for 12 damage."}] }] }, "run": {"act": 1, "floor": 1, "ascension": 0}, "player": { "character": "The Ironclad", "hp": 80, "max_hp": 80, "block": 0, "energy": 3, "max_energy": 3, "hand": [{ "id": "STRIKE_IRONCLAD", "name": "Strike", "type": "Attack", "cost": "1", "star_cost": null, "description": "Deal 6 damage.", "rarity": "Basic", "is_upgraded": false, "keywords": [], "index": 1, "target_type": "AnyEnemy", "can_play": true, "unplayable_reason": null }], "draw_pile_count": 5, "discard_pile_count": 0, "exhaust_pile_count": 0, "draw_pile": [{"name": "Defend", "cost": "1", "star_cost": null, "description": "Gain 5 Block."}], "discard_pile": [], "exhaust_pile": [], "gold": 99, "status": [], "relics": [], "potions": [], "max_potion_slots": 3 } } ``` Critical details: - **`cost` is a STRING** (`"1"`), not an int. Parse it. Some cards may be `"X"`. - **`intents[].label` carries the damage as a STRING** (`"12"`), and may be `"12x2"` for multi-hit. This is the authoritative number for incoming damage. - **Pile cards carry only `{name, cost, star_cost, description}`** — no `id`, no `type`, no `index`. Deck composition must therefore be counted **by name**, not by type. This caused a bug. - `intents` is absent when the enemy has no next move. - `status` entries are powers: `{id, name, amount, type, description, keywords}`. - `target_type` values seen: `Self`, `AnyEnemy`. - `unplayable_reason` is set when `can_play` is false. ## `rewards` ```json { "state_type": "rewards", "rewards": { "items": [ {"index": 0, "type": "gold", "description": "19 Gold", "gold_amount": 19}, {"index": 1, "type": "potion", "description": "Energy Potion", "potion_id": "ENERGY_POTION", "potion_name": "Energy Potion", "potion_description": "Gain [ironclad_energy_icon.png][ironclad_energy_icon.png]."} ], "can_proceed": true }, "run": {"act": 1, "floor": 1, "ascension": 0}, "player": { "...": "same as combat, but no hand/energy" } } ``` - `items[]` contains **only enabled rewards**, re-indexed from 0 on every claim. Claim **right-to-left**, or index 0 is reclaimed forever. - Reward `type` values: `gold`, `potion`, `relic`, `card`, `special_card`. - `items` can be `[]` while `can_proceed` is true. Then the correct action is `proceed`. ## `card_reward` ```json { "state_type": "card_reward", "card_reward": { "cards": [{"id": "SETUP_STRIKE", "name": "Setup Strike", "type": "Attack", "cost": "1", "star_cost": null, "description": "Deal 7 damage. Gain 2 Strength this turn.", "rarity": "Common", "is_upgraded": false, "keywords": [], "index": 0}], "can_skip": true }, "run": {...}, "player": {...} } ``` **The deck is NOT exposed here.** Deck-building decisions need a composition snapshot taken from a combat state, which does expose all four piles. ## `card_select` ```json { "state_type": "card_select", "card_select": { "screen_type": "upgrade", "prompt": "Choose a card to Upgrade.", "cards": [{"id": "STRIKE_IRONCLAD", "name": "Strike", "type": "Attack", "cost": "1", "description": "Deal 6 damage.", "rarity": "Basic", "is_upgraded": false, "index": 0}], "preview_showing": true, "preview_cards": [ {"name": "Strike", "description": "Deal 6 damage.", "is_upgraded": false}, {"name": "Strike+", "description": "Deal 9 damage.", "is_upgraded": true} ], "can_cancel": true, "can_confirm": true } } ``` `preview_cards` is a before/after pair — useful for showing Jev what the upgrade would actually do. `screen_type` values include `upgrade`. **`select_card` TOGGLES on grid screens.** Calling it twice on one index deselects and freezes the screen. When `preview_showing` is true and `can_confirm` is true, the correct action is `confirm_selection`. ## `event` ```json { "state_type": "event", "event": { "in_dialogue": false, "body": "…event text…", "options": [{"index": 0, "title": "…", "description": "…", "is_locked": false, "is_proceed": false, "was_chosen": false, "relic_name": "…", "relic_description": "…", "keywords": []}] } } ``` Ancient events begin with `in_dialogue: true`, which requires `advance_dialogue` first. Option 0 is frequently locked, which is why a blind `choose_event_option(index=0)` gets rejected. ## `rest_site` ```json { "state_type": "rest_site", "rest_site": { "options": [{"index": 0, "id": "…", "name": "Rest", "description": "…", "is_enabled": true}], "can_proceed": true } } ``` **The field is `name`, not `title`**, plus `id` and `is_enabled`. ## `treasure` ```json { "state_type": "treasure", "treasure": { "relics": [{"index": 0, "id": "…", "name": "…", "description": "…", "rarity": "…", "keywords": []}], "can_proceed": true } } ``` The chest **auto-opens**. While it opens, the response is `{"message": "Opening chest..."}` with **no `relics` key** and no `can_proceed`. Claiming then is rejected. Wait while `relics` is absent. ## `shop` ```json { "state_type": "shop", "shop": { "items": [{"index": 0, "category": "…", "price": 0, "is_stocked": true, "can_afford": true, "name": "…", "description": "…"}], "can_proceed": true } } ``` Items carry `price`, `is_stocked`, and `can_afford` — everything needed to decide a purchase without extra arithmetic on the model side. ## `shop` ```json { "state_type": "shop", "shop": { "items": [ {"index": 0, "category": "card", "price": 72, "is_stocked": true, "can_afford": true, "on_sale": false, "card_id": "STOMP", "card_name": "Stomp", "card_type": "Attack", "card_cost": "3", "card_rarity": "Uncommon", "card_description": "Deal 12 damage to ALL enemies...", "keywords": []}, {"index": 7, "category": "relic", "price": 199, "is_stocked": true, "can_afford": true, "relic_id": "BLOOD_VIAL", "relic_name": "Blood Vial", "relic_description": "At the start of each combat, heal 2 HP."}, {"index": 10, "category": "potion", "price": 48, "is_stocked": true, "can_afford": true, "potion_id": "BLOCK_POTION", "potion_name": "Block Potion", "potion_description": "Gain 12 Block."}, {"index": 13, "category": "card_removal", "price": 75, "is_stocked": true, "can_afford": true} ], "can_proceed": false } } ``` **The name and description fields are category-specific:** | category | name | description | |---|---|---| | `card` | `card_name` | `card_description` | | `relic` | `relic_name` | `relic_description` | | `potion` | `potion_name` | `potion_description` | | `card_removal` | *neither* | *neither* | Reading `name`/`description` yields `None` for every category. `brain.py` resolves them via `shop_item_text()`. A shop can offer **14+ affordable items**. Do not put them all in one `Choice`; see [02](02-system-one-jev.md). **`can_proceed` is unreliable here** — measured `false` while `proceed()` worked. Never wait on it. ## `hand_select` ```json { "state_type": "hand_select", "hand_select": { "mode": "simple_select", "prompt": "Choose any number of cards to replace.", "cards": [], "selected_cards": [{"index": 0, "name": "Defend"}], "can_confirm": true }, "battle": {"...": "..."}, "run": {}, "player": {} } ``` `cards` is what is **still selectable**; `selected_cards` is what is **already chosen**. `combat_select_card(card_index)` indexes into the selectable cards, not the hand. When `cards` is empty, `combat_select_card(0)` fails with `Card index 0 out of range (0 selectable cards)` — the action is `combat_confirm_selection`. ## `bundle_select` ```json { "state_type": "bundle_select", "bundle_select": { "screen_type": "bundle", "prompt": "Choose a bundle.", "bundles": [ {"index": 0, "card_count": 3, "cards": [ {"id": "ANGER", "name": "Anger", "type": "Attack", "cost": "0", "description": "Deal 6 damage. Add a copy of this card into your Discard Pile.", "rarity": "Common", "is_upgraded": false, "keywords": [], "index": 0} ]} ], "preview_showing": true, "preview_cards": [], "can_cancel": true, "can_confirm": true } } ``` Identical preview semantics to `card_select`. `select_bundle` errors with "A bundle preview is already open - confirm or cancel it first". ## Still not captured Documented in `raw-simplified.md` but never observed live, so the field names are unverified: `relic_select`, `crystal_sphere`, `fake_merchant`, `game_over`, `overlay`. `brain.py` parses them defensively for that reason. --- # Index spaces **Five actions take an index. They are not the same index space.** This is the single most dangerous thing in the API, because the parameter names are nearly identical and a wrong index either hits the wrong card or silently fails. | Action | Parameter | Indexes into | Notes | |---|---|---|---| | `play_card` | `card_index` | `combatState.Hand.Cards[N]` | matches `player.hand[N]` | | `combat_select_card` | `card_index` | `hand.ActiveHolders[N]` | **selectable cards only** | | `select_card` | `index` | the grid screen's card holders | matches `card_select.cards[N]` | | `select_card_reward` | `card_index` | the reward screen's holders | matches `card_reward.cards[N]` | | `shop_purchase` | `index` | the merchant inventory | matches `shop.items[N]` | Verified in `McpMod.Actions.cs`: ```csharp // play_card var hand = player.PlayerCombatState?.Hand; var card = hand.Cards[cardIndex]; // combat_select_card var holders = hand.ActiveHolders; if (index < 0 || index >= holders.Count) return Error($"Card index {index} out of range ({holders.Count} selectable cards)"); ``` `play_card` and `combat_select_card` look like the same call. They are **not**. `player.hand` is every card in hand; `ActiveHolders` is only the selectable subset. ## `hand_select` has two index spaces under one name ```json { "cards": [{"index": 0, "name": "Defend"}], "selected_cards": [{"index": 0, "name": "Defend"}] } ``` Both are called `index` and they are built from different arrays: - `cards[]` is built by iterating `hand.ActiveHolders` with a fresh counter, so `cards[i].index == i` and indexes the **selectable** cards. - `selected_cards[]` is built by iterating `selectedHolders` with a **separate** counter, so it indexes an entirely different array. Passing a `selected_cards` index to `combat_select_card` is wrong. This produced the observed failure: ``` action rejected: Card index 0 out of range (0 selectable cards) ``` ## Indices shift, so the loop must be closed - Playing a card removes it from hand and **renumbers every later index**. The mod's own notes say to play right-to-left to keep indices stable, or re-read between plays. - Claiming a reward **rebuilds and renumbers** the rewards list. Claim right-to-left. - The shop renumbers after a purchase. **Rule:** observe, act ONCE, observe again. Never precompute an action list. ## The lesson The original card-selection bug was **not** a wrong index. The index field was correct. The bug was choosing an index by **list position** while ignoring the card's own context — `name`, `type`, `rarity`, `is_upgraded` — which was present in the state the whole time. Since the grid lists basic Strikes first, position 0 was always a Strike, so the bot only ever upgraded Strikes. Selection must be by **identity**, and the index is only the handle used to address that identity.