fix(bot): correct combat estimates and enforce client and runner failures
This commit is contained in:
parent
62693618db
commit
3f243eaeee
10 changed files with 859 additions and 273 deletions
87
brain.py
87
brain.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue