V3 — Ordered List (cautious scraping)
V2 has a self-destructive habit: scrap effects live high in its priority order, so it happily scraps cards it just bought — cannibalising the very deck it is trying to build. Head to head, that costs it dearly (the plain ordered list loses 8–92 against this variant). V3 is identical to V2 except for one rule: never scrap your own non-starter cards. Only the starting-deck Scouts and Vipers may be scrapped — thinning the weak starters out of the deck is good; everything else stays. Scrapping trade-row cards (denying the opponent a buy) is unaffected, since that never touches your own deck.
lesson_7/policies/v3/__init__.py
"""V3 -- Ordered List (cautious scraping): never scrap your own non-starter
cards (only Scouts/Vipers may be scrapped)."""
from __future__ import annotations
from random import Random
import star_engine as se
from ..base import Actions, Policy
from ..common import cautious_action, one_hot
class V3(Policy):
def __init__(self) -> None:
super().__init__("V3 Ordered List (cautious scraping)")
def get_action_probabilities(
self, game: se.Game, actions: Actions, rng: Random
) -> list[float]:
return one_hot(actions, cautious_action(game, actions))
The filter, and the fallback when the engine forces a scrap and no Scout/Viper is available (scrap the least valuable card):
lesson_7/policies/common.py (excerpt)
def cautious_action(game: se.Game, actions: Actions) -> se.LegalAction:
"""V3: ordered list, but never scrap the player's own non-starter cards."""
candidates, bw_specs = _cautious_candidates(actions, game)
if not candidates:
return _forced_scrap(actions)
return _select(candidates, _by_category, _cautious_key_for(bw_specs))
def _cautious_candidates(
actions: Actions, game: se.Game
) -> tuple[list[se.LegalAction], dict | None]:
"""Drop the player's own non-starter scraps (only Scouts/Vipers may be
scrapped; trade-row scraps and non-scrap actions are kept). Returns
(candidates, bw_specs)."""
bw_specs = _brain_world_specs(actions, game)
def allowed(a: se.LegalAction) -> bool:
if a.kind in _OWN_SCRAP_KINDS:
return a.name in _STARTERS
if a.kind == se.KIND_USE_BRAIN_WORLD:
if not bw_specs: # None (misaligned) or {} (no BW) -> disallow
return False
cards = _bw_cards(bw_specs[a.index])
return bool(cards) and all(c in _STARTERS for c in cards)
return True # not an own-card scrap (incl. trade-row scraps) -> keep
return [a for a in actions if allowed(a)], bw_specs
def _forced_scrap(actions: Actions) -> se.LegalAction:
# Forced scrap (engine offers only own-scraps) and none is a Scout/Viper:
# scrap the least-valuable card (lowest cost, then name, then index).
return min(actions, key=lambda a: (a.cost, a.name, a.index))
The subtle part is Brain World, whose one engine action encodes a specific pair of cards to scrap. To know which cards each Brain World action would scrap, the Python side replicates the engine's enumeration order exactly — and conservatively disallows Brain World entirely if the two ever disagree:
lesson_7/policies/common.py (excerpt)
# --- cautious scraping (V3) --------------------------------------------------
def _enumerate_brain_world(hand: list[int], discard: list[int]) -> list[tuple]:
"""Replicate `emit_brain_world_actions` (engine/src/engine.rs) exactly, so
the i-th spec aligns with the i-th UseBrainWorld action the engine emitted.
Each spec is a tuple of (zone, card_index) picks. Order MUST match the
engine."""
specs: list[tuple] = []
n = len(hand)
# 1-scrap from hand only.
for c in range(n):
if hand[c] > 0:
specs.append((("hand", c),))
# 1-scrap from discard only.
for c in range(n):
if discard[c] > 0:
specs.append((("discard", c),))
# 2-scrap from hand only (canonical x <= y).
for x in range(n):
for y in range(x, n):
ok = hand[x] >= 2 if x == y else (hand[x] >= 1 and hand[y] >= 1)
if ok:
specs.append((("hand", x), ("hand", y)))
# 2-scrap from discard only.
for x in range(n):
for y in range(x, n):
ok = discard[x] >= 2 if x == y else (discard[x] >= 1 and discard[y] >= 1)
if ok:
specs.append((("discard", x), ("discard", y)))
# 1 from hand + 1 from discard.
for hc in range(n):
if hand[hc] == 0:
continue
for dc in range(n):
if discard[dc] == 0:
continue
specs.append((("hand", hc), ("discard", dc)))
return specs
def _brain_world_specs(actions: Actions, game: se.Game) -> dict | None:
"""Map each UseBrainWorld action's index -> its (zone, card_index) picks, by
aligning the replicated enumeration with the emitted actions. Returns None if
the counts disagree (then the caller conservatively disallows Brain World)."""
bw = sorted(
(a for a in actions if a.kind == se.KIND_USE_BRAIN_WORLD), key=lambda a: a.index
)
if not bw:
return {}
specs = _enumerate_brain_world(list(game.my_hand), list(game.my_discard))
if len(specs) != len(bw):
return None # misaligned -> treat Brain World as not-allowed (safe)
return {a.index: specs[i] for i, a in enumerate(bw)}