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

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import sys
import brain
import facts
import sts2
PASS = 0
@ -805,6 +806,16 @@ check("the required count is parsed from the prompt",
brain.card_select_need("Choose 5 cards to Remove."), 5)
check("single-select defaults to 1",
brain.card_select_need("Choose a card to Upgrade."), 1)
# The number is not always immediately followed by "cards":
# "Choose 2 Common Cards to Add to Your Deck." has "Common" in between. A
# stricter pattern returned 1 and stalled the screen waiting for a confirm.
check("the count survives an adjective",
brain.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2)
check("an ADD screen is not an upgrade screen",
brain.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."),
"add")
check("a REMOVE prompt beats a generic screen_type",
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove")
multi_state = {
"state_type": "card_select",
@ -820,6 +831,26 @@ seq = [brain.decide(multi_state, None).action for _ in range(6)]
check("multi-select picks 5 then waits",
seq, ["select_card"] * 5 + ["__wait__"])
# An ADD screen: 2 cards, then the game closes the screen by itself (no confirm).
force_reset()
add_state = {
"state_type": "card_select",
"card_select": {"screen_type": "simple_select",
"prompt": "Choose 2 Common Cards to Add to Your Deck.",
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
"preview_showing": False,
"can_cancel": False, "can_confirm": False},
"run": {"act": 1, "floor": 5}, "player": player(),
}
add_seq = [brain.decide(add_state, None).action for _ in range(4)]
check("an ADD screen picks exactly 2 then waits",
add_seq, ["select_card", "select_card", "__wait__", "__wait__"])
# Distinct picks need a fresh screen, or the picks continue from above.
force_reset()
add_idxs = [brain.decide(add_state, None).params.get("index") for _ in range(2)]
check("ADD picks are distinct", len(set(add_idxs)), 2)
# Every pick must be a DIFFERENT index, or select_card toggles it back off.
force_reset()
idxs = [brain.decide(multi_state, None).params.get("index") for _ in range(5)]
@ -955,6 +986,106 @@ check("...and names the pending epoch", "IRONCLAD5_EPOCH" in (blocked or ""), Tr
check("preflight ignores non-menu states",
run_module.preflight({"state_type": "monster"}), None)
print()
print("=== defense is decided in CODE, before Jev ===")
# THE_KIN_BOSS killed 9 of 37 runs: 5-10 turn fights, 44-80 HP lost, ~10-13 a
# turn, with block cards in hand. Both old paths preferred damage -- the
# fallback called a 12-damage hit at 80 max HP "chip", and Jev, asked "which
# play best advances winning this fight?", chose Bash at 0.42 confidence.
kin_hand = [
card("Strike", index=0),
card("Strike", index=1),
card("Defend", index=2, desc="Gain 5 Block.", ctype="Skill", target="Self"),
card("Defend", index=3, desc="Gain 5 Block.", ctype="Skill", target="Self"),
]
def blk(obs):
"""The block value of whatever combat_decision picks, or None."""
d = brain.combat_decision(facts.combat_facts(obs), None)
if d.action != "play_card":
return d.action
return facts.block_value(facts.combat_facts(obs).hand[d.params["card_index"]])
boss = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=74)
check("Kin turn at 74/80 HP forces a BLOCK", blk(boss) > 0, True)
boss60 = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=60)
check("...and still blocks at 60/80", blk(boss60) > 0, True)
# The rule must NOT fire on a short fight, or the bot stops front-loading.
short = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=15, intent=6)], hp=74)
check("a 15 HP enemy does not force a block", blk(short), 0)
quiet = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=140, intent=0)], hp=74)
check("nothing incoming does not force a block", blk(quiet), 0)
# Defense must never pre-empt lethal.
lethal = combat(hand=kin_hand + [card("Bludgeon", index=4, cost="3", desc="Deal 32 damage.")],
enemies=[enemy("KIN_0", hp=20, intent=12)], hp=74)
d = brain.combat_decision(facts.combat_facts(lethal), None)
check("lethal still beats blocking",
facts.combat_facts(lethal).hand[d.params["card_index"]]["name"], "Bludgeon")
# The dead question must stay dead. Assert on the QUESTION being built, not
# on the string: the replacement comment legitimately names it.
check("the should_defend question is no longer asked",
'questions["should_defend"]' in open("brain.py").read(), False)
check("...and nothing reads it either",
"response.get(\"should_defend\")" in open("brain.py").read(), False)
print()
print("=== relic_select reads the ids it asked for (regression) ===")
# The questions are keyed `good_relicN`. Ranking on `relicN` found nothing, so
# `best_by_noul` returned (None, 0.0) for every state and the path could only
# ever take the rarest relic -- every answer Jev gave was silently dropped.
# The highest-rated relic here is deliberately the COMMON one, so a rarity
# fallback cannot produce the expected answer by accident.
relic_obs = {
"state_type": "relic_select",
"relic_select": {
"can_skip": False,
"relics": [
{"index": 0, "name": "Common Relic", "rarity": "Common",
"description": "Does something small."},
{"index": 1, "name": "Rare Relic", "rarity": "Rare",
"description": "Does something large."},
],
},
"player": {"character": "The Ironclad"},
}
relic_stub = StubClient(noul=0.30,
noul_override={"good_relic0": 0.80, "good_relic1": 0.40})
d = brain.relic_select_decision(relic_obs, relic_stub)
check("relic_select acts on Jev's answer", d.source, "jev")
check("...and takes the relic Jev rated highest, not the rarest",
d.params.get("index"), 0)
print()
print("=== the lethal search honours enemy block and player statuses ===")
# `effective_hp` is hp + block and the search is raw damage, so block is
# subtracted once. Subtracting it in both places needed hp + 2*block and threw
# away real kills; and the executor used to pass `[]` for the statuses, so
# facts could report lethal while the code meant to execute it found nothing.
blocked = combat(hand=[card(index=n) for n in range(3)],
enemies=[enemy("E_0", hp=10, block=5)])
bf = facts.combat_facts(blocked)
check("18 raw vs 10 hp + 5 block is lethal", bf.lethal_available, True)
check("...and the executor finds the line",
bool(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])),
True)
strong = combat(hand=[card(index=n) for n in range(2)],
enemies=[enemy("E_0", hp=18, intent=0)],
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
sf = facts.combat_facts(strong)
check("the executor sees the player's Strength",
[c["name"] for c in (brain._lethal_line(sf.playable, sf.energy,
sf.player_status, sf.enemies[0]) or [])],
["Strike", "Strike"])
check("...where an empty status list finds nothing",
brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0]), None)
print()
print(f"=== {PASS} passed, {FAIL} failed ===")
sys.exit(1 if FAIL else 0)