Mining the 37 run files showed 81% of runs (30/37) die in Act 1, and 43% to an
Act 1 boss. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10
turns and cost 44-80 HP -- about 10-13 a turn, with block cards in hand the
whole time. Four defects, fixed here together because they were found and
verified as one combat-correctness pass.
1. NO DEFENCE POLICY (the big one, found by mining the data)
facts classes a hit of <=15% of max HP as THREAT_CHIP. At 80 max HP that is
12, exactly what the boss deals, and _fallback_combat only blocked for HEAVY
or worse while HP was HEALTHY (>60%). So at 74/80 HP the bot attacked through
the boss's main attack and only started blocking below 48 HP.
_jev_combat also asked a should_defend Noul on every combat turn and never
read it -- grep -rn should_defend returned one line, the one creating it.
Defence therefore fell to choice("Which single play best advances winning
this fight?"), which is damage-biased: on the real Kin state Jev answered
Bash at 0.42 confidence, below the 0.45 gate, so it fell through to the
fallback, which also chose damage. Both paths agreed on the wrong answer.
Blocking is arithmetic, so it is now decided in code before Jev is asked,
using turns_to_kill, projected_incoming, affordable_loss and must_block /
block_urgent. Measured against all 600 real combat captures, the rule changes
8 of 68 in-play turns (11.8%) and stays silent on short fights and when
nothing is incoming.
2. ENEMY BLOCK COUNTED TWICE IN THE LETHAL SEARCH
`total - max(0, enemy.block) >= enemy.effective_hp` subtracts block a second
time, because effective_hp is already hp + block. A 10 hp / 5 block enemy
against 18 raw damage read as "not lethal" and real kills were discarded.
3. THE LETHAL EXECUTOR IGNORED PLAYER STATUSES
`_lethal_line(f.playable, f.energy, [], enemy)` passed an empty status list,
so facts reported lethal_available: true while the code meant to execute the
kill found nothing. CombatFacts.player_status is now passed through.
4. RELIC_SELECT ASKED good_relicN AND READ relicN
Every answer missed, so best_by_noul returned (None, 0.0) for every state and
the path could only ever take the rarest relic.
Tests: 50 in test_facts.py and 131 in test_brain.py, with a regression case for
each -- a blocked enemy, a Strength-carrying player, a relic offer whose
highest-rated relic is deliberately the common one so the rarity fallback cannot
pass by accident, and the Kin turn itself.
1091 lines
49 KiB
Python
1091 lines
49 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 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),
|
|
}
|
|
|
|
|
|
_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)
|
|
# 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",
|
|
brain.card_select_need("Choose 2 Common Cards to Add to Your Deck."), 2)
|
|
check("an ADD screen is not an upgrade screen",
|
|
brain.screen_kind("simple_select", "Choose 2 Common Cards to Add to Your Deck."),
|
|
"add")
|
|
check("a REMOVE prompt beats a generic screen_type",
|
|
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove")
|
|
|
|
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__"])
|
|
|
|
# An ADD screen: 2 cards, then the game closes the screen by itself (no confirm).
|
|
force_reset()
|
|
add_state = {
|
|
"state_type": "card_select",
|
|
"card_select": {"screen_type": "simple_select",
|
|
"prompt": "Choose 2 Common Cards to Add to Your Deck.",
|
|
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
|
|
"preview_showing": False,
|
|
"can_cancel": False, "can_confirm": False},
|
|
"run": {"act": 1, "floor": 5}, "player": player(),
|
|
}
|
|
add_seq = [brain.decide(add_state, None).action for _ in range(4)]
|
|
check("an ADD screen picks exactly 2 then waits",
|
|
add_seq, ["select_card", "select_card", "__wait__", "__wait__"])
|
|
|
|
# Distinct picks need a fresh screen, or the picks continue from above.
|
|
force_reset()
|
|
add_idxs = [brain.decide(add_state, None).params.get("index") for _ in range(2)]
|
|
check("ADD picks are distinct", len(set(add_idxs)), 2)
|
|
|
|
# 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("=== 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 = brain.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 = brain.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("brain.py").read(), False)
|
|
check("...and nothing reads it either",
|
|
"response.get(\"should_defend\")" in open("brain.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 = brain.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(brain._lethal_line(bf.playable, bf.energy, bf.player_status, bf.enemies[0])),
|
|
True)
|
|
|
|
strong = combat(hand=[card(index=n) 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 sees the player's Strength",
|
|
[c["name"] for c in (brain._lethal_line(sf.playable, sf.energy,
|
|
sf.player_status, sf.enemies[0]) or [])],
|
|
["Strike", "Strike"])
|
|
check("...where an empty status list finds nothing",
|
|
brain._lethal_line(sf.playable, sf.energy, [], sf.enemies[0]), None)
|
|
|
|
print()
|
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
|
sys.exit(1 if FAIL else 0)
|