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
504
facts.py
Normal file
504
facts.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
facts.py -- compute hard facts in code so Jev never has to.
|
||||
|
||||
Why this module exists
|
||||
----------------------
|
||||
Measured against jev-1.13.0: given energy 3, hand Strike(1c/6dmg) x3 plus
|
||||
Bash(2c/8dmg), and a target on 19 HP, Jev bucketed the max damage as
|
||||
"18_to_23" (correct) but answered "is lethal available?" as 0.79 YES
|
||||
(wrong -- the true max is 18). It reported 0.79 confidence on that wrong
|
||||
answer, so confidence gating cannot catch arithmetic errors.
|
||||
|
||||
Conclusion: every comparison, sum, and threshold is computed here. Jev is
|
||||
handed conclusions ("lethal_available": true) and is only ever asked about
|
||||
preference, priority, and semantics.
|
||||
|
||||
Damage model and its honest limits
|
||||
----------------------------------
|
||||
Handled:
|
||||
* base damage parsed from the card description
|
||||
* multi-hit ("Deal 3 damage 2 times.")
|
||||
* area of effect ("... to ALL enemies.")
|
||||
* Strength on the attacker (+ per hit)
|
||||
* Vulnerable on the target (x1.5)
|
||||
* Weak on the attacker (x0.75)
|
||||
* enemy Block
|
||||
Not handled (documented, not guessed):
|
||||
* ordering effects (Bash applying Vulnerable before a later attack)
|
||||
* relics that modify damage
|
||||
* enemy powers that reduce incoming damage
|
||||
* X-cost cards
|
||||
So `lethal_available` is a LOWER bound: true means lethal is genuinely
|
||||
reachable. False may still be reachable in game. That is the safe direction
|
||||
for a bot -- we never claim lethal we cannot deliver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thresholds -- all policy lives here, not in the prompt
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
THREAT_NONE = "none"
|
||||
THREAT_CHIP = "chip" # <= 15% of current HP
|
||||
THREAT_HEAVY = "heavy" # <= 40% of current HP
|
||||
THREAT_SEVERE = "severe" # <= 70% of current HP
|
||||
THREAT_LETHAL = "lethal" # >= current HP
|
||||
|
||||
HP_HEALTHY = "healthy" # > 60%
|
||||
HP_WOUNDED = "wounded" # 30-60%
|
||||
HP_CRITICAL = "critical" # < 30%
|
||||
|
||||
DAMAGE_RE = re.compile(
|
||||
r"Deal\s+(\d+)\s+damage(?:\s+(\d+)\s+times)?(?:\s+to\s+ALL\s+enemies)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
AOE_RE = re.compile(r"to\s+ALL\s+enemies", re.IGNORECASE)
|
||||
BLOCK_RE = re.compile(r"Gain\s+(\d+)\s+Block", re.IGNORECASE)
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int = 0) -> int:
|
||||
"""Card costs arrive as strings ('1', '2', sometimes 'X')."""
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def power_amount(statuses: list | None, name: str) -> int:
|
||||
"""Read a power amount by display name, e.g. 'Strength'."""
|
||||
total = 0
|
||||
for entry in statuses or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if str(entry.get("name", "")).lower() == name.lower():
|
||||
total += _as_int(entry.get("amount"), 0)
|
||||
return total
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Card damage
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardDamage:
|
||||
base: int
|
||||
hits: int
|
||||
is_aoe: bool
|
||||
|
||||
@property
|
||||
def raw_total(self) -> int:
|
||||
return self.base * self.hits
|
||||
|
||||
|
||||
def block_value(card: dict) -> int:
|
||||
"""Block a card grants, parsed from its description."""
|
||||
match = BLOCK_RE.search(card.get("description") or "")
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def parse_card_damage(card: dict) -> CardDamage:
|
||||
match = DAMAGE_RE.search(card.get("description") or "")
|
||||
if not match:
|
||||
return CardDamage(base=0, hits=0, is_aoe=False)
|
||||
base = int(match.group(1))
|
||||
hits = int(match.group(2)) if match.group(2) else 1
|
||||
return CardDamage(
|
||||
base=base,
|
||||
hits=hits,
|
||||
is_aoe=bool(AOE_RE.search(card.get("description") or "")),
|
||||
)
|
||||
|
||||
|
||||
def damage_to_target(
|
||||
card: dict,
|
||||
attacker_status: list | None,
|
||||
target_status: list | None,
|
||||
target_block: int = 0,
|
||||
) -> int:
|
||||
"""Damage one card deals to one target after buffs, debuffs, and block."""
|
||||
dmg = parse_card_damage(card)
|
||||
if dmg.raw_total == 0:
|
||||
return 0
|
||||
|
||||
strength = power_amount(attacker_status, "Strength")
|
||||
per_hit = dmg.base + strength
|
||||
if power_amount(attacker_status, "Weak"):
|
||||
per_hit = int(per_hit * 0.75)
|
||||
|
||||
total = per_hit * dmg.hits
|
||||
|
||||
if power_amount(target_status, "Vulnerable"):
|
||||
total = int(total * 1.5)
|
||||
|
||||
return max(0, total - max(0, target_block))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Enemy facts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class EnemyFact:
|
||||
entity_id: str
|
||||
name: str
|
||||
hp: int
|
||||
max_hp: int
|
||||
block: int
|
||||
incoming_damage: int
|
||||
intent_kinds: list[str] = field(default_factory=list)
|
||||
intent_text: list[str] = field(default_factory=list)
|
||||
statuses: list[str] = field(default_factory=list)
|
||||
is_minion: bool = False
|
||||
|
||||
@property
|
||||
def effective_hp(self) -> int:
|
||||
return self.hp + self.block
|
||||
|
||||
@property
|
||||
def hp_pct(self) -> float:
|
||||
return self.hp / self.max_hp if self.max_hp else 0.0
|
||||
|
||||
def to_state(self) -> dict:
|
||||
return {
|
||||
"id": self.entity_id,
|
||||
"name": self.name,
|
||||
"hp_state": _hp_bucket(self.hp_pct, self.hp),
|
||||
"incoming": self.incoming_damage,
|
||||
"intends": ", ".join(self.intent_text) or "unknown",
|
||||
"statuses": self.statuses or "none",
|
||||
}
|
||||
|
||||
|
||||
def _hp_bucket(pct: float, hp: int | None = None) -> str:
|
||||
"""
|
||||
Bucket health by percentage, but never call very low ABSOLUTE HP healthy.
|
||||
|
||||
Measured: an event option set the player's max HP to 1. At 1/1 the
|
||||
percentage is 100% and this returned "healthy", so the bot walked into a
|
||||
normal fight at 1 HP and died. A percentage is meaningless once max HP
|
||||
itself is tiny.
|
||||
"""
|
||||
if hp is not None and hp <= 5:
|
||||
return HP_CRITICAL
|
||||
if pct > 0.6:
|
||||
return HP_HEALTHY
|
||||
if pct >= 0.3:
|
||||
return HP_WOUNDED
|
||||
return HP_CRITICAL
|
||||
|
||||
|
||||
def _intent_damage(intent: dict) -> int:
|
||||
"""
|
||||
Intent damage. `label` is the authoritative number the game shows,
|
||||
e.g. '12' or '12x2'. Fall back to parsing the description.
|
||||
"""
|
||||
if intent.get("type") != "Attack":
|
||||
return 0
|
||||
label = str(intent.get("label") or "").strip()
|
||||
multi = re.match(r"^(\d+)\s*[xX×]\s*(\d+)$", label)
|
||||
if multi:
|
||||
return int(multi.group(1)) * int(multi.group(2))
|
||||
if label.isdigit():
|
||||
return int(label)
|
||||
found = re.search(r"(\d+)", label)
|
||||
if found:
|
||||
return int(found.group(1))
|
||||
desc = intent.get("description") or ""
|
||||
found = re.search(r"(\d+)\s*damage", desc, re.IGNORECASE)
|
||||
return int(found.group(1)) if found else 0
|
||||
|
||||
|
||||
def enemy_facts(enemy: dict) -> EnemyFact:
|
||||
statuses = enemy.get("status") or []
|
||||
intents = enemy.get("intents") or []
|
||||
|
||||
names = [str(s.get("name")) for s in statuses if isinstance(s, dict) and s.get("name")]
|
||||
|
||||
return EnemyFact(
|
||||
entity_id=str(enemy.get("entity_id") or "?"),
|
||||
name=str(enemy.get("name") or "?"),
|
||||
hp=_as_int(enemy.get("hp")),
|
||||
max_hp=_as_int(enemy.get("max_hp")),
|
||||
block=_as_int(enemy.get("block")),
|
||||
incoming_damage=sum(_intent_damage(i) for i in intents if isinstance(i, dict)),
|
||||
intent_kinds=[str(i.get("type")) for i in intents if isinstance(i, dict)],
|
||||
intent_text=[
|
||||
str(i.get("description") or i.get("title") or i.get("type"))
|
||||
for i in intents
|
||||
if isinstance(i, dict)
|
||||
],
|
||||
statuses=names,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lethal search
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Combat facts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CombatFacts:
|
||||
round: int
|
||||
in_play_phase: bool
|
||||
turn: str
|
||||
energy: int
|
||||
max_energy: int
|
||||
hp: int
|
||||
max_hp: int
|
||||
block: int
|
||||
incoming_damage: int
|
||||
enemies: list[EnemyFact]
|
||||
hand: list[dict]
|
||||
playable: list[dict]
|
||||
potions: list[dict]
|
||||
lethal_available: bool
|
||||
killable: list[str]
|
||||
deck_counts: dict
|
||||
draw_pile_count: int
|
||||
discard_pile_count: int
|
||||
exhaust_pile_count: int
|
||||
|
||||
@property
|
||||
def hp_pct(self) -> float:
|
||||
return self.hp / self.max_hp if self.max_hp else 0.0
|
||||
|
||||
@property
|
||||
def hp_bucket(self) -> str:
|
||||
return _hp_bucket(self.hp_pct, self.hp)
|
||||
|
||||
@property
|
||||
def unblocked_damage(self) -> int:
|
||||
return max(0, self.incoming_damage - self.block)
|
||||
|
||||
@property
|
||||
def threat(self) -> str:
|
||||
incoming = self.unblocked_damage
|
||||
if incoming <= 0:
|
||||
return THREAT_NONE
|
||||
if incoming >= self.hp:
|
||||
return THREAT_LETHAL
|
||||
if incoming <= self.max_hp * 0.15:
|
||||
return THREAT_CHIP
|
||||
if incoming <= self.max_hp * 0.40:
|
||||
return THREAT_HEAVY
|
||||
return THREAT_SEVERE
|
||||
|
||||
@property
|
||||
def total_enemy_hp(self) -> int:
|
||||
return sum(e.effective_hp for e in self.enemies)
|
||||
|
||||
@property
|
||||
def max_block_available(self) -> int:
|
||||
"""Block obtainable from playable cards within the energy budget."""
|
||||
energy = self.energy
|
||||
total = 0
|
||||
for card in sorted(self.playable, key=block_value, reverse=True):
|
||||
value = block_value(card)
|
||||
if value <= 0:
|
||||
continue
|
||||
cost = _as_int(card.get("cost"))
|
||||
if cost <= energy:
|
||||
total += value
|
||||
energy -= cost
|
||||
return total
|
||||
|
||||
@property
|
||||
def survives_with_cards(self) -> bool:
|
||||
"""
|
||||
True when existing block plus reachable card block covers the incoming
|
||||
damage. Used to decide whether a potion is the ONLY way to survive.
|
||||
"""
|
||||
return self.block + self.max_block_available >= self.incoming_damage
|
||||
|
||||
def to_state(self) -> dict:
|
||||
"""
|
||||
The compact, semantic state handed to Jev.
|
||||
|
||||
Deliberately contains NO raw numbers that Jev would have to compare.
|
||||
Buckets and booleans only, so arithmetic never crosses the boundary.
|
||||
"""
|
||||
return {
|
||||
"combat": {
|
||||
"round": self.round,
|
||||
"your_turn": self.in_play_phase,
|
||||
"energy": self.energy,
|
||||
"your_health": self.hp_bucket,
|
||||
"incoming_threat": self.threat,
|
||||
"lethal_available": self.lethal_available,
|
||||
"can_survive_with_cards": self.survives_with_cards,
|
||||
"enemies_you_can_kill_now": self.killable or "none",
|
||||
"enemies": [e.to_state() for e in self.enemies],
|
||||
"hand": [
|
||||
{
|
||||
"index": c.get("index"),
|
||||
"name": c.get("name"),
|
||||
"cost": _as_int(c.get("cost")),
|
||||
"type": c.get("type"),
|
||||
"targets": c.get("target_type"),
|
||||
"text": c.get("description"),
|
||||
}
|
||||
for c in self.hand
|
||||
],
|
||||
# Potions cost no energy, so they are always an alternative.
|
||||
# `slot` is the index use_potion expects, NOT the list position.
|
||||
"potions": [
|
||||
{
|
||||
"slot": p.get("slot"),
|
||||
"name": p.get("name"),
|
||||
"targets": p.get("target_type"),
|
||||
"text": p.get("description"),
|
||||
}
|
||||
for p in self.potions
|
||||
],
|
||||
"cannot_play": [
|
||||
{"index": c.get("index"), "name": c.get("name"),
|
||||
"why": c.get("unplayable_reason")}
|
||||
for c in self.hand
|
||||
if not c.get("can_play")
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def describe(self) -> str:
|
||||
lines = [
|
||||
f"round={self.round} turn={self.turn} play_phase={self.in_play_phase}",
|
||||
f"energy={self.energy}/{self.max_energy} hand={len(self.hand)} playable={len(self.playable)}",
|
||||
f"hp={self.hp}/{self.max_hp} ({self.hp_bucket}) block={self.block}",
|
||||
f"incoming={self.incoming_damage} unblocked={self.unblocked_damage} threat={self.threat}",
|
||||
f"lethal_available={self.lethal_available} killable={self.killable}",
|
||||
f"block_available={self.max_block_available} survives_with_cards={self.survives_with_cards}",
|
||||
f"potions={[p.get('name') for p in self.potions]}",
|
||||
f"piles: draw={self.draw_pile_count} discard={self.discard_pile_count} exhaust={self.exhaust_pile_count}",
|
||||
f"deck: {self.deck_counts}",
|
||||
]
|
||||
for e in self.enemies:
|
||||
lines.append(
|
||||
f" enemy {e.entity_id}: {e.name} {e.hp}/{e.max_hp} block={e.block} "
|
||||
f"incoming={e.incoming_damage} intents={e.intent_kinds}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def combat_facts(observation: dict) -> CombatFacts:
|
||||
battle = observation.get("battle") or {}
|
||||
player = observation.get("player") or {}
|
||||
|
||||
enemies = [enemy_facts(e) for e in (battle.get("enemies") or [])]
|
||||
|
||||
hand = list(player.get("hand") or [])
|
||||
energy = _as_int(player.get("energy"))
|
||||
player_status = player.get("status") or []
|
||||
|
||||
playable = [c for c in hand if c.get("can_play")]
|
||||
|
||||
# A card is playable only if we can also afford it.
|
||||
affordable = [c for c in playable if _as_int(c.get("cost")) <= energy]
|
||||
|
||||
killable: list[str] = []
|
||||
for enemy in enemies:
|
||||
best = _subset_damage(affordable, energy, player_status, enemy)
|
||||
if enemy.effective_hp > 0 and best >= enemy.effective_hp:
|
||||
killable.append(enemy.entity_id)
|
||||
|
||||
lethal = bool(enemies) and len(killable) == len(enemies)
|
||||
|
||||
# draw/discard/exhaust piles expose only {name, cost, star_cost, description}
|
||||
# with no `type`, so composition is counted by card name for every pile.
|
||||
deck_counts: dict[str, int] = {}
|
||||
for pile in ("hand", "draw_pile", "discard_pile", "exhaust_pile"):
|
||||
for card in player.get(pile) or []:
|
||||
key = str(card.get("name") or "?")
|
||||
deck_counts[key] = deck_counts.get(key, 0) + 1
|
||||
|
||||
return CombatFacts(
|
||||
round=_as_int(battle.get("round")),
|
||||
in_play_phase=bool(battle.get("is_play_phase")),
|
||||
turn=str(battle.get("turn") or "?"),
|
||||
energy=energy,
|
||||
max_energy=_as_int(player.get("max_energy")),
|
||||
hp=_as_int(player.get("hp")),
|
||||
max_hp=_as_int(player.get("max_hp")),
|
||||
block=_as_int(player.get("block")),
|
||||
incoming_damage=sum(e.incoming_damage for e in enemies),
|
||||
enemies=enemies,
|
||||
hand=hand,
|
||||
playable=affordable,
|
||||
potions=[p for p in (player.get("potions") or []) if isinstance(p, dict)],
|
||||
lethal_available=lethal,
|
||||
killable=killable,
|
||||
deck_counts=deck_counts,
|
||||
draw_pile_count=_as_int(player.get("draw_pile_count")),
|
||||
discard_pile_count=_as_int(player.get("discard_pile_count")),
|
||||
exhaust_pile_count=_as_int(player.get("exhaust_pile_count")),
|
||||
)
|
||||
|
||||
|
||||
def _subset_damage(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact) -> int:
|
||||
"""Exact max damage over subsets, honouring energy and enemy block."""
|
||||
best = 0
|
||||
n = min(len(playable), 12)
|
||||
for r in range(1, n + 1):
|
||||
for combo in itertools.combinations(range(n), r):
|
||||
cost = sum(_as_int(playable[i].get("cost")) for i in combo)
|
||||
if cost > energy:
|
||||
continue
|
||||
total = 0
|
||||
for i in combo:
|
||||
card = playable[i]
|
||||
dmg = parse_card_damage(card)
|
||||
if dmg.raw_total == 0:
|
||||
continue
|
||||
per_hit = dmg.base + power_amount(attacker_status, "Strength")
|
||||
if power_amount(attacker_status, "Weak"):
|
||||
per_hit = int(per_hit * 0.75)
|
||||
card_total = per_hit * dmg.hits
|
||||
if power_amount(enemy_status_names(enemy), "Vulnerable"):
|
||||
card_total = int(card_total * 1.5)
|
||||
total += card_total
|
||||
if total > best:
|
||||
best = total
|
||||
return max(0, best - max(0, enemy.block))
|
||||
|
||||
|
||||
def enemy_status_names(enemy: EnemyFact) -> list:
|
||||
"""Adapt the name list back into the shape power_amount expects."""
|
||||
return [{"name": n, "amount": 1} for n in enemy.statuses]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "capture/09_now.json"
|
||||
obs = json.loads(pathlib.Path(path).read_text())
|
||||
|
||||
if obs.get("state_type") not in ("monster", "elite", "boss"):
|
||||
print(f"not a combat state: state_type={obs.get('state_type')!r}")
|
||||
return 1
|
||||
|
||||
facts = combat_facts(obs)
|
||||
print("=== FACTS ===")
|
||||
print(facts.describe())
|
||||
print()
|
||||
print("=== SEMANTIC STATE FOR JEV ===")
|
||||
print(json.dumps(facts.to_state(), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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