fix(bot): correct combat estimates and enforce client and runner failures

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 3f243eaeee
10 changed files with 859 additions and 273 deletions

237
facts.py
View file

@ -14,24 +14,19 @@ 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.
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
@ -89,7 +84,7 @@ def power_amount(statuses: list | None, name: str) -> int:
@dataclass(frozen=True)
class CardDamage:
base: int
base: int # displayed damage per hit, not the card's unmodified base value
hits: int
is_aoe: bool
@ -99,8 +94,8 @@ class CardDamage:
def block_value(card: dict) -> int:
"""Block a card grants, parsed from its description."""
match = BLOCK_RE.search(card.get("description") or "")
"""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
@ -123,22 +118,17 @@ def damage_to_target(
target_status: list | None,
target_block: int = 0,
) -> int:
"""Damage one card deals to one target after buffs, debuffs, and block."""
"""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)
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
per_hit = dmg.base
if power_amount(target_status, "Vulnerable"):
total = int(total * 1.5)
return max(0, total - max(0, target_block))
per_hit = per_hit * 3 // 2
return max(0, per_hit * dmg.hits - max(0, target_block))
# --------------------------------------------------------------------------
@ -250,9 +240,82 @@ def enemy_facts(enemy: dict) -> EnemyFact:
# --------------------------------------------------------------------------
# Lethal search
# 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
# --------------------------------------------------------------------------
@ -273,7 +336,7 @@ class CombatFacts:
playable: list[dict]
potions: list[dict]
player_status: list
lethal_available: bool
lethal_available: bool | None
killable: list[str]
deck_counts: dict
deck_summary: dict
@ -312,26 +375,17 @@ class CombatFacts:
@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
"""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:
"""
True when existing block plus reachable card block covers the incoming
damage. Used to decide whether a potion is the ONLY way to survive.
Estimate survival after known incoming attacks and the block plan.
Survival can include HP loss. Card side effects are not simulated.
"""
return self.block + self.max_block_available >= self.incoming_damage
remaining = max(0, self.incoming_damage - self.block - self.max_block_available)
return remaining < self.hp
# ---------------------------------------------------------------------
# Fight-length projection.
@ -346,15 +400,15 @@ class CombatFacts:
@property
def attack_power(self) -> int:
"""Best single-turn damage the hand can produce, ignoring targets."""
"""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 = _as_int(card.get("cost"))
if cost <= energy:
cost = fixed_energy_cost(card)
if cost is not None and cost <= energy:
total += dmg
energy -= cost
return total
@ -362,11 +416,10 @@ class CombatFacts:
@property
def turns_to_kill(self) -> int:
"""
Turns this fight still needs, from this turn's reachable damage.
Estimate remaining turns by assuming later hands resemble this hand.
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".
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:
@ -391,14 +444,12 @@ class CombatFacts:
@property
def must_block(self) -> bool:
"""
True when the rest of this fight will cost more HP than we can spare.
Request current block when estimated future damage exceeds the HP budget.
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).
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.incoming_damage <= 0:
if self.unblocked_damage <= 0:
return False
return self.projected_incoming > self.affordable_loss
@ -409,7 +460,7 @@ class CombatFacts:
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:
if self.unblocked_damage <= 0:
return False
return (
self.threat in (THREAT_SEVERE, THREAT_LETHAL)
@ -421,8 +472,8 @@ class CombatFacts:
"""
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.
Include computed comparisons so Jev need not perform arithmetic.
Preserve displayed costs, including X. Lethal can be unknown (None).
"""
return {
"combat": {
@ -450,7 +501,7 @@ class CombatFacts:
{
"index": c.get("index"),
"name": c.get("name"),
"cost": _as_int(c.get("cost")),
"cost": c.get("cost"),
"type": c.get("type"),
"targets": c.get("target_type"),
"text": c.get("description"),
@ -510,15 +561,20 @@ def combat_facts(observation: dict) -> CombatFacts:
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]
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.effective_hp > 0 and best >= enemy.effective_hp:
if enemy.hp > 0 and best >= enemy.effective_hp:
killable.append(enemy.entity_id)
lethal = bool(enemies) and len(killable) == len(enemies)
# 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.
@ -554,42 +610,13 @@ def combat_facts(observation: dict) -> CombatFacts:
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
"""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:
"""Adapt the name list back into the shape power_amount expects."""
return [{"name": n, "amount": 1} for n in enemy.statuses]
"""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: