refactor(policy): extract combat selection and context modules
This commit is contained in:
parent
bf41945ef9
commit
484551047c
9 changed files with 1247 additions and 1167 deletions
300
policy/combat.py
Normal file
300
policy/combat.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue