sts2-bot/facts.py

714 lines
26 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 limits
---------------------------
Hand descriptions from STS2MCP already include attacker modifiers such as
Strength and Weak. Never apply those modifiers to the displayed damage again.
Pile descriptions have a different display context and are not damage inputs.
The direct-damage search supports fixed energy costs and plain damage text.
It excludes compound/conditional cards, star costs, and unsupported powers.
Target Vulnerable is applied per hit; enemy block is absorbed once per line.
The search does not simulate relic hooks, card ordering effects, or shared
resources across enemies. `lethal_available` is None for multi-enemy combat
and unsupported power contexts. Other results describe this limited model,
not a full game simulation. Fight duration and future damage are estimates.
"""
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 # displayed damage per hit, not the card's unmodified base value
hits: int
is_aoe: bool
@property
def raw_total(self) -> int:
return self.base * self.hits
def block_value(card: dict) -> int:
"""Displayed immediate block; do not treat conditional triggers as block now."""
match = BLOCK_RE.match((card.get("description") or "").strip())
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:
"""Estimate damage from HAND text, with target Vulnerable and block.
`attacker_status` remains accepted for callers, but its modifiers are
already included in the displayed value. This is not a base-card API.
Other target powers and relic hooks are outside this calculation.
"""
dmg = parse_card_damage(card)
per_hit = dmg.base
if power_amount(target_status, "Vulnerable"):
per_hit = per_hit * 3 // 2
return max(0, per_hit * dmg.hits - 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)
status_amounts: list[dict] = 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",
# Names AND amounts: an enemy at Strength 6 and Strength 1 used to
# look identical to Jev. Amounts are context, not a comparison, so
# no arithmetic crosses the boundary.
"statuses": self.status_amounts 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")]
amounts = [
{"name": str(s.get("name")), "amount": _as_int(s.get("amount"))}
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,
status_amounts=amounts,
)
# --------------------------------------------------------------------------
# Small-hand calculations. Unknown costs never become free cards.
# --------------------------------------------------------------------------
def fixed_energy_cost(card: dict) -> int | None:
"""A supported fixed cost, or None for unknown/X costs and star spending."""
if card.get("star_cost") not in (None, 0, "0"):
return None
text = str(card.get("cost", "")).strip()
return int(text) if text.isdecimal() else None
def best_block_line(playable: list[dict], energy: int) -> list[dict]:
"""Maximize displayed immediate block with fixed costs, using each card once.
This ignores draw outcomes and other side effects. Ties use less energy,
then fewer cards. The returned list is a plan, not a queued action list.
"""
plans: dict[int, tuple[int, list[dict]]] = {0: (0, [])}
for card in playable:
cost = fixed_energy_cost(card)
value = block_value(card)
if cost is None or cost > energy or value <= 0:
continue
for spent, (total, line) in list(plans.items()):
new_spent = spent + cost
if new_spent > energy:
continue
candidate = (total + value, line + [card])
old = plans.get(new_spent)
if old is None or (candidate[0], -len(candidate[1])) > (old[0], -len(old[1])):
plans[new_spent] = candidate
_, (_, line) = max(plans.items(), key=lambda item: (item[1][0], -item[0], -len(item[1][1])))
return line
def damage_context_supported(attacker_status: list, enemy: EnemyFact) -> bool:
"""Reject power contexts outside the limited direct-damage calculation."""
known = {"strength", "weak", "vulnerable", "dexterity", "frail"}
names = [str(s.get("name", "")).lower() for s in attacker_status if isinstance(s, dict)]
return all(n in known for n in names + [n.lower() for n in enemy.statuses])
def damage_subsets(playable: list[dict], energy: int, attacker_status: list, enemy: EnemyFact):
"""Yield (cards, raw damage) for supported subsets, shortest first.
Only plain direct attacks are modeled. A matching phrase inside a
conditional or a card with additional effects is not a supported attack.
"""
if not damage_context_supported(attacker_status, enemy):
return
candidates = [
c for c in playable
if c.get("type") == "Attack"
and c.get("target_type") in ("AnyEnemy", "AllEnemies")
and fixed_energy_cost(c) is not None
and DAMAGE_RE.fullmatch((c.get("description") or "").strip().rstrip("."))
][:12]
for count in range(1, len(candidates) + 1):
for subset in itertools.combinations(candidates, count):
if sum(fixed_energy_cost(c) for c in subset) > energy:
continue
total = sum(damage_to_target(c, attacker_status, enemy_status_names(enemy)) for c in subset)
yield list(subset), total
def lethal_line(playable: list[dict], energy: int, attacker_status: list,
enemy: EnemyFact) -> list[dict] | None:
"""Shortest supported direct-damage line against one enemy, if found."""
if enemy.hp <= 0:
return None
for cards, total in damage_subsets(playable, energy, attacker_status, enemy):
if total >= enemy.effective_hp:
return sorted(cards, key=lambda c: -parse_card_damage(c).raw_total)
return None
# --------------------------------------------------------------------------
# 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]
player_status: list
lethal_available: bool | None
killable: list[str]
deck_counts: dict
deck_summary: 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:
"""Maximum displayed immediate block in the fixed-cost model."""
return sum(block_value(c) for c in best_block_line(self.playable, self.energy))
@property
def survives_with_cards(self) -> bool:
"""
Estimate survival after known incoming attacks and the block plan.
Survival can include HP loss. Card side effects are not simulated.
"""
remaining = max(0, self.incoming_damage - self.block - self.max_block_available)
return remaining < self.hp
# ---------------------------------------------------------------------
# Fight-length projection.
#
# The old model asked "is THIS turn's hit big?" (see `threat`). That is
# myopic and it lost 9 runs to THE_KIN_BOSS: at 80 max HP a hit of <=12 is
# classed CHIP, so the bot attacked through the boss's main attack, took
# ~10-13 a turn, and died in 5-10 turns having dealt 44-80 damage to
# itself. The quantity that decides whether to block is the TOTAL damage
# the rest of the fight will cost, not one turn of it.
# ---------------------------------------------------------------------
@property
def attack_power(self) -> int:
"""Greedy damage estimate, ignoring targets, ordering, and unknown costs."""
energy = self.energy
total = 0
for card in sorted(self.playable, key=lambda x: -parse_card_damage(x).raw_total):
dmg = parse_card_damage(card).raw_total
if dmg <= 0:
continue
cost = fixed_energy_cost(card)
if cost is not None and cost <= energy:
total += dmg
energy -= cost
return total
@property
def turns_to_kill(self) -> int:
"""
Estimate remaining turns by assuming later hands resemble this hand.
This ignores future draws and effects. A block-only hand produces a
large estimate; it does not establish the actual fight duration.
"""
power = self.attack_power
if power <= 0:
return 20
return max(1, -(-self.total_enemy_hp // power))
@property
def projected_incoming(self) -> int:
"""Total damage the rest of this fight is likely to deal us."""
return self.turns_to_kill * self.incoming_damage
@property
def affordable_loss(self) -> int:
"""
HP we can spend on this fight and still enter the next one healthy.
HP is a resource (it carries across the act), so we do not want to
leave a fight at 1 HP. Keep a floor of 30% of max HP.
"""
return max(0, self.hp - int(self.max_hp * 0.30))
@property
def must_block(self) -> bool:
"""
Request current block when estimated future damage exceeds the HP budget.
This is a heuristic. It must stop requesting block once the current
incoming damage is covered; ordinary block does not cover future turns.
"""
if self.unblocked_damage <= 0:
return False
return self.projected_incoming > self.affordable_loss
@property
def block_urgent(self) -> bool:
"""
Block now even at the cost of tempo: this turn alone is dangerous.
Separate from `must_block` because it also covers the case where the
current hit is lethal-ish regardless of how long the fight lasts.
"""
if self.unblocked_damage <= 0:
return False
return (
self.threat in (THREAT_SEVERE, THREAT_LETHAL)
or self.hp_bucket in (HP_WOUNDED, HP_CRITICAL)
or self.must_block
)
def to_state(self) -> dict:
"""
The compact, semantic state handed to Jev.
Include computed comparisons so Jev need not perform arithmetic.
Preserve displayed costs, including X. Lethal can be unknown (None).
"""
return {
"combat": {
"round": self.round,
"your_turn": self.in_play_phase,
"energy": self.energy,
"your_health": self.hp_bucket,
"your_statuses": [
{"name": str(s.get("name")), "amount": _as_int(s.get("amount"))}
for s in self.player_status
if isinstance(s, dict) and s.get("name")
] or "none",
"incoming_threat": self.threat,
# The fight-length view. `threat` alone is myopic: at 80 max HP
# a 12-damage hit reads as "chip" even when the fight will run
# 10 more turns and kill us. These expose the accumulated view
# without handing Jev any arithmetic to do.
"fight_is_grinding": self.must_block,
"this_turn_is_dangerous": self.block_urgent,
"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": 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 fixed_energy_cost(c) is None or fixed_energy_cost(c) <= energy]
killable: list[str] = []
for enemy in enemies:
best = _subset_damage(affordable, energy, player_status, enemy)
if enemy.hp > 0 and best >= enemy.effective_hp:
killable.append(enemy.entity_id)
# Individually killable enemies may require the SAME cards and energy.
# Do not claim joint lethal without a shared-resource search.
lethal = bool(killable) if len(enemies) == 1 else None
if len(enemies) == 1 and not damage_context_supported(player_status, enemies[0]):
lethal = None
# 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)],
player_status=player_status,
lethal_available=lethal,
killable=killable,
deck_counts=deck_counts,
deck_summary=deck_summary(player),
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:
"""Maximum supported raw damage; compare with HP + block exactly once."""
return max((total for _, total in damage_subsets(playable, energy, attacker_status, enemy)), default=0)
def enemy_status_names(enemy: EnemyFact) -> list:
"""Preserve observed amounts; accept legacy name-only EnemyFact values."""
return enemy.status_amounts or [{"name": n, "amount": 1} for n in enemy.statuses]
def deck_summary(player: dict) -> dict:
"""
Stable aggregates over ALL FOUR PILES, for macro decisions.
The reward, shop and map states do NOT expose the deck, so this travels with
the composition snapshot taken during combat. It is deliberately a
*supplement* to the name->count map, never a replacement: card identities
are the signal for synergy, redundancy and upgrades, and only counts can
carry them. These aggregates are the part that is arithmetic, so it is
computed here and never asked of the model.
Every field is computed over the whole deck and is stable across snapshots
of the same deck. There is deliberately no cost aggregate: the state does
not expose a card's BASE cost, and a temporary in-combat cost modifier
applies to the copy in hand. Measured over 600 captures, one Strike read
`cost: "0"` in hand while the same Strike read `cost: "1"` in the draw pile,
so an average cost would differ between two snapshots of an identical deck.
"""
names: list[str] = []
attacks = blocks = other = upgraded = 0
for pile in ("hand", "draw_pile", "discard_pile", "exhaust_pile"):
for card in player.get(pile) or []:
if not isinstance(card, dict):
continue
name = str(card.get("name") or "?")
names.append(name)
if name.endswith("+"):
upgraded += 1
text = card.get("description") or ""
if DAMAGE_RE.search(text):
attacks += 1
elif BLOCK_RE.search(text):
blocks += 1
else:
other += 1
if not names:
return {}
return {
"size": len(names),
"attacks": attacks,
"block_cards": blocks,
"other_cards": other,
"upgraded": upgraded,
}
def deck_context(deck: Any) -> Any:
"""
What a macro question receives as deck context: the full name->count map
(card identities, which carry the synergy and redundancy signal) plus the
stable aggregates. Tolerates a legacy flat name->count snapshot.
"""
if not deck:
return "unknown"
if isinstance(deck, dict) and "counts" in deck:
counts = deck.get("counts") or {}
if not counts:
return "unknown"
return {"cards": counts, **({"summary": deck["summary"]}
if deck.get("summary") else {})}
if isinstance(deck, dict):
return {"cards": deck}
return deck
# --------------------------------------------------------------------------
# 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())