Per-state decision logic: Jev-first with deterministic fallbacks for combat, card rewards, relic select, map, card select, events, rest sites, shops, treasures, bundles, and hand select. 113 tests.
960 lines
43 KiB
Python
960 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
test_brain.py -- structural regression tests for the decision layer.
|
|
|
|
These catch PROGRAMMATIC bugs, not decision quality: wrong action names,
|
|
actions that are not legal for the current state_type, handlers that crash on
|
|
a shape, and fallbacks that ignore their own inputs.
|
|
|
|
Run with no model: every case uses client=None so the deterministic paths are
|
|
exercised. Fast, offline, and it fails loudly.
|
|
|
|
python3 test_brain.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
import brain
|
|
import sts2
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
|
|
# `__wait__` is our internal "re-observe" marker, not a game action.
|
|
INTERNAL = {"__wait__"}
|
|
|
|
|
|
def check(label: str, got, want) -> None:
|
|
global PASS, FAIL
|
|
if got == want:
|
|
PASS += 1
|
|
print(f" ok {label}: {got!r}")
|
|
else:
|
|
FAIL += 1
|
|
print(f" FAIL {label}: got {got!r}, want {want!r}")
|
|
|
|
|
|
def legal(obs: dict, decision: brain.Decision | None) -> bool:
|
|
"""A decision is valid when its action is legal for this state_type."""
|
|
if decision is None:
|
|
return False
|
|
if decision.action in INTERNAL:
|
|
return True
|
|
allowed = sts2.LEGAL_ACTIONS.get(obs.get("state_type"), ())
|
|
return decision.action in allowed
|
|
|
|
|
|
def expect_legal(label: str, obs: dict) -> brain.Decision | None:
|
|
global PASS, FAIL
|
|
try:
|
|
decision = brain.decide(obs, None)
|
|
except Exception as exc: # noqa: BLE001 - the point is to catch anything
|
|
FAIL += 1
|
|
print(f" FAIL {label}: raised {type(exc).__name__}: {exc}")
|
|
return None
|
|
if decision is None:
|
|
FAIL += 1
|
|
print(f" FAIL {label}: returned None (no handler)")
|
|
return None
|
|
if not legal(obs, decision):
|
|
FAIL += 1
|
|
allowed = sts2.LEGAL_ACTIONS.get(obs.get("state_type"), ())
|
|
print(f" FAIL {label}: {decision.action!r} not legal for "
|
|
f"{obs.get('state_type')!r} (allowed: {allowed})")
|
|
return decision
|
|
PASS += 1
|
|
print(f" ok {label}: {decision}")
|
|
return decision
|
|
|
|
|
|
def player(**kw) -> dict:
|
|
base = {
|
|
"character": "The Ironclad", "hp": 60, "max_hp": 80, "block": 0,
|
|
"gold": 150, "status": [], "relics": [], "potions": [],
|
|
"max_potion_slots": 3,
|
|
}
|
|
base.update(kw)
|
|
return base
|
|
|
|
|
|
def card(name="Strike", index=0, cost="1", desc="Deal 6 damage.",
|
|
ctype="Attack", target="AnyEnemy", can_play=True, rarity="Common",
|
|
upgraded=False) -> dict:
|
|
return {
|
|
"id": name.upper(), "name": name, "type": ctype, "cost": cost,
|
|
"star_cost": None, "description": desc, "rarity": rarity,
|
|
"is_upgraded": upgraded, "keywords": [], "index": index,
|
|
"target_type": target, "can_play": can_play, "unplayable_reason": None,
|
|
}
|
|
|
|
|
|
def enemy(entity_id="NIBBIT_0", hp=44, block=0, intent=12) -> dict:
|
|
intents = []
|
|
if intent:
|
|
intents = [{"type": "Attack", "label": str(intent), "title": "Aggressive",
|
|
"description": f"This enemy intends to Attack for {intent} damage."}]
|
|
return {"entity_id": entity_id, "combat_id": 1, "name": entity_id.split("_")[0],
|
|
"hp": hp, "max_hp": hp, "block": block, "status": [], "intents": intents}
|
|
|
|
|
|
def combat(state_type="monster", hand=None, enemies=None, **pkw) -> dict:
|
|
return {
|
|
"state_type": state_type,
|
|
"battle": {"round": 1, "turn": "player", "is_play_phase": True,
|
|
"enemies": enemies if enemies is not None else [enemy()]},
|
|
"run": {"act": 1, "floor": 5, "ascension": 0},
|
|
"player": player(energy=3, max_energy=3,
|
|
hand=hand if hand is not None else [card()],
|
|
draw_pile=[], draw_pile_count=0,
|
|
discard_pile=[], discard_pile_count=0,
|
|
exhaust_pile=[], exhaust_pile_count=0, **pkw),
|
|
}
|
|
|
|
|
|
_reset_counter = 0
|
|
|
|
|
|
def force_reset() -> None:
|
|
"""Force a screen-group change so every per-screen guard is cleared.
|
|
|
|
`_reset_screen_guards` only resets on a GROUP CHANGE, so calling it with
|
|
the same group twice is a deliberate no-op. Tests need a guaranteed reset.
|
|
"""
|
|
global _reset_counter
|
|
_reset_counter += 1
|
|
brain._reset_screen_guards(f"__test{_reset_counter}__")
|
|
|
|
|
|
print("=== 1. every state_type produces a LEGAL action ===")
|
|
|
|
cases: list[tuple[str, dict]] = [
|
|
("menu:main", {"state_type": "menu", "menu_screen": "main",
|
|
"options": ["singleplayer", "settings", "quit"]}),
|
|
("menu:main+continue", {"state_type": "menu", "menu_screen": "main",
|
|
"options": ["continue", "abandon_run", "quit"]}),
|
|
("menu:mode-select", {"state_type": "menu", "menu_screen": "singleplayer",
|
|
"options": [{"name": "standard", "enabled": True}]}),
|
|
("menu:character-select", {"state_type": "menu", "menu_screen": "character_select",
|
|
"message": "Select a character.",
|
|
"options": [{"name": "IRONCLAD", "enabled": True},
|
|
{"name": "embark", "enabled": True}]}),
|
|
("menu:character-selected", {"state_type": "menu", "menu_screen": "character_select",
|
|
"message": "Selected The Ironclad. Use 'confirm' to embark.",
|
|
"options": [{"name": "IRONCLAD", "enabled": True},
|
|
{"name": "embark", "enabled": True}]}),
|
|
("menu:tutorial", {"state_type": "menu", "menu_screen": "tutorial_prompt",
|
|
"options": [{"name": "no", "enabled": True}]}),
|
|
("game_over", {"state_type": "game_over",
|
|
"game_over": {"message": "Run ended.", "options": ["main_menu"]}}),
|
|
("monster", combat("monster")),
|
|
("elite", combat("elite")),
|
|
("boss", combat("boss")),
|
|
("combat:not-play-phase",
|
|
{**combat(), "battle": {"round": 1, "turn": "enemy", "is_play_phase": False,
|
|
"enemies": [enemy()]}}),
|
|
("combat:no-playable",
|
|
combat(hand=[card(can_play=False)])),
|
|
("hand_select:empty", {"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select",
|
|
"prompt": "Choose any number of cards to replace.",
|
|
"cards": [],
|
|
"selected_cards": [{"index": 0, "name": "Defend"}],
|
|
"can_confirm": True}}),
|
|
("hand_select:with-cards", {"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select",
|
|
"prompt": "Choose cards to exhaust.",
|
|
"cards": [card("Strike", 0), card("Bash", 1)],
|
|
"selected_cards": [],
|
|
"can_confirm": False}}),
|
|
("rewards:items", {"state_type": "rewards",
|
|
"rewards": {"items": [{"index": 0, "type": "gold",
|
|
"description": "19 Gold", "gold_amount": 19}],
|
|
"can_proceed": True}}),
|
|
("rewards:empty", {"state_type": "rewards",
|
|
"rewards": {"items": [], "can_proceed": True}}),
|
|
("card_reward", {"state_type": "card_reward",
|
|
"card_reward": {"cards": [card("Stomp", 0), card("Inflame", 1, rarity="Uncommon")],
|
|
"can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("map", {"state_type": "map",
|
|
"map": {"visited": [], "next_options": [
|
|
{"index": 0, "col": 0, "row": 1, "type": "Monster"},
|
|
{"index": 1, "col": 2, "row": 1, "type": "Rest"}],
|
|
"nodes": []},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("map:no-options", {"state_type": "map", "map": {"next_options": []},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("event", {"state_type": "event",
|
|
"event": {"in_dialogue": False, "body": "…",
|
|
"options": [{"index": 0, "title": "Stop",
|
|
"description": "Take what you have and leave.",
|
|
"is_locked": False, "is_proceed": False,
|
|
"was_chosen": False}]},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("event:dialogue", {"state_type": "event",
|
|
"event": {"in_dialogue": True, "body": "…", "options": []},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("event:all-locked", {"state_type": "event",
|
|
"event": {"in_dialogue": False, "body": "…",
|
|
"options": [{"index": 0, "title": "Locked",
|
|
"description": "", "is_locked": True,
|
|
"is_proceed": False, "was_chosen": False}]},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("rest_site", {"state_type": "rest_site",
|
|
"rest_site": {"options": [
|
|
{"index": 0, "id": "REST", "name": "Rest",
|
|
"description": "Heal 24 HP.", "is_enabled": True},
|
|
{"index": 1, "id": "SMITH", "name": "Smith",
|
|
"description": "Upgrade a card.", "is_enabled": True}],
|
|
"can_proceed": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("rest_site:empty", {"state_type": "rest_site",
|
|
"rest_site": {"options": [], "can_proceed": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("shop", {"state_type": "shop",
|
|
"shop": {"items": [{"index": 0, "category": "relic", "price": 150,
|
|
"is_stocked": True, "can_afford": True,
|
|
"relic_name": "Vajra",
|
|
"relic_description": "Start each combat with 1 Strength."}],
|
|
"can_proceed": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("shop:nothing-affordable",
|
|
{"state_type": "shop",
|
|
"shop": {"items": [{"index": 0, "category": "relic", "price": 900,
|
|
"is_stocked": True, "can_afford": False,
|
|
"relic_name": "X", "relic_description": "y"}],
|
|
"can_proceed": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
# fake_merchant nests its shop one level deeper. This case exists because
|
|
# reading only obs["shop"] made the bot leave every fake merchant instantly.
|
|
("fake_merchant", {"state_type": "fake_merchant",
|
|
"fake_merchant": {"event_id": "FAKE_MERCHANT",
|
|
"started_fight": False,
|
|
"shop": {"items": [
|
|
{"index": 0, "category": "potion",
|
|
"price": 60, "is_stocked": True,
|
|
"can_afford": True,
|
|
"potion_name": "Fire Potion",
|
|
"potion_description": "Deal 20 damage."}],
|
|
"can_proceed": False}},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("treasure:opening", {"state_type": "treasure",
|
|
"treasure": {"message": "Opening chest..."},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("treasure:relics", {"state_type": "treasure",
|
|
"treasure": {"relics": [{"index": 0, "id": "NUNCHAKU",
|
|
"name": "Nunchaku", "rarity": "Common",
|
|
"description": "..."}],
|
|
"can_proceed": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("card_select:grid", {"state_type": "card_select",
|
|
"card_select": {"screen_type": "upgrade",
|
|
"prompt": "Choose a card to Upgrade.",
|
|
"cards": [card("Strike", 0), card("Bash", 1)],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("card_select:preview", {"state_type": "card_select",
|
|
"card_select": {"screen_type": "upgrade",
|
|
"prompt": "Choose a card to Upgrade.",
|
|
"cards": [card("Strike", 0)],
|
|
"preview_showing": True, "can_confirm": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("card_select:empty", {"state_type": "card_select",
|
|
"card_select": {"screen_type": "upgrade", "cards": [],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("bundle_select", {"state_type": "bundle_select",
|
|
"bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.",
|
|
"bundles": [
|
|
{"index": 0, "card_count": 3,
|
|
"cards": [card("Anger", 0)]},
|
|
{"index": 1, "card_count": 3,
|
|
"cards": [card("Pommel Strike", 0)]}],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("bundle_select:preview", {"state_type": "bundle_select",
|
|
"bundle_select": {"screen_type": "bundle",
|
|
"bundles": [{"index": 0, "cards": []}],
|
|
"preview_showing": True, "can_confirm": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("relic_select", {"state_type": "relic_select",
|
|
"relic_select": {"prompt": "Choose a relic.",
|
|
"relics": [{"index": 0, "id": "A", "name": "Vajra",
|
|
"rarity": "Common", "description": "x"},
|
|
{"index": 1, "id": "B", "name": "Lantern",
|
|
"rarity": "Uncommon", "description": "y"}],
|
|
"can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("crystal_sphere", {"state_type": "crystal_sphere",
|
|
"crystal_sphere": {"grid_width": 3, "grid_height": 3,
|
|
"instructions_title": "Divine"},
|
|
"run": {"act": 1, "floor": 3}, "player": player()}),
|
|
("unknown", {"state_type": "unknown", "room_type": None}),
|
|
("overlay", {"state_type": "overlay"}),
|
|
]
|
|
|
|
for label, obs in cases:
|
|
expect_legal(label, obs)
|
|
|
|
print()
|
|
print("=== 2. structural invariants ===")
|
|
|
|
# Every documented state_type must have a handler.
|
|
handled = {c[1]["state_type"] for c in cases}
|
|
unhandled = [st for st in sts2.LEGAL_ACTIONS if st not in handled]
|
|
check("every state_type covered by a test case", unhandled, [])
|
|
|
|
# Every action the decision layer can emit must exist in LEGAL_ACTIONS.
|
|
emitted = set()
|
|
for _, obs in cases:
|
|
d = brain.decide(obs, None)
|
|
if d:
|
|
emitted.add(d.action)
|
|
declared = {a for actions in sts2.LEGAL_ACTIONS.values() for a in actions}
|
|
check("every emitted action is declared somewhere", sorted(emitted - declared - INTERNAL), [])
|
|
|
|
print()
|
|
print("=== 3. fallbacks must respect their inputs ===")
|
|
|
|
# Removal must not target an upgraded card while a plain Strike exists.
|
|
from_brain = brain.card_select_fallback(
|
|
[card("Strike+", 0, upgraded=True), card("Strike", 1), card("Bash", 2)],
|
|
"remove",
|
|
)
|
|
check("remove prefers an un-upgraded Strike", from_brain["name"], "Strike")
|
|
|
|
# Upgrade must not target a basic card while a real card exists.
|
|
from_brain = brain.card_select_fallback(
|
|
[card("Strike", 0), card("Demon Form", 1, rarity="Rare")], "upgrade"
|
|
)
|
|
check("upgrade prefers a non-basic card", from_brain["name"], "Demon Form")
|
|
|
|
# Upgrade must never target an already-upgraded card.
|
|
from_brain = brain.card_select_fallback(
|
|
[card("Strike", 0, upgraded=True), card("Defend", 1)], "upgrade"
|
|
)
|
|
check("upgrade skips an already-upgraded card", from_brain["name"], "Defend")
|
|
|
|
# Card reward fallback must prefer rarity, not index 0.
|
|
from_brain = brain.best_by_rarity(
|
|
[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")
|
|
|
|
# hand_select must not give up a good card when a Strike is available.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.",
|
|
"cards": [card("Bash", 0), card("Strike", 1)],
|
|
"selected_cards": [], "can_confirm": False}},
|
|
None,
|
|
)
|
|
check("hand_select gives up the Strike, not Bash", d.params.get("card_index"), 1)
|
|
|
|
# ...and must stop rather than exhaust the whole deck.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose any number.",
|
|
"cards": [card("Bash", 0), card("Uppercut", 1)],
|
|
"selected_cards": [{"index": 0, "name": "Strike"}],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("hand_select keeps good cards and confirms", d.action, "combat_confirm_selection")
|
|
|
|
# fake_merchant nests its inventory at fake_merchant.shop. Prove it is found:
|
|
# with no Jev, the reason differs depending on whether an affordable item parsed.
|
|
def fake_merchant(can_afford: bool) -> dict:
|
|
return {
|
|
"state_type": "fake_merchant",
|
|
"fake_merchant": {
|
|
"event_id": "FAKE_MERCHANT", "started_fight": False,
|
|
"shop": {"items": [{"index": 0, "category": "potion", "price": 60,
|
|
"is_stocked": True, "can_afford": can_afford,
|
|
"potion_name": "Fire Potion",
|
|
"potion_description": "Deal 20 damage."}],
|
|
"can_proceed": False},
|
|
},
|
|
"run": {"act": 1, "floor": 3}, "player": player(),
|
|
}
|
|
|
|
|
|
d = brain.decide(fake_merchant(True), None)
|
|
check("fake_merchant finds its nested shop", "no jev" in d.reason, True)
|
|
|
|
d = brain.decide(fake_merchant(False), None)
|
|
check("fake_merchant reports nothing affordable",
|
|
"nothing affordable" in d.reason, True)
|
|
|
|
# A skipped card reward is NOT consumed by the game. Without the guard the bot
|
|
# loops claim_reward -> card screen -> skip -> claim_reward forever.
|
|
force_reset()
|
|
rewards_with_card = {
|
|
"state_type": "rewards",
|
|
"rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold",
|
|
"gold_amount": 18},
|
|
{"index": 1, "type": "card",
|
|
"description": "Add a card to your deck."}],
|
|
"can_proceed": True},
|
|
"run": {"act": 1, "floor": 2}, "player": player(),
|
|
}
|
|
|
|
d = brain.decide(rewards_with_card, None)
|
|
check("rewards claims the last item first", d.params.get("index"), 1)
|
|
|
|
brain._rewards_skipped_card = True
|
|
d = brain.decide(rewards_with_card, None)
|
|
check("a skipped card reward is not re-claimed", d.action, "claim_reward")
|
|
check("...and the gold is taken instead", d.params.get("index"), 0)
|
|
|
|
brain._rewards_skipped_card = True
|
|
d = brain.decide(
|
|
{"state_type": "rewards",
|
|
"rewards": {"items": [{"index": 0, "type": "card",
|
|
"description": "Add a card to your deck."}],
|
|
"can_proceed": True},
|
|
"run": {"act": 1, "floor": 2}, "player": player()},
|
|
None,
|
|
)
|
|
check("rewards with only a skipped card proceeds", d.action, "proceed")
|
|
|
|
# rewards -> card_reward must stay in ONE screen group, or the skip flag is
|
|
# cleared on every hop and the loop returns.
|
|
brain._reset_screen_guards("rewards")
|
|
brain._rewards_skipped_card = True
|
|
brain._reset_screen_guards("card_reward")
|
|
check("skip flag survives rewards <-> card_reward", brain._rewards_skipped_card, True)
|
|
brain._reset_screen_guards("monster")
|
|
check("skip flag clears when leaving the flow", brain._rewards_skipped_card, False)
|
|
|
|
class StubClient:
|
|
"""
|
|
Deterministic stand-in for JevClient so the model paths can be tested
|
|
offline. Returns a high Noul for every ranking question and the first
|
|
option for every Choice, unless overridden.
|
|
"""
|
|
|
|
def __init__(self, noul: float = 0.90, choice_override: dict | None = None,
|
|
noul_override: dict | None = None):
|
|
self.model = "stub"
|
|
self._noul = noul
|
|
self._choice = choice_override or {}
|
|
self._noul_override = noul_override or {}
|
|
|
|
def __repr__(self) -> str:
|
|
return "StubClient()"
|
|
|
|
def ask(self, state, questions, model=None):
|
|
from jev import ChoiceAnswer, NoulAnswer, JevResponse
|
|
|
|
answers = {}
|
|
for qid, q in questions.items():
|
|
if q.get("type") == "choice":
|
|
options = list((q.get("criteria") or {}).keys())
|
|
picked = self._choice.get(qid, options[0] if options else "")
|
|
answers[qid] = ChoiceAnswer(
|
|
choice=picked,
|
|
probabilities={o: (0.9 if o == picked else 0.02) for o in options},
|
|
confidence=0.9,
|
|
)
|
|
elif q.get("type") == "noul":
|
|
answers[qid] = NoulAnswer(
|
|
noul=self._noul_override.get(qid, self._noul)
|
|
)
|
|
else:
|
|
from jev import ScoreAnswer
|
|
answers[qid] = ScoreAnswer(score=1.0, confidence=0.9)
|
|
return JevResponse(answers=answers, model="stub", input_tokens=0,
|
|
output_tokens=0, latency_s=0.0)
|
|
|
|
|
|
print()
|
|
print("=== 4. indices must come from the data, never from list position ===")
|
|
|
|
# Every indexed action is given cards whose `index` field does NOT match their
|
|
# list position. Any code that selects by array position will emit 0/1/2 and
|
|
# fail here. This is the regression test for the bug that made the bot upgrade
|
|
# only Strikes: it chose cards[0] rather than reading the card's identity.
|
|
OFFSET = 5
|
|
|
|
def offset_card(name, pos, **kw) -> dict:
|
|
return card(name, index=OFFSET + pos, **kw)
|
|
|
|
valid_indices = {OFFSET, OFFSET + 1, OFFSET + 2}
|
|
stub = StubClient()
|
|
|
|
# card_select (upgrade)
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_select",
|
|
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
|
"cards": [offset_card("Strike", 0), offset_card("Defend", 1),
|
|
offset_card("Demon Form", 2, rarity="Rare")],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
stub,
|
|
)
|
|
check("card_select emits the card's own index", d.params.get("index") in valid_indices, True)
|
|
check("...and picks by identity, not position",
|
|
d.params.get("index"), OFFSET + 2)
|
|
|
|
# card_reward with the stub. `skip_all` is answered NO so the take path is
|
|
# exercised; otherwise a stub that says yes to everything always skips.
|
|
take_stub = StubClient(noul_override={"skip_all": 0.10})
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0),
|
|
offset_card("Rare B", 1, rarity="Rare")],
|
|
"can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
take_stub,
|
|
)
|
|
check("card_reward emits the card's own index", d.params.get("card_index") in valid_indices, True)
|
|
|
|
# And with `skip_all` answered YES, Jev's skip is honoured.
|
|
skip_stub = StubClient(noul_override={"skip_all": 0.95})
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
skip_stub,
|
|
)
|
|
check("jev can decide to skip", d.action, "skip_card_reward")
|
|
check("...and the reason cites jev", "jev:" in d.reason, True)
|
|
|
|
# An UNCERTAIN skip answer must not skip -- take the best card instead.
|
|
unsure_stub = StubClient(noul_override={"skip_all": 0.55})
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
unsure_stub,
|
|
)
|
|
check("an uncertain skip answer takes a card", d.action, "select_card_reward")
|
|
|
|
# `can_skip: false` must take a card even if Jev would skip.
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
skip_stub,
|
|
)
|
|
check("cannot skip -> takes a card anyway", d.action, "select_card_reward")
|
|
|
|
# hand_select (in-combat selectable subset)
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.",
|
|
"cards": [offset_card("Bash", 0), offset_card("Strike", 1)],
|
|
"selected_cards": [], "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
None,
|
|
)
|
|
check("hand_select emits the selectable card's own index",
|
|
d.params.get("card_index"), OFFSET + 1)
|
|
|
|
# rewards
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "rewards",
|
|
"rewards": {"items": [{"index": OFFSET, "type": "gold", "description": "1 Gold"},
|
|
{"index": OFFSET + 1, "type": "gold", "description": "2 Gold"}],
|
|
"can_proceed": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
None,
|
|
)
|
|
check("rewards emits the item's own index", d.params.get("index"), OFFSET + 1)
|
|
|
|
# shop
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "shop",
|
|
"shop": {"items": [{"index": OFFSET, "category": "relic", "price": 10,
|
|
"is_stocked": True, "can_afford": True,
|
|
"relic_name": "Vajra", "relic_description": "x"},
|
|
{"index": OFFSET + 1, "category": "relic", "price": 10,
|
|
"is_stocked": True, "can_afford": True,
|
|
"relic_name": "Lantern", "relic_description": "y"}],
|
|
"can_proceed": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
stub,
|
|
)
|
|
check("shop emits the item's own index", d.params.get("index") in valid_indices, True)
|
|
|
|
# map
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "map",
|
|
"map": {"next_options": [{"index": OFFSET, "col": 0, "row": 1, "type": "Monster"},
|
|
{"index": OFFSET + 1, "col": 2, "row": 1, "type": "Elite"}],
|
|
"nodes": []},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
stub,
|
|
)
|
|
check("map emits the node's own index", d.params.get("index") in valid_indices, True)
|
|
|
|
# bundle_select
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "bundle_select",
|
|
"bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.",
|
|
"bundles": [{"index": OFFSET, "cards": [card("A", 0)]},
|
|
{"index": OFFSET + 1, "cards": [card("B", 0)]}],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
stub,
|
|
)
|
|
check("bundle_select emits the bundle's own index", d.params.get("index") in valid_indices, True)
|
|
|
|
# relic_select
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "relic_select",
|
|
"relic_select": {"prompt": "Choose a relic.",
|
|
"relics": [{"index": OFFSET, "name": "Vajra", "rarity": "Common",
|
|
"description": "x"},
|
|
{"index": OFFSET + 1, "name": "Lantern",
|
|
"rarity": "Uncommon", "description": "y"}],
|
|
"can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
stub,
|
|
)
|
|
check("relic_select emits the relic's own index", d.params.get("index") in valid_indices, True)
|
|
|
|
# A state whose list positions are ALL zero-ish while the real indices are high:
|
|
# any position-based code returns 0, which is not a valid index here.
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_select",
|
|
"card_select": {"screen_type": "remove", "prompt": "Choose a card to Remove.",
|
|
"cards": [offset_card("Bash", 0), offset_card("Strike", 1)],
|
|
"preview_showing": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
None,
|
|
)
|
|
check("a position-based pick of 0 would be invalid here",
|
|
d.params.get("index") != 0, True)
|
|
|
|
print()
|
|
print("=== 5. character select needs a select-then-embark sequence ===")
|
|
|
|
# The state carries NO "selected" indicator -- the mod hardcodes `message` to
|
|
# "Select a character." whatever is chosen. So the handler selects once and
|
|
# embarks when the screen comes back unchanged. Without this the bot selects
|
|
# the already-selected character forever and stalls.
|
|
char_select_state = {
|
|
"state_type": "menu", "menu_screen": "character_select",
|
|
"message": "Select a character.",
|
|
"options": [{"name": "IRONCLAD", "enabled": True},
|
|
{"name": "SILENT", "enabled": True},
|
|
{"name": "confirm", "enabled": True},
|
|
{"name": "embark", "enabled": True},
|
|
{"name": "back", "enabled": True}],
|
|
}
|
|
|
|
force_reset()
|
|
d1 = brain.decide(char_select_state, None)
|
|
check("character_select first selects a character", d1.params.get("option"), "IRONCLAD")
|
|
|
|
# Same screen unchanged -> a character is already selected -> embark.
|
|
d2 = brain.decide(char_select_state, None)
|
|
check("character_select then embarks", d2.params.get("option"), "embark")
|
|
|
|
# The sequence ALTERNATES, which is what makes it self-correcting: a rejected
|
|
# embark ("select a character first") is always followed by a fresh select.
|
|
d2b = brain.decide(char_select_state, None)
|
|
check("a third call re-selects", d2b.params.get("option"), "IRONCLAD")
|
|
d2c = brain.decide(char_select_state, None)
|
|
check("and then embarks again", d2c.params.get("option"), "embark")
|
|
|
|
# A changing signature must not derail the alternation.
|
|
changed = dict(char_select_state)
|
|
changed["characters"] = [{"name": "The Ironclad", "id": "IRONCLAD", "locked": False}]
|
|
d2d = brain.decide(changed, None)
|
|
check("a changing signature does not derail the cycle",
|
|
d2d.params.get("option"), "IRONCLAD")
|
|
|
|
# Both actions are legal for this screen.
|
|
check("select is legal", legal(char_select_state, d1), True)
|
|
check("embark is legal", legal(char_select_state, d2), True)
|
|
|
|
# Leaving the screen must clear the guard, or the next run embarks immediately
|
|
# without selecting anything.
|
|
brain._reset_screen_guards("menu", "main")
|
|
d3 = brain.decide(char_select_state, None)
|
|
check("guard resets when leaving character select", d3.params.get("option"), "IRONCLAD")
|
|
|
|
# The alternation must also reset, or a fresh run embarks without selecting.
|
|
brain._reset_screen_guards("menu", "main")
|
|
d3b = brain.decide(char_select_state, None)
|
|
check("alternation resets too", d3b.params.get("option"), "IRONCLAD")
|
|
|
|
# menu_screen must be part of the screen key: main menu and character select are
|
|
# both state_type "menu" but have different valid actions.
|
|
brain._reset_screen_guards("menu", "main")
|
|
check("main menu and character select are different screens",
|
|
brain._screen_group("menu", "main") != brain._screen_group("menu", "character_select"),
|
|
True)
|
|
|
|
# combat_select_card TOGGLES: re-selecting an already-chosen card deselects it,
|
|
# so the state never changes and the loop stalls. `selected_cards` is the
|
|
# authoritative list of what is already chosen.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose any number.",
|
|
"cards": [card("Defend", 0), card("Defend", 1), card("Strike", 2)],
|
|
"selected_cards": [{"index": 0, "name": "Defend"}],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("hand_select skips an already-chosen card", d.params.get("card_index"), 2)
|
|
|
|
# Every selectable card already chosen -> confirm rather than toggle forever.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose any number.",
|
|
"cards": [card("Defend", 0)],
|
|
"selected_cards": [{"index": 0, "name": "Defend"}],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("hand_select confirms when all are chosen", d.action, "combat_confirm_selection")
|
|
|
|
# Duplicate names: two Defends, one already chosen, one still selectable.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose any number.",
|
|
"cards": [card("Defend", 0), card("Defend", 1)],
|
|
"selected_cards": [{"index": 0, "name": "Defend"}],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("hand_select still offers the second Defend", d.params.get("card_index"), 1)
|
|
|
|
# `upgrade_select` pre-selects the card; selecting is a no-op and only the
|
|
# confirm makes progress. Measured live: cards=[Defend], can_confirm=true, and
|
|
# combat_select_card changed nothing across 18 attempts.
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "upgrade_select", "prompt": "Confirm Card to Upgrade",
|
|
"cards": [card("Defend", 0)], "selected_cards": [],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("upgrade_select confirms instead of selecting", d.action, "combat_confirm_selection")
|
|
|
|
# No enemies but combat still in progress (Phrog Parasite spawning the next
|
|
# wave). Measured live: the bot emitted play_card(card_index=1) with no target
|
|
# and the game rejected it with "Card requires a target".
|
|
force_reset()
|
|
obs_no_enemies = combat(enemies=[])
|
|
d = brain.decide(obs_no_enemies, None)
|
|
check("no enemies -> wait, do not play", d.action, "__wait__")
|
|
|
|
# A target-requiring card must never be emitted without a target.
|
|
force_reset()
|
|
obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()])
|
|
d = brain.decide(obs_one_enemy, None)
|
|
check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0")
|
|
|
|
force_reset()
|
|
d = brain.decide(obs_no_enemies, StubClient())
|
|
check("even with jev, no enemies means wait", d.action, "__wait__")
|
|
|
|
# The enchant screen: screen_type is the RAW C# class name, select_card toggles
|
|
# 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.
|
|
check("raw class name normalises to upgrade",
|
|
brain.screen_kind("NDeckEnchantSelectScreen"), "upgrade")
|
|
check("friendly names still work",
|
|
(brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform"))
|
|
|
|
enchant_state = {
|
|
"state_type": "card_select",
|
|
"card_select": {"screen_type": "NDeckEnchantSelectScreen",
|
|
"prompt": "Choose a card to Enchant.",
|
|
"cards": [offset_card("Strike", 0), offset_card("Perfected Strike", 1)],
|
|
"preview_showing": False,
|
|
"can_cancel": False, "can_confirm": True},
|
|
"run": {"act": 1, "floor": 13}, "player": player(),
|
|
}
|
|
|
|
force_reset()
|
|
d1 = brain.decide(enchant_state, None)
|
|
check("enchant screen selects first", d1.action, "select_card")
|
|
|
|
d2 = brain.decide(enchant_state, None)
|
|
check("enchant screen then confirms", d2.action, "confirm_selection")
|
|
|
|
# The full flow must terminate: select, confirm, done.
|
|
force_reset()
|
|
seq = [brain.decide(enchant_state, None).action, brain.decide(enchant_state, None).action]
|
|
check("enchant flow terminates", seq, ["select_card", "confirm_selection"])
|
|
|
|
# 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:
|
|
# the handler re-selected the same index forever and stalled.
|
|
check("the required count is parsed from the prompt",
|
|
brain.card_select_need("Choose 5 cards to Remove."), 5)
|
|
check("single-select defaults to 1",
|
|
brain.card_select_need("Choose a card to Upgrade."), 1)
|
|
|
|
multi_state = {
|
|
"state_type": "card_select",
|
|
"card_select": {"screen_type": "select", "prompt": "Choose 5 cards to Remove.",
|
|
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
|
|
"preview_showing": False,
|
|
"can_cancel": False, "can_confirm": False},
|
|
"run": {"act": 2, "floor": 18}, "player": player(),
|
|
}
|
|
|
|
force_reset()
|
|
seq = [brain.decide(multi_state, None).action for _ in range(6)]
|
|
check("multi-select picks 5 then waits",
|
|
seq, ["select_card"] * 5 + ["__wait__"])
|
|
|
|
# Every pick must be a DIFFERENT index, or select_card toggles it back off.
|
|
force_reset()
|
|
idxs = [brain.decide(multi_state, None).params.get("index") for _ in range(5)]
|
|
check("each pick targets a different index", len(set(idxs)), 5)
|
|
|
|
# Once can_confirm turns true, it confirms.
|
|
force_reset()
|
|
for _ in range(5):
|
|
brain.decide(multi_state, None)
|
|
confirmable = dict(multi_state)
|
|
confirmable["card_select"] = dict(multi_state["card_select"], can_confirm=True)
|
|
d = brain.decide(confirmable, None)
|
|
check("multi-select confirms once the count is met", d.action, "confirm_selection")
|
|
|
|
# Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old
|
|
# unconditional crystal_sphere_proceed was rejected and stalled the run.
|
|
force_reset()
|
|
cs_state = {
|
|
"state_type": "crystal_sphere",
|
|
"crystal_sphere": {"grid_width": 11, "grid_height": 11,
|
|
"cells": [], "revealed_items": [], "tool": "big",
|
|
"clickable_cells": [{"x": 5, "y": 5}, {"x": 0, "y": 0}],
|
|
"can_proceed": False},
|
|
"run": {"act": 2, "floor": 25}, "player": player(),
|
|
}
|
|
d = brain.decide(cs_state, None)
|
|
check("crystal sphere reveals a cell first", d.action, "crystal_sphere_click_cell")
|
|
check("...starting from the centre", (d.params.get("x"), d.params.get("y")), (5, 5))
|
|
|
|
# It must not re-click the same cell.
|
|
d2 = brain.decide(cs_state, None)
|
|
check("crystal sphere does not repeat a cell",
|
|
(d2.params.get("x"), d2.params.get("y")) != (d.params.get("x"), d.params.get("y")), True)
|
|
|
|
# Once can_proceed unlocks, it proceeds.
|
|
force_reset()
|
|
cs_done = dict(cs_state)
|
|
cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True)
|
|
d3 = brain.decide(cs_done, None)
|
|
check("crystal sphere proceeds when unlocked", d3.action, "crystal_sphere_proceed")
|
|
|
|
# "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
|
|
# 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 counted prompt needs N", brain.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)
|
|
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
|
|
"cards": [card("Defend", 0)],
|
|
"selected_cards": [{"index": 0, "name": "Strike"}],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("a satisfied singular prompt confirms", d.action, "combat_confirm_selection")
|
|
|
|
# An unsatisfied counted prompt keeps selecting.
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "hand_select",
|
|
"hand_select": {"mode": "simple_select", "prompt": "Choose 2 cards to discard.",
|
|
"cards": [card("Strike", 0), card("Defend", 1)],
|
|
"selected_cards": [],
|
|
"can_confirm": True}},
|
|
None,
|
|
)
|
|
check("an unsatisfied counted prompt selects", d.action, "combat_select_card")
|
|
|
|
# The `combined` policy: Jev declines to skip, but if nothing it rated clears
|
|
# the floor, stay lean anyway. This is the safety net against deck bloat.
|
|
_saved_policy = brain.CARD_SKIP_POLICY
|
|
try:
|
|
weak_stub = StubClient(noul=0.50, noul_override={"skip_all": 0.10})
|
|
|
|
brain.CARD_SKIP_POLICY = "jev"
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
weak_stub,
|
|
)
|
|
check("jev policy takes a weak card", d.action, "select_card_reward")
|
|
|
|
brain.CARD_SKIP_POLICY = "combined"
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
weak_stub,
|
|
)
|
|
check("combined policy skips a weak card", d.action, "skip_card_reward")
|
|
check("...and says why", "combined:" in d.reason, True)
|
|
|
|
# A strong card is taken under BOTH policies.
|
|
strong_stub = StubClient(noul=0.80, noul_override={"skip_all": 0.10})
|
|
for policy in ("jev", "combined"):
|
|
brain.CARD_SKIP_POLICY = policy
|
|
force_reset()
|
|
d = brain.decide(
|
|
{"state_type": "card_reward",
|
|
"card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True},
|
|
"run": {"act": 1, "floor": 3}, "player": player()},
|
|
strong_stub,
|
|
)
|
|
check(f"{policy} takes a strong card", d.action, "select_card_reward")
|
|
finally:
|
|
brain.CARD_SKIP_POLICY = _saved_policy
|
|
|
|
# Preflight must catch states the bot cannot leave, so a whole session does not
|
|
# silently burn its step budget. This actually happened: an A/B arm ran with 0
|
|
# decisions because the Timeline epoch was pending.
|
|
import run as run_module
|
|
|
|
check("preflight passes a normal main menu",
|
|
run_module.preflight({"state_type": "menu", "menu_screen": "main",
|
|
"options": ["singleplayer", "settings", "quit"]}), None)
|
|
|
|
blocked = run_module.preflight({
|
|
"state_type": "menu", "menu_screen": "main",
|
|
"options": ["settings", "quit"],
|
|
"blocked_options": [{"name": "timeline", "enabled": False,
|
|
"reason": "manual_epoch_reveal_required",
|
|
"pending_epoch_ids": ["IRONCLAD5_EPOCH"]}],
|
|
})
|
|
check("preflight catches the timeline blocker", bool(blocked), True)
|
|
check("...and names the pending epoch", "IRONCLAD5_EPOCH" in (blocked or ""), True)
|
|
|
|
check("preflight ignores non-menu states",
|
|
run_module.preflight({"state_type": "monster"}), None)
|
|
|
|
print()
|
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
|
sys.exit(1 if FAIL else 0)
|