refactor(policy): extract combat selection and context modules

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

View file

@ -2,11 +2,13 @@
## Code map ## Code map
- `facts.py`: pure game-state parsing and arithmetic. - `facts.py`: pure game-state parsing and arithmetic.
- `brain.py`: decision policy and session-owned `PolicyContext`; proposes one action per observation. - `brain.py`: policy entry point; dispatch, navigation, shops, and minigames.
- `policy/combat.py` and `policy/selection.py`: combat and selection proposals.
- `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`.
- `run.py`: observedecideact loop, captures, and session attribution. - `run.py`: observedecideact loop, captures, and session attribution.
- `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
- `migrate.py`: dataset migration and integrity checks. - `migrate.py`: dataset migration and integrity checks.
- `CONTEXT.md`: domain terms. `docs/DATASET.md`: dataset schema and limits. - `CONTEXT.md`: domain terms. `docs/POLICY.md`: policy boundaries. `docs/DATASET.md`: dataset schema and limits.
- `docs/research/11-prototype-hardening.md`: audited findings and the proposed development order. - `docs/research/11-prototype-hardening.md`: audited findings and the proposed development order.
- Treat historical claims in `docs/DESIGN.md` as context; verify against current code. - Treat historical claims in `docs/DESIGN.md` as context; verify against current code.

1146
brain.py

File diff suppressed because it is too large Load diff

66
docs/POLICY.md Normal file
View file

@ -0,0 +1,66 @@
# Policy structure
## Current boundaries
```text
run.py observe, execute, report results, record the session
brain.py entry point, dispatch, navigation, shops, minigames
policy/
__init__.py package marker; no registration or initialization
context.py Decision, PendingAction, PolicyContext
combat.py combat proposals and model questions
selection.py card, relic, bundle, hand, and reward proposals
facts.py observation parsing and combat calculations
```
`brain.decide()` remains the policy entry point. The runner owns one
`PolicyContext` per session and reports action results to it.
`brain.Decision` and `brain.PolicyContext` remain available as direct imports
of the shared types, so the runner interface does not change.
Policy modules may ask Jev for preferences. They must not call the game API.
`context.py` does not call either service. Combat arithmetic remains in
`facts.py`, not duplicated inside combat policy.
The dependency direction is simple:
- The runner uses `brain`.
- `brain` uses the combat, selection, and context modules.
- Combat and selection use context, facts, and the model client.
- Context uses only the Python standard library.
- No policy module imports `brain` or the runner.
## State and configuration
A proposal does not update execution memory. Accepted requests are reconciled
with fresh observations. See [policy-state behavior and limits](research/13-policy-state.md)
for the evidence required by each guarded action.
The existing card-skip setting stays in `brain.CARD_SKIP_POLICY`. The dispatcher
passes its value explicitly to `selection.card_reward_decision(skip_policy=...)`.
This avoids a circular import and preserves the existing command-line behavior.
It is configuration, not per-screen action memory.
## Why stop here
These are plain modules, not a plugin framework or class hierarchy. Navigation,
shops, and minigames stay together until a further split helps development.
The extraction does not change game decisions, confidence gates, fallbacks,
state reconciliation, or recording. Run identity and persistent-deck provenance
remain separate work.
## Extraction verification
- 262 existing assertions passed. No test files or assertions were added for the extraction.
- Thirteen isolated whole-process scenarios passed with local HTTP fixtures.
- All 346 stored observations produced identical decisions before and after extraction,
in both model-free mode and local-model-stub mode: 692 comparisons.
- The local model requests also matched, including their state and questions.
- Thirty-six function/class definitions retained identical abstract syntax trees.
Only the two dispatch functions and explicit card-skip parameter changed.
- Dataset integrity, Python compilation, and shell syntax checks passed.
The differential and whole-process probes were temporary. They made no live
game or paid model calls. These checks establish refactoring equivalence for
the tested inputs, not correctness for every possible game state.

View file

@ -6,9 +6,9 @@ This pass replaces per-screen globals with explicit session memory. It follows
[the correctness pass](12-correctness-pass.md). It does not redesign recording, [the correctness pass](12-correctness-pass.md). It does not redesign recording,
identify runs, or establish persistent-deck provenance. identify runs, or establish persistent-deck provenance.
`brain.py` remains one file. Moving policy code and changing its state behavior This state pass kept `brain.py` as one file. Moving policy code and changing its
at the same time would make failures harder to diagnose. A later extraction state behavior together would make failures harder to diagnose. The subsequent
can preserve the interface and the runner-level tests. [policy extraction](../POLICY.md) preserves the interface and runner-level tests.
## Lifecycle ## Lifecycle
@ -103,9 +103,9 @@ use the actual runner and policy together, with isolated game/model boundaries.
## Next boundaries ## Next boundaries
The state interface now gives a stable boundary for extracting combat and Combat and selection policy have since been extracted without changing execution
selection policy without changing execution behavior. Keep a small dispatcher; behavior. See [the current policy structure](../POLICY.md). No plugin framework
no plugin framework or class hierarchy is needed. or class hierarchy was added.
The next state work is run identity and deck provenance. Recording should then The next state work is run identity and deck provenance. Recording should then
link observations, proposals, action attempts, results, and reconciliation. link observations, proposals, action attempts, results, and reconciliation.

1
policy/__init__.py Normal file
View file

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

300
policy/combat.py Normal file
View file

@ -0,0 +1,300 @@
"""Combat proposals. Arithmetic and supported damage searches remain in facts.py."""
from __future__ import annotations
from typing import Any
import facts as F
from jev import JevClient, NoulAnswer, choice, gate, noul
from .context import Decision
# Confidence below this escalates instead of acting.
CONFIDENCE_FLOOR = 0.55
def _lethal_line(playable: list[dict], energy: int, player_status: list,
enemy: F.EnemyFact) -> list[dict] | None:
"""Use the same supported damage calculation as the fact layer."""
return F.lethal_line(playable, energy, player_status, enemy)
def _fallback_combat(f: F.CombatFacts) -> Decision:
"""
Documented heuristic, used when Jev is unsure or unavailable.
Order follows the STS2MCP strategy notes: never block a sleeping or
buffing enemy, and do not waste energy on block when nothing is incoming.
"""
playable = f.playable
# Don't die holding potions. If the incoming hit is lethal and a potion is
# available, spend one -- "Dying with full potions is the worst outcome."
if f.threat == F.THREAT_LETHAL:
usable = _usable_potions(f)
if usable:
return Decision("use_potion", _potion_params(usable[0], f),
"incoming damage is lethal; spend a potion", "fallback")
# Nothing incoming: spend everything on damage.
if f.unblocked_damage <= 0:
attacks = [c for c in playable if F.parse_card_damage(c).raw_total > 0]
if attacks:
best = max(attacks, key=lambda c: F.parse_card_damage(c).raw_total)
return Decision(
"play_card",
_target_params(best, f),
"no incoming damage, take the biggest attack",
"fallback",
)
if playable:
return Decision("play_card", _target_params(playable[0], f),
"nothing incoming, dump a card", "fallback")
return Decision("end_turn", {}, "no playable cards", "fallback")
# Only spend energy on block when the hit actually matters. At full health
# against a small hit in a SHORT fight, front-loading damage is better:
# HP is a resource. (STS2MCP strategy notes: "HP is a resource, not a
# score", "Front-load damage".)
#
# The old test was `threat in (HEAVY, SEVERE, LETHAL) or hp in (WOUNDED,
# CRITICAL)`, which ignored any hit of <=12 at 80 max HP. That is how the
# bot lost 9 runs to THE_KIN_BOSS. `block_urgent` adds the fight-length
# view: a small hit that repeats for 10 turns is not a small hit.
blockers = F.best_block_line(playable, f.energy)
if blockers and f.block_urgent:
best = blockers[0]
return Decision("play_card", _target_params(best, f),
f"threat={f.threat} hp={f.hp_bucket}, take block",
"fallback")
# Otherwise hit the enemy closest to death.
attacks = [c for c in playable if F.parse_card_damage(c).raw_total > 0]
if attacks and f.enemies:
weakest = min(f.enemies, key=lambda e: e.effective_hp)
best = max(attacks, key=lambda c: F.parse_card_damage(c).raw_total)
params = _target_params(best, f, force_target=weakest.entity_id)
return Decision("play_card", params,
f"no block available, focus {weakest.entity_id}", "fallback")
if playable:
return Decision("play_card", _target_params(playable[0], f),
"no better option", "fallback")
return Decision("end_turn", {}, "nothing playable", "fallback")
def _target_params(card: dict, f: F.CombatFacts, force_target: str | None = None) -> dict:
params: dict[str, Any] = {"card_index": card.get("index")}
if card.get("target_type") == "AnyEnemy":
if force_target:
params["target"] = force_target
elif len(f.enemies) == 1:
params["target"] = f.enemies[0].entity_id
elif f.enemies:
params["target"] = min(f.enemies, key=lambda e: e.effective_hp).entity_id
# With no enemies there is no target to give. The caller must not play
# this card at all -- the game rejects it with
# "Card requires a target. Provide 'target' with an entity_id."
return params
def _usable_potions(f: F.CombatFacts) -> list[dict]:
"""Potions the game says can be used right now."""
return [p for p in f.potions if p.get("can_use_in_combat", True)]
def _potion_params(potion: dict, f: F.CombatFacts) -> dict:
"""`slot` is the potion slot index, not the list position."""
params: dict[str, Any] = {"slot": potion.get("slot", 0)}
if potion.get("target_type") == "AnyEnemy" and f.enemies:
params["target"] = min(f.enemies, key=lambda e: e.effective_hp).entity_id
return params
def _jev_combat(f: F.CombatFacts, client: JevClient) -> Decision:
"""
Ask Jev for this turn's decision. ONE batched call for everything.
Potions are included in the same call. They cost no energy, so they are a
parallel option rather than a separate step, and adding the questions costs
no measurable latency (1 question 0.73s, 3 questions over a full state
0.90s). Run 001 died to the Act 1 boss holding all three potions, so this
is the highest-value gap to close.
"""
options: dict[str, str] = {}
by_key: dict[str, dict] = {}
for c in f.playable:
key = f"card{c['index']}"
by_key[key] = c
options[key] = (
f"{c['name']} (cost {c.get('cost')}): {c.get('description')}"
)
potion_options: dict[str, str] = {}
potion_by_key: dict[str, dict] = {}
for p in _usable_potions(f):
key = f"potion{p.get('slot')}"
potion_by_key[key] = p
potion_options[key] = f"{p.get('name')}: {p.get('description')}"
if not options and not potion_options:
return Decision("end_turn", {}, "nothing playable and no usable potions", "code")
questions: dict[str, dict] = {}
if options:
questions["best_play"] = choice(
"Which single play best advances winning this fight?", options)
if potion_options:
questions["use_potion"] = noul(
"Given `combat.incoming_threat`, `combat.your_health` and `combat.round`, "
"is spending a potion this turn clearly better than saving it?"
)
questions["which_potion"] = choice(
"If a potion is used now, which one?", potion_options)
if len(f.enemies) > 1:
questions["target"] = choice(
"If a single-target attack is played, which enemy should it hit first?",
{
e.entity_id: (
f"{e.name}, health {F._hp_bucket(e.hp_pct, e.hp)}, "
f"incoming {e.incoming_damage}, "
f"{', '.join(e.intent_text) or 'intent unknown'}"
)
for e in f.enemies
},
)
# NOTE: there used to be a `should_defend` Noul here. It was asked on every
# combat turn and never read -- `grep -rn should_defend` returned only the
# line that created it -- so it cost latency and did nothing. The defense
# decision is now made in code before Jev is consulted (see
# `combat_decision`), and the fight-length facts it needs are in
# `combat.fight_is_grinding` / `combat.this_turn_is_dangerous`.
response = client.ask(f.to_state(), questions)
# Potions first: buffs and debuffs should land before the cards they boost.
if potion_options:
wants = response.get("use_potion")
pick_potion = response.get("which_potion")
# CODE decides THAT a potion must be used when the incoming hit is
# lethal and no card play can prevent it. Jev only decides WHICH.
# Measured: on this exact state Jev answered use_potion=0.61, just
# under the 0.65 Noul floor, and the bot would have played a Strike
# and died. Saving a potion is not a choice when the alternative is
# losing the run.
hard_need = f.threat == F.THREAT_LETHAL and not f.survives_with_cards
soft_ok = isinstance(wants, NoulAnswer) and wants.yes and gate(wants)
if hard_need:
potion = None
if pick_potion is not None and gate(pick_potion):
potion = potion_by_key.get(pick_potion.choice)
if potion is None:
usable = _usable_potions(f)
potion = usable[0] if usable else None
if potion is not None:
return Decision(
"use_potion",
_potion_params(potion, f),
"lethal hit and no card can prevent it; spend a potion",
"code",
)
if soft_ok and pick_potion is not None and gate(pick_potion):
potion = potion_by_key.get(pick_potion.choice)
if potion is not None:
return Decision(
"use_potion",
_potion_params(potion, f),
f"jev used {potion.get('name')}",
"jev",
pick_potion.confidence,
)
if not options:
return Decision("end_turn", {}, "no playable cards", "code")
pick = response["best_play"]
if not gate(pick):
return _fallback_combat(f)
card = by_key.get(pick.choice)
if card is None:
return _fallback_combat(f)
params = _target_params(card, f)
target_answer = response.get("target")
if (
params.get("card_index") is not None
and card.get("target_type") == "AnyEnemy"
and target_answer is not None
and gate(target_answer, CONFIDENCE_FLOOR)
):
params["target"] = target_answer.choice
return Decision(
"play_card",
params,
f"jev chose {card['name']}",
"jev",
confidence=pick.confidence,
)
def combat_decision(f: F.CombatFacts, client: JevClient | None) -> Decision:
# Between turns (enemy acting, animations, post-combat) there is nothing to
# do but re-observe. Acting here just produces rejected actions.
if not f.in_play_phase:
return Decision("__wait__", {}, "not our play phase; re-observe", "code")
# Enemies gone but combat still in progress: the game is spawning the next
# wave (Phrog Parasite does this). Measured, playing here emits a targetless
# attack and the game rejects it with "Card requires a target".
if not f.enemies:
return Decision("__wait__", {}, "no enemies yet; re-observe", "code")
# 1. A supported direct-damage line against one enemy.
for enemy in f.enemies:
line = _lethal_line(f.playable, f.energy, f.player_status, enemy)
if line:
card = line[0]
params = _target_params(card, f, force_target=enemy.entity_id)
return Decision(
"play_card", params,
f"direct-damage line on {enemy.entity_id} ({len(line)} cards)", "code",
)
# 2. Defense, decided in CODE and taken before Jev is asked.
#
# Measured, this was the single biggest hole. `THE_KIN_BOSS` ended 9 of 37
# runs; those fights lasted 5-10 turns and cost 44-80 HP, ~10-13 a turn,
# with block cards in hand the whole time. Both decision paths preferred
# damage: the fallback classed a 12-damage hit at 80 max HP as "chip", and
# Jev, asked "which play best advances winning this fight?", chose Bash at
# 0.42 confidence.
#
# The fight-duration projection is a heuristic, not a known future.
# Use it only while there is a current block deficit. Start a maximum-block
# plan instead of greedily choosing the largest individual block card.
if f.block_urgent:
blockers = F.best_block_line(f.playable, f.energy)
if blockers:
best = blockers[0]
return Decision(
"play_card",
_target_params(best, f),
f"defense forced: {f.projected_incoming} projected vs "
f"{f.affordable_loss} affordable ({f.turns_to_kill} turns)",
"code",
)
# 3. Jev for preference, 4. heuristic if it is unsure.
if client is not None:
# The runner owns the failure budget and the model-free retry.
return _jev_combat(f, client)
return _fallback_combat(f)

149
policy/context.py Normal file
View file

@ -0,0 +1,149 @@
"""Shared proposal types and session-owned policy memory. No game or model calls."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
@dataclass
class Decision:
action: str
params: dict = field(default_factory=dict)
reason: str = ""
source: str = "code" # code | jev | fallback
confidence: float | None = None
def __str__(self) -> str:
params = ", ".join(f"{k}={v!r}" for k, v in self.params.items())
conf = f" conf={self.confidence:.2f}" if self.confidence is not None else ""
return f"[{self.source}{conf}] {self.action}({params}) # {self.reason}"
# rewards and card_reward are two views of one flow: claiming a card reward
# opens the card screen, and skipping returns to the rewards screen. Treat them
# as ONE screen group so per-flow state is not cleared on every hop.
SCREEN_GROUPS = {"rewards": "rewards_flow", "card_reward": "rewards_flow"}
def _screen_group(state_type: str | None, menu_screen: str | None = None) -> str | None:
"""
A stable key for "which screen am I on".
The `menu` state_type covers the main menu, mode select, character select
and the tutorial prompt. Those are different screens with different valid
actions, so the menu_screen is part of the key.
"""
if state_type == "menu":
return f"menu:{menu_screen}"
return SCREEN_GROUPS.get(state_type, state_type)
@dataclass
class PendingAction:
"""An accepted request, not proof that the game completed it."""
decision: Decision
screen: dict
@dataclass
class PolicyContext:
"""Session-owned memory. Proposals never record execution.
Grid toggles and character selection lack observable selection fields in
the mod. Their accepted requests are tracked explicitly, not called facts.
Other guarded actions wait for relevant screen evidence before continuing.
"""
screen_group: str | None = None
accepted_card_indices: set[int] = field(default_factory=set)
accepted_card_grid: list[dict] | None = None
accepted_crystal_cells: set[tuple[int, int]] = field(default_factory=set)
rewards_skipped_card: bool = False
character_selected: bool = False
pending: PendingAction | None = None
def observe(self, obs: dict) -> None:
st = obs.get("state_type")
if st in ("unknown", "overlay"):
return # A transient overlay is not evidence of completion.
group = _screen_group(st, obs.get("menu_screen"))
if group != self.screen_group:
self.screen_group = group
self.accepted_card_indices.clear()
self.accepted_card_grid = None
self.accepted_crystal_cells.clear()
self.rewards_skipped_card = False
self.character_selected = False
self.pending = None
return
pending = self.pending
if pending is None:
return
action, params = pending.decision.action, pending.decision.params
screen = obs.get(st) or {}
if action == "select_card" and pending.screen.get("screen_type") != "choose":
# The API exposes no selected indices. Reserve accepted toggles
# after a fresh read, even when their effect is not visible yet.
if screen.get("cards") != pending.screen.get("cards"):
return # Cannot map a toggle safely onto a changed grid.
self.accepted_card_indices.add(params["index"])
self.accepted_card_grid = pending.screen.get("cards")
elif action == "menu_select" and params.get("option") != "embark":
self.character_selected = True
elif action == "skip_card_reward":
if st != "rewards":
return
self.rewards_skipped_card = True
elif action in ("confirm_selection", "cancel_selection",
"confirm_bundle_selection", "cancel_bundle_selection",
"combat_confirm_selection"):
return # Wait for the screen to close; do not cancel a slow confirm.
elif action == "crystal_sphere_click_cell":
cell = (params["x"], params["y"])
clickable = {(c.get("x"), c.get("y"))
for c in screen.get("clickable_cells", [])}
if cell in clickable and not screen.get("can_proceed"):
return
self.accepted_crystal_cells.add(cell)
elif action == "shop_purchase":
# Gold alone can change for unrelated reasons. Require this item
# to change/disappear, or a screen transition (e.g. card removal).
before = pending.screen.get("shop", pending.screen)
after = screen.get("shop", screen)
index = params["index"]
old = next((i for i in before.get("items", []) if i.get("index") == index), None)
new = next((i for i in after.get("items", []) if i.get("index") == index), None)
def inventory_item(item):
return {k: v for k, v in item.items() if k not in ("can_afford", "price")} if item else None
if inventory_item(old) == inventory_item(new):
return
elif action == "combat_select_card":
if len(screen.get("selected_cards") or []) <= len(pending.screen.get("selected_cards") or []):
return
elif action == "select_bundle":
if not screen.get("preview_showing") or not screen.get("can_confirm"):
return
else:
return # Direct choices and embark require a screen transition.
self.pending = None
def record_result(self, obs: dict, decision: Decision, *, accepted: bool) -> None:
"""Called only after a real action result. Transport errors stop the runner."""
if not accepted:
self.pending = None
if decision.action == "menu_select" and decision.params.get("option") == "embark":
self.character_selected = False
return
st = obs.get("state_type")
guarded = decision.action in {
"select_card", "confirm_selection", "cancel_selection",
"select_bundle", "confirm_bundle_selection", "cancel_bundle_selection",
"shop_purchase", "skip_card_reward", "crystal_sphere_click_cell",
"combat_select_card", "combat_confirm_selection",
} or (st == "menu" and obs.get("menu_screen") == "character_select")
if guarded:
# Detach from mutable fixtures/callers. This is a small screen node,
# not another copy of the entire combat observation.
screen = json.loads(json.dumps(obs.get(st) or {}))
self.pending = PendingAction(decision, screen)

687
policy/selection.py Normal file
View file

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

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import sys import sys
import brain import brain
from policy import combat as combat_policy, selection
import facts import facts
import sts2 import sts2
@ -306,26 +307,26 @@ print()
print("=== 3. fallbacks must respect their inputs ===") print("=== 3. fallbacks must respect their inputs ===")
# Removal must not target an upgraded card while a plain Strike exists. # Removal must not target an upgraded card while a plain Strike exists.
from_brain = brain.card_select_fallback( from_brain = selection.card_select_fallback(
[card("Strike+", 0, upgraded=True), card("Strike", 1), card("Bash", 2)], [card("Strike+", 0, upgraded=True), card("Strike", 1), card("Bash", 2)],
"remove", "remove",
) )
check("remove prefers an un-upgraded Strike", from_brain["name"], "Strike") check("remove prefers an un-upgraded Strike", from_brain["name"], "Strike")
# Upgrade must not target a basic card while a real card exists. # Upgrade must not target a basic card while a real card exists.
from_brain = brain.card_select_fallback( from_brain = selection.card_select_fallback(
[card("Strike", 0), card("Demon Form", 1, rarity="Rare")], "upgrade" [card("Strike", 0), card("Demon Form", 1, rarity="Rare")], "upgrade"
) )
check("upgrade prefers a non-basic card", from_brain["name"], "Demon Form") check("upgrade prefers a non-basic card", from_brain["name"], "Demon Form")
# Upgrade must never target an already-upgraded card. # Upgrade must never target an already-upgraded card.
from_brain = brain.card_select_fallback( from_brain = selection.card_select_fallback(
[card("Strike", 0, upgraded=True), card("Defend", 1)], "upgrade" [card("Strike", 0, upgraded=True), card("Defend", 1)], "upgrade"
) )
check("upgrade skips an already-upgraded card", from_brain["name"], "Defend") check("upgrade skips an already-upgraded card", from_brain["name"], "Defend")
# Card reward fallback must prefer rarity, not index 0. # Card reward fallback must prefer rarity, not index 0.
from_brain = brain.best_by_rarity( from_brain = selection.best_by_rarity(
[card("Common A", 0), card("Rare B", 1, rarity="Rare"), card("Uncommon C", 2, rarity="Uncommon")] [card("Common A", 0), card("Rare B", 1, rarity="Rare"), card("Uncommon C", 2, rarity="Uncommon")]
) )
check("card reward fallback takes the rarest", from_brain["name"], "Rare B") check("card reward fallback takes the rarest", from_brain["name"], "Rare B")
@ -658,27 +659,27 @@ check("even with jev, no enemies means wait", d.action, "__wait__")
# with NO preview, and can_confirm is true from the start. Measured live: the # with NO preview, and can_confirm is true from the start. Measured live: the
# handler re-selected index 10 forever because preview_showing stayed false. # handler re-selected index 10 forever because preview_showing stayed false.
check("raw class name normalises to upgrade", check("raw class name normalises to upgrade",
brain.screen_kind("NDeckEnchantSelectScreen"), "upgrade") selection.screen_kind("NDeckEnchantSelectScreen"), "upgrade")
check("friendly names still work", check("friendly names still work",
(brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform")) (selection.screen_kind("remove"), selection.screen_kind("transform")), ("remove", "transform"))
# MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all # MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
# five are picked, and card_select has no `selected_cards` field. Measured live: # five are picked, and card_select has no `selected_cards` field. Measured live:
# the handler re-selected the same index forever and stalled. # the handler re-selected the same index forever and stalled.
check("the required count is parsed from the prompt", check("the required count is parsed from the prompt",
brain.card_select_need("Choose 5 cards to Remove."), 5) selection.card_select_need("Choose 5 cards to Remove."), 5)
check("single-select defaults to 1", check("single-select defaults to 1",
brain.card_select_need("Choose a card to Upgrade."), 1) selection.card_select_need("Choose a card to Upgrade."), 1)
# The number is not always immediately followed by "cards": # The number is not always immediately followed by "cards":
# "Choose 2 Common Cards to Add to Your Deck." has "Common" in between. A # "Choose 2 Common Cards to Add to Your Deck." has "Common" in between. A
# stricter pattern returned 1 and stalled the screen waiting for a confirm. # stricter pattern returned 1 and stalled the screen waiting for a confirm.
check("the count survives an adjective", check("the count survives an adjective",
brain.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2) selection.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2)
check("an ADD screen is not an upgrade screen", check("an ADD screen is not an upgrade screen",
brain.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."), selection.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."),
"add") "add")
check("a REMOVE prompt beats a generic screen_type", check("a REMOVE prompt beats a generic screen_type",
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove") selection.screen_kind("select", "Choose 5 cards to Remove."), "remove")
# Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old # Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old
# unconditional crystal_sphere_proceed was rejected and stalled the run. # unconditional crystal_sphere_proceed was rejected and stalled the run.
@ -703,9 +704,9 @@ check("crystal sphere proceeds when unlocked", d3.action, "crystal_sphere_procee
# "Choose a card to Exhaust." is mode simple_select with can_confirm TRUE and # "Choose a card to Exhaust." is mode simple_select with can_confirm TRUE and
# one card already chosen. Measured live: the handler only confirmed for # one card already chosen. Measured live: the handler only confirmed for
# upgrade_select, so it toggled between two cards forever. # upgrade_select, so it toggled between two cards forever.
check("a singular prompt needs 1", brain.hand_select_need("Choose a card to Exhaust."), 1) check("a singular prompt needs 1", selection.hand_select_need("Choose a card to Exhaust."), 1)
check("a counted prompt needs N", brain.hand_select_need("Choose 2 cards to discard."), 2) check("a counted prompt needs N", selection.hand_select_need("Choose 2 cards to discard."), 2)
check("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None) check("\"any number\" is unbounded", selection.hand_select_need("Choose any number of cards to replace."), None)
d = brain.decide( d = brain.decide(
{"state_type": "hand_select", {"state_type": "hand_select",
@ -805,7 +806,7 @@ kin_hand = [
def blk(obs): def blk(obs):
"""The block value of whatever combat_decision picks, or None.""" """The block value of whatever combat_decision picks, or None."""
d = brain.combat_decision(facts.combat_facts(obs), None) d = combat_policy.combat_decision(facts.combat_facts(obs), None)
if d.action != "play_card": if d.action != "play_card":
return d.action return d.action
return facts.block_value(facts.combat_facts(obs).hand[d.params["card_index"]]) return facts.block_value(facts.combat_facts(obs).hand[d.params["card_index"]])
@ -826,16 +827,16 @@ check("nothing incoming does not force a block", blk(quiet), 0)
# Defense must never pre-empt lethal. # Defense must never pre-empt lethal.
lethal = combat(hand=kin_hand + [card("Bludgeon", index=4, cost="3", desc="Deal 32 damage.")], lethal = combat(hand=kin_hand + [card("Bludgeon", index=4, cost="3", desc="Deal 32 damage.")],
enemies=[enemy("KIN_0", hp=20, intent=12)], hp=74) enemies=[enemy("KIN_0", hp=20, intent=12)], hp=74)
d = brain.combat_decision(facts.combat_facts(lethal), None) d = combat_policy.combat_decision(facts.combat_facts(lethal), None)
check("lethal still beats blocking", check("lethal still beats blocking",
facts.combat_facts(lethal).hand[d.params["card_index"]]["name"], "Bludgeon") facts.combat_facts(lethal).hand[d.params["card_index"]]["name"], "Bludgeon")
# The dead question must stay dead. Assert on the QUESTION being built, not # The dead question must stay dead. Assert on the QUESTION being built, not
# on the string: the replacement comment legitimately names it. # on the string: the replacement comment legitimately names it.
check("the should_defend question is no longer asked", check("the should_defend question is no longer asked",
'questions["should_defend"]' in open("brain.py").read(), False) 'questions["should_defend"]' in open("policy/combat.py").read(), False)
check("...and nothing reads it either", check("...and nothing reads it either",
"response.get(\"should_defend\")" in open("brain.py").read(), False) "response.get(\"should_defend\")" in open("policy/combat.py").read(), False)
print() print()
print("=== relic_select reads the ids it asked for (regression) ===") print("=== relic_select reads the ids it asked for (regression) ===")
@ -859,7 +860,7 @@ relic_obs = {
} }
relic_stub = StubClient(noul=0.30, relic_stub = StubClient(noul=0.30,
noul_override={"good_relic0": 0.80, "good_relic1": 0.40}) noul_override={"good_relic0": 0.80, "good_relic1": 0.40})
d = brain.relic_select_decision(relic_obs, relic_stub) d = selection.relic_select_decision(relic_obs, relic_stub)
check("relic_select acts on Jev's answer", d.source, "jev") check("relic_select acts on Jev's answer", d.source, "jev")
check("...and takes the relic Jev rated highest, not the rarest", check("...and takes the relic Jev rated highest, not the rarest",
d.params.get("index"), 0) d.params.get("index"), 0)
@ -875,7 +876,7 @@ blocked = combat(hand=[card(index=n) for n in range(3)],
bf = facts.combat_facts(blocked) bf = facts.combat_facts(blocked)
check("18 raw vs 10 hp + 5 block is lethal", bf.lethal_available, True) check("18 raw vs 10 hp + 5 block is lethal", bf.lethal_available, True)
check("...and the executor finds the line", check("...and the executor finds the line",
bool(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])), bool(combat_policy._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])),
True) True)
strong = combat(hand=[card(index=n, desc="Deal 12 damage.") for n in range(2)], strong = combat(hand=[card(index=n, desc="Deal 12 damage.") for n in range(2)],
@ -883,11 +884,11 @@ strong = combat(hand=[card(index=n, desc="Deal 12 damage.") for n in range(2)],
status=[{"name": "Strength", "amount": 6, "type": "Buff"}]) status=[{"name": "Strength", "amount": 6, "type": "Buff"}])
sf = facts.combat_facts(strong) sf = facts.combat_facts(strong)
check("the executor uses Strength-adjusted hand text", check("the executor uses Strength-adjusted hand text",
[c["name"] for c in (brain._lethal_line(sf.playable, sf.energy, [c["name"] for c in (combat_policy._lethal_line(sf.playable, sf.energy,
sf.player_status, sf.enemies[0]) or [])], sf.player_status, sf.enemies[0]) or [])],
["Strike", "Strike"]) ["Strike", "Strike"])
check("...and does not need to add Strength again", check("...and does not need to add Strength again",
bool(brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0])), True) bool(combat_policy._lethal_line(sf.playable, sf.energy, [], sf.enemies[0])), True)
print() print()
print("=== correctness: shared damage and block plans ===") print("=== correctness: shared damage and block plans ===")
@ -897,7 +898,7 @@ adjusted = combat(hand=[card(index=5, desc="Deal 8 damage.")],
status=[{"name": "Strength", "amount": 2}]) status=[{"name": "Strength", "amount": 2}])
af = facts.combat_facts(adjusted) af = facts.combat_facts(adjusted)
check("executor does not invent a Strength-based lethal line", check("executor does not invent a Strength-based lethal line",
brain._lethal_line(af.playable, af.energy, af.player_status, af.enemies[0]), None) combat_policy._lethal_line(af.playable, af.energy, af.player_status, af.enemies[0]), None)
defenders = [card("Big Block", index=5, cost="2", desc="Gain 9 Block.", ctype="Skill", target="Self"), defenders = [card("Big Block", index=5, cost="2", desc="Gain 9 Block.", ctype="Skill", target="Self"),
card("Small Block", index=8, desc="Gain 6 Block.", ctype="Skill", target="Self"), card("Small Block", index=8, desc="Gain 6 Block.", ctype="Skill", target="Self"),