701 lines
26 KiB
Python
701 lines
26 KiB
Python
#!/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"visible combat piles: {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:
|
||
"""Aggregate visible combat piles, not the persistent run deck.
|
||
|
||
Generated cards, Status cards, temporary upgrades, and missing pile data
|
||
limit this evidence. These counts can change during combat. Cost is omitted
|
||
because temporary modifiers make displayed costs unsuitable for a base-cost
|
||
aggregate. The historical function name is retained for existing callers.
|
||
"""
|
||
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:
|
||
"""Supply card evidence with its limits; unscoped legacy data is unknown."""
|
||
if not deck:
|
||
return "unknown"
|
||
if isinstance(deck, dict) and "counts" in deck:
|
||
counts = deck.get("counts") or {}
|
||
if not counts:
|
||
return "unknown"
|
||
provenance = deck.get("provenance")
|
||
if not isinstance(provenance, dict) or not provenance.get("run_id"):
|
||
return "unknown"
|
||
return {"cards": counts, "provenance": provenance,
|
||
**({"summary": deck["summary"]} if deck.get("summary") else {})}
|
||
return "unknown"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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())
|