sts2-bot/docs/research/04-state-shapes.md
0xrsydn fb32822468 Add design doc and research notes
DESIGN.md covers the three-layer architecture (facts in code, Jev for
tactics, gated escalation for macro). research/ documents the engine and
mod surface, the Jev classifier's measured behavior, the STS2MCP HTTP
interface, state shapes, failure modes, decision architecture, and a
run log of the first four sessions.
2026-09-22 00:01:22 +07:00

14 KiB

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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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.

can_proceed is unreliable here — measured false while proceed() worked. Never wait on it.

hand_select

{
  "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

{
  "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:

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

{
  "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.