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.
430 lines
14 KiB
Markdown
430 lines
14 KiB
Markdown
# 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.
|