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

@ -158,7 +158,44 @@ check("playable count excludes locked", len(f.playable), 3)
check("lethal still reachable", f.lethal_available, True)
print()
print("=== 6. real captured state ===")
print("=== 6. fight-length projection (the THE_KIN_BOSS fix) ===")
# THE_KIN_BOSS killed 9 of 37 runs. Those fights ran 5-10 turns and cost
# 44-80 HP, ~10-13 a turn, with block cards in hand. At 80 max HP a hit of
# <=12 was classed CHIP and ignored, so the bot attacked until it died.
kin_hand = [
card("Strike", 1, "Deal 6 damage.", index=0),
card("Strike", 1, "Deal 6 damage.", index=1),
card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self", index=2),
card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self", index=3),
]
kin = observation(kin_hand, [enemy("KIN_0", 140, intent_damage=12)], hp=74)
f = facts.combat_facts(kin)
check("12 damage at 80 max HP is still 'chip'", f.threat, "chip")
check("74/80 HP is 'healthy'", f.hp_bucket, "healthy")
check("...yet the fight is long", f.turns_to_kill > 5, True)
check("...so projected damage is large", f.projected_incoming > f.affordable_loss, True)
check("-> the bot MUST block", f.must_block, True)
check("-> block is urgent", f.block_urgent, True)
# The same rule must stay silent on a short fight, or the bot would never
# front-load damage again.
trivial = observation(kin_hand, [enemy("NIBBIT_0", 15, intent_damage=6)], hp=74)
g = facts.combat_facts(trivial)
check("short fight: projection is small", g.projected_incoming <= g.affordable_loss, True)
check("short fight: no forced block", g.must_block, False)
check("short fight: not urgent", g.block_urgent, False)
no_incoming = observation(kin_hand, [enemy("NIBBIT_0", 140)], hp=74)
h = facts.combat_facts(no_incoming)
check("nothing incoming: never blocks", h.must_block, False)
check("nothing incoming: not urgent", h.block_urgent, False)
hurt = observation(kin_hand, [enemy("NIBBIT_0", 140, intent_damage=12)], hp=30)
i = facts.combat_facts(hurt)
check("low HP forces block even in a short fight", i.block_urgent, True)
print()
print("=== 7. real captured state ===")
real = pathlib.Path("capture/09_now.json")
if real.exists():
f = facts.combat_facts(json.loads(real.read_text()))
@ -173,6 +210,41 @@ if real.exists():
else:
print(" (no capture/09_now.json, skipped)")
print()
print("=== 8. enemy block is subtracted ONCE (regression) ===")
# `effective_hp` is hp + block, and `_subset_damage` returns RAW damage. An
# earlier version subtracted block in both places, so a kill needed hp + 2*block
# and real lethal lines were discarded whenever an enemy played Defend.
blocked_hand = [card("Strike", 1, "Deal 6 damage.", index=n) for n in range(3)]
blocked = observation(blocked_hand, [enemy("E_0", 10, block=5)])
b = facts.combat_facts(blocked)
check("raw subset damage is not block-adjusted",
facts._subset_damage(b.playable, 3, [], b.enemies[0]), 18)
check("18 raw vs 10 hp + 5 block is lethal", b.lethal_available, True)
check("...and the enemy is listed killable", b.killable, ["E_0"])
# The same rule with block that genuinely absorbs the kill must stay false.
survives = observation(blocked_hand, [enemy("E_0", 20, block=5)])
s = facts.combat_facts(survives)
check("18 raw vs 20 hp + 5 block is not lethal", s.lethal_available, False)
check("...and nothing is listed killable", s.killable, [])
print()
print("=== 9. player statuses reach the lethal search (regression) ===")
# The executor used to call the search with `[]`, so facts could report
# `lethal_available: true` while the code meant to execute the kill found
# nothing -- Strength and Weak were invisible to it.
strong_hand = [card("Strike", 1, "Deal 6 damage.", index=n) for n in range(2)]
strong = observation(strong_hand, [enemy("E_0", 18)],
status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
st = facts.combat_facts(strong)
check("Strength is carried on the facts", st.player_status[0]["name"], "Strength")
check("12+12 raw damage vs 18 hp is lethal", st.lethal_available, True)
check("ignoring the statuses misses it",
facts._subset_damage(st.playable, st.energy, [], st.enemies[0]), 12)
check("using them finds it",
facts._subset_damage(st.playable, st.energy, st.player_status, st.enemies[0]), 24)
print()
print(f"=== {PASS} passed, {FAIL} failed ===")
sys.exit(1 if FAIL else 0)