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.
This commit is contained in:
0xrsydn 2026-09-21 17:09:43 +07:00
commit fb32822468
9 changed files with 2341 additions and 0 deletions

View file

@ -0,0 +1,430 @@
# 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.