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.
250 lines
9.6 KiB
Python
250 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
test_facts.py -- regression tests for the fact layer.
|
|
|
|
The first case is the exact scenario where jev-1.13.0 failed: it answered
|
|
"is lethal available?" as 0.79 YES when the true answer was NO. facts.py
|
|
must get this right, because Jev is never allowed to do this arithmetic.
|
|
|
|
Run: python3 test_facts.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
import facts
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
|
|
|
|
def check(label: str, got, want) -> None:
|
|
global PASS, FAIL
|
|
if got == want:
|
|
PASS += 1
|
|
print(f" ok {label}: {got!r}")
|
|
else:
|
|
FAIL += 1
|
|
print(f" FAIL {label}: got {got!r}, want {want!r}")
|
|
|
|
|
|
def card(name, cost, description, ctype="Attack", target="AnyEnemy", index=0, can_play=True):
|
|
return {
|
|
"id": name.upper(),
|
|
"name": name,
|
|
"type": ctype,
|
|
"cost": str(cost),
|
|
"star_cost": None,
|
|
"description": description,
|
|
"rarity": "Basic",
|
|
"is_upgraded": False,
|
|
"keywords": [],
|
|
"index": index,
|
|
"target_type": target,
|
|
"can_play": can_play,
|
|
"unplayable_reason": None,
|
|
}
|
|
|
|
|
|
def enemy(entity_id, hp, max_hp=None, block=0, intent_damage=0, status=None):
|
|
intents = []
|
|
if intent_damage:
|
|
intents = [{
|
|
"type": "Attack",
|
|
"label": str(intent_damage),
|
|
"title": "Aggressive",
|
|
"description": f"This enemy intends to Attack for {intent_damage} damage.",
|
|
}]
|
|
return {
|
|
"entity_id": entity_id,
|
|
"combat_id": 1,
|
|
"name": entity_id.split("_")[0],
|
|
"hp": hp,
|
|
"max_hp": max_hp or hp,
|
|
"block": block,
|
|
"status": status or [],
|
|
"intents": intents,
|
|
}
|
|
|
|
|
|
def observation(hand, enemies, energy=3, hp=80, max_hp=80, block=0, status=None):
|
|
return {
|
|
"state_type": "monster",
|
|
"battle": {
|
|
"round": 1,
|
|
"turn": "player",
|
|
"is_play_phase": True,
|
|
"enemies": enemies,
|
|
},
|
|
"run": {"act": 1, "floor": 1, "ascension": 0},
|
|
"player": {
|
|
"character": "The Ironclad",
|
|
"hp": hp,
|
|
"max_hp": max_hp,
|
|
"block": block,
|
|
"energy": energy,
|
|
"max_energy": energy,
|
|
"status": status or [],
|
|
"hand": hand,
|
|
"draw_pile": [],
|
|
"draw_pile_count": 0,
|
|
"discard_pile": [],
|
|
"discard_pile_count": 0,
|
|
"exhaust_pile": [],
|
|
"exhaust_pile_count": 0,
|
|
"gold": 99,
|
|
"relics": [],
|
|
"potions": [],
|
|
"max_potion_slots": 3,
|
|
},
|
|
}
|
|
|
|
|
|
print("=== 1. Jev's failing case: energy 3, Strike x3 + Bash, target 19 HP ===")
|
|
jev_hand = [
|
|
card("Strike", 1, "Deal 6 damage.", index=0),
|
|
card("Strike", 1, "Deal 6 damage.", index=1),
|
|
card("Strike", 1, "Deal 6 damage.", index=2),
|
|
card("Bash", 2, "Deal 8 damage. Apply 2 Vulnerable.", index=3),
|
|
]
|
|
f19 = facts.combat_facts(observation(jev_hand, [enemy("NIBBIT_0", 19)]))
|
|
check("max damage subset", facts._subset_damage(f19.playable, 3, [], f19.enemies[0]), 18)
|
|
check("lethal vs 19 HP (Jev said 0.79 YES -- wrong)", f19.lethal_available, False)
|
|
|
|
f18 = facts.combat_facts(observation(jev_hand, [enemy("NIBBIT_0", 18)]))
|
|
check("lethal vs 18 HP", f18.lethal_available, True)
|
|
check("killable list", f18.killable, ["NIBBIT_0"])
|
|
|
|
print()
|
|
print("=== 2. damage parsing ===")
|
|
multi = card("Dagger Spray", 1, "Deal 3 damage 2 times.")
|
|
d = facts.parse_card_damage(multi)
|
|
check("multi-hit base", d.base, 3)
|
|
check("multi-hit hits", d.hits, 2)
|
|
check("multi-hit total", d.raw_total, 6)
|
|
|
|
aoe = card("Cleave", 1, "Deal 8 damage to ALL enemies.")
|
|
check("aoe flag", facts.parse_card_damage(aoe).is_aoe, True)
|
|
|
|
plain = card("Defend", 1, "Gain 5 Block.", ctype="Skill", target="Self")
|
|
check("non-damage card", facts.parse_card_damage(plain).raw_total, 0)
|
|
|
|
print()
|
|
print("=== 3. modifiers ===")
|
|
strike = card("Strike", 1, "Deal 6 damage.")
|
|
check("bare strike", facts.damage_to_target(strike, [], [], 0), 6)
|
|
check("+2 Strength", facts.damage_to_target(strike, [{"name": "Strength", "amount": 2}], [], 0), 8)
|
|
check("target Vulnerable (x1.5)", facts.damage_to_target(strike, [], [{"name": "Vulnerable", "amount": 2}], 0), 9)
|
|
check("attacker Weak (x0.75)", facts.damage_to_target(strike, [{"name": "Weak", "amount": 1}], [], 0), 4)
|
|
check("enemy block absorbed", facts.damage_to_target(strike, [], [], 4), 2)
|
|
check("block exceeds damage", facts.damage_to_target(strike, [], [], 10), 0)
|
|
|
|
print()
|
|
print("=== 4. threat buckets (incoming vs max HP) ===")
|
|
for incoming, want in [(0, "none"), (10, "chip"), (30, "heavy"), (50, "severe"), (80, "lethal")]:
|
|
f = facts.combat_facts(observation([], [enemy("X_0", 10, intent_damage=incoming)]))
|
|
check(f"incoming {incoming}", f.threat, want)
|
|
|
|
print()
|
|
print("=== 5. unplayable cards are excluded ===")
|
|
locked = card("Ascender's Bane", 1, "Unplayable.", index=0, can_play=False)
|
|
locked["unplayable_reason"] = "Unplayable"
|
|
strikes = [card("Strike", 1, "Deal 6 damage.", index=i) for i in range(1, 4)]
|
|
f = facts.combat_facts(observation([locked] + strikes, [enemy("NIBBIT_0", 18)]))
|
|
check("playable count excludes locked", len(f.playable), 3)
|
|
check("lethal still reachable", f.lethal_available, True)
|
|
|
|
print()
|
|
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()))
|
|
check("state is play phase", f.in_play_phase, True)
|
|
check("energy", f.energy, 3)
|
|
check("hand size", len(f.hand), 5)
|
|
check("incoming", f.incoming_damage, 12)
|
|
check("threat", f.threat, "chip")
|
|
check("lethal", f.lethal_available, False)
|
|
check("deck composition counted by name", f.deck_counts.get("Strike"), 5)
|
|
print(f" deck: {f.deck_counts}")
|
|
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)
|