#!/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 from policy import combat as combat_policy, selection import facts 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), } 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 = selection.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 = selection.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 = selection.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 = selection.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. 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) 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) 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}) 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}) 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}) 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. 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) 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 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 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 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 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 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. 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() # 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". 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. 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") 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", selection.screen_kind("NDeckEnchantSelectScreen"), "upgrade") check("friendly names still work", (selection.screen_kind("remove"), selection.screen_kind("transform")), ("remove", "transform")) # 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", selection.card_select_need("Choose 5 cards to Remove."), 5) check("single-select defaults to 1", selection.card_select_need("Choose a card to Upgrade."), 1) # The number is not always immediately followed by "cards": # "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. check("the count survives an adjective", selection.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2) check("an ADD screen is not an upgrade screen", selection.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."), "add") check("a REMOVE prompt beats a generic screen_type", selection.screen_kind("select", "Choose 5 cards to Remove."), "remove") # Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old # unconditional crystal_sphere_proceed was rejected and stalled the run. 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)) # Once can_proceed unlocks, it proceeds. 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", selection.hand_select_need("Choose a card to Exhaust."), 1) check("a counted prompt needs N", selection.hand_select_need("Choose 2 cards to discard."), 2) check("\"any number\" is unbounded", selection.hand_select_need("Choose any number of cards to replace."), None) 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. 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" 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" 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 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("=== defense is decided in CODE, before Jev ===") # THE_KIN_BOSS killed 9 of 37 runs: 5-10 turn fights, 44-80 HP lost, ~10-13 a # turn, with block cards in hand. Both old paths preferred damage -- the # fallback called a 12-damage hit at 80 max HP "chip", and Jev, asked "which # play best advances winning this fight?", chose Bash at 0.42 confidence. kin_hand = [ card("Strike", index=0), card("Strike", index=1), card("Defend", index=2, desc="Gain 5 Block.", ctype="Skill", target="Self"), card("Defend", index=3, desc="Gain 5 Block.", ctype="Skill", target="Self"), ] def blk(obs): """The block value of whatever combat_decision picks, or None.""" d = combat_policy.combat_decision(facts.combat_facts(obs), None) if d.action != "play_card": return d.action return facts.block_value(facts.combat_facts(obs).hand[d.params["card_index"]]) boss = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=74) check("Kin turn at 74/80 HP forces a BLOCK", blk(boss) > 0, True) boss60 = combat(hand=kin_hand, enemies=[enemy("KIN_0", hp=140, intent=12)], hp=60) check("...and still blocks at 60/80", blk(boss60) > 0, True) # The rule must NOT fire on a short fight, or the bot stops front-loading. short = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=15, intent=6)], hp=74) check("a 15 HP enemy does not force a block", blk(short), 0) quiet = combat(hand=kin_hand, enemies=[enemy("NIBBIT_0", hp=140, intent=0)], hp=74) check("nothing incoming does not force a block", blk(quiet), 0) # Defense must never pre-empt lethal. 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) d = combat_policy.combat_decision(facts.combat_facts(lethal), None) check("lethal still beats blocking", facts.combat_facts(lethal).hand[d.params["card_index"]]["name"], "Bludgeon") # The dead question must stay dead. Assert on the QUESTION being built, not # on the string: the replacement comment legitimately names it. check("the should_defend question is no longer asked", 'questions["should_defend"]' in open("policy/combat.py").read(), False) check("...and nothing reads it either", "response.get(\"should_defend\")" in open("policy/combat.py").read(), False) print() print("=== relic_select reads the ids it asked for (regression) ===") # The questions are keyed `good_relicN`. Ranking on `relicN` found nothing, so # `best_by_noul` returned (None, 0.0) for every state and the path could only # ever take the rarest relic -- every answer Jev gave was silently dropped. # The highest-rated relic here is deliberately the COMMON one, so a rarity # fallback cannot produce the expected answer by accident. relic_obs = { "state_type": "relic_select", "relic_select": { "can_skip": False, "relics": [ {"index": 0, "name": "Common Relic", "rarity": "Common", "description": "Does something small."}, {"index": 1, "name": "Rare Relic", "rarity": "Rare", "description": "Does something large."}, ], }, "player": {"character": "The Ironclad"}, } relic_stub = StubClient(noul=0.30, noul_override={"good_relic0": 0.80, "good_relic1": 0.40}) d = selection.relic_select_decision(relic_obs, relic_stub) check("relic_select acts on Jev's answer", d.source, "jev") check("...and takes the relic Jev rated highest, not the rarest", d.params.get("index"), 0) print() print("=== the lethal search honours enemy block and player statuses ===") # `effective_hp` is hp + block and the search is raw damage, so block is # subtracted once. Subtracting it in both places needed hp + 2*block and threw # away real kills; and the executor used to pass `[]` for the statuses, so # facts could report lethal while the code meant to execute it found nothing. blocked = combat(hand=[card(index=n) for n in range(3)], enemies=[enemy("E_0", hp=10, block=5)]) bf = facts.combat_facts(blocked) check("18 raw vs 10 hp + 5 block is lethal", bf.lethal_available, True) check("...and the executor finds the line", bool(combat_policy._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])), True) strong = combat(hand=[card(index=n, desc="Deal 12 damage.") for n in range(2)], enemies=[enemy("E_0", hp=18, intent=0)], status=[{"name": "Strength", "amount": 6, "type": "Buff"}]) sf = facts.combat_facts(strong) check("the executor uses Strength-adjusted hand text", [c["name"] for c in (combat_policy._lethal_line(sf.playable, sf.energy, sf.player_status, sf.enemies[0]) or [])], ["Strike", "Strike"]) check("...and does not need to add Strength again", bool(combat_policy._lethal_line(sf.playable, sf.energy, [], sf.enemies[0])), True) print() print("=== correctness: shared damage and block plans ===") adjusted = combat(hand=[card(index=5, desc="Deal 8 damage.")], enemies=[enemy("E_0", hp=9, intent=0)], status=[{"name": "Strength", "amount": 2}]) af = facts.combat_facts(adjusted) check("executor does not invent a Strength-based lethal line", 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"), card("Small Block", index=8, desc="Gain 6 Block.", ctype="Skill", target="Self"), card("Small Block", index=11, desc="Gain 6 Block.", ctype="Skill", target="Self")] blocking = combat(hand=defenders, enemies=[enemy("E_0", hp=100, intent=12)], hp=30) blocking["player"]["energy"] = 2 bd = brain.decide(blocking, None) check("forced defense starts a maximum-block plan", bd.params.get("card_index") in (8, 11), True) covered = combat(hand=[card("Defend", index=5, desc="Gain 5 Block.", ctype="Skill", target="Self"), card(index=8)], enemies=[enemy("E_0", hp=100, intent=12)], hp=30, block=12) check("fully blocked incoming does not force Defend", brain.decide(covered, None).params.get("card_index"), 8) print() print(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0)