Mining the 37 run files showed 81% of runs (30/37) die in Act 1, and 43% to an
Act 1 boss. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10
turns and cost 44-80 HP -- about 10-13 a turn, with block cards in hand the
whole time. Four defects, fixed here together because they were found and
verified as one combat-correctness pass.
1. NO DEFENCE POLICY (the big one, found by mining the data)
facts classes a hit of <=15% of max HP as THREAT_CHIP. At 80 max HP that is
12, exactly what the boss deals, and _fallback_combat only blocked for HEAVY
or worse while HP was HEALTHY (>60%). So at 74/80 HP the bot attacked through
the boss's main attack and only started blocking below 48 HP.
_jev_combat also asked a should_defend Noul on every combat turn and never
read it -- grep -rn should_defend returned one line, the one creating it.
Defence therefore fell to choice("Which single play best advances winning
this fight?"), which is damage-biased: on the real Kin state Jev answered
Bash at 0.42 confidence, below the 0.45 gate, so it fell through to the
fallback, which also chose damage. Both paths agreed on the wrong answer.
Blocking is arithmetic, so it is now decided in code before Jev is asked,
using turns_to_kill, projected_incoming, affordable_loss and must_block /
block_urgent. Measured against all 600 real combat captures, the rule changes
8 of 68 in-play turns (11.8%) and stays silent on short fights and when
nothing is incoming.
2. ENEMY BLOCK COUNTED TWICE IN THE LETHAL SEARCH
`total - max(0, enemy.block) >= enemy.effective_hp` subtracts block a second
time, because effective_hp is already hp + block. A 10 hp / 5 block enemy
against 18 raw damage read as "not lethal" and real kills were discarded.
3. THE LETHAL EXECUTOR IGNORED PLAYER STATUSES
`_lethal_line(f.playable, f.energy, [], enemy)` passed an empty status list,
so facts reported lethal_available: true while the code meant to execute the
kill found nothing. CombatFacts.player_status is now passed through.
4. RELIC_SELECT ASKED good_relicN AND READ relicN
Every answer missed, so best_by_noul returned (None, 0.0) for every state and
the path could only ever take the rarest relic.
Tests: 50 in test_facts.py and 131 in test_brain.py, with a regression case for
each -- a blocked enemy, a Strength-carrying player, a relic offer whose
highest-rated relic is deliberately the common one so the rarity fallback cannot
pass by accident, and the Kin turn itself.
687 lines
24 KiB
Python
687 lines
24 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 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)
|
||
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,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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]
|
||
player_status: list
|
||
lethal_available: bool
|
||
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:
|
||
"""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
|
||
|
||
# ---------------------------------------------------------------------
|
||
# 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:
|
||
"""Best single-turn damage the hand can produce, ignoring targets."""
|
||
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 = _as_int(card.get("cost"))
|
||
if cost <= energy:
|
||
total += dmg
|
||
energy -= cost
|
||
return total
|
||
|
||
@property
|
||
def turns_to_kill(self) -> int:
|
||
"""
|
||
Turns this fight still needs, from this turn's reachable damage.
|
||
|
||
Deliberately pessimistic: it assumes every later turn looks like this
|
||
one. If the hand is all block, the estimate is huge, which correctly
|
||
pushes toward "this fight will grind me down".
|
||
"""
|
||
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:
|
||
"""
|
||
True when the rest of this fight will cost more HP than we can spare.
|
||
|
||
This is the decision the bot was missing. It fires on the Kin turn
|
||
(74/80 HP, 12 incoming, ~12 turns to kill -> 144 projected vs 50
|
||
affordable) and stays silent on a trivial fight (15 HP enemy, 6
|
||
incoming, 2 turns -> 12 projected vs 50 affordable).
|
||
"""
|
||
if self.incoming_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.incoming_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.
|
||
|
||
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,
|
||
"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": _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)],
|
||
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:
|
||
"""
|
||
Exact max RAW damage over subsets, honouring energy and attacker statuses.
|
||
|
||
Raw means before enemy block: compare the result against `enemy.effective_hp`
|
||
(hp + block) so the block is subtracted exactly once. Subtracting it here and
|
||
comparing against `effective_hp` required hp + 2*block, which silently
|
||
discarded real lethal lines whenever an enemy played Defend.
|
||
"""
|
||
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 best
|
||
|
||
|
||
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]
|
||
|
||
|
||
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())
|