fix(combat): block on projected fight damage, and three lethal-search defects

Mining the 37 run files showed 81% of runs (30/37) die in Act 1, and 43% to an
Act 1 boss. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10
turns and cost 44-80 HP -- about 10-13 a turn, with block cards in hand the
whole time. Four defects, fixed here together because they were found and
verified as one combat-correctness pass.

1. NO DEFENCE POLICY (the big one, found by mining the data)

   facts classes a hit of <=15% of max HP as THREAT_CHIP. At 80 max HP that is
   12, exactly what the boss deals, and _fallback_combat only blocked for HEAVY
   or worse while HP was HEALTHY (>60%). So at 74/80 HP the bot attacked through
   the boss's main attack and only started blocking below 48 HP.

   _jev_combat also asked a should_defend Noul on every combat turn and never
   read it -- grep -rn should_defend returned one line, the one creating it.
   Defence therefore fell to choice("Which single play best advances winning
   this fight?"), which is damage-biased: on the real Kin state Jev answered
   Bash at 0.42 confidence, below the 0.45 gate, so it fell through to the
   fallback, which also chose damage. Both paths agreed on the wrong answer.

   Blocking is arithmetic, so it is now decided in code before Jev is asked,
   using turns_to_kill, projected_incoming, affordable_loss and must_block /
   block_urgent. Measured against all 600 real combat captures, the rule changes
   8 of 68 in-play turns (11.8%) and stays silent on short fights and when
   nothing is incoming.

2. ENEMY BLOCK COUNTED TWICE IN THE LETHAL SEARCH

   `total - max(0, enemy.block) >= enemy.effective_hp` subtracts block a second
   time, because effective_hp is already hp + block. A 10 hp / 5 block enemy
   against 18 raw damage read as "not lethal" and real kills were discarded.

3. THE LETHAL EXECUTOR IGNORED PLAYER STATUSES

   `_lethal_line(f.playable, f.energy, [], enemy)` passed an empty status list,
   so facts reported lethal_available: true while the code meant to execute the
   kill found nothing. CombatFacts.player_status is now passed through.

4. RELIC_SELECT ASKED good_relicN AND READ relicN

   Every answer missed, so best_by_noul returned (None, 0.0) for every state and
   the path could only ever take the rarest relic.

Tests: 50 in test_facts.py and 131 in test_brain.py, with a regression case for
each -- a blocked enemy, a Strength-carrying player, a relic offer whose
highest-rated relic is deliberately the common one so the rarity fallback cannot
pass by accident, and the Kin turn itself.
This commit is contained in:
0xrsydn 2026-09-22 06:06:06 +07:00
commit 471c77b353
4 changed files with 474 additions and 35 deletions

115
brain.py
View file

@ -112,7 +112,10 @@ def _lethal_line(playable: list[dict], energy: int, player_status: list,
if F.power_amount(F.enemy_status_names(enemy), "Vulnerable"):
card_total = int(card_total * 1.5)
total += card_total
if total - max(0, enemy.block) >= enemy.effective_hp:
# `total` is raw damage; `effective_hp` is hp + block, so the block
# is subtracted exactly once. Subtracting it here as well required
# hp + 2*block and discarded real lethal lines.
if total >= enemy.effective_hp:
if best is None or len(cards) < len(best):
best = cards
if best:
@ -154,15 +157,16 @@ def _fallback_combat(f: F.CombatFacts) -> Decision:
return Decision("end_turn", {}, "no playable cards", "fallback")
# Only spend energy on block when the hit actually matters. At full health
# against a small hit, front-loading damage is better: HP is a resource.
# (STS2MCP strategy notes: "HP is a resource, not a score",
# "Front-load damage", "Don't waste energy on block when enemies aren't attacking".)
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)
)
# against a small hit in a SHORT fight, front-loading damage is better:
# HP is a resource. (STS2MCP strategy notes: "HP is a resource, not a
# score", "Front-load damage".)
#
# The old test was `threat in (HEAVY, SEVERE, LETHAL) or hp in (WOUNDED,
# CRITICAL)`, which ignored any hit of <=12 at 80 max HP. That is how the
# bot lost 9 runs to THE_KIN_BOSS. `block_urgent` adds the fight-length
# view: a small hit that repeats for 10 turns is not a small hit.
blockers = [c for c in playable if _block_value(c) > 0]
if blockers and must_respect:
if blockers and f.block_urgent:
best = max(blockers, key=lambda c: _block_value(c))
return Decision("play_card", _target_params(best, f),
f"threat={f.threat} hp={f.hp_bucket}, take block",
@ -275,10 +279,12 @@ def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision:
},
)
questions["should_defend"] = noul(
"Given `combat.incoming_threat` and `combat.your_health`, "
"is preventing damage more valuable than dealing damage this turn?"
)
# NOTE: there used to be a `should_defend` Noul here. It was asked on every
# combat turn and never read -- `grep -rn should_defend` returned only the
# line that created it -- so it cost latency and did nothing. The defense
# decision is now made in code before Jev is consulted (see
# `combat_decision`), and the fight-length facts it needs are in
# `combat.fight_is_grinding` / `combat.this_turn_is_dangerous`.
response = client.ask(f.to_state(), questions)
@ -368,7 +374,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
# 1. Deterministic lethal.
for enemy in f.enemies:
line = _lethal_line(f.playable, f.energy, [], enemy)
line = _lethal_line(f.playable, f.energy, f.player_status, enemy)
if line:
card = line[0]
params = _target_params(card, f, force_target=enemy.entity_id)
@ -377,7 +383,32 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
f"lethal line on {enemy.entity_id} ({len(line)} cards)", "code",
)
# 2. Jev for preference, 3. heuristic if it is unsure.
# 2. Defense, decided in CODE and taken before Jev is asked.
#
# Measured, this was the single biggest hole. `THE_KIN_BOSS` ended 9 of 37
# runs; those fights lasted 5-10 turns and cost 44-80 HP, ~10-13 a turn,
# with block cards in hand the whole time. Both decision paths preferred
# damage: the fallback classed a 12-damage hit at 80 max HP as "chip", and
# Jev, asked "which play best advances winning this fight?", chose Bash at
# 0.42 confidence.
#
# Blocking is a fact about arithmetic -- total incoming over the remaining
# fight versus the HP we can spare -- so it belongs here, not in a
# preference judgement. Jev is not asked to make it.
if f.block_urgent:
blockers = [c for c in f.playable if F.block_value(c) > 0]
if blockers:
# Block hardest first; a single Defend is still better than a Bash.
best = max(blockers, key=lambda c: F.block_value(c))
return Decision(
"play_card",
_target_params(best, f),
f"defense forced: {f.projected_incoming} projected vs "
f"{f.affordable_loss} affordable ({f.turns_to_kill} turns)",
"code",
)
# 3. Jev for preference, 4. heuristic if it is unsure.
if client is not None:
try:
return _jev_combat(f, client)
@ -449,7 +480,7 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
(player.get("hp") or 0) / (player.get("max_hp") or 1),
player.get("hp"),
),
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"offered": {
f"card{c['index']}": {
"name": c["name"],
@ -592,7 +623,12 @@ def relic_select_decision(obs: dict, client: JevClient | None) -> Decision:
return Decision("skip_relic_selection", {},
f"jev: skip (noul={wants.noul:.2f})", "jev", wants.noul)
best_key, best_noul = best_by_noul(response, keys, CARD_PICK_THRESHOLD)
# The questions are keyed `good_relicN`, so the ranking MUST read the
# prefixed ids. Reading `relicN` found nothing, `best_by_noul` returned
# (None, 0.0) for every state, and this path could only ever take the
# rarest relic -- every answer Jev gave was silently dropped.
best_key, best_noul = best_by_noul(response, [f"good_{k}" for k in keys],
CARD_PICK_THRESHOLD)
if best_key is None:
chosen = rarity_pick()
return Decision("select_relic", {"index": chosen.get("index", 0)},
@ -600,7 +636,8 @@ def relic_select_decision(obs: dict, client: JevClient | None) -> Decision:
f"took the rarest ({chosen.get('name')})",
"fallback", best_noul or None)
chosen = next((r for r in relics if f"relic{r.get('index', 0)}" == best_key), None)
chosen = next((r for r in relics
if f"good_relic{r.get('index', 0)}" == best_key), None)
if chosen is None:
chosen = rarity_pick()
return Decision("select_relic", {"index": chosen.get("index", 0)},
@ -682,7 +719,7 @@ def map_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Deci
"character": player.get("character"),
"health": F._hp_bucket(hp_pct, hp),
"gold": gold,
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"act_boss": boss.get("name") if isinstance(boss, dict) else None,
}
@ -742,17 +779,18 @@ def removal_rank(card: dict) -> int:
def screen_kind(screen: str, prompt: str = "") -> str:
"""
Normalise a card_select screen to one of: upgrade, remove, transform.
Normalise a card_select screen to one of: upgrade, remove, transform, add.
Two traps, both measured:
Three traps, all measured:
* The mod maps only four screens to friendly names and falls through to
the RAW C# CLASS NAME for everything else -- e.g.
"NDeckEnchantSelectScreen".
* `screen_type` can be the generic "select" while the PROMPT says what is
actually happening. Measured: screen_type "select" with prompt
"Choose 5 cards to Remove." was treated as an upgrade, so Jev was asked
"would upgrading this make the deck stronger?" on a REMOVAL screen and
offered to remove Bash.
* `screen_type` can be the generic "select"/"simple_select" while the
PROMPT says what is actually happening. Measured: "Choose 5 cards to
Remove." was treated as an upgrade, so Jev was asked "would upgrading
this make the deck stronger?" on a REMOVAL screen and offered to remove
Bash.
* "Choose 2 Common Cards to Add to Your Deck." is neither: it is ADDING.
So the prompt is consulted too.
"""
@ -761,6 +799,10 @@ def screen_kind(screen: str, prompt: str = "") -> str:
return "remove"
if "transform" in text:
return "transform"
if "add to your deck" in text or "add to your deck" in text:
return "add"
if "add" in text and "deck" in text:
return "add"
# upgrade, smith, enchant: pick the card that benefits most.
return "upgrade"
@ -771,6 +813,9 @@ def card_select_fallback(cards: list[dict], screen: str,
kind = screen_kind(screen, prompt)
if kind in ("remove", "transform"):
return min(cards, key=removal_rank)
if kind == "add":
# Adding: take the rarest card on offer.
return max(cards, key=lambda c: RARITY_RANK.get(str(c.get("rarity")), 1))
return min(cards, key=upgrade_rank)
@ -791,8 +836,12 @@ def card_select_need(prompt: str) -> int:
Measured: "Choose 5 cards to Remove." with can_confirm FALSE until all five
are picked, and `card_select` exposes NO `selected_cards` field. So the
count is parsed from the prompt and tracked by us.
The pattern must not assume the word "cards" directly follows the number:
"Choose 2 Common Cards to Add to Your Deck." has "Common" in between, and a
stricter pattern silently returned 1 and stalled the screen.
"""
match = re.search(r"choose\s+(\d+)\s+cards?", str(prompt or ""), re.IGNORECASE)
match = re.search(r"choose\s+(\d+)", str(prompt or ""), re.IGNORECASE)
return int(match.group(1)) if match else 1
@ -868,6 +917,10 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None)
f"Would transforming `cards.{key}` into a random card make "
"this deck stronger?"
)
elif kind == "add":
questions[f"good_{key}"] = noul(
f"Would adding `cards.{key}` to this deck make it stronger?"
)
else:
questions[f"good_{key}"] = noul(
f"Would upgrading `cards.{key}` make this deck stronger?"
@ -882,7 +935,7 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None)
{
"prompt": prompt,
"screen_type": screen,
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"cards": {
f"card{c['index']}": {
"name": c["name"],
@ -1136,7 +1189,7 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
player.get("hp"),
),
"gold": gold,
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"items": {
key: {
"name": shop_item_text(item)[0],
@ -1218,7 +1271,7 @@ def treasure_decision(obs: dict, client: JevClient | None,
(player.get("hp") or 0) / (player.get("max_hp") or 1),
player.get("hp"),
),
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"relics": {
f"relic{r.get('index', 0)}": {
"name": r.get("name"),
@ -1364,7 +1417,7 @@ def bundle_select_decision(obs: dict, client: JevClient | None,
response = client.ask(
{
"prompt": bs.get("prompt"),
"deck_composition": deck or "unknown",
"deck_composition": F.deck_context(deck),
"bundles": {
f"bundle{b.get('index', 0)}": {
"cards": [c.get("name") for c in (b.get("cards") or [])]