V2 — Ordered List
V2 is the first policy that acts with intent. Every legal action arrives from
the engine pre-classified into one of seven categories, and the category number doubles as
a priority: resolve pending choices first, then buy, then use base abilities, then play
cards, then attack bases, then attack the opponent, and only then end the turn. Within a
category, a documented tie-break makes the choice fully deterministic (e.g. purchases:
most expensive first, then alphabetical). The full design rationale lives in
docs/lesson_7_action_ordering.md.
| Priority | Bucket | Tie-break |
|---|---|---|
| 0 | Pending choices (forced discards, scraps, …) | name, then index |
| 1 | Purchase | cost DESC, then name |
| 2 | Base ability | name |
| 3 | Play card | name |
| 4 | Attack base | outposts first, defense DESC, cost DESC, name DESC |
| 5 | Attack opponent | — |
| 6 | End turn | — |
The policy module is a thin shell — it turns the chosen action into a one-hot distribution:
lesson_7/policies/v2/__init__.py
"""V2 -- Ordered List: deterministic priority play."""
from __future__ import annotations
from random import Random
import star_engine as se
from ..base import Actions, Policy
from ..common import one_hot, ordered_list_action
class V2(Policy):
def __init__(self) -> None:
super().__init__("V2 Ordered List")
def get_action_probabilities(
self, game: se.Game, actions: Actions, rng: Random
) -> list[float]:
return one_hot(actions, ordered_list_action(game, actions))
The selection itself is a low-cardinality two-pass scan (the engine auto-resolves forced moves, so a decision typically offers ≤ 10 actions): find the highest-priority bucket present, then take the argmin of that bucket's tie-break key.
lesson_7/policies/common.py (excerpt)
def ordered_list_action(game: se.Game, actions: Actions) -> se.LegalAction:
"""V2: first action by fixed priority (lowest category number), tie-broken by
that category's key."""
return _select(actions, _by_category, lambda cat: _KEY_FOR[cat])
def _by_category(a: se.LegalAction) -> float:
return a.category
def _select(
actions: Actions,
priority: Callable[[se.LegalAction], float],
key_for: Callable[[float], Callable[[se.LegalAction], tuple]],
) -> se.LegalAction:
"""Low-cardinality selection: one O(n) pass to the highest-priority bucket
present (smallest `priority` value), then a small argmin within it.
`priority` maps an action -> its priority value; `key_for` maps a priority
value -> the tie-break key fn for that bucket."""
best = min(priority(a) for a in actions)
members = [a for a in actions if priority(a) == best]
return min(members, key=key_for(best))
# Per-category tie-break key, indexed by the category integer (0..6), which is
# also the priority. Mirrors docs/lesson_7_action_ordering.md.
_KEY_FOR: dict[int, Callable[[se.LegalAction], tuple]] = {
se.CAT_PENDING: lambda a: (a.name, a.index),
se.CAT_PURCHASE: _key_purchase,
se.CAT_BASE_ABILITY: _key_name_asc,
se.CAT_PLAY_CARD: _key_name_asc,
se.CAT_ATTACK_BASE: _key_attack_base,
se.CAT_ATTACK_OPPONENT: _key_index,
se.CAT_END_TURN: _key_index,
}
def _key_purchase(a: se.LegalAction):
# Most expensive first, then alphabetical: cost DESC, name ASC.
return (-a.cost, a.name)
def _key_attack_base(a: se.LegalAction):
# Outposts first, then defense (hit points) DESC, cost DESC, name DESC.
return (not a.is_outpost, -a.defense, -a.cost, _RevStr(a.name))
# --- ordered-list tie-breaks -------------------------------------------------
class _RevStr:
"""Wrapper that reverses string ordering, so a `min()` key can express
"name DESCENDING" inside an otherwise-ascending tuple key."""
__slots__ = ("s",)
def __init__(self, s: str) -> None:
self.s = s
def __lt__(self, other: "_RevStr") -> bool:
return self.s > other.s
def __eq__(self, other: object) -> bool:
return isinstance(other, _RevStr) and self.s == other.s
All of common.py (shared by V2–V4) is on
its own page.