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.
178 lines
5.9 KiB
Python
178 lines
5.9 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. 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)
|