300 lines
12 KiB
Python
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)
|