sts2-bot/facts.py
0xrsydn 68e946ef3a 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.
2026-09-22 00:01:50 +07:00

504 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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