"""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)