1720 lines
68 KiB
Python
1720 lines
68 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
brain.py -- the decision layer.
|
|
|
|
Precedence, and why it is ordered this way:
|
|
|
|
1. CODE searches supported direct-damage lines in facts.py. The policy plays
|
|
one card, then observes again. This is a limited model, not a complete
|
|
combat simulation. Jev is never asked to calculate damage.
|
|
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 json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import facts as F
|
|
from jev import JevClient, 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 _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)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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)
|