Add combat facts layer with tests

Pure computation: lethal damage lines, block values, threat model,
deck counts, affordability. Jev never does arithmetic; facts.py
computes the numbers and passes conclusions in.
This commit is contained in:
0xrsydn 2026-09-21 17:09:43 +07:00
commit 68e946ef3a
2 changed files with 682 additions and 0 deletions

178
test_facts.py Normal file
View file

@ -0,0 +1,178 @@
#!/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)