#!/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())