sts2-bot/brain.py
0xrsydn 471c77b353 fix(combat): block on projected fight damage, and three lethal-search defects
Mining the 37 run files showed 81% of runs (30/37) die in Act 1, and 43% to an
Act 1 boss. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10
turns and cost 44-80 HP -- about 10-13 a turn, with block cards in hand the
whole time. Four defects, fixed here together because they were found and
verified as one combat-correctness pass.

1. NO DEFENCE POLICY (the big one, found by mining the data)

   facts classes a hit of <=15% of max HP as THREAT_CHIP. At 80 max HP that is
   12, exactly what the boss deals, and _fallback_combat only blocked for HEAVY
   or worse while HP was HEALTHY (>60%). So at 74/80 HP the bot attacked through
   the boss's main attack and only started blocking below 48 HP.

   _jev_combat also asked a should_defend Noul on every combat turn and never
   read it -- grep -rn should_defend returned one line, the one creating it.
   Defence therefore fell to choice("Which single play best advances winning
   this fight?"), which is damage-biased: on the real Kin state Jev answered
   Bash at 0.42 confidence, below the 0.45 gate, so it fell through to the
   fallback, which also chose damage. Both paths agreed on the wrong answer.

   Blocking is arithmetic, so it is now decided in code before Jev is asked,
   using turns_to_kill, projected_incoming, affordable_loss and must_block /
   block_urgent. Measured against all 600 real combat captures, the rule changes
   8 of 68 in-play turns (11.8%) and stays silent on short fights and when
   nothing is incoming.

2. ENEMY BLOCK COUNTED TWICE IN THE LETHAL SEARCH

   `total - max(0, enemy.block) >= enemy.effective_hp` subtracts block a second
   time, because effective_hp is already hp + block. A 10 hp / 5 block enemy
   against 18 raw damage read as "not lethal" and real kills were discarded.

3. THE LETHAL EXECUTOR IGNORED PLAYER STATUSES

   `_lethal_line(f.playable, f.energy, [], enemy)` passed an empty status list,
   so facts reported lethal_available: true while the code meant to execute the
   kill found nothing. CombatFacts.player_status is now passed through.

4. RELIC_SELECT ASKED good_relicN AND READ relicN

   Every answer missed, so best_by_noul returned (None, 0.0) for every state and
   the path could only ever take the rarest relic.

Tests: 50 in test_facts.py and 131 in test_brain.py, with a regression case for
each -- a blocked enemy, a Strength-carrying player, a relic offer whose
highest-rated relic is deliberately the common one so the rarity fallback cannot
pass by accident, and the Kin turn itself.
2026-09-22 06:09:16 +07:00

1771 lines
69 KiB
Python

#!/usr/bin/env python3
"""
brain.py -- the decision layer.
Precedence, and why it is ordered this way:
1. CODE decides lethal. facts.py proves lethal is reachable; a deterministic
greedy then executes it. Jev is never asked "can I kill this", because it
answered that question wrongly at 0.79 confidence.
2. CODE decides the fallback. When Jev is unsure, or unavailable, a documented
heuristic (adapted from the STS2MCP AGENTS.md strategy notes) acts instead.
3. JEV decides preference. Only when lethal is not available and the fallback
is not forced do we ask Jev which play is best. That is a judgement about
semantics, which is what Jev is actually good at.
Every decision returns exactly ONE action. Card indices shift on every play,
so the loop must re-observe after each action.
"""
from __future__ import annotations
import itertools
import json
import re
from dataclasses import dataclass, field
from typing import Any
import facts as F
from jev import JevClient, JevError, ChoiceAnswer, NoulAnswer, choice, gate, noul
# Events are high-stakes and often irreversible, so the bar is higher than for
# combat. Measured: Jev picked a run-ending option at confidence 0.49.
EVENT_TOP_MIN = 0.60
EVENT_MARGIN_MIN = 0.30
# Deterministic safety net for uncertain event choices.
#
# A "does this risk losing the run?" Noul was tried and REMOVED: measured, it
# ranked "Lose Everything" (0.46) as LESS risky than "Keep Deciphering" (0.52)
# and lower than "Stop" would suggest. It is actively misleading, so danger is
# detected by keywords instead. Lower score is safer.
EVENT_RISK_WORDS = (
"everything", "keep", "continue", "deeper", "again", "more", "all of",
"gamble", "risk", "sacrifice", "lose", "push", "betray", "accept",
)
EVENT_STOP_WORDS = (
"stop", "leave", "proceed", "back", "refuse", "decline", "end",
"take what", "walk away", "done", "nothing",
)
def event_safety_rank(option: dict) -> int:
"""Lower is safer. Deterministic; never consults the model."""
text = f"{option.get('title', '')} {option.get('description', '')}".lower()
score = sum(1 for w in EVENT_RISK_WORDS if w in text)
score -= sum(1 for w in EVENT_STOP_WORDS if w in text)
return score
# Confidence below this escalates instead of acting.
CONFIDENCE_FLOOR = 0.55
@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}"
# --------------------------------------------------------------------------
# Combat
# --------------------------------------------------------------------------
def _affordable(hand: list[dict], energy: int) -> list[dict]:
return [
c for c in hand
if c.get("can_play") and F._as_int(c.get("cost")) <= energy
]
def _lethal_line(playable: list[dict], energy: int, player_status: list,
enemy: F.EnemyFact) -> list[dict] | None:
"""
Smallest energy-cost subset of playable cards that kills `enemy`.
Returns the cards to play, highest damage first. None when lethal is
unreachable. Deterministic, so Jev is never involved.
"""
best: list[dict] | None = None
n = min(len(playable), 12)
for r in range(1, n + 1):
for combo in itertools.combinations(range(n), r):
cards = [playable[i] for i in combo]
cost = sum(F._as_int(c.get("cost")) for c in cards)
if cost > energy:
continue
total = 0
for c in cards:
dmg = F.parse_card_damage(c)
if dmg.raw_total == 0:
continue
per_hit = dmg.base + F.power_amount(player_status, "Strength")
if F.power_amount(player_status, "Weak"):
per_hit = int(per_hit * 0.75)
card_total = per_hit * dmg.hits
if F.power_amount(F.enemy_status_names(enemy), "Vulnerable"):
card_total = int(card_total * 1.5)
total += card_total
# `total` is raw damage; `effective_hp` is hp + block, so the block
# is subtracted exactly once. Subtracting it here as well required
# hp + 2*block and discarded real lethal lines.
if total >= enemy.effective_hp:
if best is None or len(cards) < len(best):
best = cards
if best:
best.sort(key=lambda c: -F.parse_card_damage(c).raw_total)
return best
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 = [c for c in playable if _block_value(c) > 0]
if blockers and f.block_urgent:
best = max(blockers, key=lambda c: _block_value(c))
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 _block_value(card: dict) -> int:
return F.block_value(card)
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 _needs_enemy_target(card: dict) -> bool:
return card.get("target_type") == "AnyEnemy"
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 {F._as_int(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. Deterministic lethal.
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"lethal 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.
#
# Blocking is a fact about arithmetic -- total incoming over the remaining
# fight versus the HP we can spare -- so it belongs here, not in a
# preference judgement. Jev is not asked to make it.
if f.block_urgent:
blockers = [c for c in f.playable if F.block_value(c) > 0]
if blockers:
# Block hardest first; a single Defend is still better than a Bash.
best = max(blockers, key=lambda c: F.block_value(c))
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:
try:
return _jev_combat(f, client)
except JevError as exc:
print(f" [jev unavailable: {str(exc)[:90]}]")
return _fallback_combat(f)
# --------------------------------------------------------------------------
# Non-combat states
# --------------------------------------------------------------------------
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) -> 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)
):
global _rewards_skipped_card
_rewards_skipped_card = True
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 CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD:
_rewards_skipped_card = True
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:
_rewards_skipped_card = True
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)
MAP_NODE_MEANINGS = {
"Monster": "A normal fight. Costs some health, pays a card reward and gold.",
"Elite": "A hard fight. Pays a relic. Dangerous at low health.",
"Rest": "A campfire. Heal, or upgrade a card.",
"Shop": "Spend gold on cards, relics, potions, or removing a card.",
"Treasure": "A free relic with no fight.",
"Unknown": "Unknown. Could be a fight, an event, a shop, or treasure.",
"Boss": "The act boss. Ends the act.",
}
def _fallback_map(opts: list[dict], hp_pct: float, gold: int) -> Decision:
"""Documented pathing heuristic from the STS2MCP strategy notes."""
kinds = {str(o.get("type")): o for o in opts}
if hp_pct < 0.5 and "Rest" in kinds:
return Decision("choose_map_node", {"index": kinds["Rest"]["index"]},
f"hp {hp_pct:.0%} is low, take the rest site", "fallback")
if hp_pct > 0.7 and "Elite" in kinds:
return Decision("choose_map_node", {"index": kinds["Elite"]["index"]},
f"hp {hp_pct:.0%} is healthy, take the elite for a relic", "fallback")
if "Treasure" in kinds:
return Decision("choose_map_node", {"index": kinds["Treasure"]["index"]},
"free relic", "fallback")
if gold >= 100 and "Shop" in kinds:
return Decision("choose_map_node", {"index": kinds["Shop"]["index"]},
f"{gold} gold, visit the shop", "fallback")
if "Unknown" in kinds and hp_pct > 0.6:
return Decision("choose_map_node", {"index": kinds["Unknown"]["index"]},
"healthy enough to gamble on unknown", "fallback")
return Decision("choose_map_node", {"index": opts[0]["index"]},
"default to the first option", "fallback")
def map_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Decision:
"""
Map pathing. A macro decision: elite fights pay relics but can end a run.
The choice depends on health, gold, and deck quality, so Jev is asked.
"""
m = obs.get("map") or {}
opts = [o for o in (m.get("next_options") or []) if isinstance(o, dict)]
if not opts:
return Decision("__wait__", {}, "map has no next options yet; re-observe", "code")
if len(opts) == 1:
return Decision("choose_map_node", {"index": opts[0]["index"]},
f"only option: {opts[0].get('type')}", "code")
player = obs.get("player") or {}
run = obs.get("run") or {}
hp = player.get("hp") or 0
max_hp = player.get("max_hp") or 1
hp_pct = hp / max_hp if max_hp else 0.0
gold = player.get("gold") or 0
if client is None:
return _fallback_map(opts, hp_pct, gold)
options = {
f"node{o['index']}": (
f"{o.get('type')} (column {o.get('col')}, row {o.get('row')}). "
f"{MAP_NODE_MEANINGS.get(str(o.get('type')), '')}"
)
for o in opts
}
boss = m.get("boss") or {}
state = {
"run": {"act": run.get("act"), "floor": run.get("floor")},
"character": player.get("character"),
"health": F._hp_bucket(hp_pct, hp),
"gold": gold,
"deck_composition": F.deck_context(deck),
"act_boss": boss.get("name") if isinstance(boss, dict) else None,
}
response = client.ask(state, {
"next_node": choice(
"Which node should the player travel to next? Health is a resource, "
"but an elite fight at low health can end the run.",
options,
)
})
pick = response["next_node"]
if not gate(pick):
return _fallback_map(opts, hp_pct, gold)
node = next((o for o in opts if f"node{o['index']}" == pick.choice), None)
if node is None:
return _fallback_map(opts, hp_pct, gold)
return Decision("choose_map_node", {"index": node["index"]},
f"jev chose {node.get('type')}", "jev", pick.confidence)
_last_card_select_sig: str | None = None
_card_select_picked = False
_card_select_confirmed = False
_card_select_chosen: list[int] = []
# 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:
"""Emit a select_card and remember that this index has been toggled on."""
global _card_select_picked
_card_select_picked = True
if index not in _card_select_chosen:
_card_select_chosen.append(index)
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) -> 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 left over from a desynced state makes `confirm_selection`
report ok while changing nothing. If the same screen reappears
unchanged, reset with cancel_selection instead of confirming again.
"""
global _last_card_select_sig, _card_select_picked, _card_select_confirmed
cs = obs.get("card_select") or {}
sig = json.dumps(cs, sort_keys=True)
repeated = sig == _last_card_select_sig
_last_card_select_sig = sig
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 len(_card_select_chosen) >= need:
if cs.get("can_confirm"):
if _card_select_confirmed:
return Decision("cancel_selection", {},
"confirm did not apply; reset the screen", "code")
_card_select_confirmed = True
return Decision("confirm_selection", {},
f"confirm {len(_card_select_chosen)}/{need} selected", "code")
# Enough chosen but the game has not enabled confirm yet. Selecting more
# would overshoot, so wait.
return Decision("__wait__", {},
f"{len(_card_select_chosen)}/{need} chosen; 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, so never re-select an index we already toggled on.
remaining = [c for c in cards if c.get("index") not in _card_select_chosen]
if not remaining:
if cs.get("can_confirm"):
_card_select_confirmed = True
return Decision("confirm_selection", {}, "all selectable cards chosen", "code")
return Decision("__wait__", {},
f"{len(_card_select_chosen)}/{need} chosen; 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 are `remaining` -- never an index we already toggled on.
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)
def event_decision(obs: dict, client: JevClient | None) -> Decision:
"""
Events. Option 0 is often locked, and Ancient events start in dialogue,
which is why a blind choose_event_option(index=0) gets rejected.
"""
ev = obs.get("event") or {}
if ev.get("in_dialogue"):
return Decision("advance_dialogue", {}, "click through dialogue", "code")
opts = [o for o in (ev.get("options") or []) if isinstance(o, dict)]
usable = [o for o in opts if not o.get("is_locked") and not o.get("was_chosen")]
if not usable:
usable = [o for o in opts if not o.get("is_locked")]
if not usable:
return Decision("__wait__", {}, "no usable event options; re-observe", "code")
pool = [o for o in usable if not o.get("is_proceed")] or usable
if len(pool) == 1:
return Decision("choose_event_option", {"index": pool[0]["index"]},
f"only option: {pool[0].get('title')}", "code")
if client is None:
return Decision("choose_event_option", {"index": pool[0]["index"]},
"first usable option", "fallback")
player = obs.get("player") or {}
options = {
f"opt{o['index']}": f"{o.get('title')}: {o.get('description')}"
for o in pool
}
questions: dict[str, dict] = {
"best_option": choice(
"Which event option is the best choice for the player?", options)
}
response = client.ask(
{
"event": ev.get("body") or "",
"character": player.get("character"),
"health": F._hp_bucket(
(player.get("hp") or 0) / (player.get("max_hp") or 1),
player.get("hp"),
),
"gold": player.get("gold"),
},
questions,
)
pick = response["best_option"]
if isinstance(pick, ChoiceAnswer):
top = pick.probabilities.get(pick.choice, 0.0)
runner = max(
(v for k, v in pick.probabilities.items() if k != pick.choice),
default=0.0,
)
else:
top = runner = 0.0
chosen = next((o for o in pool if f"opt{o['index']}" == getattr(pick, "choice", None)), None)
# Act on the model only when it is BOTH confident and not obviously risky.
if (
chosen is not None
and top >= EVENT_TOP_MIN
and (top - runner) >= EVENT_MARGIN_MIN
and event_safety_rank(chosen) <= 0
):
return Decision("choose_event_option", {"index": chosen["index"]},
f"jev chose {chosen.get('title')} ({top:.2f})", "jev",
getattr(pick, "confidence", None))
safest = min(pool, key=event_safety_rank)
if chosen is not None and event_safety_rank(chosen) > 0:
reason = f"jev picked risky '{chosen.get('title')}'; took safest option"
else:
reason = f"uncertain ({top:.2f}); took safest option"
return Decision("choose_event_option", {"index": safest["index"]}, reason,
"fallback", top or None)
def rest_site_decision(obs: dict) -> Decision:
"""
Heal when hurt, otherwise upgrade. (STS2MCP notes: rest before boss.)
Rest options expose `name` and `id`, NOT `title`, plus `is_enabled`.
"""
rs = obs.get("rest_site") or {}
opts = [
o for o in (rs.get("options") or [])
if isinstance(o, dict) and o.get("is_enabled", True)
]
player = obs.get("player") or {}
hp_pct = (player.get("hp") or 0) / (player.get("max_hp") or 1)
if not opts:
# As with the shop, do not gate the exit on `can_proceed`.
return Decision("proceed", {}, "nothing to do here; proceed", "code")
def label(o: dict) -> str:
return f"{o.get('name', '')} {o.get('id', '')} {o.get('description', '')}".lower()
rest = next((o for o in opts if "rest" in label(o) or "heal" in label(o)), None)
smith = next((o for o in opts if "smith" in label(o) or "upgrade" in label(o)), None)
if hp_pct < 0.6 and rest is not None:
return Decision("choose_rest_option", {"index": rest["index"]},
f"hp {hp_pct:.0%}, heal", "fallback")
if smith is not None:
return Decision("choose_rest_option", {"index": smith["index"]},
f"hp {hp_pct:.0%}, upgrade a card", "fallback")
return Decision("choose_rest_option", {"index": opts[0]["index"]},
"first rest option", "fallback")
_last_shop_sig: str | None = None
_last_shop_purchased = False
# Minimum absolute Noul before buying anything in a shop. Absolute judgements
# can legitimately be low for every candidate, so this is a floor, not a rank.
SHOP_BUY_THRESHOLD = 0.60
# Minimum absolute Noul before adding an offered card to the deck.
CARD_PICK_THRESHOLD = 0.60
# How the card-reward skip is decided.
# "jev" : Jev decides via `skip_all`. Measured, Jev almost never says
# skip (13 takes / 0 skips over three sessions), so the deck
# grows by roughly +3.3 cards versus the threshold policy.
# "combined" : skip when Jev says skip OR when the best card is clearly weak.
# Keeps Jev in charge of the ranking while putting a floor under
# the deck size.
CARD_SKIP_POLICY = "jev"
def shop_item_text(item: dict) -> tuple[str, str]:
"""
Resolve a shop item's display name and description.
The shop uses category-specific field names, verified live:
card -> card_name / card_description
relic -> relic_name / relic_description
potion -> potion_name / potion_description
card_removal -> neither
Reading `name`/`description` yields None for every category.
"""
category = str(item.get("category") or "")
if category == "card_removal":
return "Card Removal", "Remove one card from your deck permanently."
for prefix in ("card", "relic", "potion"):
name = item.get(f"{prefix}_name")
if name:
return str(name), str(item.get(f"{prefix}_description") or "")
return str(item.get("name") or "?"), str(item.get("description") or "")
def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Decision:
"""
Shop. Run 001 finished with 279 unspent gold because this always skipped.
The state carries `price`, `is_stocked` and `can_afford` per item, so no
affordability arithmetic needs to reach the model.
"""
global _last_shop_sig, _last_shop_purchased
# `fake_merchant` nests its inventory one level deeper: fake_merchant.shop.
# Reading only obs["shop"] made every fake-merchant shop look empty, so the
# bot always left immediately without buying.
node = obs.get("shop")
if not isinstance(node, dict):
fm = obs.get("fake_merchant")
if isinstance(fm, dict):
node = fm.get("shop") if isinstance(fm.get("shop"), dict) else fm
node = node if isinstance(node, dict) else {}
items = [i for i in (node.get("items") or []) if isinstance(i, dict)]
affordable = [
i for i in items
if i.get("is_stocked", True) and i.get("can_afford", True)
]
if not affordable:
# `can_proceed` is UNRELIABLE here: measured False while proceed()
# worked and moved the game to the map. Never wait on it indefinitely.
return Decision("proceed", {}, "nothing affordable; leave", "code")
# Only stop buying when we purchased from this EXACT shop state and it did
# not change. Keying on the signature alone made a fresh shop look stalled
# because module-level state leaked in from an earlier screen.
sig = json.dumps(node, sort_keys=True)
if sig != _last_shop_sig:
_last_shop_sig = sig
_last_shop_purchased = False
elif _last_shop_purchased:
return Decision("proceed", {}, "shop unchanged after a purchase; leave", "code")
if client is None:
return Decision("proceed", {}, "no jev; skip shop", "fallback")
player = obs.get("player") or {}
run = obs.get("run") or {}
gold = player.get("gold") or 0
# A shop can offer 14+ affordable items. A single Choice over all of them
# dilutes the probability mass: measured, the top option scored only 0.26
# (runner 0.17, margin 0.09) and the margin gate correctly rejected it, so
# the bot would always leave. Use the documented re-ranking pattern instead:
# one ABSOLUTE Noul per candidate, then take the argmax in code. Absolute
# judgements do not dilute as the candidate count grows.
item_keys: dict[str, dict] = {f"item{i['index']}": i for i in affordable}
state = {
"run": {"act": run.get("act"), "floor": run.get("floor")},
"character": player.get("character"),
"health": F._hp_bucket(
(player.get("hp") or 0) / (player.get("max_hp") or 1),
player.get("hp"),
),
"gold": gold,
"deck_composition": F.deck_context(deck),
"items": {
key: {
"name": shop_item_text(item)[0],
"category": item.get("category"),
"price": item.get("price"),
"text": shop_item_text(item)[1],
}
for key, item in item_keys.items()
},
}
# Ask about DECK FIT ONLY. Measured on this exact shop: putting the price
# into the question collapsed the spread across candidates from 0.48 to
# 0.28 and pulled the top item down from 0.67 to 0.51. Weighing value
# against a number is exactly what Jev is documented to fail at.
# Affordability is already filtered in code, so the price stays in the
# state for context but stays OUT of the question.
questions: dict[str, dict] = {}
for key in item_keys:
questions[f"worth_{key}"] = noul(
f"Would `items.{key}` make this deck stronger?"
)
response = client.ask(state, questions)
ranked = [
(ans.noul, key)
for key in item_keys
if isinstance((ans := response.get(f"worth_{key}")), NoulAnswer)
]
if not ranked:
return Decision("proceed", {}, "no usable answers; leave", "fallback")
best_noul, best_key = max(ranked)
if best_noul < SHOP_BUY_THRESHOLD:
return Decision("proceed", {},
f"best item only noul={best_noul:.2f}; leave", "jev", best_noul)
item = item_keys[best_key]
_last_shop_purchased = True
return Decision("shop_purchase", {"index": item["index"]},
f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})",
"jev", best_noul)
def treasure_decision(obs: dict, client: JevClient | None,
deck: dict | None = None) -> Decision:
"""
The chest auto-opens. While it opens, `relics` is absent and only
`can_proceed` is set, so claiming then just gets rejected.
A chest usually offers one relic, in which case there is nothing to judge.
Jev is only consulted when there is a real choice.
"""
tr = obs.get("treasure") or {}
relics = [r for r in (tr.get("relics") or []) if isinstance(r, dict)]
if not relics:
if tr.get("can_proceed"):
return Decision("proceed", {}, "treasure done; proceed", "code")
return Decision("__wait__", {}, "chest opening; re-observe", "code")
if len(relics) == 1 or client is None:
return Decision("claim_treasure_relic", {"index": relics[0].get("index", 0)},
f"claim {relics[0].get('name')}", "code")
options = {
f"relic{r.get('index', 0)}": (
f"{r.get('name')} ({r.get('rarity')}): {r.get('description')}"
)
for r in relics
}
player = obs.get("player") or {}
response = client.ask(
{
"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),
"relics": {
f"relic{r.get('index', 0)}": {
"name": r.get("name"),
"text": r.get("description"),
}
for r in relics
},
},
{"best_relic": choice("Which relic is strongest for this run?", options)},
)
pick = response["best_relic"]
if not gate(pick):
return Decision("claim_treasure_relic", {"index": relics[0].get("index", 0)},
"low confidence; take the first", "fallback")
chosen = next((r for r in relics if f"relic{r.get('index', 0)}" == pick.choice), None)
if chosen is None:
return Decision("claim_treasure_relic", {"index": relics[0].get("index", 0)},
"unmapped choice", "fallback")
return Decision("claim_treasure_relic", {"index": chosen.get("index", 0)},
f"jev chose {chosen.get('name')}", "jev", pick.confidence)
# Rough potion value, used only to choose which potion to drop when every slot
# is full. Lower is discarded first. Unknown potions rank in the middle.
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)
_last_rewards_sig: str | None = None
_rewards_skipped_card = False
def rewards_decision(obs: dict) -> 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 _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")
_last_bundle_sig: str | None = None
def bundle_select_decision(obs: dict, client: JevClient | None,
deck: dict | None) -> Decision:
"""
Bundle choice: pick one of several 3-card bundles.
Same trap as `card_select`: `select_bundle` errors with "A bundle preview
is already open - confirm or cancel it first" once a preview is showing.
Confirm instead, and reset with cancel if a repeated state proves the
confirm did not apply.
"""
global _last_bundle_sig
bs = obs.get("bundle_select") or {}
sig = json.dumps(bs, sort_keys=True)
repeated = sig == _last_bundle_sig
_last_bundle_sig = sig
if bs.get("preview_showing") and bs.get("can_confirm"):
if repeated:
return Decision("cancel_bundle_selection", {},
"bundle preview did not apply; reset", "code")
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")
_crystal_clicked: set[tuple[int, int]] = set()
def crystal_sphere_decision(obs: dict) -> Decision:
"""
Crystal Sphere minigame.
Measured: `can_proceed` is FALSE until tiles are revealed, so the old
unconditional crystal_sphere_proceed was rejected and the run stalled.
Reveal clickable cells until the proceed button unlocks.
"""
cs = obs.get("crystal_sphere") or {}
if cs.get("can_proceed"):
return Decision("crystal_sphere_proceed", {}, "minigame done; proceed", "code")
clickable = [c for c in (cs.get("clickable_cells") or []) if isinstance(c, dict)]
# Never re-click a cell: that wastes a divination and can loop.
fresh = [
c for c in clickable
if (c.get("x"), c.get("y")) not in _crystal_clicked
]
if not fresh:
if clickable:
return Decision("crystal_sphere_proceed", {},
"nothing new to reveal; try to proceed", "fallback")
return Decision("__wait__", {}, "no clickable cells; re-observe", "code")
# Prefer the cell closest to the centre, which is where items tend to sit.
width = cs.get("grid_width") or 0
height = cs.get("grid_height") or 0
cx, cy = (width - 1) / 2, (height - 1) / 2
cell = min(fresh, key=lambda c: abs(c.get("x", 0) - cx) + abs(c.get("y", 0) - cy))
_crystal_clicked.add((cell.get("x"), cell.get("y")))
return Decision("crystal_sphere_click_cell",
{"x": cell.get("x"), "y": cell.get("y")},
f"reveal ({cell.get('x')},{cell.get('y')})", "code")
def simple_decision(obs: dict, client: JevClient | None = None,
deck: dict | None = None) -> Decision | None:
"""Mechanical screens. Most need no model -- they are pure procedure."""
st = obs.get("state_type")
if st == "menu":
screen = obs.get("menu_screen")
if screen == "main":
opts = obs.get("options") or []
names = [o if isinstance(o, str) else o.get("name") for o in opts]
return Decision("menu_select",
{"option": "continue" if "continue" in names else "singleplayer"},
"main menu", "code")
if screen == "tutorial_prompt":
return Decision("menu_select", {"option": "no"}, "disable tutorials", "code")
# Mode select, which appears after the first epoch unlock. `embark` is
# rejected here; only standard/daily/custom/back are valid.
if screen == "singleplayer":
return Decision("menu_select", {"option": "standard"},
"choose standard mode", "code")
# `embark` is REJECTED with "Embark button not available - select a
# character first" unless a character is chosen, and the state carries
# NO "selected" indicator: the mod hardcodes `message` to
# "Select a character." regardless. Verified in AddCharacterSelectMenuState.
#
# Embarking immediately after selecting is also FLAKY -- measured, three
# consecutive "select a character first" rejections -- because the
# selection has not registered yet. So ALTERNATE: select, embark,
# select, embark. A rejected embark is always followed by a fresh
# select, which makes the sequence self-correcting whatever the timing.
if screen == "character_select":
global _charselect_phase
options = obs.get("options") or []
names = [o if isinstance(o, str) else o.get("name") for o in options]
pick = next((c for c in ("IRONCLAD", "SILENT") if c in names), None)
if _charselect_phase == 0 and pick is not None:
_charselect_phase = 1
return Decision("menu_select", {"option": pick},
f"select {pick}", "code")
_charselect_phase = 0
return Decision("menu_select", {"option": "embark"},
"embark (a rejected embark is followed by a re-select)",
"code")
return None
if st == "game_over":
return Decision("menu_select", {"option": "main_menu"}, "run ended", "code")
if st == "rewards":
return rewards_decision(obs)
if st == "card_reward":
return card_reward_decision(obs, client, deck)
if st == "relic_select":
return relic_select_decision(obs, client)
if st == "map":
return map_decision(obs, client, deck)
if st == "rest_site":
return rest_site_decision(obs)
if st == "treasure":
return treasure_decision(obs, client, deck)
if st == "event":
return event_decision(obs, client)
if st in ("shop", "fake_merchant"):
return shop_decision(obs, client, deck)
if st == "hand_select":
return hand_select_decision(obs, client, deck)
if st == "card_select":
return card_select_decision(obs, client, deck)
if st == "bundle_select":
return bundle_select_decision(obs, client, deck)
if st == "crystal_sphere":
return crystal_sphere_decision(obs)
# Transitions and unhandled overlays are not dead ends. Wait and look again;
# run.py's unchanged-state guard bounds this so a real dead end still stops.
if st in ("unknown", "overlay"):
return Decision("__wait__", {}, f"{st} state; re-observe", "code")
return None
_last_state_type: str | None = None
_last_charselect_sig: str | None = None
_charselect_seen = False
_charselect_phase = 0
# 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)
def _reset_screen_guards(state_type: str | None, menu_screen: str | None = None) -> None:
"""
Clear per-screen module state when the screen changes.
These guards detect "the same screen reappeared unchanged", which is only
meaningful within a single screen. Left alone they leak across screens and
runs, so a fresh shop or card grid gets mistaken for a stalled one. Found
by test_brain.py: a fresh fake-merchant shop was reported as
"unchanged after a purchase" because a signature from an earlier case was
still set.
`unknown` and `overlay` are TRANSITIONS, not screens. Resetting on them
wipes the state mid-flow -- during embark the state flickers through
`unknown`, which used to clear the character-select guard and restart the
select/embark cycle.
"""
global _last_state_type, _last_card_select_sig, _last_bundle_sig
global _last_shop_sig, _last_shop_purchased, _rewards_skipped_card
global _charselect_seen, _charselect_phase
global _card_select_picked, _card_select_confirmed, _card_select_chosen
global _crystal_clicked
if state_type in ("unknown", "overlay"):
return
group = _screen_group(state_type, menu_screen)
if group == _last_state_type:
return
_last_state_type = group
_last_card_select_sig = None
_last_bundle_sig = None
_last_shop_sig = None
_last_shop_purchased = False
_rewards_skipped_card = False
_charselect_seen = False
_charselect_phase = 0
_card_select_picked = False
_card_select_confirmed = False
_card_select_chosen.clear()
_crystal_clicked.clear()
def decide(obs: dict, client: JevClient | None, deck: dict | None = None) -> Decision | None:
_reset_screen_guards(obs.get("state_type"), obs.get("menu_screen"))
st = obs.get("state_type")
if st in ("monster", "elite", "boss"):
return combat_decision(F.combat_facts(obs), client)
return simple_decision(obs, client, deck)