sts2-bot/docs/research/08-what-actually-wins.md
0xrsydn c00cfe78c3 docs(research): what actually wins in Slay the Spire 2
Two findings, both from measurement rather than the public guides.

1. The public STS2 meta is not usable as evidence. Deck-size targets disagree by
3x across five sources (12-18 to 25-35), and one tier list names Demon Form,
Barricade, Limit Break and Body Slam as STS2 Ironclad cards -- those are Slay the
Spire 1 cards, so that page is an STS1 list retitled. Only Offering and Break
appear on more than one list.

2. Our own run files are far better evidence, and they contain 1052 labelled
decisions. They show the bot is NOT bloating (249 picks from 1087 offers = 23%
per card, ~69% per reward), that 81% of runs die in Act 1, and that 43% die to an
Act 1 boss with `THE_KIN_BOSS` alone killing 9.

The binding constraint is combat defence, not deck composition: those Kin fights
ran 5-10 turns and cost 44-80 HP with block cards in hand the whole time.
2026-09-22 06:09:16 +07:00

244 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 08 — What actually wins (and why the web meta is not usable)
Status: **current**. Supersedes the assumption in `06-decision-architecture.md`
that deck composition is the primary lever.
## Question
Before tuning decision quality, establish what actually decides a run in STS2:
deck archetype, deck size ("lean weight"), card rarity, or something else.
## Finding 1 — the public STS2 "meta" is not usable as evidence
Searched for deck archetypes, tier lists and Act 1 advice. The sources
contradict each other badly, and at least one is a copy of a Slay the Spire 1
list relabelled.
Deck size target, by source:
| Source | Deck size target |
|---|---|
| metabot.gg | 1218 |
| gamestrategyhub | 1520 |
| games.gg | ~22 |
| slashskill | 2530 |
| sts2guides | 2535 |
That is a 3x range. A real meta does not disagree with itself by 3x.
Ironclad S-tier, by source:
| Source | S-tier |
|---|---|
| slaythespire2.space | Demon Form, Barricade, Dark Embrace, Limit Break, Body Slam, Whirlwind |
| spireforge | Barricade, Cruelty, Dark Embrace |
| stratgg | Offering, Pyre, Battle Trance |
| metabot.gg | Apotheosis, Secret Weapon, Mad Science, Folly, Tank |
| nat1gaming | Offering, Break, Feed |
**`slaythespire2.space` lists Demon Form, Barricade, Limit Break, Body Slam and
Whirlwind as STS2 Ironclad cards. Those are Slay the Spire 1 cards.** That page
is an STS1 tier list with the title changed.
Only two cards — **Offering** and **Break** — appear on more than one list.
Conclusion: these are AI-generated SEO pages. Do not build a scoring model on
them. The one idea they agree on is qualitative and obvious ("commit to one
archetype, keep the deck lean"), which we cannot operationalise without numbers.
## Finding 2 — our own run history is far better evidence
37 runs on disk, all reachable and machine-readable:
```
~/Library/Application Support/SlayTheSpire2/steam/<id>/modded/profile1/saves/history/*.run
```
Each file records `killed_by_encounter`, `win`, the full deck, relics, and
`map_point_history` — a per-map-point log that includes **every card offered
with a `was_picked` flag**, plus per-fight `damage_taken`, `potion_used`,
`rest_site_choices` and `upgraded_cards`.
That is a labelled dataset of our own drafting and combat outcomes.
### Outcome distribution
```
runs=37 wins=0 max_pts=33
act1 deaths: 30/37 (81%)
act2 deaths: 4/37
act3 deaths: 3/37
```
| Killed by | Count |
|---|---|
| `THE_KIN_BOSS` | **9** |
| `VANTOM_BOSS` | 5 |
| `CEREMONIAL_BEAST_BOSS` | 2 |
| Act 1 normals/elites | 21 |
**43% of runs die to an Act 1 boss. `THE_KIN_BOSS` alone kills 9 of 37.** That
is the single largest lever in the dataset.
### The bot is not bloating
```
total offers=1087 total picks=249 overall pick rate=23%
rewards=323 rewards where a card was taken=249 -> 77% taken, 23% skipped
```
**Descriptive, not a benchmark.** The per-card rate (23%) is mechanically
diluted by offer size — 280 of 323 rewards offered 3 cards, some offered up to 7
— so it cannot diagnose skip policy on its own. The metric that can is the share
of rewards taken (77%) and, below it, cards added per map point. We have no
reference for what a good skip rate is in STS2, so this says what the bot does,
not whether it is right.
The "deck bloat" hypothesis is **not supported**: 23% of rewards were skipped,
one card was taken per reward at most, and 81% of runs never reach the point
where deck composition matters.
### Drafting profile (offers / picks)
| Card | Seen | Took | Pick rate |
|---|---|---|---|
| `PERFECTED_STRIKE` | 17 | 17 | 100% |
| `EVIL_EYE` | 19 | 12 | 63% |
| `POMMEL_STRIKE` | 26 | 14 | 54% |
| `SHRUG_IT_OFF` | 30 | 16 | 53% |
| `ARMAMENTS` | 39 | 19 | 49% |
| `TREMBLE` | 45 | **0** | **0%** |
| `RUPTURE` | 18 | 0 | 0% |
| `FIGHT_ME` | 17 | 0 | 0% |
| `SWORD_BOOMERANG` | 16 | 0 | 0% |
`TREMBLE` is the most-offered card in the game and the bot has never once taken
it. Worth auditing, but it is not the binding constraint.
## Finding 3 — the binding constraint is combat defense, not deck building
Every `THE_KIN_BOSS` fight, from the run files:
```
damage taken: [44, 50, 50, 53, 63, 64, 70, 75, 80]
potions used: [3, 3, 2, 1, 3, 3, 2, 3, 2]
turns: [6, 9, 10, 6, 6, 10, 9, 7, 5]
deck size: [17, 18, 19, 19, 20, 20, 21, 22, 23]
```
The bot enters at ~7480 HP and dies. Fights last **510 turns** and it takes
**4480 damage**, i.e. **~1013 per turn, almost entirely unblocked**. It spends
13 potions and still loses.
Every one of those decks contained block cards:
```
DEFEND_IRONCLAD 9/9
BASH 9/9
STRIKE_IRONCLAD 9/9
```
So the cards are there. The bot does not play them.
### Root cause A — the fallback treats a boss's main attack as "chip damage"
`facts.py`:
```python
THREAT_CHIP = "chip" # <= 15% of max HP
THREAT_HEAVY = "heavy" # <= 40% of max HP
HP_HEALTHY = "healthy" # > 60%
```
At `max_hp = 80` that gives `CHIP <= 12`, and `HEALTHY` for `hp > 48`.
`brain.py::_fallback_combat`:
```python
must_respect = (
f.threat in (F.THREAT_HEAVY, F.THREAT_SEVERE, F.THREAT_LETHAL)
or f.hp_bucket in (F.HP_WOUNDED, F.HP_CRITICAL)
)
```
So at 74/80 HP, any hit of **12 or less is ignored**. Measured, reproduced:
```
hp incoming threat hp_bucket fallback play
74 12 chip healthy Bash (dmg) <- takes the full 12
74 13 heavy healthy Defend (block)
60 12 chip healthy Bash (dmg) <- still attacking
45 12 chip wounded Defend (block) <- too late, 29 HP gone
```
The boss's main attack lands exactly on the `CHIP`/`HEAVY` boundary, so the bot
attacks and eats it. It only starts blocking once HP drops below 48 — after
donating ~29 HP.
The model is **myopic**: it asks "is this turn's hit big?" when the correct
question is "will the total damage over the remaining fight kill me?".
### Root cause B — `should_defend` is asked and then discarded
`brain.py::_jev_combat` builds this question:
```python
questions["should_defend"] = noul(
"Given `combat.incoming_threat` and `combat.your_health`, "
"is preventing damage more valuable than dealing damage this turn?"
)
```
It appears **exactly once in the entire codebase** — at the line where it is
created. It is never read.
```
$ grep -rn 'should_defend' *.py
brain.py:278: questions["should_defend"] = noul(
```
So it costs latency in every combat turn and does nothing.
### Root cause C — the surviving question is damage-biased
Defense is therefore delegated entirely to:
```python
questions["best_play"] = choice(
"Which single play best advances winning this fight?", options)
```
"Advances winning" biases toward damage, and a `Choice` over 58 cards is the
question shape measured in `02-system-one-jev.md` as **diluting with option
count**.
Measured directly against the Kin state (74/80 HP, 12 incoming, turn 6):
```
$ python3 -c "... brain._jev_combat(f, jev.JevClient()) ..."
play_card {'card_index': 4, 'target': 'kin'} | jev chose Bash | conf: 0.42
-> Bash DAMAGE
```
Jev picks **Bash** at confidence **0.42**. That is below the 0.45 gate, so
`_jev_combat` bails to `_fallback_combat`, which also picks damage. **Both
paths agree on the wrong answer.**
## Conclusion
The bot has no defense policy. In boss fights it attacks until it dies, and the
data shows exactly that: 510 turn fights, 1013 unblocked damage per turn,
4480 damage taken, 9 runs ended by one boss.
Priority order for the next work:
1. **Make defense a first-class, code-computed decision.** Not a Jev preference.
The quantity that matters is total incoming damage over the expected
remaining fight, not this turn's hit.
2. **Fix or delete `should_defend`.** A dead question that costs latency is
worse than no question.
3. **Only then** revisit deck composition. 81% of runs never reach the point
where it matters.
Deck size, rarity and archetype are real concerns in Slay the Spire, but they
are not what is killing this bot right now.