309 lines
13 KiB
Python
309 lines
13 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 gzip
|
|
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. hand descriptions include attacker modifiers ===")
|
|
strike = card("Strike", 1, "Deal 6 damage.")
|
|
check("bare strike", facts.damage_to_target(strike, [], [], 0), 6)
|
|
strong_strike = card("Strike", 1, "Deal 8 damage.")
|
|
check("Strength is already in hand text", facts.damage_to_target(
|
|
strong_strike, [{"name": "Strength", "amount": 2}], [], 0), 8)
|
|
check("target Vulnerable (x1.5)", facts.damage_to_target(strike, [], [{"name": "Vulnerable", "amount": 2}], 0), 9)
|
|
weak_strike = card("Strike", 1, "Deal 4 damage.")
|
|
check("Weak is already in hand text", facts.damage_to_target(
|
|
weak_strike, [{"name": "Weak", "amount": 1}], [], 0), 4)
|
|
check("Vulnerable rounds each hit, not the combined total", facts.damage_to_target(
|
|
multi, [], [{"name": "Vulnerable", "amount": 1}], 0), 8)
|
|
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. lethal calculations use displayed damage once ===")
|
|
# Hand text already includes Strength; statuses remain available as context.
|
|
strong_hand = [card("Strike", 1, "Deal 12 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("displayed damage works without reapplying statuses",
|
|
facts._subset_damage(st.playable, st.energy, [], st.enemies[0]), 24)
|
|
check("passing statuses must not apply Strength twice",
|
|
facts._subset_damage(st.playable, st.energy, st.player_status, st.enemies[0]), 24)
|
|
|
|
print()
|
|
print("=== 10. captured hand text is not base damage ===")
|
|
fixture = pathlib.Path("dataset/states/06/0618279b1a19a34c32469d654d00386fa904e76fc1cf8ee0943d47469d257a49.json.gz")
|
|
captured = json.loads(gzip.decompress(fixture.read_bytes()))
|
|
cp = captured["player"]
|
|
cs = next(c for c in cp["hand"] if c["name"] == "Strike")
|
|
check("captured Strength", facts.power_amount(cp["status"], "Strength"), 2)
|
|
check("captured Strike text", cs["description"], "Deal 8 damage.")
|
|
check("captured damage without target modifiers",
|
|
facts.damage_to_target(cs, cp["status"], [], 0), 8)
|
|
cf = facts.combat_facts(observation([cs], [enemy("E_0", 9)], energy=1, status=cp["status"]))
|
|
check("8 displayed damage cannot kill 9 HP", cf.lethal_available, False)
|
|
|
|
print()
|
|
print("=== 11. individual killability does not prove joint lethal ===")
|
|
two = facts.combat_facts(observation([strike], [enemy("E_0", 6), enemy("E_1", 6)], energy=1))
|
|
check("each target is individually killable", two.killable, ["E_0", "E_1"])
|
|
check("joint lethal is unknown until a shared-resource search exists", two.lethal_available, None)
|
|
check("the model receives unknown, not a false proof", two.to_state()["combat"]["lethal_available"], None)
|
|
|
|
print()
|
|
print("=== 12. fixed-cost block optimization and covered damage ===")
|
|
block_hand = [card("Big Block", 2, "Gain 9 Block.", ctype="Skill", target="Self", index=0),
|
|
card("Small Block", 1, "Gain 6 Block.", ctype="Skill", target="Self", index=1),
|
|
card("Small Block", 1, "Gain 6 Block.", ctype="Skill", target="Self", index=2)]
|
|
bf = facts.combat_facts(observation(block_hand, [enemy("E_0", 100, intent_damage=12)], energy=2))
|
|
check("two small blocks beat the largest card", bf.max_block_available, 12)
|
|
free = card("Free Block", 0, "Gain 3 Block.", ctype="Skill", target="Self", index=3)
|
|
ff = facts.combat_facts(observation([free] + block_hand, [enemy("E_0", 100)], energy=2))
|
|
check("zero-cost block is counted once", ff.max_block_available, 15)
|
|
covered = facts.combat_facts(observation(block_hand, [enemy("E_0", 100, intent_damage=12)],
|
|
hp=30, block=12))
|
|
check("covered damage does not force block", covered.block_urgent, False)
|
|
check("covered damage does not demand more current block", covered.must_block, False)
|
|
partial = facts.combat_facts(observation([plain], [enemy("E_0", 100, intent_damage=12)], hp=10))
|
|
check("survival allows nonlethal HP loss", partial.survives_with_cards, True)
|
|
exact = facts.combat_facts(observation([plain], [enemy("E_0", 100, intent_damage=15)], hp=10))
|
|
check("zero HP is not survival", exact.survives_with_cards, False)
|
|
|
|
print()
|
|
print("=== 13. unsupported mechanics do not yield deterministic lethal lines ===")
|
|
x_card = card("Variable Attack", "X", "Deal 20 damage.")
|
|
xf = facts.combat_facts(observation([x_card], [enemy("E_0", 10)], energy=1))
|
|
check("X cost is not a free card in damage search",
|
|
facts._subset_damage(xf.playable, xf.energy, [], xf.enemies[0]), 0)
|
|
check("X cost remains visible to the model", xf.to_state()["combat"]["hand"][0]["cost"], "X")
|
|
conditional = card("Conditional", 1, "If the enemy is Vulnerable, deal 20 damage.")
|
|
cc = facts.combat_facts(observation([conditional], [enemy("E_0", 10)]))
|
|
check("conditional text is not unconditional damage",
|
|
facts._subset_damage(cc.playable, cc.energy, [], cc.enemies[0]), 0)
|
|
armored = facts.combat_facts(observation([strike], [enemy("E_0", 6,
|
|
status=[{"name": "Intangible", "amount": 1}])]))
|
|
check("unsupported target powers do not produce a killable target", armored.killable, [])
|
|
|
|
print()
|
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
|
sys.exit(1 if FAIL else 0)
|