refactor(policy): extract combat selection and context modules

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

View file

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