Add decision brain with tests
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.
This commit is contained in:
parent
1623377769
commit
bbb91e2f33
2 changed files with 2678 additions and 0 deletions
960
test_brain.py
Normal file
960
test_brain.py
Normal file
|
|
@ -0,0 +1,960 @@
|
||||||
|
#!/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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue