refactor(policy): extract combat selection and context modules

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 484551047c
9 changed files with 1247 additions and 1167 deletions

1
policy/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Policy components. Use brain.decide as the observe-to-proposal entry point."""

300
policy/combat.py Normal file
View 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
View 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
View 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")