#!/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. 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(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0)