diff --git a/facts.py b/facts.py new file mode 100644 index 0000000..d7a981b --- /dev/null +++ b/facts.py @@ -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()) diff --git a/test_facts.py b/test_facts.py new file mode 100644 index 0000000..375294a --- /dev/null +++ b/test_facts.py @@ -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)