#!/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)