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
1
policy/__init__.py
Normal file
1
policy/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Policy components. Use brain.decide as the observe-to-proposal entry point."""
|
||||
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)
|
||||
149
policy/context.py
Normal file
149
policy/context.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Shared proposal types and session-owned policy memory. No game or model calls."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Decision:
|
||||
action: str
|
||||
params: dict = field(default_factory=dict)
|
||||
reason: str = ""
|
||||
source: str = "code" # code | jev | fallback
|
||||
confidence: float | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
params = ", ".join(f"{k}={v!r}" for k, v in self.params.items())
|
||||
conf = f" conf={self.confidence:.2f}" if self.confidence is not None else ""
|
||||
return f"[{self.source}{conf}] {self.action}({params}) # {self.reason}"
|
||||
|
||||
|
||||
# rewards and card_reward are two views of one flow: claiming a card reward
|
||||
# opens the card screen, and skipping returns to the rewards screen. Treat them
|
||||
# as ONE screen group so per-flow state is not cleared on every hop.
|
||||
SCREEN_GROUPS = {"rewards": "rewards_flow", "card_reward": "rewards_flow"}
|
||||
|
||||
|
||||
def _screen_group(state_type: str | None, menu_screen: str | None = None) -> str | None:
|
||||
"""
|
||||
A stable key for "which screen am I on".
|
||||
|
||||
The `menu` state_type covers the main menu, mode select, character select
|
||||
and the tutorial prompt. Those are different screens with different valid
|
||||
actions, so the menu_screen is part of the key.
|
||||
"""
|
||||
if state_type == "menu":
|
||||
return f"menu:{menu_screen}"
|
||||
return SCREEN_GROUPS.get(state_type, state_type)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAction:
|
||||
"""An accepted request, not proof that the game completed it."""
|
||||
|
||||
decision: Decision
|
||||
screen: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyContext:
|
||||
"""Session-owned memory. Proposals never record execution.
|
||||
|
||||
Grid toggles and character selection lack observable selection fields in
|
||||
the mod. Their accepted requests are tracked explicitly, not called facts.
|
||||
Other guarded actions wait for relevant screen evidence before continuing.
|
||||
"""
|
||||
|
||||
screen_group: str | None = None
|
||||
accepted_card_indices: set[int] = field(default_factory=set)
|
||||
accepted_card_grid: list[dict] | None = None
|
||||
accepted_crystal_cells: set[tuple[int, int]] = field(default_factory=set)
|
||||
rewards_skipped_card: bool = False
|
||||
character_selected: bool = False
|
||||
pending: PendingAction | None = None
|
||||
|
||||
def observe(self, obs: dict) -> None:
|
||||
st = obs.get("state_type")
|
||||
if st in ("unknown", "overlay"):
|
||||
return # A transient overlay is not evidence of completion.
|
||||
group = _screen_group(st, obs.get("menu_screen"))
|
||||
if group != self.screen_group:
|
||||
self.screen_group = group
|
||||
self.accepted_card_indices.clear()
|
||||
self.accepted_card_grid = None
|
||||
self.accepted_crystal_cells.clear()
|
||||
self.rewards_skipped_card = False
|
||||
self.character_selected = False
|
||||
self.pending = None
|
||||
return
|
||||
pending = self.pending
|
||||
if pending is None:
|
||||
return
|
||||
action, params = pending.decision.action, pending.decision.params
|
||||
screen = obs.get(st) or {}
|
||||
if action == "select_card" and pending.screen.get("screen_type") != "choose":
|
||||
# The API exposes no selected indices. Reserve accepted toggles
|
||||
# after a fresh read, even when their effect is not visible yet.
|
||||
if screen.get("cards") != pending.screen.get("cards"):
|
||||
return # Cannot map a toggle safely onto a changed grid.
|
||||
self.accepted_card_indices.add(params["index"])
|
||||
self.accepted_card_grid = pending.screen.get("cards")
|
||||
elif action == "menu_select" and params.get("option") != "embark":
|
||||
self.character_selected = True
|
||||
elif action == "skip_card_reward":
|
||||
if st != "rewards":
|
||||
return
|
||||
self.rewards_skipped_card = True
|
||||
elif action in ("confirm_selection", "cancel_selection",
|
||||
"confirm_bundle_selection", "cancel_bundle_selection",
|
||||
"combat_confirm_selection"):
|
||||
return # Wait for the screen to close; do not cancel a slow confirm.
|
||||
elif action == "crystal_sphere_click_cell":
|
||||
cell = (params["x"], params["y"])
|
||||
clickable = {(c.get("x"), c.get("y"))
|
||||
for c in screen.get("clickable_cells", [])}
|
||||
if cell in clickable and not screen.get("can_proceed"):
|
||||
return
|
||||
self.accepted_crystal_cells.add(cell)
|
||||
elif action == "shop_purchase":
|
||||
# Gold alone can change for unrelated reasons. Require this item
|
||||
# to change/disappear, or a screen transition (e.g. card removal).
|
||||
before = pending.screen.get("shop", pending.screen)
|
||||
after = screen.get("shop", screen)
|
||||
index = params["index"]
|
||||
old = next((i for i in before.get("items", []) if i.get("index") == index), None)
|
||||
new = next((i for i in after.get("items", []) if i.get("index") == index), None)
|
||||
def inventory_item(item):
|
||||
return {k: v for k, v in item.items() if k not in ("can_afford", "price")} if item else None
|
||||
if inventory_item(old) == inventory_item(new):
|
||||
return
|
||||
elif action == "combat_select_card":
|
||||
if len(screen.get("selected_cards") or []) <= len(pending.screen.get("selected_cards") or []):
|
||||
return
|
||||
elif action == "select_bundle":
|
||||
if not screen.get("preview_showing") or not screen.get("can_confirm"):
|
||||
return
|
||||
else:
|
||||
return # Direct choices and embark require a screen transition.
|
||||
self.pending = None
|
||||
|
||||
def record_result(self, obs: dict, decision: Decision, *, accepted: bool) -> None:
|
||||
"""Called only after a real action result. Transport errors stop the runner."""
|
||||
if not accepted:
|
||||
self.pending = None
|
||||
if decision.action == "menu_select" and decision.params.get("option") == "embark":
|
||||
self.character_selected = False
|
||||
return
|
||||
st = obs.get("state_type")
|
||||
guarded = decision.action in {
|
||||
"select_card", "confirm_selection", "cancel_selection",
|
||||
"select_bundle", "confirm_bundle_selection", "cancel_bundle_selection",
|
||||
"shop_purchase", "skip_card_reward", "crystal_sphere_click_cell",
|
||||
"combat_select_card", "combat_confirm_selection",
|
||||
} or (st == "menu" and obs.get("menu_screen") == "character_select")
|
||||
if guarded:
|
||||
# Detach from mutable fixtures/callers. This is a small screen node,
|
||||
# not another copy of the entire combat observation.
|
||||
screen = json.loads(json.dumps(obs.get(st) or {}))
|
||||
self.pending = PendingAction(decision, screen)
|
||||
687
policy/selection.py
Normal file
687
policy/selection.py
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
"""Card, relic, bundle, and reward proposals. Execution belongs to the runner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import facts as F
|
||||
from jev import JevClient, NoulAnswer, choice, gate, noul
|
||||
from .context import Decision, PolicyContext
|
||||
|
||||
# Minimum absolute Noul before adding an offered card to the deck.
|
||||
CARD_PICK_THRESHOLD = 0.60
|
||||
|
||||
|
||||
RARITY_RANK = {"Basic": 0, "Common": 1, "Uncommon": 2, "Rare": 3, "Special": 4}
|
||||
|
||||
|
||||
def best_by_noul(response, keys, threshold: float) -> tuple[str | None, float]:
|
||||
"""
|
||||
Pick the highest absolute Noul among `keys`, or (None, best) below threshold.
|
||||
|
||||
Absolute Nouls do not dilute as the candidate count grows, unlike a single
|
||||
Choice over many options. This is the documented re-ranking pattern.
|
||||
"""
|
||||
ranked = [
|
||||
(ans.noul, key)
|
||||
for key in keys
|
||||
if isinstance((ans := response.get(key)), NoulAnswer)
|
||||
]
|
||||
if not ranked:
|
||||
return None, 0.0
|
||||
best_noul, best_key = max(ranked)
|
||||
return (best_key if best_noul >= threshold else None), best_noul
|
||||
|
||||
|
||||
def best_by_rarity(cards: list[dict]) -> dict | None:
|
||||
"""Deterministic fallback: prefer the rarest card, never blindly index 0."""
|
||||
if not cards:
|
||||
return None
|
||||
return max(cards, key=lambda c: RARITY_RANK.get(str(c.get("rarity")), 1))
|
||||
|
||||
|
||||
def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None,
|
||||
*, skip_policy: str = "jev") -> Decision:
|
||||
"""
|
||||
Deck building -- the macro decision that actually decides a run.
|
||||
|
||||
The reward state does NOT expose the deck, so `deck` is a composition
|
||||
snapshot taken from a combat state (which exposes all four piles).
|
||||
"""
|
||||
reward = obs.get("card_reward") or {}
|
||||
cards = reward.get("cards") or []
|
||||
if not cards:
|
||||
return Decision("skip_card_reward", {}, "no cards offered", "code")
|
||||
|
||||
if client is None:
|
||||
fallback_card = best_by_rarity(cards)
|
||||
return Decision("select_card_reward", {"card_index": fallback_card["index"]},
|
||||
"no jev; took the rarest", "fallback")
|
||||
|
||||
run = obs.get("run") or {}
|
||||
player = obs.get("player") or {}
|
||||
|
||||
state = {
|
||||
"run": {
|
||||
"act": run.get("act"),
|
||||
"floor": run.get("floor"),
|
||||
"ascension": run.get("ascension"),
|
||||
},
|
||||
"character": player.get("character"),
|
||||
"health": F._hp_bucket(
|
||||
(player.get("hp") or 0) / (player.get("max_hp") or 1),
|
||||
player.get("hp"),
|
||||
),
|
||||
"deck_composition": F.deck_context(deck),
|
||||
"offered": {
|
||||
f"card{c['index']}": {
|
||||
"name": c["name"],
|
||||
"type": c["type"],
|
||||
"text": c["description"],
|
||||
}
|
||||
for c in cards
|
||||
},
|
||||
}
|
||||
|
||||
# ONE question decides skip, and one per card decides WHICH.
|
||||
#
|
||||
# Previously a hardcoded CARD_PICK_THRESHOLD of 0.60 made the skip call, and
|
||||
# Jev only ranked. Measured, Jev's ratings form a smooth continuum from 0.45
|
||||
# to 0.68, so that threshold was cutting the distribution in half -- the
|
||||
# skip was the threshold's decision, not the model's. Now Jev decides skip
|
||||
# explicitly and the per-card ratings only choose which card to take.
|
||||
keys = [f"card{c['index']}" for c in cards]
|
||||
questions: dict[str, dict] = {
|
||||
"skip_all": noul(
|
||||
"Should this deck skip EVERY card in `offered` and stay lean? "
|
||||
"A lean deck draws its key cards more often, so skipping is often "
|
||||
"correct."
|
||||
)
|
||||
}
|
||||
for c in cards:
|
||||
questions[f"good_card{c['index']}"] = noul(
|
||||
f"Would `offered.card{c['index']}` make this deck stronger?"
|
||||
)
|
||||
|
||||
response = client.ask(state, questions)
|
||||
|
||||
can_skip = reward.get("can_skip", True)
|
||||
skip_answer = response.get("skip_all")
|
||||
|
||||
# JEV decides the skip. Only a confident yes skips.
|
||||
if (
|
||||
can_skip
|
||||
and isinstance(skip_answer, NoulAnswer)
|
||||
and skip_answer.yes
|
||||
and gate(skip_answer)
|
||||
):
|
||||
return Decision("skip_card_reward", {},
|
||||
f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean",
|
||||
"jev", skip_answer.noul)
|
||||
|
||||
# Otherwise take the card Jev rated highest.
|
||||
best_key, best_noul = best_by_noul(response, [f"good_{k}" for k in keys], 0.0)
|
||||
|
||||
# "combined": Jev declined to skip, but if nothing it rated clears the
|
||||
# floor the deck is better off lean. This is the safety net that stops the
|
||||
# deck bloating when Jev is indifferent.
|
||||
if skip_policy == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD:
|
||||
return Decision("skip_card_reward", {},
|
||||
f"combined: jev said don't skip but best={best_noul:.2f} "
|
||||
f"< {CARD_PICK_THRESHOLD}; keep the deck lean",
|
||||
"fallback", best_noul)
|
||||
|
||||
if best_key is None:
|
||||
if can_skip:
|
||||
return Decision("skip_card_reward", {},
|
||||
"no usable ratings; keeping the deck lean", "fallback")
|
||||
fallback_card = best_by_rarity(cards)
|
||||
return Decision("select_card_reward", {"card_index": fallback_card["index"]},
|
||||
"no ratings and cannot skip; took the rarest", "fallback")
|
||||
|
||||
card = next((c for c in cards if f"good_card{c['index']}" == best_key), None)
|
||||
if card is None:
|
||||
fallback_card = best_by_rarity(cards)
|
||||
return Decision("select_card_reward",
|
||||
{"card_index": fallback_card["index"]},
|
||||
"unmapped choice", "fallback")
|
||||
return Decision("select_card_reward", {"card_index": card["index"]},
|
||||
f"jev took {card['name']} (noul={best_noul:.2f})",
|
||||
"jev", best_noul)
|
||||
|
||||
|
||||
def relic_select_decision(obs: dict, client: JevClient | None) -> Decision:
|
||||
"""
|
||||
Boss/elite relic choice. Verified shape: `relic_select.relics[]` with
|
||||
index/id/name/description/rarity, plus `can_skip`.
|
||||
"""
|
||||
node = obs.get("relic_select") or obs.get("relics") or {}
|
||||
if not isinstance(node, dict):
|
||||
node = {}
|
||||
relics = [r for r in (node.get("relics") or node.get("options") or [])
|
||||
if isinstance(r, dict)]
|
||||
|
||||
if not relics:
|
||||
return Decision("skip_relic_selection", {}, "no relics parsed", "code")
|
||||
|
||||
# A single offered relic is not a choice.
|
||||
if len(relics) == 1:
|
||||
return Decision("select_relic", {"index": relics[0].get("index", 0)},
|
||||
f"only relic: {relics[0].get('name')}", "code")
|
||||
|
||||
def rarity_pick() -> dict:
|
||||
return max(relics, key=lambda r: RARITY_RANK.get(str(r.get("rarity")), 1))
|
||||
|
||||
if client is None:
|
||||
chosen = rarity_pick()
|
||||
return Decision("select_relic", {"index": chosen.get("index", 0)},
|
||||
f"no jev; took the rarest ({chosen.get('name')})", "fallback")
|
||||
|
||||
keys = [f"relic{r.get('index', 0)}" for r in relics]
|
||||
questions = {
|
||||
f"good_{k}": noul(
|
||||
f"Would taking `relics.{k}` make this run stronger than the other "
|
||||
"relics offered?"
|
||||
)
|
||||
for k in keys
|
||||
}
|
||||
if node.get("can_skip"):
|
||||
questions["want_any"] = noul(
|
||||
"Is any relic in `relics` worth taking over skipping?"
|
||||
)
|
||||
|
||||
response = client.ask(
|
||||
{
|
||||
"character": (obs.get("player") or {}).get("character"),
|
||||
"relics": {
|
||||
f"relic{r.get('index', 0)}": {
|
||||
"name": r.get("name"),
|
||||
"rarity": r.get("rarity"),
|
||||
"text": r.get("description"),
|
||||
}
|
||||
for r in relics
|
||||
},
|
||||
},
|
||||
questions,
|
||||
)
|
||||
|
||||
wants = response.get("want_any")
|
||||
if isinstance(wants, NoulAnswer) and (not wants.yes or not gate(wants)):
|
||||
if node.get("can_skip"):
|
||||
return Decision("skip_relic_selection", {},
|
||||
f"jev: skip (noul={wants.noul:.2f})", "jev", wants.noul)
|
||||
|
||||
# The questions are keyed `good_relicN`, so the ranking MUST read the
|
||||
# prefixed ids. Reading `relicN` found nothing, `best_by_noul` returned
|
||||
# (None, 0.0) for every state, and this path could only ever take the
|
||||
# rarest relic -- every answer Jev gave was silently dropped.
|
||||
best_key, best_noul = best_by_noul(response, [f"good_{k}" for k in keys],
|
||||
CARD_PICK_THRESHOLD)
|
||||
if best_key is None:
|
||||
chosen = rarity_pick()
|
||||
return Decision("select_relic", {"index": chosen.get("index", 0)},
|
||||
f"nothing cleared {CARD_PICK_THRESHOLD} (best {best_noul:.2f}); "
|
||||
f"took the rarest ({chosen.get('name')})",
|
||||
"fallback", best_noul or None)
|
||||
|
||||
chosen = next((r for r in relics
|
||||
if f"good_relic{r.get('index', 0)}" == best_key), None)
|
||||
if chosen is None:
|
||||
chosen = rarity_pick()
|
||||
return Decision("select_relic", {"index": chosen.get("index", 0)},
|
||||
"unmapped choice", "fallback")
|
||||
return Decision("select_relic", {"index": chosen.get("index", 0)},
|
||||
f"jev chose {chosen.get('name')} (noul={best_noul:.2f})",
|
||||
"jev", best_noul)
|
||||
|
||||
|
||||
# Deterministic fallbacks for a grid selection screen, used only when Jev does
|
||||
# not clear the threshold. Lower rank wins.
|
||||
#
|
||||
# The old fallback was `cards[0]`, which on an upgrade screen is always a basic
|
||||
# Strike -- which is why the bot only ever upgraded Strikes.
|
||||
def upgrade_rank(card: dict) -> int:
|
||||
name = str(card.get("name") or "")
|
||||
if card.get("is_upgraded"):
|
||||
return 9
|
||||
if name.startswith("Bash"):
|
||||
return 0
|
||||
if not (name.startswith("Strike") or name.startswith("Defend")):
|
||||
return 1
|
||||
return 5 if name.startswith("Strike") else 6
|
||||
|
||||
|
||||
def removal_rank(card: dict) -> int:
|
||||
"""For a removal screen the ordering is inverted: shed basics first."""
|
||||
# An already-upgraded card is worth keeping, so check that BEFORE the name:
|
||||
# otherwise "Strike+" matches the Strike rule and becomes the top target.
|
||||
if card.get("is_upgraded"):
|
||||
return 9
|
||||
name = str(card.get("name") or "")
|
||||
if name.startswith("Strike"):
|
||||
return 0
|
||||
if name.startswith("Defend"):
|
||||
return 1
|
||||
return 3
|
||||
|
||||
|
||||
def screen_kind(screen: str, prompt: str = "") -> str:
|
||||
"""
|
||||
Normalise a card_select screen to one of: upgrade, remove, transform, add.
|
||||
|
||||
Three traps, all measured:
|
||||
* The mod maps only four screens to friendly names and falls through to
|
||||
the RAW C# CLASS NAME for everything else -- e.g.
|
||||
"NDeckEnchantSelectScreen".
|
||||
* `screen_type` can be the generic "select"/"simple_select" while the
|
||||
PROMPT says what is actually happening. Measured: "Choose 5 cards to
|
||||
Remove." was treated as an upgrade, so Jev was asked "would upgrading
|
||||
this make the deck stronger?" on a REMOVAL screen and offered to remove
|
||||
Bash.
|
||||
* "Choose 2 Common Cards to Add to Your Deck." is neither: it is ADDING.
|
||||
|
||||
So the prompt is consulted too.
|
||||
"""
|
||||
text = f"{screen or ''} {prompt or ''}".lower()
|
||||
if "remove" in text:
|
||||
return "remove"
|
||||
if "transform" in text:
|
||||
return "transform"
|
||||
if "add to your deck" in text or "add to your deck" in text:
|
||||
return "add"
|
||||
if "add" in text and "deck" in text:
|
||||
return "add"
|
||||
# upgrade, smith, enchant: pick the card that benefits most.
|
||||
return "upgrade"
|
||||
|
||||
|
||||
def card_select_fallback(cards: list[dict], screen: str,
|
||||
prompt: str = "") -> dict:
|
||||
"""Deterministic pick when the model does not clear the threshold."""
|
||||
kind = screen_kind(screen, prompt)
|
||||
if kind in ("remove", "transform"):
|
||||
return min(cards, key=removal_rank)
|
||||
if kind == "add":
|
||||
# Adding: take the rarest card on offer.
|
||||
return max(cards, key=lambda c: RARITY_RANK.get(str(c.get("rarity")), 1))
|
||||
return min(cards, key=upgrade_rank)
|
||||
|
||||
|
||||
def _card_select_pick(index: int, reason: str, source: str,
|
||||
confidence: float | None = None) -> Decision:
|
||||
"""Propose a toggle. Only an accepted request enters policy memory."""
|
||||
return Decision("select_card", {"index": index}, reason, source, confidence)
|
||||
|
||||
|
||||
def card_select_need(prompt: str) -> int:
|
||||
"""
|
||||
How many cards this screen wants.
|
||||
|
||||
Measured: "Choose 5 cards to Remove." with can_confirm FALSE until all five
|
||||
are picked, and `card_select` exposes NO `selected_cards` field. So the
|
||||
count is parsed from the prompt and tracked by us.
|
||||
|
||||
The pattern must not assume the word "cards" directly follows the number:
|
||||
"Choose 2 Common Cards to Add to Your Deck." has "Common" in between, and a
|
||||
stricter pattern silently returned 1 and stalled the screen.
|
||||
"""
|
||||
match = re.search(r"choose\s+(\d+)", str(prompt or ""), re.IGNORECASE)
|
||||
return int(match.group(1)) if match else 1
|
||||
|
||||
|
||||
def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None,
|
||||
context: PolicyContext) -> Decision:
|
||||
"""
|
||||
Grid selection overlay: upgrade, transform, remove, choose-a-card.
|
||||
|
||||
Two traps, both observed live:
|
||||
* `select_card` TOGGLES on grid screens. Calling it twice on one index
|
||||
deselects and freezes the screen.
|
||||
* A preview can make `confirm_selection` report ok without a visible
|
||||
change. The context waits for the screen to close instead of repeating
|
||||
or cancelling an action whose outcome is still unknown.
|
||||
"""
|
||||
cs = obs.get("card_select") or {}
|
||||
chosen = context.accepted_card_indices
|
||||
|
||||
prompt = str(cs.get("prompt") or "Choose a card.")
|
||||
screen = str(cs.get("screen_type") or "")
|
||||
need = card_select_need(prompt)
|
||||
|
||||
# Confirm once enough cards have been picked. Do NOT rely on
|
||||
# `preview_showing`: screen_type "NDeckEnchantSelectScreen" toggles a
|
||||
# selection with NO preview at all. And do NOT confirm early on a
|
||||
# multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
|
||||
# five are chosen.
|
||||
if cs.get("can_confirm") and (len(chosen) >= need or cs.get("preview_showing")):
|
||||
return Decision("confirm_selection", {}, "confirm the selection", "code")
|
||||
if len(chosen) >= need:
|
||||
# Enough chosen but the game has not enabled confirm yet. Selecting more
|
||||
# would overshoot, so wait.
|
||||
return Decision("__wait__", {},
|
||||
f"{len(chosen)}/{need} accepted toggles; waiting for confirm",
|
||||
"code")
|
||||
|
||||
cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)]
|
||||
if not cards:
|
||||
return Decision("cancel_selection", {}, "no cards to select", "code")
|
||||
|
||||
# `select_card` TOGGLES. Do not repeat an accepted request for this grid.
|
||||
remaining = [c for c in cards if c.get("index") not in chosen]
|
||||
if not remaining:
|
||||
if cs.get("can_confirm"):
|
||||
return Decision("confirm_selection", {}, "all selectable cards chosen", "code")
|
||||
return Decision("__wait__", {},
|
||||
f"{len(chosen)}/{need} accepted toggles; nothing new to select",
|
||||
"code")
|
||||
|
||||
# One ABSOLUTE Noul per candidate, argmax in code. A single Choice over a
|
||||
# 13+ card deck diluted badly and the fallback then always took index 0.
|
||||
# Candidates exclude indices with accepted toggle requests.
|
||||
keys = [f"card{c['index']}" for c in remaining]
|
||||
kind = screen_kind(screen, prompt)
|
||||
questions: dict[str, dict] = {}
|
||||
for c in remaining:
|
||||
key = f"card{c['index']}"
|
||||
if kind == "remove":
|
||||
questions[f"good_{key}"] = noul(
|
||||
f"Would removing `cards.{key}` make this deck stronger?"
|
||||
)
|
||||
elif kind == "transform":
|
||||
questions[f"good_{key}"] = noul(
|
||||
f"Would transforming `cards.{key}` into a random card make "
|
||||
"this deck stronger?"
|
||||
)
|
||||
elif kind == "add":
|
||||
questions[f"good_{key}"] = noul(
|
||||
f"Would adding `cards.{key}` to this deck make it stronger?"
|
||||
)
|
||||
else:
|
||||
questions[f"good_{key}"] = noul(
|
||||
f"Would upgrading `cards.{key}` make this deck stronger?"
|
||||
)
|
||||
|
||||
if client is None or len(remaining) == 1:
|
||||
fallback_card = card_select_fallback(remaining, screen, prompt)
|
||||
return _card_select_pick(fallback_card["index"],
|
||||
f"no jev; {screen or 'select'} heuristic", "fallback")
|
||||
|
||||
response = client.ask(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"screen_type": screen,
|
||||
"deck_composition": F.deck_context(deck),
|
||||
"cards": {
|
||||
f"card{c['index']}": {
|
||||
"name": c["name"],
|
||||
"text": c["description"],
|
||||
"already_upgraded": c.get("is_upgraded"),
|
||||
}
|
||||
for c in remaining
|
||||
},
|
||||
},
|
||||
questions,
|
||||
)
|
||||
|
||||
best_key, best_noul = best_by_noul(
|
||||
response, [f"good_{k}" for k in keys], CARD_PICK_THRESHOLD
|
||||
)
|
||||
if best_key is None:
|
||||
fallback_card = card_select_fallback(remaining, screen, prompt)
|
||||
return _card_select_pick(
|
||||
fallback_card["index"],
|
||||
f"no card cleared {CARD_PICK_THRESHOLD} (best {best_noul:.2f}); "
|
||||
f"used the {screen or 'select'} heuristic",
|
||||
"fallback", best_noul or None)
|
||||
|
||||
card = next((c for c in remaining if f"good_card{c['index']}" == best_key), None)
|
||||
if card is None:
|
||||
fallback_card = card_select_fallback(remaining, screen, prompt)
|
||||
return _card_select_pick(fallback_card["index"], "unmapped choice", "fallback")
|
||||
return _card_select_pick(card["index"],
|
||||
f"jev chose {card['name']} (noul={best_noul:.2f})",
|
||||
"jev", best_noul)
|
||||
|
||||
|
||||
POTION_VALUE = {
|
||||
"FRUIT_JUICE": 3,
|
||||
"BLOCK_POTION": 4,
|
||||
"BLOOD_POTION": 4,
|
||||
"WEAK_POTION": 4,
|
||||
"FIRE_POTION": 5,
|
||||
"SWIFT_POTION": 5,
|
||||
"FEAR_POTION": 5,
|
||||
"STRENGTH_POTION": 6,
|
||||
"DEXTERITY_POTION": 6,
|
||||
"EXPLOSIVE_POTION": 6,
|
||||
"ENERGY_POTION": 7,
|
||||
}
|
||||
DEFAULT_POTION_VALUE = 5
|
||||
|
||||
|
||||
def _potions_full(obs: dict) -> bool:
|
||||
p = obs.get("player") or {}
|
||||
return len(p.get("potions") or []) >= (p.get("max_potion_slots") or 0)
|
||||
|
||||
|
||||
def _weakest_potion_slot(obs: dict) -> int | None:
|
||||
potions = [p for p in ((obs.get("player") or {}).get("potions") or [])
|
||||
if isinstance(p, dict)]
|
||||
if not potions:
|
||||
return None
|
||||
weakest = min(
|
||||
potions,
|
||||
key=lambda p: POTION_VALUE.get(str(p.get("id")), DEFAULT_POTION_VALUE),
|
||||
)
|
||||
return weakest.get("slot", 0)
|
||||
|
||||
|
||||
def rewards_decision(obs: dict, context: PolicyContext) -> Decision:
|
||||
"""
|
||||
Reward screen.
|
||||
|
||||
Two traps, both observed live:
|
||||
* Claim right-to-left. Each claim rebuilds the item list and shifts
|
||||
every later index, so claiming from the front loops forever.
|
||||
* A potion reward with every slot full reports ok and is silently
|
||||
dropped, leaving the item in place forever. Free a slot first.
|
||||
* A SKIPPED CARD REWARD IS NOT CONSUMED. The card stays in the list, so
|
||||
claim -> card screen -> skip -> claim again loops forever. Track the
|
||||
skip and ignore card rewards afterwards.
|
||||
"""
|
||||
node = obs.get("rewards") or {}
|
||||
items = [i for i in (node.get("items") or []) if isinstance(i, dict)]
|
||||
|
||||
if context.rewards_skipped_card:
|
||||
items = [i for i in items if i.get("type") != "card"]
|
||||
|
||||
if not items:
|
||||
return Decision("proceed", {}, "no claimable rewards left; proceed", "code")
|
||||
|
||||
last = items[-1]
|
||||
idx = last.get("index", len(items) - 1)
|
||||
|
||||
if last.get("type") == "potion" and _potions_full(obs):
|
||||
slot = _weakest_potion_slot(obs)
|
||||
if slot is not None:
|
||||
return Decision(
|
||||
"discard_potion", {"slot": slot},
|
||||
f"potion slots full; drop slot {slot} to take {last.get('potion_name')}",
|
||||
"fallback",
|
||||
)
|
||||
|
||||
return Decision("claim_reward", {"index": idx},
|
||||
f"claim reward {idx} (right-to-left)", "code")
|
||||
|
||||
|
||||
def bundle_select_decision(obs: dict, client: JevClient | None,
|
||||
deck: dict | None) -> Decision:
|
||||
"""
|
||||
Bundle choice: pick one of several 3-card bundles.
|
||||
|
||||
`select_bundle` errors when a preview is already open. Confirm the preview
|
||||
instead. The context waits for evidence after either accepted request;
|
||||
an unchanged read does not prove that confirmation failed.
|
||||
"""
|
||||
bs = obs.get("bundle_select") or {}
|
||||
|
||||
if bs.get("preview_showing") and bs.get("can_confirm"):
|
||||
return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code")
|
||||
|
||||
bundles = [b for b in (bs.get("bundles") or []) if isinstance(b, dict)]
|
||||
if not bundles:
|
||||
return Decision("cancel_bundle_selection", {}, "no bundles; cancel", "code")
|
||||
|
||||
if client is None or len(bundles) == 1:
|
||||
return Decision("select_bundle", {"index": bundles[0].get("index", 0)},
|
||||
"select the first bundle", "fallback")
|
||||
|
||||
options = {
|
||||
f"bundle{b.get('index', 0)}": "; ".join(
|
||||
f"{c.get('name')} ({c.get('description')})"
|
||||
for c in (b.get("cards") or [])
|
||||
)
|
||||
for b in bundles
|
||||
}
|
||||
|
||||
response = client.ask(
|
||||
{
|
||||
"prompt": bs.get("prompt"),
|
||||
"deck_composition": F.deck_context(deck),
|
||||
"bundles": {
|
||||
f"bundle{b.get('index', 0)}": {
|
||||
"cards": [c.get("name") for c in (b.get("cards") or [])]
|
||||
}
|
||||
for b in bundles
|
||||
},
|
||||
},
|
||||
{"best_bundle": choice("Which bundle best improves this deck?", options)},
|
||||
)
|
||||
|
||||
pick = response["best_bundle"]
|
||||
if not gate(pick):
|
||||
return Decision("select_bundle", {"index": bundles[0].get("index", 0)},
|
||||
"low confidence; select the first", "fallback")
|
||||
chosen = next((b for b in bundles if f"bundle{b.get('index', 0)}" == pick.choice), None)
|
||||
if chosen is None:
|
||||
return Decision("select_bundle", {"index": bundles[0].get("index", 0)},
|
||||
"unmapped choice", "fallback")
|
||||
return Decision("select_bundle", {"index": chosen.get("index", 0)},
|
||||
f"jev chose bundle {chosen.get('index')}", "jev", pick.confidence)
|
||||
|
||||
|
||||
def hand_select_need(prompt: str) -> int | None:
|
||||
"""
|
||||
How many cards a hand-select prompt wants, or None for "any number".
|
||||
|
||||
Measured: mode was `simple_select` with prompt "Choose a card to Exhaust."
|
||||
and can_confirm TRUE. The handler only confirmed for `upgrade_select`, so it
|
||||
kept toggling between two cards forever.
|
||||
"""
|
||||
text = str(prompt or "").lower()
|
||||
if "any number" in text:
|
||||
return None
|
||||
match = re.search(r"choose\s+(\d+)\s+cards?", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 1 # "Choose a card to ..."
|
||||
|
||||
|
||||
def hand_select_decision(obs: dict, client: JevClient | None,
|
||||
deck: dict | None) -> Decision:
|
||||
"""
|
||||
In-combat hand selection: exhaust, discard, or replace cards.
|
||||
|
||||
`cards` lists what is still selectable and `selected_cards` lists what is
|
||||
already chosen. When `cards` is empty the only useful action is to confirm;
|
||||
blindly sending combat_select_card(index=0) fails with
|
||||
"Card index 0 out of range (0 selectable cards)".
|
||||
|
||||
Which cards to give up is a real deck decision, but the prompt is usually
|
||||
"replace/exhaust any number", so a deterministic preference for basic
|
||||
Strikes and Defends is both safe and close to optimal.
|
||||
"""
|
||||
hs = obs.get("hand_select") or {}
|
||||
cards = [c for c in (hs.get("cards") or []) if isinstance(c, dict)]
|
||||
selected = hs.get("selected_cards") or []
|
||||
mode = str(hs.get("mode") or "")
|
||||
|
||||
# `upgrade_select` pre-selects the card and only needs confirming. Measured:
|
||||
# mode=upgrade_select, prompt="Confirm Card to Upgrade", can_confirm=true,
|
||||
# cards=[Defend]. Sending combat_select_card is a silent no-op there, so the
|
||||
# loop spun 18 times with no progress. combat_confirm_selection closes it.
|
||||
if mode == "upgrade_select" and hs.get("can_confirm"):
|
||||
return Decision("combat_confirm_selection", {},
|
||||
"upgrade target already chosen; confirm", "code")
|
||||
|
||||
if not cards:
|
||||
if hs.get("can_confirm"):
|
||||
return Decision("combat_confirm_selection", {},
|
||||
f"nothing left to select ({len(selected)} chosen); confirm",
|
||||
"code")
|
||||
return Decision("__wait__", {}, "hand select not ready; re-observe", "code")
|
||||
|
||||
# `combat_select_card` TOGGLES. Re-selecting a card we already chose
|
||||
# DESELECTS it, so the state never changes and the loop stalls -- measured,
|
||||
# 15 consecutive "give up Defend" with no progress. `selected_cards` lists
|
||||
# what is already chosen, so exclude those by NAME (the two lists use
|
||||
# different index spaces, so names are the only reliable match).
|
||||
already: dict[str, int] = {}
|
||||
for entry in selected:
|
||||
if isinstance(entry, dict):
|
||||
name = str(entry.get("name") or "")
|
||||
already[name] = already.get(name, 0) + 1
|
||||
|
||||
remaining: list[dict] = []
|
||||
for c in cards:
|
||||
name = str(c.get("name") or "")
|
||||
if already.get(name, 0) > 0:
|
||||
already[name] -= 1
|
||||
continue
|
||||
remaining.append(c)
|
||||
|
||||
if not remaining:
|
||||
if hs.get("can_confirm"):
|
||||
return Decision("combat_confirm_selection", {},
|
||||
f"everything selectable is already chosen "
|
||||
f"({len(selected)}); confirm", "code")
|
||||
return Decision("__wait__", {}, "nothing new to select; re-observe", "code")
|
||||
|
||||
# Confirm as soon as the prompt's requirement is met. A singular prompt
|
||||
# ("Choose a card to Exhaust.") needs exactly one; "any number" needs us to
|
||||
# decide when to stop.
|
||||
need = hand_select_need(hs.get("prompt"))
|
||||
if need is not None and len(selected) >= need and hs.get("can_confirm"):
|
||||
return Decision("combat_confirm_selection", {},
|
||||
f"{len(selected)}/{need} chosen; confirm", "code")
|
||||
|
||||
def give_up_priority(card: dict) -> int | None:
|
||||
"""Only basic Strikes and Defends are worth giving up. None means keep."""
|
||||
name = str(card.get("name") or "")
|
||||
if name.startswith("Strike"):
|
||||
return 0
|
||||
if name.startswith("Defend"):
|
||||
return 1
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
(give_up_priority(c), c)
|
||||
for c in remaining
|
||||
if give_up_priority(c) is not None
|
||||
]
|
||||
|
||||
if candidates:
|
||||
_, target = min(candidates, key=lambda pair: pair[0])
|
||||
return Decision("combat_select_card", {"card_index": target.get("index", 0)},
|
||||
f"give up {target.get('name')}", "fallback")
|
||||
|
||||
# Nothing disposable left. Do NOT feed good cards to a "choose any number"
|
||||
# prompt: measured, the previous rule gave up Uppercut, Stomp and Bash.
|
||||
if hs.get("can_confirm"):
|
||||
return Decision("combat_confirm_selection", {},
|
||||
f"only good cards left; keep them ({len(selected)} chosen)",
|
||||
"code")
|
||||
|
||||
# The prompt requires a selection, so give up the first thing available.
|
||||
target = remaining[0]
|
||||
return Decision("combat_select_card", {"card_index": target.get("index", 0)},
|
||||
f"forced selection; gave up {target.get('name')}", "fallback")
|
||||
Loading…
Add table
Add a link
Reference in a new issue