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:
parent
5b57fc7af3
commit
68e946ef3a
2 changed files with 682 additions and 0 deletions
178
test_facts.py
Normal file
178
test_facts.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue