sts2-bot/policy/combat.py

300 lines
12 KiB
Python

"""Combat proposals. Arithmetic and supported damage searches remain in facts.py."""
from __future__ import annotations
from typing import Any
import facts as F
from jev import JevClient, NoulAnswer, choice, gate, noul
from .context import Decision
# Confidence below this escalates instead of acting.
CONFIDENCE_FLOOR = 0.55
def _lethal_line(playable: list[dict], energy: int, player_status: list,
enemy: F.EnemyFact) -> list[dict] | None:
"""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:
"""
Documented heuristic, used when Jev is unsure or unavailable.
Order follows the STS2MCP strategy notes: never block a sleeping or
buffing enemy, and do not waste energy on block when nothing is incoming.
"""
playable = f.playable
# Don't die holding potions. If the incoming hit is lethal and a potion is
# available, spend one -- "Dying with full potions is the worst outcome."
if f.threat == F.THREAT_LETHAL:
usable = _usable_potions(f)
if usable:
return Decision("use_potion", _potion_params(usable[0], f),
"incoming damage is lethal; spend a potion", "fallback")
# Nothing incoming: spend everything on damage.
if f.unblocked_damage <= 0:
attacks = [c for c in playable if F.parse_card_damage(c).raw_total > 0]
if attacks:
best = max(attacks, key=lambda c: F.parse_card_damage(c).raw_total)
return Decision(
"play_card",
_target_params(best, f),
"no incoming damage, take the biggest attack",
"fallback",
)
if playable:
return Decision("play_card", _target_params(playable[0], f),
"nothing incoming, dump a card", "fallback")
return Decision("end_turn", {}, "no playable cards", "fallback")
# Only spend energy on block when the hit actually matters. At full health
# against a small hit in a SHORT fight, front-loading damage is better:
# HP is a resource. (STS2MCP strategy notes: "HP is a resource, not a
# score", "Front-load damage".)
#
# The old test was `threat in (HEAVY, SEVERE, LETHAL) or hp in (WOUNDED,
# 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 = F.best_block_line(playable, f.energy)
if blockers and f.block_urgent:
best = blockers[0]
return Decision("play_card", _target_params(best, f),
f"threat={f.threat} hp={f.hp_bucket}, take block",
"fallback")
# Otherwise hit the enemy closest to death.
attacks = [c for c in playable if F.parse_card_damage(c).raw_total > 0]
if attacks and f.enemies:
weakest = min(f.enemies, key=lambda e: e.effective_hp)
best = max(attacks, key=lambda c: F.parse_card_damage(c).raw_total)
params = _target_params(best, f, force_target=weakest.entity_id)
return Decision("play_card", params,
f"no block available, focus {weakest.entity_id}", "fallback")
if playable:
return Decision("play_card", _target_params(playable[0], f),
"no better option", "fallback")
return Decision("end_turn", {}, "nothing playable", "fallback")
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":
if force_target:
params["target"] = force_target
elif len(f.enemies) == 1:
params["target"] = f.enemies[0].entity_id
elif f.enemies:
params["target"] = min(f.enemies, key=lambda e: e.effective_hp).entity_id
# With no enemies there is no target to give. The caller must not play
# this card at all -- the game rejects it with
# "Card requires a target. Provide 'target' with an entity_id."
return params
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)]
def _potion_params(potion: dict, f: F.CombatFacts) -> dict:
"""`slot` is the potion slot index, not the list position."""
params: dict[str, Any] = {"slot": potion.get("slot", 0)}
if potion.get("target_type") == "AnyEnemy" and f.enemies:
params["target"] = min(f.enemies, key=lambda e: e.effective_hp).entity_id
return params
def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision:
"""
Ask Jev for this turn's decision. ONE batched call for everything.
Potions are included in the same call. They cost no energy, so they are a
parallel option rather than a separate step, and adding the questions costs
no measurable latency (1 question 0.73s, 3 questions over a full state
0.90s). Run 001 died to the Act 1 boss holding all three potions, so this
is the highest-value gap to close.
"""
options: dict[str, str] = {}
by_key: dict[str, dict] = {}
for c in f.playable:
key = f"card{c['index']}"
by_key[key] = c
options[key] = (
f"{c['name']} (cost {c.get('cost')}): {c.get('description')}"
)
potion_options: dict[str, str] = {}
potion_by_key: dict[str, dict] = {}
for p in _usable_potions(f):
key = f"potion{p.get('slot')}"
potion_by_key[key] = p
potion_options[key] = f"{p.get('name')}: {p.get('description')}"
if not options and not potion_options:
return Decision("end_turn", {}, "nothing playable and no usable potions", "code")
questions: dict[str, dict] = {}
if options:
questions["best_play"] = choice(
"Which single play best advances winning this fight?", options)
if potion_options:
questions["use_potion"] = noul(
"Given `combat.incoming_threat`, `combat.your_health` and `combat.round`, "
"is spending a potion this turn clearly better than saving it?"
)
questions["which_potion"] = choice(
"If a potion is used now, which one?", potion_options)
if len(f.enemies) > 1:
questions["target"] = choice(
"If a single-target attack is played, which enemy should it hit first?",
{
e.entity_id: (
f"{e.name}, health {F._hp_bucket(e.hp_pct, e.hp)}, "
f"incoming {e.incoming_damage}, "
f"{', '.join(e.intent_text) or 'intent unknown'}"
)
for e in f.enemies
},
)
# NOTE: there used to be a `should_defend` Noul here. It was asked on every
# combat turn and never read -- `grep -rn should_defend` returned only the
# line that created it -- so it cost latency and did nothing. The defense
# decision is now made in code before Jev is consulted (see
# `combat_decision`), and the fight-length facts it needs are in
# `combat.fight_is_grinding` / `combat.this_turn_is_dangerous`.
response = client.ask(f.to_state(), questions)
# Potions first: buffs and debuffs should land before the cards they boost.
if potion_options:
wants = response.get("use_potion")
pick_potion = response.get("which_potion")
# CODE decides THAT a potion must be used when the incoming hit is
# lethal and no card play can prevent it. Jev only decides WHICH.
# Measured: on this exact state Jev answered use_potion=0.61, just
# under the 0.65 Noul floor, and the bot would have played a Strike
# and died. Saving a potion is not a choice when the alternative is
# losing the run.
hard_need = f.threat == F.THREAT_LETHAL and not f.survives_with_cards
soft_ok = isinstance(wants, NoulAnswer) and wants.yes and gate(wants)
if hard_need:
potion = None
if pick_potion is not None and gate(pick_potion):
potion = potion_by_key.get(pick_potion.choice)
if potion is None:
usable = _usable_potions(f)
potion = usable[0] if usable else None
if potion is not None:
return Decision(
"use_potion",
_potion_params(potion, f),
"lethal hit and no card can prevent it; spend a potion",
"code",
)
if soft_ok and pick_potion is not None and gate(pick_potion):
potion = potion_by_key.get(pick_potion.choice)
if potion is not None:
return Decision(
"use_potion",
_potion_params(potion, f),
f"jev used {potion.get('name')}",
"jev",
pick_potion.confidence,
)
if not options:
return Decision("end_turn", {}, "no playable cards", "code")
pick = response["best_play"]
if not gate(pick):
return _fallback_combat(f)
card = by_key.get(pick.choice)
if card is None:
return _fallback_combat(f)
params = _target_params(card, f)
target_answer = response.get("target")
if (
params.get("card_index") is not None
and card.get("target_type") == "AnyEnemy"
and target_answer is not None
and gate(target_answer, CONFIDENCE_FLOOR)
):
params["target"] = target_answer.choice
return Decision(
"play_card",
params,
f"jev chose {card['name']}",
"jev",
confidence=pick.confidence,
)
def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
# Between turns (enemy acting, animations, post-combat) there is nothing to
# do but re-observe. Acting here just produces rejected actions.
if not f.in_play_phase:
return Decision("__wait__", {}, "not our play phase; re-observe", "code")
# Enemies gone but combat still in progress: the game is spawning the next
# wave (Phrog Parasite does this). Measured, playing here emits a targetless
# attack and the game rejects it with "Card requires a target".
if not f.enemies:
return Decision("__wait__", {}, "no enemies yet; re-observe", "code")
# 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:
card = line[0]
params = _target_params(card, f, force_target=enemy.entity_id)
return Decision(
"play_card", params,
f"direct-damage line on {enemy.entity_id} ({len(line)} cards)", "code",
)
# 2. Defense, decided in CODE and taken before Jev is asked.
#
# Measured, this was the single biggest hole. `THE_KIN_BOSS` ended 9 of 37
# runs; those fights lasted 5-10 turns and cost 44-80 HP, ~10-13 a turn,
# with block cards in hand the whole time. Both decision paths preferred
# damage: the fallback classed a 12-damage hit at 80 max HP as "chip", and
# Jev, asked "which play best advances winning this fight?", chose Bash at
# 0.42 confidence.
#
# 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 = F.best_block_line(f.playable, f.energy)
if blockers:
best = blockers[0]
return Decision(
"play_card",
_target_params(best, f),
f"defense forced: {f.projected_incoming} projected vs "
f"{f.affordable_loss} affordable ({f.turns_to_kill} turns)",
"code",
)
# 3. Jev for preference, 4. heuristic if it is unsure.
if client is not None:
# The runner owns the failure budget and the model-free retry.
return _jev_combat(f, client)
return _fallback_combat(f)