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.
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:
costis a STRING ("1"), not an int. Parse it. Some cards may be"X".intents[].labelcarries 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}— noid, notype, noindex. Deck composition must therefore be counted by name, not by type. This caused a bug. intentsis absent when the enemy has no next move.statusentries are powers:{id, name, amount, type, description, keywords}.target_typevalues seen:Self,AnyEnemy.unplayable_reasonis set whencan_playis 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
typevalues:gold,potion,relic,card,special_card. itemscan be[]whilecan_proceedis true. Then the correct action isproceed.
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 iteratinghand.ActiveHolderswith a fresh counter, socards[i].index == iand indexes the selectable cards.selected_cards[]is built by iteratingselectedHolderswith 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.