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

View file

@ -4,9 +4,9 @@ brain.py -- the decision layer.
Precedence, and why it is ordered this way:
1. CODE decides lethal. facts.py proves lethal is reachable; a deterministic
greedy then executes it. Jev is never asked "can I kill this", because it
answered that question wrongly at 0.79 confidence.
1. CODE searches supported direct-damage lines in facts.py. The policy plays
one card, then observes again. This is a limited model, not a complete
combat simulation. Jev is never asked to calculate damage.
2. CODE decides the fallback. When Jev is unsure, or unavailable, a documented
heuristic (adapted from the STS2MCP AGENTS.md strategy notes) acts instead.
3. JEV decides preference. Only when lethal is not available and the fallback
@ -19,14 +19,13 @@ so the loop must re-observe after each action.
from __future__ import annotations
import itertools
import json
import re
from dataclasses import dataclass, field
from typing import Any
import facts as F
from jev import JevClient, JevError, ChoiceAnswer, NoulAnswer, choice, gate, noul
from jev import JevClient, ChoiceAnswer, NoulAnswer, choice, gate, noul
# Events are high-stakes and often irreversible, so the bar is higher than for
# combat. Measured: Jev picked a run-ending option at confidence 0.49.
@ -78,49 +77,10 @@ class Decision:
# Combat
# --------------------------------------------------------------------------
def _affordable(hand: list[dict], energy: int) -> list[dict]:
return [
c for c in hand
if c.get("can_play") and F._as_int(c.get("cost")) <= energy
]
def _lethal_line(playable: list[dict], energy: int, player_status: list,
enemy: F.EnemyFact) -> list[dict] | None:
"""
Smallest energy-cost subset of playable cards that kills `enemy`.
Returns the cards to play, highest damage first. None when lethal is
unreachable. Deterministic, so Jev is never involved.
"""
best: list[dict] | None = None
n = min(len(playable), 12)
for r in range(1, n + 1):
for combo in itertools.combinations(range(n), r):
cards = [playable[i] for i in combo]
cost = sum(F._as_int(c.get("cost")) for c in cards)
if cost > energy:
continue
total = 0
for c in cards:
dmg = F.parse_card_damage(c)
if dmg.raw_total == 0:
continue
per_hit = dmg.base + F.power_amount(player_status, "Strength")
if F.power_amount(player_status, "Weak"):
per_hit = int(per_hit * 0.75)
card_total = per_hit * dmg.hits
if F.power_amount(F.enemy_status_names(enemy), "Vulnerable"):
card_total = int(card_total * 1.5)
total += card_total
# `total` is raw damage; `effective_hp` is hp + block, so the block
# is subtracted exactly once. Subtracting it here as well required
# hp + 2*block and discarded real lethal lines.
if total >= enemy.effective_hp:
if best is None or len(cards) < len(best):
best = cards
if best:
best.sort(key=lambda c: -F.parse_card_damage(c).raw_total)
return best
"""Use the same supported damage calculation as the fact layer."""
return F.lethal_line(playable, energy, player_status, enemy)
def _fallback_combat(f: F.CombatFacts) -> Decision:
@ -165,9 +125,9 @@ def _fallback_combat(f: F.CombatFacts) -> Decision:
# CRITICAL)`, which ignored any hit of <=12 at 80 max HP. That is how the
# bot lost 9 runs to THE_KIN_BOSS. `block_urgent` adds the fight-length
# view: a small hit that repeats for 10 turns is not a small hit.
blockers = [c for c in playable if _block_value(c) > 0]
blockers = F.best_block_line(playable, f.energy)
if blockers and f.block_urgent:
best = max(blockers, key=lambda c: _block_value(c))
best = blockers[0]
return Decision("play_card", _target_params(best, f),
f"threat={f.threat} hp={f.hp_bucket}, take block",
"fallback")
@ -187,10 +147,6 @@ def _fallback_combat(f: F.CombatFacts) -> Decision:
return Decision("end_turn", {}, "nothing playable", "fallback")
def _block_value(card: dict) -> int:
return F.block_value(card)
def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None) -> dict:
params: dict[str, Any] = {"card_index": card.get("index")}
if card.get("target_type") == "AnyEnemy":
@ -206,10 +162,6 @@ def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None
return params
def _needs_enemy_target(card: dict) -> bool:
return card.get("target_type") == "AnyEnemy"
def _usable_potions(f: F.CombatFacts) -> list[dict]:
"""Potions the game says can be used right now."""
return [p for p in f.potions if p.get("can_use_in_combat", True)]
@ -239,7 +191,7 @@ def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision:
key = f"card{c['index']}"
by_key[key] = c
options[key] = (
f"{c['name']} (cost {F._as_int(c.get('cost'))}): {c.get('description')}"
f"{c['name']} (cost {c.get('cost')}): {c.get('description')}"
)
potion_options: dict[str, str] = {}
@ -372,7 +324,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
if not f.enemies:
return Decision("__wait__", {}, "no enemies yet; re-observe", "code")
# 1. Deterministic lethal.
# 1. A supported direct-damage line against one enemy.
for enemy in f.enemies:
line = _lethal_line(f.playable, f.energy, f.player_status, enemy)
if line:
@ -380,7 +332,7 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
params = _target_params(card, f, force_target=enemy.entity_id)
return Decision(
"play_card", params,
f"lethal line on {enemy.entity_id} ({len(line)} cards)", "code",
f"direct-damage line on {enemy.entity_id} ({len(line)} cards)", "code",
)
# 2. Defense, decided in CODE and taken before Jev is asked.
@ -392,14 +344,13 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
# Jev, asked "which play best advances winning this fight?", chose Bash at
# 0.42 confidence.
#
# Blocking is a fact about arithmetic -- total incoming over the remaining
# fight versus the HP we can spare -- so it belongs here, not in a
# preference judgement. Jev is not asked to make it.
# The fight-duration projection is a heuristic, not a known future.
# Use it only while there is a current block deficit. Start a maximum-block
# plan instead of greedily choosing the largest individual block card.
if f.block_urgent:
blockers = [c for c in f.playable if F.block_value(c) > 0]
blockers = F.best_block_line(f.playable, f.energy)
if blockers:
# Block hardest first; a single Defend is still better than a Bash.
best = max(blockers, key=lambda c: F.block_value(c))
best = blockers[0]
return Decision(
"play_card",
_target_params(best, f),
@ -410,10 +361,8 @@ def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
# 3. Jev for preference, 4. heuristic if it is unsure.
if client is not None:
try:
return _jev_combat(f, client)
except JevError as exc:
print(f" [jev unavailable: {str(exc)[:90]}]")
# The runner owns the failure budget and the model-free retry.
return _jev_combat(f, client)
return _fallback_combat(f)