FastGameState를 기본 게임 상태로 전환
This commit is contained in:
@@ -5,6 +5,7 @@ from .bots import (
|
|||||||
available_bot_names,
|
available_bot_names,
|
||||||
build_bot,
|
build_bot,
|
||||||
)
|
)
|
||||||
|
from .engines import FastGameState as GameState
|
||||||
from .env import LostCitiesEnv
|
from .env import LostCitiesEnv
|
||||||
from .evaluation import (
|
from .evaluation import (
|
||||||
GameResult,
|
GameResult,
|
||||||
@@ -17,7 +18,6 @@ from .evaluation import (
|
|||||||
play_match,
|
play_match,
|
||||||
)
|
)
|
||||||
from .game import (
|
from .game import (
|
||||||
GameState,
|
|
||||||
IllegalMoveError,
|
IllegalMoveError,
|
||||||
LostCitiesConfig,
|
LostCitiesConfig,
|
||||||
classic_config,
|
classic_config,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..game import GameState
|
from ..engines import FastGameState as GameState
|
||||||
from ..interfaces import BotInput, Snapshot
|
from ..interfaces import BotInput, Snapshot
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -10,7 +10,7 @@ except ImportError as exc: # pragma: no cover
|
|||||||
|
|
||||||
|
|
||||||
def legal_from_obs(obs_or_state: BotInput) -> np.ndarray:
|
def legal_from_obs(obs_or_state: BotInput) -> np.ndarray:
|
||||||
if isinstance(obs_or_state, GameState):
|
if isinstance(obs_or_state, GameState) or hasattr(obs_or_state, "legal_mask"):
|
||||||
return np.asarray(obs_or_state.legal_mask(), dtype=bool)
|
return np.asarray(obs_or_state.legal_mask(), dtype=bool)
|
||||||
if isinstance(obs_or_state, Snapshot):
|
if isinstance(obs_or_state, Snapshot):
|
||||||
return np.asarray(obs_or_state.legal_mask, dtype=bool)
|
return np.asarray(obs_or_state.legal_mask, dtype=bool)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from ..game import Card, GameState, LostCitiesConfig
|
from ..engines import FastGameState as GameState
|
||||||
|
from ..game import Card, LostCitiesConfig
|
||||||
from ..interfaces import BotInput, LostCitiesBot
|
from ..interfaces import BotInput, LostCitiesBot
|
||||||
from .base import first_legal, legal_from_obs
|
from .base import first_legal, legal_from_obs
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ class SafeHeuristicBot(LostCitiesBot):
|
|||||||
self.params = params or SafeHeuristicParams()
|
self.params = params or SafeHeuristicParams()
|
||||||
|
|
||||||
def act(self, obs_or_state: BotInput) -> int:
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
if not isinstance(obs_or_state, GameState):
|
if not isinstance(obs_or_state, GameState) and not hasattr(obs_or_state, "legal_mask"):
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"SafeHeuristicBot fallback to first legal: input_type=%s",
|
"SafeHeuristicBot fallback to first legal: input_type=%s",
|
||||||
type(obs_or_state).__name__,
|
type(obs_or_state).__name__,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..game import GameState
|
from ..engines import FastGameState as GameState
|
||||||
from ..interfaces import BotInput, Snapshot
|
from ..interfaces import BotInput, Snapshot
|
||||||
from .base import first_legal, legal_from_obs
|
from .base import first_legal, legal_from_obs
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ class PassiveDiscardBot:
|
|||||||
"""Baseline that avoids opening expeditions whenever discarding is legal."""
|
"""Baseline that avoids opening expeditions whenever discarding is legal."""
|
||||||
|
|
||||||
def act(self, obs_or_state: BotInput) -> int:
|
def act(self, obs_or_state: BotInput) -> int:
|
||||||
if isinstance(obs_or_state, GameState):
|
if isinstance(obs_or_state, GameState) or hasattr(obs_or_state, "legal_mask"):
|
||||||
return self._act_phase_local(
|
return self._act_phase_local(
|
||||||
obs_or_state.phase,
|
obs_or_state.phase,
|
||||||
obs_or_state.legal_mask(),
|
obs_or_state.legal_mask(),
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ cdef class FastGameState:
|
|||||||
cdef int cards_per_color
|
cdef int cards_per_color
|
||||||
cdef int stride
|
cdef int stride
|
||||||
|
|
||||||
cdef int* deck
|
cdef int* deck_cards
|
||||||
cdef int deck_len
|
cdef int deck_len
|
||||||
cdef int* hands
|
cdef int* hand_cards
|
||||||
cdef int hand_lens[2]
|
cdef int hand_lens[2]
|
||||||
cdef int* expeditions
|
cdef int* expedition_cards
|
||||||
cdef int* expedition_lens
|
cdef int* expedition_lens
|
||||||
cdef int* discards
|
cdef int* discard_cards
|
||||||
cdef int* discard_lens
|
cdef int* discard_lens
|
||||||
cdef int* last_numeric_ranks
|
cdef int* last_numeric_ranks
|
||||||
cdef int* handshake_counts
|
cdef int* handshake_counts
|
||||||
@@ -61,6 +61,7 @@ cdef class FastGameState:
|
|||||||
cpdef list legal_draw_mask(self)
|
cpdef list legal_draw_mask(self)
|
||||||
cpdef list legal_mask(self)
|
cpdef list legal_mask(self)
|
||||||
cpdef list unified_legal_mask(self)
|
cpdef list unified_legal_mask(self)
|
||||||
|
cpdef object unified_legal_mask_np(self)
|
||||||
cpdef list legal_actions(self)
|
cpdef list legal_actions(self)
|
||||||
cpdef list unified_legal_actions(self)
|
cpdef list unified_legal_actions(self)
|
||||||
cpdef int from_unified_action(self, int action_id)
|
cpdef int from_unified_action(self, int action_id)
|
||||||
@@ -106,3 +107,4 @@ cdef class FastGameState:
|
|||||||
cdef int _card_color(self, int card)
|
cdef int _card_color(self, int card)
|
||||||
cdef int _card_rank(self, int card)
|
cdef int _card_rank(self, int card)
|
||||||
cdef object _card_snapshot(self, int card)
|
cdef object _card_snapshot(self, int card)
|
||||||
|
cdef object _card_obj(self, int card)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import random
|
|||||||
from libc.string cimport memcpy
|
from libc.string cimport memcpy
|
||||||
from libc.stdlib cimport free, malloc, realloc
|
from libc.stdlib cimport free, malloc, realloc
|
||||||
|
|
||||||
from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping
|
from ..game import Card, IllegalMoveError, LostCitiesConfig, config_from_mapping
|
||||||
|
|
||||||
|
|
||||||
cdef inline int _phase_card():
|
cdef inline int _phase_card():
|
||||||
@@ -20,11 +20,11 @@ cdef inline int _phase_draw():
|
|||||||
|
|
||||||
cdef class FastGameState:
|
cdef class FastGameState:
|
||||||
def __cinit__(self):
|
def __cinit__(self):
|
||||||
self.deck = NULL
|
self.deck_cards = NULL
|
||||||
self.hands = NULL
|
self.hand_cards = NULL
|
||||||
self.expeditions = NULL
|
self.expedition_cards = NULL
|
||||||
self.expedition_lens = NULL
|
self.expedition_lens = NULL
|
||||||
self.discards = NULL
|
self.discard_cards = NULL
|
||||||
self.discard_lens = NULL
|
self.discard_lens = NULL
|
||||||
self.last_numeric_ranks = NULL
|
self.last_numeric_ranks = NULL
|
||||||
self.handshake_counts = NULL
|
self.handshake_counts = NULL
|
||||||
@@ -38,16 +38,16 @@ cdef class FastGameState:
|
|||||||
self._configure(config)
|
self._configure(config)
|
||||||
|
|
||||||
def __dealloc__(self):
|
def __dealloc__(self):
|
||||||
if self.deck != NULL:
|
if self.deck_cards != NULL:
|
||||||
free(self.deck)
|
free(self.deck_cards)
|
||||||
if self.hands != NULL:
|
if self.hand_cards != NULL:
|
||||||
free(self.hands)
|
free(self.hand_cards)
|
||||||
if self.expeditions != NULL:
|
if self.expedition_cards != NULL:
|
||||||
free(self.expeditions)
|
free(self.expedition_cards)
|
||||||
if self.expedition_lens != NULL:
|
if self.expedition_lens != NULL:
|
||||||
free(self.expedition_lens)
|
free(self.expedition_lens)
|
||||||
if self.discards != NULL:
|
if self.discard_cards != NULL:
|
||||||
free(self.discards)
|
free(self.discard_cards)
|
||||||
if self.discard_lens != NULL:
|
if self.discard_lens != NULL:
|
||||||
free(self.discard_lens)
|
free(self.discard_lens)
|
||||||
if self.last_numeric_ranks != NULL:
|
if self.last_numeric_ranks != NULL:
|
||||||
@@ -75,13 +75,13 @@ cdef class FastGameState:
|
|||||||
self.cards_per_color = self.n_ranks + self.n_handshakes
|
self.cards_per_color = self.n_ranks + self.n_handshakes
|
||||||
self.stride = self.n_ranks + 1
|
self.stride = self.n_ranks + 1
|
||||||
|
|
||||||
self.deck = <int*>malloc(self.total_cards * sizeof(int))
|
self.deck_cards = <int*>malloc(self.total_cards * sizeof(int))
|
||||||
self.hands = <int*>malloc(2 * self.hand_size * sizeof(int))
|
self.hand_cards = <int*>malloc(2 * self.hand_size * sizeof(int))
|
||||||
self.expeditions = <int*>malloc(
|
self.expedition_cards = <int*>malloc(
|
||||||
2 * self.n_colors * self.cards_per_color * sizeof(int)
|
2 * self.n_colors * self.cards_per_color * sizeof(int)
|
||||||
)
|
)
|
||||||
self.expedition_lens = <int*>malloc(2 * self.n_colors * sizeof(int))
|
self.expedition_lens = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||||
self.discards = <int*>malloc(self.n_colors * self.cards_per_color * sizeof(int))
|
self.discard_cards = <int*>malloc(self.n_colors * self.cards_per_color * sizeof(int))
|
||||||
self.discard_lens = <int*>malloc(self.n_colors * sizeof(int))
|
self.discard_lens = <int*>malloc(self.n_colors * sizeof(int))
|
||||||
self.last_numeric_ranks = <int*>malloc(2 * self.n_colors * sizeof(int))
|
self.last_numeric_ranks = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||||
self.handshake_counts = <int*>malloc(2 * self.n_colors * sizeof(int))
|
self.handshake_counts = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||||
@@ -92,11 +92,11 @@ cdef class FastGameState:
|
|||||||
self.undo_stack_capacity * sizeof(UndoRecord)
|
self.undo_stack_capacity * sizeof(UndoRecord)
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
self.deck == NULL
|
self.deck_cards == NULL
|
||||||
or self.hands == NULL
|
or self.hand_cards == NULL
|
||||||
or self.expeditions == NULL
|
or self.expedition_cards == NULL
|
||||||
or self.expedition_lens == NULL
|
or self.expedition_lens == NULL
|
||||||
or self.discards == NULL
|
or self.discard_cards == NULL
|
||||||
or self.discard_lens == NULL
|
or self.discard_lens == NULL
|
||||||
or self.last_numeric_ranks == NULL
|
or self.last_numeric_ranks == NULL
|
||||||
or self.handshake_counts == NULL
|
or self.handshake_counts == NULL
|
||||||
@@ -159,11 +159,11 @@ cdef class FastGameState:
|
|||||||
cdef FastGameState state = cls(config)
|
cdef FastGameState state = cls(config)
|
||||||
state.deck_len = len(encoded)
|
state.deck_len = len(encoded)
|
||||||
for i, card in enumerate(encoded):
|
for i, card in enumerate(encoded):
|
||||||
state.deck[i] = <int>card
|
state.deck_cards[i] = <int>card
|
||||||
for _ in range(config.hand_size):
|
for _ in range(config.hand_size):
|
||||||
for player in range(2):
|
for player in range(2):
|
||||||
state.deck_len -= 1
|
state.deck_len -= 1
|
||||||
state.hands[state._hand_index(player, state.hand_lens[player])] = state.deck[
|
state.hand_cards[state._hand_index(player, state.hand_lens[player])] = state.deck_cards[
|
||||||
state.deck_len
|
state.deck_len
|
||||||
]
|
]
|
||||||
state.hand_lens[player] += 1
|
state.hand_lens[player] += 1
|
||||||
@@ -186,7 +186,7 @@ cdef class FastGameState:
|
|||||||
)
|
)
|
||||||
state.deck_len = len(cards)
|
state.deck_len = len(cards)
|
||||||
for index, card in enumerate(cards):
|
for index, card in enumerate(cards):
|
||||||
state.deck[index] = <int>card
|
state.deck_cards[index] = <int>card
|
||||||
|
|
||||||
for player in range(2):
|
for player in range(2):
|
||||||
cards = [
|
cards = [
|
||||||
@@ -199,7 +199,7 @@ cdef class FastGameState:
|
|||||||
)
|
)
|
||||||
state.hand_lens[player] = len(cards)
|
state.hand_lens[player] = len(cards)
|
||||||
for index, card in enumerate(cards):
|
for index, card in enumerate(cards):
|
||||||
state.hands[state._hand_index(player, index)] = <int>card
|
state.hand_cards[state._hand_index(player, index)] = <int>card
|
||||||
|
|
||||||
for player in range(2):
|
for player in range(2):
|
||||||
for color in range(state.n_colors):
|
for color in range(state.n_colors):
|
||||||
@@ -214,7 +214,7 @@ cdef class FastGameState:
|
|||||||
)
|
)
|
||||||
state.expedition_lens[state._expedition_len_index(player, color)] = len(cards)
|
state.expedition_lens[state._expedition_len_index(player, color)] = len(cards)
|
||||||
for index, card in enumerate(cards):
|
for index, card in enumerate(cards):
|
||||||
state.expeditions[state._expedition_index(player, color, index)] = <int>card
|
state.expedition_cards[state._expedition_index(player, color, index)] = <int>card
|
||||||
|
|
||||||
for color in range(state.n_colors):
|
for color in range(state.n_colors):
|
||||||
cards = [_encode_card_snapshot(card, config) for card in snapshot["discards"][color]]
|
cards = [_encode_card_snapshot(card, config) for card in snapshot["discards"][color]]
|
||||||
@@ -225,7 +225,7 @@ cdef class FastGameState:
|
|||||||
)
|
)
|
||||||
state.discard_lens[color] = len(cards)
|
state.discard_lens[color] = len(cards)
|
||||||
for index, card in enumerate(cards):
|
for index, card in enumerate(cards):
|
||||||
state.discards[state._discard_index(color, index)] = <int>card
|
state.discard_cards[state._discard_index(color, index)] = <int>card
|
||||||
|
|
||||||
state.current_player = int(snapshot.get("current_player", 0))
|
state.current_player = int(snapshot.get("current_player", 0))
|
||||||
state.phase = snapshot.get("phase", "card")
|
state.phase = snapshot.get("phase", "card")
|
||||||
@@ -263,13 +263,58 @@ cdef class FastGameState:
|
|||||||
def action_size(self):
|
def action_size(self):
|
||||||
return self.card_action_size + self.draw_action_size
|
return self.card_action_size + self.draw_action_size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deck(self):
|
||||||
|
return [self._card_obj(self.deck_cards[i]) for i in range(self.deck_len)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hands(self):
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
self._card_obj(self.hand_cards[self._hand_index(player, i)])
|
||||||
|
for i in range(self.hand_lens[player])
|
||||||
|
]
|
||||||
|
for player in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expeditions(self):
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
self._card_obj(
|
||||||
|
self.expedition_cards[
|
||||||
|
self._expedition_index(player, color, i)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for i in range(
|
||||||
|
self.expedition_lens[
|
||||||
|
self._expedition_len_index(player, color)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for color in range(self.n_colors)
|
||||||
|
]
|
||||||
|
for player in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discards(self):
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
self._card_obj(self.discard_cards[self._discard_index(color, i)])
|
||||||
|
for i in range(self.discard_lens[color])
|
||||||
|
]
|
||||||
|
for color in range(self.n_colors)
|
||||||
|
]
|
||||||
|
|
||||||
def to_snapshot(self):
|
def to_snapshot(self):
|
||||||
return {
|
return {
|
||||||
"config": self.config.to_snapshot(),
|
"config": self.config.to_snapshot(),
|
||||||
"deck": [self._card_snapshot(self.deck[i]) for i in range(self.deck_len)],
|
"deck": [self._card_snapshot(self.deck_cards[i]) for i in range(self.deck_len)],
|
||||||
"hands": [
|
"hands": [
|
||||||
[
|
[
|
||||||
self._card_snapshot(self.hands[self._hand_index(player, i)])
|
self._card_snapshot(self.hand_cards[self._hand_index(player, i)])
|
||||||
for i in range(self.hand_lens[player])
|
for i in range(self.hand_lens[player])
|
||||||
]
|
]
|
||||||
for player in range(2)
|
for player in range(2)
|
||||||
@@ -278,7 +323,7 @@ cdef class FastGameState:
|
|||||||
[
|
[
|
||||||
[
|
[
|
||||||
self._card_snapshot(
|
self._card_snapshot(
|
||||||
self.expeditions[self._expedition_index(player, color, i)]
|
self.expedition_cards[self._expedition_index(player, color, i)]
|
||||||
)
|
)
|
||||||
for i in range(
|
for i in range(
|
||||||
self.expedition_lens[
|
self.expedition_lens[
|
||||||
@@ -292,7 +337,7 @@ cdef class FastGameState:
|
|||||||
],
|
],
|
||||||
"discards": [
|
"discards": [
|
||||||
[
|
[
|
||||||
self._card_snapshot(self.discards[self._discard_index(color, i)])
|
self._card_snapshot(self.discard_cards[self._discard_index(color, i)])
|
||||||
for i in range(self.discard_lens[color])
|
for i in range(self.discard_lens[color])
|
||||||
]
|
]
|
||||||
for color in range(self.n_colors)
|
for color in range(self.n_colors)
|
||||||
@@ -309,13 +354,13 @@ cdef class FastGameState:
|
|||||||
cpdef FastGameState clone(self):
|
cpdef FastGameState clone(self):
|
||||||
cdef FastGameState other = FastGameState(self.config)
|
cdef FastGameState other = FastGameState(self.config)
|
||||||
other.deck_len = self.deck_len
|
other.deck_len = self.deck_len
|
||||||
memcpy(other.deck, self.deck, self.deck_len * sizeof(int))
|
memcpy(other.deck_cards, self.deck_cards, self.deck_len * sizeof(int))
|
||||||
memcpy(other.hands, self.hands, 2 * self.hand_size * sizeof(int))
|
memcpy(other.hand_cards, self.hand_cards, 2 * self.hand_size * sizeof(int))
|
||||||
other.hand_lens[0] = self.hand_lens[0]
|
other.hand_lens[0] = self.hand_lens[0]
|
||||||
other.hand_lens[1] = self.hand_lens[1]
|
other.hand_lens[1] = self.hand_lens[1]
|
||||||
memcpy(
|
memcpy(
|
||||||
other.expeditions,
|
other.expedition_cards,
|
||||||
self.expeditions,
|
self.expedition_cards,
|
||||||
2 * self.n_colors * self.cards_per_color * sizeof(int),
|
2 * self.n_colors * self.cards_per_color * sizeof(int),
|
||||||
)
|
)
|
||||||
memcpy(
|
memcpy(
|
||||||
@@ -324,8 +369,8 @@ cdef class FastGameState:
|
|||||||
2 * self.n_colors * sizeof(int),
|
2 * self.n_colors * sizeof(int),
|
||||||
)
|
)
|
||||||
memcpy(
|
memcpy(
|
||||||
other.discards,
|
other.discard_cards,
|
||||||
self.discards,
|
self.discard_cards,
|
||||||
self.n_colors * self.cards_per_color * sizeof(int),
|
self.n_colors * self.cards_per_color * sizeof(int),
|
||||||
)
|
)
|
||||||
memcpy(other.discard_lens, self.discard_lens, self.n_colors * sizeof(int))
|
memcpy(other.discard_lens, self.discard_lens, self.n_colors * sizeof(int))
|
||||||
@@ -361,7 +406,7 @@ cdef class FastGameState:
|
|||||||
if self.terminal:
|
if self.terminal:
|
||||||
return mask
|
return mask
|
||||||
for slot in range(self.hand_lens[self.current_player]):
|
for slot in range(self.hand_lens[self.current_player]):
|
||||||
card = self.hands[self._hand_index(self.current_player, slot)]
|
card = self.hand_cards[self._hand_index(self.current_player, slot)]
|
||||||
mask[2 * slot] = self._can_play_encoded_card_c(self.current_player, card)
|
mask[2 * slot] = self._can_play_encoded_card_c(self.current_player, card)
|
||||||
mask[2 * slot + 1] = True
|
mask[2 * slot + 1] = True
|
||||||
return mask
|
return mask
|
||||||
@@ -510,6 +555,48 @@ cdef class FastGameState:
|
|||||||
cpdef int last_numeric_rank(self, int player, int color):
|
cpdef int last_numeric_rank(self, int player, int color):
|
||||||
return self.last_numeric_ranks[self._expedition_len_index(player, color)]
|
return self.last_numeric_ranks[self._expedition_len_index(player, color)]
|
||||||
|
|
||||||
|
def has_numeric(self, int player, int color):
|
||||||
|
return self.last_numeric_rank(player, color) > 0
|
||||||
|
|
||||||
|
def can_play_card(self, int player, object card):
|
||||||
|
return self.can_play_encoded_card(player, _encode_card_snapshot(card, self.config))
|
||||||
|
|
||||||
|
def hand_slots(self, player=None):
|
||||||
|
cdef int p = self.current_player if player is None else int(player)
|
||||||
|
cdef list hand = []
|
||||||
|
cdef int i
|
||||||
|
for i in range(self.hand_lens[p]):
|
||||||
|
hand.append(self._card_obj(self.hand_cards[self._hand_index(p, i)]))
|
||||||
|
while len(hand) < self.hand_size:
|
||||||
|
hand.append(None)
|
||||||
|
return hand
|
||||||
|
|
||||||
|
def sort_hands(self):
|
||||||
|
self.sort_hand(0)
|
||||||
|
self.sort_hand(1)
|
||||||
|
|
||||||
|
def sort_hand(self, player=None):
|
||||||
|
cdef int p = self.current_player if player is None else int(player)
|
||||||
|
cdef int i
|
||||||
|
cdef int j
|
||||||
|
cdef int key
|
||||||
|
cdef int current
|
||||||
|
for i in range(1, self.hand_lens[p]):
|
||||||
|
key = self.hand_cards[self._hand_index(p, i)]
|
||||||
|
j = i - 1
|
||||||
|
while j >= 0 and self.hand_cards[self._hand_index(p, j)] > key:
|
||||||
|
current = self.hand_cards[self._hand_index(p, j)]
|
||||||
|
self.hand_cards[self._hand_index(p, j + 1)] = current
|
||||||
|
j -= 1
|
||||||
|
self.hand_cards[self._hand_index(p, j + 1)] = key
|
||||||
|
|
||||||
|
cpdef object unified_legal_mask_np(self):
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("numpy is required for unified_legal_mask_np") from exc
|
||||||
|
return np.asarray(self.unified_legal_mask(), dtype=bool)
|
||||||
|
|
||||||
cpdef int expedition_score(self, int player, int color):
|
cpdef int expedition_score(self, int player, int color):
|
||||||
return self.expedition_scores[self._expedition_len_index(player, color)]
|
return self.expedition_scores[self._expedition_len_index(player, color)]
|
||||||
|
|
||||||
@@ -552,7 +639,7 @@ cdef class FastGameState:
|
|||||||
seen_numeric = False
|
seen_numeric = False
|
||||||
last_rank = 0
|
last_rank = 0
|
||||||
for index in range(length):
|
for index in range(length):
|
||||||
card = self.expeditions[self._expedition_index(player, color, index)]
|
card = self.expedition_cards[self._expedition_index(player, color, index)]
|
||||||
if self._card_color(card) != color:
|
if self._card_color(card) != color:
|
||||||
raise ValueError("expedition contains wrong color")
|
raise ValueError("expedition contains wrong color")
|
||||||
rank = self._card_rank(card)
|
rank = self._card_rank(card)
|
||||||
@@ -595,7 +682,7 @@ cdef class FastGameState:
|
|||||||
return True
|
return True
|
||||||
return self._can_play_encoded_card_c(
|
return self._can_play_encoded_card_c(
|
||||||
self.current_player,
|
self.current_player,
|
||||||
self.hands[self._hand_index(self.current_player, slot)],
|
self.hand_cards[self._hand_index(self.current_player, slot)],
|
||||||
)
|
)
|
||||||
if action_id < 0 or action_id >= 1 + self.n_colors:
|
if action_id < 0 or action_id >= 1 + self.n_colors:
|
||||||
return False
|
return False
|
||||||
@@ -616,7 +703,7 @@ cdef class FastGameState:
|
|||||||
return 0
|
return 0
|
||||||
if self.phase_id == _phase_card():
|
if self.phase_id == _phase_card():
|
||||||
for slot in range(self.hand_lens[self.current_player]):
|
for slot in range(self.hand_lens[self.current_player]):
|
||||||
card = self.hands[self._hand_index(self.current_player, slot)]
|
card = self.hand_cards[self._hand_index(self.current_player, slot)]
|
||||||
if self._can_play_encoded_card_c(self.current_player, card):
|
if self._can_play_encoded_card_c(self.current_player, card):
|
||||||
out_actions[count] = 2 * slot
|
out_actions[count] = 2 * slot
|
||||||
count += 1
|
count += 1
|
||||||
@@ -645,7 +732,7 @@ cdef class FastGameState:
|
|||||||
return 0
|
return 0
|
||||||
if self.phase_id == _phase_card():
|
if self.phase_id == _phase_card():
|
||||||
for slot in range(self.hand_lens[self.current_player]):
|
for slot in range(self.hand_lens[self.current_player]):
|
||||||
card = self.hands[self._hand_index(self.current_player, slot)]
|
card = self.hand_cards[self._hand_index(self.current_player, slot)]
|
||||||
if self._can_play_encoded_card_c(self.current_player, card):
|
if self._can_play_encoded_card_c(self.current_player, card):
|
||||||
out_actions[count] = 2 * slot
|
out_actions[count] = 2 * slot
|
||||||
count += 1
|
count += 1
|
||||||
@@ -697,7 +784,7 @@ cdef class FastGameState:
|
|||||||
undo.total_score_before = self.total_scores[self.current_player]
|
undo.total_score_before = self.total_scores[self.current_player]
|
||||||
if self.phase_id == _phase_card():
|
if self.phase_id == _phase_card():
|
||||||
slot = action_id // 2
|
slot = action_id // 2
|
||||||
card = self.hands[self._hand_index(self.current_player, slot)]
|
card = self.hand_cards[self._hand_index(self.current_player, slot)]
|
||||||
color = self._card_color(card)
|
color = self._card_color(card)
|
||||||
cache_index = self._expedition_len_index(self.current_player, color)
|
cache_index = self._expedition_len_index(self.current_player, color)
|
||||||
undo.slot = slot
|
undo.slot = slot
|
||||||
@@ -709,11 +796,11 @@ cdef class FastGameState:
|
|||||||
undo.numeric_sum_before = self.numeric_sums[cache_index]
|
undo.numeric_sum_before = self.numeric_sums[cache_index]
|
||||||
undo.expedition_score_before = self.expedition_scores[cache_index]
|
undo.expedition_score_before = self.expedition_scores[cache_index]
|
||||||
elif action_id == 0:
|
elif action_id == 0:
|
||||||
undo.card = self.deck[self.deck_len - 1]
|
undo.card = self.deck_cards[self.deck_len - 1]
|
||||||
else:
|
else:
|
||||||
color = action_id - 1
|
color = action_id - 1
|
||||||
undo.color = color
|
undo.color = color
|
||||||
undo.card = self.discards[self._discard_index(color, self.discard_lens[color] - 1)]
|
undo.card = self.discard_cards[self._discard_index(color, self.discard_lens[color] - 1)]
|
||||||
|
|
||||||
cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *:
|
cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *:
|
||||||
self._fill_undo_c(action_id, undo)
|
self._fill_undo_c(action_id, undo)
|
||||||
@@ -802,7 +889,7 @@ cdef class FastGameState:
|
|||||||
cdef int slot = action_id // 2
|
cdef int slot = action_id // 2
|
||||||
cdef bint play = action_id % 2 == 0
|
cdef bint play = action_id % 2 == 0
|
||||||
cdef int player = self.current_player
|
cdef int player = self.current_player
|
||||||
cdef int card = self.hands[self._hand_index(player, slot)]
|
cdef int card = self.hand_cards[self._hand_index(player, slot)]
|
||||||
cdef int color = self._card_color(card)
|
cdef int color = self._card_color(card)
|
||||||
cdef int rank = self._card_rank(card)
|
cdef int rank = self._card_rank(card)
|
||||||
cdef int i
|
cdef int i
|
||||||
@@ -810,12 +897,12 @@ cdef class FastGameState:
|
|||||||
cdef int old_score
|
cdef int old_score
|
||||||
cdef int new_score
|
cdef int new_score
|
||||||
for i in range(slot, self.hand_lens[player] - 1):
|
for i in range(slot, self.hand_lens[player] - 1):
|
||||||
self.hands[self._hand_index(player, i)] = self.hands[self._hand_index(player, i + 1)]
|
self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i + 1)]
|
||||||
self.hand_lens[player] -= 1
|
self.hand_lens[player] -= 1
|
||||||
if play:
|
if play:
|
||||||
length_index = self._expedition_len_index(player, color)
|
length_index = self._expedition_len_index(player, color)
|
||||||
old_score = self.expedition_scores[length_index]
|
old_score = self.expedition_scores[length_index]
|
||||||
self.expeditions[self._expedition_index(player, color, self.expedition_lens[length_index])] = card
|
self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])] = card
|
||||||
self.expedition_lens[length_index] += 1
|
self.expedition_lens[length_index] += 1
|
||||||
if rank == 0:
|
if rank == 0:
|
||||||
self.handshake_counts[length_index] += 1
|
self.handshake_counts[length_index] += 1
|
||||||
@@ -830,7 +917,7 @@ cdef class FastGameState:
|
|||||||
self.expedition_scores[length_index] = new_score
|
self.expedition_scores[length_index] = new_score
|
||||||
self.total_scores[player] += new_score - old_score
|
self.total_scores[player] += new_score - old_score
|
||||||
else:
|
else:
|
||||||
self.discards[self._discard_index(color, self.discard_lens[color])] = card
|
self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card
|
||||||
self.discard_lens[color] += 1
|
self.discard_lens[color] += 1
|
||||||
self.pending_discarded_color = color
|
self.pending_discarded_color = color
|
||||||
self.phase_id = _phase_draw()
|
self.phase_id = _phase_draw()
|
||||||
@@ -845,12 +932,12 @@ cdef class FastGameState:
|
|||||||
cdef int color
|
cdef int color
|
||||||
if action_id == 0:
|
if action_id == 0:
|
||||||
self.deck_len -= 1
|
self.deck_len -= 1
|
||||||
card = self.deck[self.deck_len]
|
card = self.deck_cards[self.deck_len]
|
||||||
else:
|
else:
|
||||||
color = action_id - 1
|
color = action_id - 1
|
||||||
self.discard_lens[color] -= 1
|
self.discard_lens[color] -= 1
|
||||||
card = self.discards[self._discard_index(color, self.discard_lens[color])]
|
card = self.discard_cards[self._discard_index(color, self.discard_lens[color])]
|
||||||
self.hands[self._hand_index(player, self.hand_lens[player])] = card
|
self.hand_cards[self._hand_index(player, self.hand_lens[player])] = card
|
||||||
self.hand_lens[player] += 1
|
self.hand_lens[player] += 1
|
||||||
self.pending_discarded_color = -1
|
self.pending_discarded_color = -1
|
||||||
self.turn_count += 1
|
self.turn_count += 1
|
||||||
@@ -882,7 +969,7 @@ cdef class FastGameState:
|
|||||||
if play:
|
if play:
|
||||||
length_index = self._expedition_len_index(player, color)
|
length_index = self._expedition_len_index(player, color)
|
||||||
self.expedition_lens[length_index] -= 1
|
self.expedition_lens[length_index] -= 1
|
||||||
moved = self.expeditions[self._expedition_index(player, color, self.expedition_lens[length_index])]
|
moved = self.expedition_cards[self._expedition_index(player, color, self.expedition_lens[length_index])]
|
||||||
self.last_numeric_ranks[length_index] = undo.last_numeric_before
|
self.last_numeric_ranks[length_index] = undo.last_numeric_before
|
||||||
self.handshake_counts[length_index] = undo.handshake_count_before
|
self.handshake_counts[length_index] = undo.handshake_count_before
|
||||||
self.numeric_sums[length_index] = undo.numeric_sum_before
|
self.numeric_sums[length_index] = undo.numeric_sum_before
|
||||||
@@ -890,12 +977,12 @@ cdef class FastGameState:
|
|||||||
self.total_scores[player] = undo.total_score_before
|
self.total_scores[player] = undo.total_score_before
|
||||||
else:
|
else:
|
||||||
self.discard_lens[color] -= 1
|
self.discard_lens[color] -= 1
|
||||||
moved = self.discards[self._discard_index(color, self.discard_lens[color])]
|
moved = self.discard_cards[self._discard_index(color, self.discard_lens[color])]
|
||||||
if moved != card:
|
if moved != card:
|
||||||
raise ValueError("undo card mismatch")
|
raise ValueError("undo card mismatch")
|
||||||
for i in range(self.hand_lens[player], slot, -1):
|
for i in range(self.hand_lens[player], slot, -1):
|
||||||
self.hands[self._hand_index(player, i)] = self.hands[self._hand_index(player, i - 1)]
|
self.hand_cards[self._hand_index(player, i)] = self.hand_cards[self._hand_index(player, i - 1)]
|
||||||
self.hands[self._hand_index(player, slot)] = card
|
self.hand_cards[self._hand_index(player, slot)] = card
|
||||||
self.hand_lens[player] += 1
|
self.hand_lens[player] += 1
|
||||||
self.current_player = player
|
self.current_player = player
|
||||||
self.phase_id = _phase_card()
|
self.phase_id = _phase_card()
|
||||||
@@ -912,15 +999,15 @@ cdef class FastGameState:
|
|||||||
cdef int moved
|
cdef int moved
|
||||||
cdef int color
|
cdef int color
|
||||||
self.hand_lens[player] -= 1
|
self.hand_lens[player] -= 1
|
||||||
moved = self.hands[self._hand_index(player, self.hand_lens[player])]
|
moved = self.hand_cards[self._hand_index(player, self.hand_lens[player])]
|
||||||
if moved != card:
|
if moved != card:
|
||||||
raise ValueError("undo draw mismatch")
|
raise ValueError("undo draw mismatch")
|
||||||
if action_id == 0:
|
if action_id == 0:
|
||||||
self.deck[self.deck_len] = card
|
self.deck_cards[self.deck_len] = card
|
||||||
self.deck_len += 1
|
self.deck_len += 1
|
||||||
else:
|
else:
|
||||||
color = action_id - 1
|
color = action_id - 1
|
||||||
self.discards[self._discard_index(color, self.discard_lens[color])] = card
|
self.discard_cards[self._discard_index(color, self.discard_lens[color])] = card
|
||||||
self.discard_lens[color] += 1
|
self.discard_lens[color] += 1
|
||||||
self.current_player = player
|
self.current_player = player
|
||||||
self.phase_id = _phase_draw()
|
self.phase_id = _phase_draw()
|
||||||
@@ -949,7 +1036,7 @@ cdef class FastGameState:
|
|||||||
length = self.expedition_lens[cache_index]
|
length = self.expedition_lens[cache_index]
|
||||||
for card_index in range(length):
|
for card_index in range(length):
|
||||||
rank = self._card_rank(
|
rank = self._card_rank(
|
||||||
self.expeditions[
|
self.expedition_cards[
|
||||||
self._expedition_index(player, color, card_index)
|
self._expedition_index(player, color, card_index)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1016,6 +1103,9 @@ cdef class FastGameState:
|
|||||||
cdef object _card_snapshot(self, int card):
|
cdef object _card_snapshot(self, int card):
|
||||||
return {"color": self._card_color(card), "rank": self._card_rank(card)}
|
return {"color": self._card_color(card), "rank": self._card_rank(card)}
|
||||||
|
|
||||||
|
cdef object _card_obj(self, int card):
|
||||||
|
return Card(self._card_color(card), self._card_rank(card))
|
||||||
|
|
||||||
|
|
||||||
def _build_encoded_deck(config):
|
def _build_encoded_deck(config):
|
||||||
deck = []
|
deck = []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .game import GameState, IllegalMoveError, LostCitiesConfig
|
from .engines import FastGameState as GameState
|
||||||
|
from .game import IllegalMoveError, LostCitiesConfig
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .bots import available_bot_names, build_bot
|
from .bots import available_bot_names, build_bot
|
||||||
from .game import GameState, LostCitiesConfig, classic_config
|
from .engines import FastGameState as GameState
|
||||||
|
from .game import LostCitiesConfig, classic_config
|
||||||
from .interfaces import LostCitiesBot
|
from .interfaces import LostCitiesBot
|
||||||
|
|
||||||
BotFactory = Callable[[int | None], LostCitiesBot]
|
BotFactory = Callable[[int | None], LostCitiesBot]
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False
|
# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False
|
||||||
"""Deprecated compatibility rules engine for Lost Cities classic.
|
"""Core Lost Cities classic types.
|
||||||
|
|
||||||
This module remains the public engine while the replacement fast engine is
|
``GameState`` is provided by the C-array fast engine.
|
||||||
developed under ``coolrl_lost_cities.games.classic.engines``. New traversal,
|
|
||||||
simulation, and training work should target the fast engine once it exists.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass, fields
|
||||||
import random
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
cimport cython
|
cimport cython
|
||||||
|
|
||||||
|
|
||||||
Phase = Literal["card", "draw"]
|
Phase = Literal["card", "draw"]
|
||||||
DEPRECATED_ENGINE = True
|
|
||||||
|
|
||||||
|
|
||||||
class IllegalMoveError(ValueError):
|
class IllegalMoveError(ValueError):
|
||||||
@@ -190,621 +183,6 @@ def build_deck(config):
|
|||||||
return deck
|
return deck
|
||||||
|
|
||||||
|
|
||||||
def _card_counter(cards):
|
|
||||||
return Counter(cards)
|
|
||||||
|
|
||||||
|
|
||||||
def _cards_from_snapshot(data):
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise ValueError(f"expected card list snapshot, got {type(data).__name__}")
|
|
||||||
return [Card.from_snapshot(card) for card in data]
|
|
||||||
|
|
||||||
|
|
||||||
def _cards_to_snapshot(cards):
|
|
||||||
return [card.to_snapshot() for card in cards]
|
|
||||||
|
|
||||||
|
|
||||||
cdef class GameState:
|
|
||||||
cdef public object config
|
|
||||||
cdef public list deck
|
|
||||||
cdef public list hands
|
|
||||||
cdef public list expeditions
|
|
||||||
cdef public list discards
|
|
||||||
cdef public int current_player
|
|
||||||
cdef public str phase
|
|
||||||
cdef public object pending_discarded_color
|
|
||||||
cdef public int turn_count
|
|
||||||
cdef public bint terminal
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config,
|
|
||||||
deck=None,
|
|
||||||
hands=None,
|
|
||||||
expeditions=None,
|
|
||||||
discards=None,
|
|
||||||
int current_player=0,
|
|
||||||
phase="card",
|
|
||||||
pending_discarded_color=None,
|
|
||||||
int turn_count=0,
|
|
||||||
bint terminal=False,
|
|
||||||
):
|
|
||||||
self.config = config
|
|
||||||
self.deck = list(deck) if deck is not None else []
|
|
||||||
self.hands = hands if hands is not None else [[], []]
|
|
||||||
self.expeditions = expeditions if expeditions is not None else [
|
|
||||||
[[] for _ in range(config.n_colors)],
|
|
||||||
[[] for _ in range(config.n_colors)],
|
|
||||||
]
|
|
||||||
self.discards = discards if discards is not None else [
|
|
||||||
[] for _ in range(config.n_colors)
|
|
||||||
]
|
|
||||||
self.current_player = current_player
|
|
||||||
self.phase = phase
|
|
||||||
self.pending_discarded_color = pending_discarded_color
|
|
||||||
self.turn_count = turn_count
|
|
||||||
self.terminal = terminal
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new_game(cls, config=None, *, seed=None):
|
|
||||||
config = config or LostCitiesConfig()
|
|
||||||
config.validate()
|
|
||||||
rng = random.Random(config.seed if seed is None else seed)
|
|
||||||
deck = build_deck(config)
|
|
||||||
rng.shuffle(deck)
|
|
||||||
return cls.new_game_from_deck(deck, config)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new_game_from_deck(cls, deck, config=None):
|
|
||||||
config = config or LostCitiesConfig()
|
|
||||||
config.validate()
|
|
||||||
cards = [Card.from_snapshot(card) for card in deck]
|
|
||||||
if _card_counter(cards) != _card_counter(build_deck(config)):
|
|
||||||
raise ValueError("deck must contain exactly the cards defined by config")
|
|
||||||
|
|
||||||
state = cls.empty(config)
|
|
||||||
state.deck = list(cards)
|
|
||||||
cdef int player
|
|
||||||
for _ in range(config.hand_size):
|
|
||||||
for player in range(2):
|
|
||||||
state.hands[player].append(state.deck.pop())
|
|
||||||
state.validate_invariants()
|
|
||||||
return state
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def empty(cls, config=None):
|
|
||||||
config = config or LostCitiesConfig()
|
|
||||||
config.validate()
|
|
||||||
return cls(
|
|
||||||
config=config,
|
|
||||||
deck=[],
|
|
||||||
hands=[[], []],
|
|
||||||
expeditions=[
|
|
||||||
[[] for _ in range(config.n_colors)],
|
|
||||||
[[] for _ in range(config.n_colors)],
|
|
||||||
],
|
|
||||||
discards=[[] for _ in range(config.n_colors)],
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_snapshot(cls, snapshot, *, validate=True):
|
|
||||||
config = config_from_mapping(snapshot["config"])
|
|
||||||
phase = snapshot.get("phase", "card")
|
|
||||||
if phase not in ("card", "draw"):
|
|
||||||
raise ValueError(f"invalid phase: {phase!r}")
|
|
||||||
|
|
||||||
state = cls(
|
|
||||||
config=config,
|
|
||||||
deck=_cards_from_snapshot(snapshot["deck"]),
|
|
||||||
hands=[
|
|
||||||
_cards_from_snapshot(snapshot["hands"][0]),
|
|
||||||
_cards_from_snapshot(snapshot["hands"][1]),
|
|
||||||
],
|
|
||||||
expeditions=[
|
|
||||||
[
|
|
||||||
_cards_from_snapshot(color_cards)
|
|
||||||
for color_cards in snapshot["expeditions"][0]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
_cards_from_snapshot(color_cards)
|
|
||||||
for color_cards in snapshot["expeditions"][1]
|
|
||||||
],
|
|
||||||
],
|
|
||||||
discards=[
|
|
||||||
_cards_from_snapshot(color_cards)
|
|
||||||
for color_cards in snapshot["discards"]
|
|
||||||
],
|
|
||||||
current_player=int(snapshot.get("current_player", 0)),
|
|
||||||
phase=phase,
|
|
||||||
pending_discarded_color=snapshot.get("pending_discarded_color"),
|
|
||||||
turn_count=int(snapshot.get("turn_count", 0)),
|
|
||||||
terminal=bool(snapshot.get("terminal", False)),
|
|
||||||
)
|
|
||||||
if state.pending_discarded_color is not None:
|
|
||||||
state.pending_discarded_color = int(state.pending_discarded_color)
|
|
||||||
if validate:
|
|
||||||
state.validate_invariants()
|
|
||||||
return state
|
|
||||||
|
|
||||||
def to_snapshot(self):
|
|
||||||
return {
|
|
||||||
"config": self.config.to_snapshot(),
|
|
||||||
"deck": _cards_to_snapshot(self.deck),
|
|
||||||
"hands": [_cards_to_snapshot(hand) for hand in self.hands],
|
|
||||||
"expeditions": [
|
|
||||||
[_cards_to_snapshot(expedition) for expedition in player_expeditions]
|
|
||||||
for player_expeditions in self.expeditions
|
|
||||||
],
|
|
||||||
"discards": [_cards_to_snapshot(discard) for discard in self.discards],
|
|
||||||
"current_player": self.current_player,
|
|
||||||
"phase": self.phase,
|
|
||||||
"pending_discarded_color": self.pending_discarded_color,
|
|
||||||
"turn_count": self.turn_count,
|
|
||||||
"terminal": self.terminal,
|
|
||||||
}
|
|
||||||
|
|
||||||
cpdef GameState clone(self):
|
|
||||||
cdef GameState other = GameState.__new__(GameState)
|
|
||||||
other.config = self.config
|
|
||||||
other.deck = list(self.deck)
|
|
||||||
other.hands = [list(self.hands[0]), list(self.hands[1])]
|
|
||||||
other.expeditions = [
|
|
||||||
[list(exp) for exp in self.expeditions[0]],
|
|
||||||
[list(exp) for exp in self.expeditions[1]],
|
|
||||||
]
|
|
||||||
other.discards = [list(pile) for pile in self.discards]
|
|
||||||
other.current_player = self.current_player
|
|
||||||
other.phase = self.phase
|
|
||||||
other.pending_discarded_color = self.pending_discarded_color
|
|
||||||
other.turn_count = self.turn_count
|
|
||||||
other.terminal = self.terminal
|
|
||||||
return other
|
|
||||||
|
|
||||||
@property
|
|
||||||
def card_action_size(self):
|
|
||||||
return self.config.card_action_size
|
|
||||||
|
|
||||||
@property
|
|
||||||
def draw_action_size(self):
|
|
||||||
return self.config.draw_action_size
|
|
||||||
|
|
||||||
@property
|
|
||||||
def action_size(self):
|
|
||||||
return self.config.action_size
|
|
||||||
|
|
||||||
def sort_hands(self):
|
|
||||||
cdef int player
|
|
||||||
for player in range(2):
|
|
||||||
self.sort_hand(player)
|
|
||||||
|
|
||||||
def sort_hand(self, player=None):
|
|
||||||
cdef int p = self.current_player if player is None else int(player)
|
|
||||||
self.hands[p].sort(key=_card_sort_key)
|
|
||||||
|
|
||||||
def hand_slots(self, player=None):
|
|
||||||
cdef int p = self.current_player if player is None else int(player)
|
|
||||||
cdef list hand = self.hands[p]
|
|
||||||
cdef int hand_size = self.config.hand_size
|
|
||||||
cdef int n = len(hand)
|
|
||||||
cdef int i
|
|
||||||
cdef list out = []
|
|
||||||
for i in range(hand_size):
|
|
||||||
if i < n:
|
|
||||||
out.append(hand[i])
|
|
||||||
else:
|
|
||||||
out.append(None)
|
|
||||||
return out
|
|
||||||
|
|
||||||
cpdef int last_numeric_rank(self, int player, int color):
|
|
||||||
cdef list expedition = self.expeditions[player][color]
|
|
||||||
cdef int best = 0
|
|
||||||
cdef int n = len(expedition)
|
|
||||||
cdef int i
|
|
||||||
cdef Card card
|
|
||||||
for i in range(n):
|
|
||||||
card = <Card>expedition[i]
|
|
||||||
if card.rank == 0:
|
|
||||||
continue
|
|
||||||
if card.rank > best:
|
|
||||||
best = card.rank
|
|
||||||
return best
|
|
||||||
|
|
||||||
def has_numeric(self, int player, int color):
|
|
||||||
return self.last_numeric_rank(player, color) > 0
|
|
||||||
|
|
||||||
cpdef bint can_play_card(self, int player, Card card):
|
|
||||||
cdef int n_colors = self.config.n_colors
|
|
||||||
cdef int n_ranks = self.config.n_ranks
|
|
||||||
if card.color < 0 or card.color >= n_colors:
|
|
||||||
return False
|
|
||||||
if card.rank < 0 or card.rank > n_ranks:
|
|
||||||
return False
|
|
||||||
cdef int last_numeric = self.last_numeric_rank(player, card.color)
|
|
||||||
if card.rank == 0:
|
|
||||||
return last_numeric == 0
|
|
||||||
return card.rank > last_numeric
|
|
||||||
|
|
||||||
cpdef list legal_card_mask(self):
|
|
||||||
cdef int size = self.card_action_size
|
|
||||||
cdef list mask = [False] * size
|
|
||||||
if self.terminal:
|
|
||||||
return mask
|
|
||||||
cdef list hand = self.hands[self.current_player]
|
|
||||||
cdef int hand_size = self.config.hand_size
|
|
||||||
cdef int n = len(hand)
|
|
||||||
cdef int slot
|
|
||||||
cdef Card card
|
|
||||||
for slot in range(hand_size):
|
|
||||||
if slot >= n:
|
|
||||||
continue
|
|
||||||
card = <Card>hand[slot]
|
|
||||||
mask[2 * slot] = self.can_play_card(self.current_player, card)
|
|
||||||
mask[2 * slot + 1] = True
|
|
||||||
return mask
|
|
||||||
|
|
||||||
cpdef list legal_draw_mask(self):
|
|
||||||
cdef int size = self.draw_action_size
|
|
||||||
cdef list mask = [False] * size
|
|
||||||
if self.terminal:
|
|
||||||
return mask
|
|
||||||
mask[0] = len(self.deck) > 0
|
|
||||||
cdef int n_colors = self.config.n_colors
|
|
||||||
cdef int color
|
|
||||||
cdef object pending = self.pending_discarded_color
|
|
||||||
for color in range(n_colors):
|
|
||||||
mask[1 + color] = (
|
|
||||||
len(self.discards[color]) > 0
|
|
||||||
and (pending is None or color != pending)
|
|
||||||
)
|
|
||||||
return mask
|
|
||||||
|
|
||||||
cpdef list legal_mask(self):
|
|
||||||
if self.phase == "card":
|
|
||||||
return self.legal_card_mask()
|
|
||||||
return self.legal_draw_mask()
|
|
||||||
|
|
||||||
cpdef list unified_legal_mask(self):
|
|
||||||
cdef int draw_size = self.draw_action_size
|
|
||||||
cdef int card_size = self.card_action_size
|
|
||||||
cdef list result
|
|
||||||
if self.phase == "card":
|
|
||||||
result = self.legal_card_mask()
|
|
||||||
result.extend([False] * draw_size)
|
|
||||||
return result
|
|
||||||
result = [False] * card_size
|
|
||||||
result.extend(self.legal_draw_mask())
|
|
||||||
return result
|
|
||||||
|
|
||||||
cpdef object unified_legal_mask_np(self):
|
|
||||||
cdef int n_colors = self.config.n_colors
|
|
||||||
cdef int hand_size = self.config.hand_size
|
|
||||||
cdef int card_action_size = 2 * hand_size
|
|
||||||
cdef int draw_action_size = 1 + n_colors
|
|
||||||
cdef int total = card_action_size + draw_action_size
|
|
||||||
|
|
||||||
mask_arr = np.zeros(total, dtype=bool)
|
|
||||||
cdef unsigned char[::1] view = mask_arr.view(np.uint8)
|
|
||||||
if self.terminal:
|
|
||||||
return mask_arr
|
|
||||||
|
|
||||||
cdef int slot, n, color
|
|
||||||
cdef Card card
|
|
||||||
cdef list hand
|
|
||||||
cdef int p = self.current_player
|
|
||||||
cdef object pending
|
|
||||||
|
|
||||||
if self.phase == "card":
|
|
||||||
hand = self.hands[p]
|
|
||||||
n = len(hand)
|
|
||||||
for slot in range(hand_size):
|
|
||||||
if slot >= n:
|
|
||||||
continue
|
|
||||||
card = <Card>hand[slot]
|
|
||||||
if self.can_play_card(p, card):
|
|
||||||
view[2 * slot] = 1
|
|
||||||
view[2 * slot + 1] = 1
|
|
||||||
else:
|
|
||||||
pending = self.pending_discarded_color
|
|
||||||
if len(self.deck) > 0:
|
|
||||||
view[card_action_size] = 1
|
|
||||||
for color in range(n_colors):
|
|
||||||
if (
|
|
||||||
len(self.discards[color]) > 0
|
|
||||||
and (pending is None or color != pending)
|
|
||||||
):
|
|
||||||
view[card_action_size + 1 + color] = 1
|
|
||||||
return mask_arr
|
|
||||||
|
|
||||||
def to_unified_action(self, int action_id, phase=None):
|
|
||||||
cdef str p = self.phase if phase is None else phase
|
|
||||||
if p == "card":
|
|
||||||
if action_id < 0 or action_id >= self.card_action_size:
|
|
||||||
raise IllegalMoveError(f"card action {action_id} is out of range")
|
|
||||||
return action_id
|
|
||||||
if action_id < 0 or action_id >= self.draw_action_size:
|
|
||||||
raise IllegalMoveError(f"draw action {action_id} is out of range")
|
|
||||||
return self.card_action_size + action_id
|
|
||||||
|
|
||||||
cpdef int from_unified_action(self, int action_id):
|
|
||||||
if action_id < 0 or action_id >= self.action_size:
|
|
||||||
raise IllegalMoveError(f"action {action_id} is out of range")
|
|
||||||
if self.phase == "card":
|
|
||||||
if action_id >= self.card_action_size:
|
|
||||||
raise IllegalMoveError(
|
|
||||||
f"card action {action_id} is illegal during card phase"
|
|
||||||
)
|
|
||||||
return action_id
|
|
||||||
if action_id < self.card_action_size:
|
|
||||||
raise IllegalMoveError(
|
|
||||||
f"card action {action_id} is illegal during draw phase"
|
|
||||||
)
|
|
||||||
return action_id - self.card_action_size
|
|
||||||
|
|
||||||
cpdef apply_action(self, int action_id):
|
|
||||||
if self.terminal:
|
|
||||||
raise IllegalMoveError("game is already terminal")
|
|
||||||
cdef list mask = self.legal_mask()
|
|
||||||
if action_id < 0 or action_id >= len(mask) or not mask[action_id]:
|
|
||||||
raise IllegalMoveError(
|
|
||||||
f"illegal action {action_id} in phase {self.phase} "
|
|
||||||
f"for player {self.current_player}"
|
|
||||||
)
|
|
||||||
if self.phase == "card":
|
|
||||||
self._apply_card_action(action_id)
|
|
||||||
else:
|
|
||||||
self._apply_draw_action(action_id)
|
|
||||||
|
|
||||||
cpdef apply_unified_action(self, int action_id):
|
|
||||||
self.apply_action(self.from_unified_action(action_id))
|
|
||||||
|
|
||||||
cpdef object apply_action_with_undo(self, int action_id):
|
|
||||||
if self.terminal:
|
|
||||||
raise IllegalMoveError("game is already terminal")
|
|
||||||
cdef list mask = self.legal_mask()
|
|
||||||
if action_id < 0 or action_id >= len(mask) or not mask[action_id]:
|
|
||||||
raise IllegalMoveError(
|
|
||||||
f"illegal action {action_id} in phase {self.phase} "
|
|
||||||
f"for player {self.current_player}"
|
|
||||||
)
|
|
||||||
cdef object undo
|
|
||||||
if self.phase == "card":
|
|
||||||
undo = self._card_action_undo(action_id)
|
|
||||||
self._apply_card_action(action_id)
|
|
||||||
else:
|
|
||||||
undo = self._draw_action_undo(action_id)
|
|
||||||
self._apply_draw_action(action_id)
|
|
||||||
return undo
|
|
||||||
|
|
||||||
cpdef object apply_unified_action_with_undo(self, int action_id):
|
|
||||||
return self.apply_action_with_undo(self.from_unified_action(action_id))
|
|
||||||
|
|
||||||
cpdef undo_action(self, object undo):
|
|
||||||
cdef str phase = undo[0]
|
|
||||||
if phase == "card":
|
|
||||||
self._undo_card_action(undo)
|
|
||||||
return
|
|
||||||
if phase == "draw":
|
|
||||||
self._undo_draw_action(undo)
|
|
||||||
return
|
|
||||||
raise ValueError(f"invalid undo phase: {phase!r}")
|
|
||||||
|
|
||||||
cdef object _card_action_undo(self, int action_id):
|
|
||||||
cdef int slot = action_id // 2
|
|
||||||
cdef bint play = action_id % 2 == 0
|
|
||||||
cdef Card card = <Card>self.hands[self.current_player][slot]
|
|
||||||
return (
|
|
||||||
"card",
|
|
||||||
self.current_player,
|
|
||||||
action_id,
|
|
||||||
self.pending_discarded_color,
|
|
||||||
self.terminal,
|
|
||||||
slot,
|
|
||||||
play,
|
|
||||||
card,
|
|
||||||
)
|
|
||||||
|
|
||||||
cdef object _draw_action_undo(self, int action_id):
|
|
||||||
cdef Card card
|
|
||||||
cdef list source
|
|
||||||
if action_id == 0:
|
|
||||||
source = self.deck
|
|
||||||
else:
|
|
||||||
source = self.discards[action_id - 1]
|
|
||||||
card = <Card>source[len(source) - 1]
|
|
||||||
return (
|
|
||||||
"draw",
|
|
||||||
self.current_player,
|
|
||||||
action_id,
|
|
||||||
self.pending_discarded_color,
|
|
||||||
self.terminal,
|
|
||||||
self.turn_count,
|
|
||||||
card,
|
|
||||||
)
|
|
||||||
|
|
||||||
cdef void _apply_card_action(self, int action_id) except *:
|
|
||||||
cdef int slot = action_id // 2
|
|
||||||
cdef bint play = action_id % 2 == 0
|
|
||||||
cdef Card card = <Card>self.hands[self.current_player].pop(slot)
|
|
||||||
if play:
|
|
||||||
self.expeditions[self.current_player][card.color].append(card)
|
|
||||||
else:
|
|
||||||
self.discards[card.color].append(card)
|
|
||||||
self.pending_discarded_color = card.color
|
|
||||||
self.phase = "draw"
|
|
||||||
cdef int n_colors = self.config.n_colors
|
|
||||||
cdef int color
|
|
||||||
cdef object pending = self.pending_discarded_color
|
|
||||||
cdef bint any_legal_draw = False
|
|
||||||
if len(self.deck) == 0:
|
|
||||||
for color in range(n_colors):
|
|
||||||
if len(self.discards[color]) > 0 and (pending is None or color != pending):
|
|
||||||
any_legal_draw = True
|
|
||||||
break
|
|
||||||
if not any_legal_draw:
|
|
||||||
self.terminal = True
|
|
||||||
|
|
||||||
cdef void _apply_draw_action(self, int action_id) except *:
|
|
||||||
cdef Card card
|
|
||||||
cdef int color
|
|
||||||
if action_id == 0:
|
|
||||||
card = <Card>self.deck.pop()
|
|
||||||
else:
|
|
||||||
color = action_id - 1
|
|
||||||
card = <Card>self.discards[color].pop()
|
|
||||||
self.hands[self.current_player].append(card)
|
|
||||||
self.pending_discarded_color = None
|
|
||||||
self.turn_count += 1
|
|
||||||
if len(self.deck) == 0:
|
|
||||||
self.terminal = True
|
|
||||||
return
|
|
||||||
self.current_player = 1 - self.current_player
|
|
||||||
self.phase = "card"
|
|
||||||
|
|
||||||
cdef void _undo_card_action(self, object undo) except *:
|
|
||||||
cdef int player = <int>undo[1]
|
|
||||||
cdef object pending_before = undo[3]
|
|
||||||
cdef bint terminal_before = <bint>undo[4]
|
|
||||||
cdef int slot = <int>undo[5]
|
|
||||||
cdef bint play = <bint>undo[6]
|
|
||||||
cdef Card card = <Card>undo[7]
|
|
||||||
cdef Card moved
|
|
||||||
if play:
|
|
||||||
moved = <Card>self.expeditions[player][card.color].pop()
|
|
||||||
else:
|
|
||||||
moved = <Card>self.discards[card.color].pop()
|
|
||||||
if moved != card:
|
|
||||||
raise ValueError("undo card mismatch")
|
|
||||||
self.hands[player].insert(slot, card)
|
|
||||||
self.current_player = player
|
|
||||||
self.phase = "card"
|
|
||||||
self.pending_discarded_color = pending_before
|
|
||||||
self.terminal = terminal_before
|
|
||||||
|
|
||||||
cdef void _undo_draw_action(self, object undo) except *:
|
|
||||||
cdef int player = <int>undo[1]
|
|
||||||
cdef int action_id = <int>undo[2]
|
|
||||||
cdef object pending_before = undo[3]
|
|
||||||
cdef bint terminal_before = <bint>undo[4]
|
|
||||||
cdef int turn_count_before = <int>undo[5]
|
|
||||||
cdef Card card = <Card>undo[6]
|
|
||||||
cdef Card moved = <Card>self.hands[player].pop()
|
|
||||||
if moved != card:
|
|
||||||
raise ValueError("undo draw mismatch")
|
|
||||||
if action_id == 0:
|
|
||||||
self.deck.append(card)
|
|
||||||
else:
|
|
||||||
self.discards[action_id - 1].append(card)
|
|
||||||
self.current_player = player
|
|
||||||
self.phase = "draw"
|
|
||||||
self.pending_discarded_color = pending_before
|
|
||||||
self.turn_count = turn_count_before
|
|
||||||
self.terminal = terminal_before
|
|
||||||
|
|
||||||
cpdef int expedition_score(self, int player, int color):
|
|
||||||
return score_expedition(self.expeditions[player][color], self.config)
|
|
||||||
|
|
||||||
cpdef int total_score(self, int player):
|
|
||||||
cdef int total = 0
|
|
||||||
cdef int color
|
|
||||||
cdef int n_colors = self.config.n_colors
|
|
||||||
for color in range(n_colors):
|
|
||||||
total += score_expedition(self.expeditions[player][color], self.config)
|
|
||||||
return total
|
|
||||||
|
|
||||||
cpdef int score_diff(self, int player=0):
|
|
||||||
cdef int other = 1 - player
|
|
||||||
return self.total_score(player) - self.total_score(other)
|
|
||||||
|
|
||||||
def validate_invariants(self):
|
|
||||||
self.config.validate()
|
|
||||||
if self.current_player not in (0, 1):
|
|
||||||
raise ValueError("current_player must be 0 or 1")
|
|
||||||
if self.phase not in ("card", "draw"):
|
|
||||||
raise ValueError(f"invalid phase: {self.phase!r}")
|
|
||||||
if len(self.hands) != 2:
|
|
||||||
raise ValueError("hands must contain two players")
|
|
||||||
if len(self.expeditions) != 2:
|
|
||||||
raise ValueError("expeditions must contain two players")
|
|
||||||
if len(self.discards) != self.config.n_colors:
|
|
||||||
raise ValueError("discard pile count must match n_colors")
|
|
||||||
|
|
||||||
all_cards = []
|
|
||||||
all_cards.extend(self.deck)
|
|
||||||
for player, hand in enumerate(self.hands):
|
|
||||||
if len(hand) > self.config.hand_size:
|
|
||||||
raise ValueError(f"hand {player} exceeds hand_size")
|
|
||||||
all_cards.extend(hand)
|
|
||||||
|
|
||||||
for player, expeditions in enumerate(self.expeditions):
|
|
||||||
if len(expeditions) != self.config.n_colors:
|
|
||||||
raise ValueError("expedition color count must match n_colors")
|
|
||||||
for color, expedition in enumerate(expeditions):
|
|
||||||
self._validate_expedition(player, color, expedition)
|
|
||||||
all_cards.extend(expedition)
|
|
||||||
for discard in self.discards:
|
|
||||||
all_cards.extend(discard)
|
|
||||||
|
|
||||||
for card in all_cards:
|
|
||||||
self._validate_card(card)
|
|
||||||
if _card_counter(all_cards) != _card_counter(build_deck(self.config)):
|
|
||||||
raise ValueError("card conservation failed")
|
|
||||||
|
|
||||||
if self.phase == "card" and self.pending_discarded_color is not None:
|
|
||||||
raise ValueError("pending_discarded_color must be None during card phase")
|
|
||||||
if self.pending_discarded_color is not None:
|
|
||||||
color = self.pending_discarded_color
|
|
||||||
if color < 0 or color >= self.config.n_colors:
|
|
||||||
raise ValueError("pending_discarded_color is out of range")
|
|
||||||
if not self.discards[color]:
|
|
||||||
raise ValueError("pending discard color must have a discard pile card")
|
|
||||||
|
|
||||||
any_legal = any(self.unified_legal_mask())
|
|
||||||
if self.terminal and any_legal:
|
|
||||||
raise ValueError("terminal state must have no legal actions")
|
|
||||||
if not self.terminal and not any_legal:
|
|
||||||
raise ValueError("non-terminal state must have at least one legal action")
|
|
||||||
|
|
||||||
def _validate_card(self, Card card):
|
|
||||||
if card.color < 0 or card.color >= self.config.n_colors:
|
|
||||||
raise ValueError(f"card color out of range: {card}")
|
|
||||||
if card.rank < 0 or card.rank > self.config.n_ranks:
|
|
||||||
raise ValueError(f"card rank out of range: {card}")
|
|
||||||
|
|
||||||
def _validate_expedition(self, int player, int color, list expedition):
|
|
||||||
cdef bint seen_numeric = False
|
|
||||||
cdef int last_numeric = 0
|
|
||||||
cdef Card card
|
|
||||||
for card in expedition:
|
|
||||||
if card.color != color:
|
|
||||||
raise ValueError(
|
|
||||||
f"player {player} expedition {color} contains wrong color"
|
|
||||||
)
|
|
||||||
if card.rank == 0:
|
|
||||||
if seen_numeric:
|
|
||||||
raise ValueError(
|
|
||||||
f"player {player} expedition {color} has handshake after number"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
seen_numeric = True
|
|
||||||
if card.rank <= last_numeric:
|
|
||||||
raise ValueError(
|
|
||||||
f"player {player} expedition {color} is not strictly increasing"
|
|
||||||
)
|
|
||||||
last_numeric = card.rank
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# support pickle via snapshot round-trip
|
|
||||||
return (_rebuild_game_state, (self.to_snapshot(),))
|
|
||||||
|
|
||||||
|
|
||||||
def _rebuild_game_state(snapshot):
|
|
||||||
return GameState.from_snapshot(snapshot, validate=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _card_sort_key(Card card):
|
|
||||||
return (card.color, card.rank)
|
|
||||||
|
|
||||||
|
|
||||||
cpdef int score_expedition(list expedition, config):
|
cpdef int score_expedition(list expedition, config):
|
||||||
cdef int n = len(expedition)
|
cdef int n = len(expedition)
|
||||||
if n == 0:
|
if n == 0:
|
||||||
@@ -824,3 +202,6 @@ cpdef int score_expedition(list expedition, config):
|
|||||||
if n >= config.bonus_threshold:
|
if n >= config.bonus_threshold:
|
||||||
score += config.bonus_amount
|
score += config.bonus_amount
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
from .engines.fast import FastGameState as GameState
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Protocol, TypeAlias, runtime_checkable
|
from typing import Protocol, TypeAlias, runtime_checkable
|
||||||
|
|
||||||
from .game import GameState
|
from .engines import FastGameState as GameState
|
||||||
from .snapshots import Snapshot
|
from .snapshots import Snapshot
|
||||||
|
|
||||||
BotInput: TypeAlias = dict | GameState | Snapshot
|
BotInput: TypeAlias = dict | GameState | Snapshot
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot
|
from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot
|
||||||
from .game import Card, GameState, LostCitiesConfig, classic_config
|
from .engines import FastGameState as GameState
|
||||||
|
from .game import Card, LostCitiesConfig, classic_config
|
||||||
from .resources import theme_path
|
from .resources import theme_path
|
||||||
from .snapshots import Snapshot, snapshot_from_state, snapshot_summary
|
from .snapshots import Snapshot, snapshot_from_state, snapshot_summary
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .game import Card, GameState, LostCitiesConfig, score_expedition
|
from .engines import FastGameState as GameState
|
||||||
|
from .game import Card, LostCitiesConfig, score_expedition
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -13,7 +13,7 @@ def _card(color: int, rank: int) -> dict[str, int]:
|
|||||||
return {"color": color, "rank": rank}
|
return {"color": color, "rank": rank}
|
||||||
|
|
||||||
|
|
||||||
def _classic_snapshot(
|
def _snapshot(
|
||||||
*,
|
*,
|
||||||
deck: list[dict[str, int]] | None = None,
|
deck: list[dict[str, int]] | None = None,
|
||||||
hands: list[list[dict[str, int]]] | None = None,
|
hands: list[list[dict[str, int]]] | None = None,
|
||||||
@@ -59,25 +59,26 @@ def _classic_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_fast_new_game_from_deck_matches_game_state_snapshot() -> None:
|
def test_public_game_state_alias_matches_fast_new_game_from_deck_snapshot() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
deck = build_deck(config)
|
deck = build_deck(config)
|
||||||
|
|
||||||
classic = GameState.new_game_from_deck(deck, config)
|
assert GameState is FastGameState
|
||||||
fast = FastGameState.new_game_from_deck(deck, config)
|
left = GameState.new_game_from_deck(deck, config)
|
||||||
|
right = FastGameState.new_game_from_deck(deck, config)
|
||||||
|
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
fast.validate_invariants()
|
right.validate_invariants()
|
||||||
|
|
||||||
|
|
||||||
def test_fast_snapshot_roundtrip_preserves_snapshot() -> None:
|
def test_fast_snapshot_roundtrip_preserves_snapshot() -> None:
|
||||||
config = LostCitiesConfig(seed=11)
|
config = LostCitiesConfig(seed=11)
|
||||||
classic = GameState.new_game(config)
|
left = GameState.new_game(config)
|
||||||
fast = FastGameState.from_snapshot(classic.to_snapshot())
|
right = FastGameState.from_snapshot(left.to_snapshot())
|
||||||
|
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
restored = FastGameState.from_snapshot(fast.to_snapshot())
|
restored = FastGameState.from_snapshot(right.to_snapshot())
|
||||||
assert restored.to_snapshot() == fast.to_snapshot()
|
assert restored.to_snapshot() == right.to_snapshot()
|
||||||
|
|
||||||
|
|
||||||
def test_fast_from_snapshot_rejects_oversized_regions_before_write() -> None:
|
def test_fast_from_snapshot_rejects_oversized_regions_before_write() -> None:
|
||||||
@@ -125,39 +126,39 @@ def test_fast_validate_invariants_rejects_bad_expedition_order() -> None:
|
|||||||
FastGameState.from_snapshot(snapshot)
|
FastGameState.from_snapshot(snapshot)
|
||||||
|
|
||||||
|
|
||||||
def test_fast_pending_discard_matches_game_state() -> None:
|
def test_fast_pending_discard_sequence_is_deterministic() -> None:
|
||||||
snapshot = _classic_snapshot(
|
snapshot = _snapshot(
|
||||||
hands=[
|
hands=[
|
||||||
[_card(0, 1)],
|
[_card(0, 1)],
|
||||||
[_card(1, 1)],
|
[_card(1, 1)],
|
||||||
],
|
],
|
||||||
deck=[_card(2, 1), _card(3, 1)],
|
deck=[_card(2, 1), _card(3, 1)],
|
||||||
)
|
)
|
||||||
classic = GameState.from_snapshot(snapshot)
|
left = GameState.from_snapshot(snapshot)
|
||||||
fast = FastGameState.from_snapshot(snapshot)
|
right = FastGameState.from_snapshot(snapshot)
|
||||||
|
|
||||||
classic.apply_action(1)
|
left.apply_action(1)
|
||||||
fast.apply_action(1)
|
right.apply_action(1)
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.legal_draw_mask() == classic.legal_draw_mask()
|
assert right.legal_draw_mask() == left.legal_draw_mask()
|
||||||
assert fast.legal_draw_mask()[1] is False
|
assert right.legal_draw_mask()[1] is False
|
||||||
|
|
||||||
classic.apply_action(0)
|
left.apply_action(0)
|
||||||
fast.apply_action(0)
|
right.apply_action(0)
|
||||||
classic.apply_action(1)
|
left.apply_action(1)
|
||||||
fast.apply_action(1)
|
right.apply_action(1)
|
||||||
classic.apply_action(0)
|
left.apply_action(0)
|
||||||
fast.apply_action(0)
|
right.apply_action(0)
|
||||||
classic.apply_action(1)
|
left.apply_action(1)
|
||||||
fast.apply_action(1)
|
right.apply_action(1)
|
||||||
|
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.legal_draw_mask() == classic.legal_draw_mask()
|
assert right.legal_draw_mask() == left.legal_draw_mask()
|
||||||
assert fast.legal_draw_mask()[1] is True
|
assert right.legal_draw_mask()[1] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fast_terminal_edges_match_game_state() -> None:
|
def test_fast_terminal_edges_are_deterministic() -> None:
|
||||||
last_draw_snapshot = _classic_snapshot(
|
last_draw_snapshot = _snapshot(
|
||||||
deck=[_card(1, 1)],
|
deck=[_card(1, 1)],
|
||||||
hands=[
|
hands=[
|
||||||
[_card(0, 1)],
|
[_card(0, 1)],
|
||||||
@@ -168,16 +169,16 @@ def test_fast_terminal_edges_match_game_state() -> None:
|
|||||||
last_draw_snapshot["deck"] = [last_draw_snapshot["deck"][-1]]
|
last_draw_snapshot["deck"] = [last_draw_snapshot["deck"][-1]]
|
||||||
for card in remaining_deck:
|
for card in remaining_deck:
|
||||||
last_draw_snapshot["discards"][card["color"]].append(card)
|
last_draw_snapshot["discards"][card["color"]].append(card)
|
||||||
classic = GameState.from_snapshot(last_draw_snapshot)
|
left = GameState.from_snapshot(last_draw_snapshot)
|
||||||
fast = FastGameState.from_snapshot(last_draw_snapshot)
|
right = FastGameState.from_snapshot(last_draw_snapshot)
|
||||||
|
|
||||||
classic.apply_action(1)
|
left.apply_action(1)
|
||||||
fast.apply_action(1)
|
right.apply_action(1)
|
||||||
classic.apply_action(0)
|
left.apply_action(0)
|
||||||
fast.apply_action(0)
|
right.apply_action(0)
|
||||||
|
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.terminal is True
|
assert right.terminal is True
|
||||||
|
|
||||||
defensive_snapshot = {
|
defensive_snapshot = {
|
||||||
"config": LostCitiesConfig().to_snapshot(),
|
"config": LostCitiesConfig().to_snapshot(),
|
||||||
@@ -191,18 +192,18 @@ def test_fast_terminal_edges_match_game_state() -> None:
|
|||||||
"turn_count": 0,
|
"turn_count": 0,
|
||||||
"terminal": False,
|
"terminal": False,
|
||||||
}
|
}
|
||||||
classic = GameState.from_snapshot(defensive_snapshot, validate=False)
|
left = GameState.from_snapshot(defensive_snapshot, validate=False)
|
||||||
fast = FastGameState.from_snapshot(defensive_snapshot, validate=False)
|
right = FastGameState.from_snapshot(defensive_snapshot, validate=False)
|
||||||
|
|
||||||
classic.apply_action(1)
|
left.apply_action(1)
|
||||||
fast.apply_action(1)
|
right.apply_action(1)
|
||||||
|
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.terminal is True
|
assert right.terminal is True
|
||||||
|
|
||||||
|
|
||||||
def test_fast_last_numeric_legality_matches_game_state() -> None:
|
def test_fast_last_numeric_legality_edges() -> None:
|
||||||
handshake_snapshot = _classic_snapshot(
|
handshake_snapshot = _snapshot(
|
||||||
hands=[
|
hands=[
|
||||||
[_card(0, 1)],
|
[_card(0, 1)],
|
||||||
[],
|
[],
|
||||||
@@ -212,12 +213,12 @@ def test_fast_last_numeric_legality_matches_game_state() -> None:
|
|||||||
[[], [], [], [], []],
|
[[], [], [], [], []],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
classic = GameState.from_snapshot(handshake_snapshot)
|
left = GameState.from_snapshot(handshake_snapshot)
|
||||||
fast = FastGameState.from_snapshot(handshake_snapshot)
|
right = FastGameState.from_snapshot(handshake_snapshot)
|
||||||
assert fast.legal_card_mask() == classic.legal_card_mask()
|
assert right.legal_card_mask() == left.legal_card_mask()
|
||||||
assert fast.legal_card_mask()[0] is True
|
assert right.legal_card_mask()[0] is True
|
||||||
|
|
||||||
numeric_snapshot = _classic_snapshot(
|
numeric_snapshot = _snapshot(
|
||||||
hands=[
|
hands=[
|
||||||
[_card(0, 0), _card(0, 3), _card(0, 5)],
|
[_card(0, 0), _card(0, 3), _card(0, 5)],
|
||||||
[],
|
[],
|
||||||
@@ -227,16 +228,16 @@ def test_fast_last_numeric_legality_matches_game_state() -> None:
|
|||||||
[[], [], [], [], []],
|
[[], [], [], [], []],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
classic = GameState.from_snapshot(numeric_snapshot)
|
left = GameState.from_snapshot(numeric_snapshot)
|
||||||
fast = FastGameState.from_snapshot(numeric_snapshot)
|
right = FastGameState.from_snapshot(numeric_snapshot)
|
||||||
assert fast.legal_card_mask() == classic.legal_card_mask()
|
assert right.legal_card_mask() == left.legal_card_mask()
|
||||||
assert fast.legal_card_mask()[0] is False
|
assert right.legal_card_mask()[0] is False
|
||||||
assert fast.legal_card_mask()[2] is False
|
assert right.legal_card_mask()[2] is False
|
||||||
assert fast.legal_card_mask()[4] is True
|
assert right.legal_card_mask()[4] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fast_score_cache_and_undo_match_game_state() -> None:
|
def test_fast_score_cache_and_undo_restore_snapshot() -> None:
|
||||||
snapshot = _classic_snapshot(
|
snapshot = _snapshot(
|
||||||
hands=[
|
hands=[
|
||||||
[_card(0, 7)],
|
[_card(0, 7)],
|
||||||
[],
|
[],
|
||||||
@@ -261,26 +262,26 @@ def test_fast_score_cache_and_undo_match_game_state() -> None:
|
|||||||
[[], [], [], [], []],
|
[[], [], [], [], []],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
classic = GameState.from_snapshot(snapshot)
|
left = GameState.from_snapshot(snapshot)
|
||||||
fast = FastGameState.from_snapshot(snapshot)
|
right = FastGameState.from_snapshot(snapshot)
|
||||||
before = fast.to_snapshot()
|
before = right.to_snapshot()
|
||||||
|
|
||||||
assert fast.expedition_score(0, 0) == classic.expedition_score(0, 0)
|
assert right.expedition_score(0, 0) == left.expedition_score(0, 0)
|
||||||
assert fast.total_score(0) == classic.total_score(0)
|
assert right.total_score(0) == left.total_score(0)
|
||||||
|
|
||||||
undo = fast.apply_action_with_undo(0)
|
undo = right.apply_action_with_undo(0)
|
||||||
classic.apply_action(0)
|
left.apply_action(0)
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.expedition_score(0, 0) == classic.expedition_score(0, 0)
|
assert right.expedition_score(0, 0) == left.expedition_score(0, 0)
|
||||||
assert fast.total_score(0) == classic.total_score(0)
|
assert right.total_score(0) == left.total_score(0)
|
||||||
|
|
||||||
fast.undo_action(undo)
|
right.undo_action(undo)
|
||||||
assert fast.to_snapshot() == before
|
assert right.to_snapshot() == before
|
||||||
assert fast.total_score(0) == GameState.from_snapshot(before).total_score(0)
|
assert right.total_score(0) == GameState.from_snapshot(before).total_score(0)
|
||||||
|
|
||||||
|
|
||||||
def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
|
def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
|
||||||
snapshot = _classic_snapshot(
|
snapshot = _snapshot(
|
||||||
hands=[[], [_card(1, 1)]],
|
hands=[[], [_card(1, 1)]],
|
||||||
discards=[[_card(0, 1)], [], [], [], []],
|
discards=[[_card(0, 1)], [], [], [], []],
|
||||||
phase="draw",
|
phase="draw",
|
||||||
@@ -294,62 +295,60 @@ def test_fast_discard_draw_push_pop_restores_snapshot() -> None:
|
|||||||
assert state.to_snapshot() == before
|
assert state.to_snapshot() == before
|
||||||
|
|
||||||
|
|
||||||
def test_fast_random_action_sequence_matches_game_state() -> None:
|
def test_fast_random_action_sequence_is_deterministic() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
for seed in range(48):
|
for seed in range(48):
|
||||||
classic = GameState.new_game(config, seed=seed)
|
left = GameState.new_game(config, seed=seed)
|
||||||
fast = FastGameState.new_game(config, seed=seed)
|
right = FastGameState.new_game(config, seed=seed)
|
||||||
rng = random.Random(seed ^ 0xF457)
|
rng = random.Random(seed ^ 0xF457)
|
||||||
steps = 0
|
steps = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
assert fast.unified_legal_mask() == classic.unified_legal_mask()
|
assert right.unified_legal_mask() == left.unified_legal_mask()
|
||||||
assert fast.unified_legal_actions() == [
|
assert right.unified_legal_actions() == [
|
||||||
index for index, is_legal in enumerate(classic.unified_legal_mask()) if is_legal
|
index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal
|
||||||
]
|
]
|
||||||
assert fast.score_diff(0) == classic.score_diff(0)
|
assert right.score_diff(0) == left.score_diff(0)
|
||||||
if classic.terminal:
|
if left.terminal:
|
||||||
break
|
break
|
||||||
|
|
||||||
legal = [
|
legal = [index for index, is_legal in enumerate(left.unified_legal_mask()) if is_legal]
|
||||||
index for index, is_legal in enumerate(classic.unified_legal_mask()) if is_legal
|
|
||||||
]
|
|
||||||
action = rng.choice(legal)
|
action = rng.choice(legal)
|
||||||
classic.apply_unified_action(action)
|
left.apply_unified_action(action)
|
||||||
fast.apply_unified_action(action)
|
right.apply_unified_action(action)
|
||||||
steps += 1
|
steps += 1
|
||||||
assert steps < 1000
|
assert steps < 1000
|
||||||
|
|
||||||
|
|
||||||
def test_fast_random_bot_self_play_matches_game_state() -> None:
|
def test_fast_random_bot_self_play_is_deterministic() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
for seed in range(32):
|
for seed in range(32):
|
||||||
classic = GameState.new_game(config, seed=seed)
|
left = GameState.new_game(config, seed=seed)
|
||||||
fast = FastGameState.new_game(config, seed=seed)
|
right = FastGameState.new_game(config, seed=seed)
|
||||||
classic_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
left_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||||
fast_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
right_bots = [RandomBot(seed=seed * 2), RandomBot(seed=seed * 2 + 1)]
|
||||||
steps = 0
|
steps = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
assert fast.to_snapshot() == classic.to_snapshot()
|
assert right.to_snapshot() == left.to_snapshot()
|
||||||
if classic.terminal:
|
if left.terminal:
|
||||||
break
|
break
|
||||||
|
|
||||||
player = classic.current_player
|
player = left.current_player
|
||||||
assert fast.current_player == player
|
assert right.current_player == player
|
||||||
classic_action = classic_bots[player].act(classic)
|
left_action = left_bots[player].act(left)
|
||||||
fast_action = fast_bots[player].act({"legal_mask": fast.legal_mask()})
|
right_action = right_bots[player].act({"legal_mask": right.legal_mask()})
|
||||||
assert fast_action == classic_action
|
assert right_action == left_action
|
||||||
|
|
||||||
classic.apply_action(classic_action)
|
left.apply_action(left_action)
|
||||||
fast.apply_action(fast_action)
|
right.apply_action(right_action)
|
||||||
steps += 1
|
steps += 1
|
||||||
assert steps < 1000
|
assert steps < 1000
|
||||||
|
|
||||||
assert fast.total_score(0) == classic.total_score(0)
|
assert right.total_score(0) == left.total_score(0)
|
||||||
assert fast.total_score(1) == classic.total_score(1)
|
assert right.total_score(1) == left.total_score(1)
|
||||||
assert fast.score_diff(0) == classic.score_diff(0)
|
assert right.score_diff(0) == left.score_diff(0)
|
||||||
|
|
||||||
|
|
||||||
def test_fast_apply_undo_restores_every_legal_action() -> None:
|
def test_fast_apply_undo_restores_every_legal_action() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
config: LostCitiesConfig | None = None,
|
||||||
|
*,
|
||||||
|
deck: list[Card] | None = None,
|
||||||
|
hands: list[list[Card]] | None = None,
|
||||||
|
expeditions: list[list[list[Card]]] | None = None,
|
||||||
|
discards: list[list[Card]] | None = None,
|
||||||
|
current_player: int = 0,
|
||||||
|
phase: str = "card",
|
||||||
|
pending_discarded_color: int | None = None,
|
||||||
|
turn_count: int = 0,
|
||||||
|
terminal: bool = False,
|
||||||
|
validate: bool = False,
|
||||||
|
) -> GameState:
|
||||||
|
config = config or LostCitiesConfig()
|
||||||
|
return GameState.from_snapshot(
|
||||||
|
{
|
||||||
|
"config": config.to_snapshot(),
|
||||||
|
"deck": deck or [],
|
||||||
|
"hands": hands or [[], []],
|
||||||
|
"expeditions": expeditions
|
||||||
|
or [
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
],
|
||||||
|
"discards": discards or [[] for _ in range(config.n_colors)],
|
||||||
|
"current_player": current_player,
|
||||||
|
"phase": phase,
|
||||||
|
"pending_discarded_color": pending_discarded_color,
|
||||||
|
"turn_count": turn_count,
|
||||||
|
"terminal": terminal,
|
||||||
|
},
|
||||||
|
validate=validate,
|
||||||
|
)
|
||||||
@@ -7,6 +7,14 @@ from coolrl_lost_cities.games.classic.bots import (
|
|||||||
)
|
)
|
||||||
from coolrl_lost_cities.games.classic.bots.heuristic import draw_from_discard_action
|
from coolrl_lost_cities.games.classic.bots.heuristic import draw_from_discard_action
|
||||||
from coolrl_lost_cities.games.classic.evaluation import play_game_for_evaluation
|
from coolrl_lost_cities.games.classic.evaluation import play_game_for_evaluation
|
||||||
|
from tests.games.classic.helpers import make_state
|
||||||
|
|
||||||
|
|
||||||
|
def _expeditions(config: LostCitiesConfig) -> list[list[list[Card]]]:
|
||||||
|
return [
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
[[] for _ in range(config.n_colors)],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_bots_implement_lost_cities_bot() -> None:
|
def test_builtin_bots_implement_lost_cities_bot() -> None:
|
||||||
@@ -31,15 +39,26 @@ def test_safe_heuristic_opponent_value_ignores_hidden_hand() -> None:
|
|||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
discard_card = Card(color=0, rank=6)
|
discard_card = Card(color=0, rank=6)
|
||||||
|
|
||||||
state_a = GameState.empty(config)
|
expeditions_a = _expeditions(config)
|
||||||
state_a.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
expeditions_a[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||||
state_a.discards[0] = [discard_card]
|
state_a = make_state(
|
||||||
state_a.hands[1] = [Card(color=0, rank=5)]
|
config,
|
||||||
|
hands=[[], [Card(color=0, rank=5)]],
|
||||||
|
expeditions=expeditions_a,
|
||||||
|
discards=[[discard_card], []],
|
||||||
|
)
|
||||||
|
|
||||||
state_b = GameState.empty(config)
|
expeditions_b = _expeditions(config)
|
||||||
state_b.expeditions[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
expeditions_b[1][0] = [Card(color=0, rank=0), Card(color=0, rank=4)]
|
||||||
state_b.discards[0] = [discard_card]
|
state_b = make_state(
|
||||||
state_b.hands[1] = [Card(color=0, rank=5), Card(color=0, rank=7), Card(color=0, rank=8)]
|
config,
|
||||||
|
hands=[
|
||||||
|
[],
|
||||||
|
[Card(color=0, rank=5), Card(color=0, rank=7), Card(color=0, rank=8)],
|
||||||
|
],
|
||||||
|
expeditions=expeditions_b,
|
||||||
|
discards=[[discard_card], []],
|
||||||
|
)
|
||||||
|
|
||||||
value_a = bot._card_value_for_opponent(
|
value_a = bot._card_value_for_opponent(
|
||||||
state=state_a,
|
state=state_a,
|
||||||
@@ -62,13 +81,21 @@ def test_safe_heuristic_started_expedition_value_ignores_invalid_lower_followup(
|
|||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
high_card = Card(color=0, rank=8)
|
high_card = Card(color=0, rank=8)
|
||||||
|
|
||||||
base_state = GameState.empty(config)
|
base_expeditions = _expeditions(config)
|
||||||
base_state.expeditions[0][0] = [Card(color=0, rank=4)]
|
base_expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
base_state.hands[0] = [high_card]
|
base_state = make_state(
|
||||||
|
config,
|
||||||
|
hands=[[high_card], []],
|
||||||
|
expeditions=base_expeditions,
|
||||||
|
)
|
||||||
|
|
||||||
lower_followup_state = GameState.empty(config)
|
lower_expeditions = _expeditions(config)
|
||||||
lower_followup_state.expeditions[0][0] = [Card(color=0, rank=4)]
|
lower_expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
lower_followup_state.hands[0] = [Card(color=0, rank=5), high_card]
|
lower_followup_state = make_state(
|
||||||
|
config,
|
||||||
|
hands=[[Card(color=0, rank=5), high_card], []],
|
||||||
|
expeditions=lower_expeditions,
|
||||||
|
)
|
||||||
|
|
||||||
base_value = bot._started_expedition_play_value(
|
base_value = bot._started_expedition_play_value(
|
||||||
state=base_state,
|
state=base_state,
|
||||||
@@ -92,12 +119,15 @@ def test_safe_heuristic_draws_playable_discard_instead_of_deck() -> None:
|
|||||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
|
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=3)
|
||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
|
|
||||||
state = GameState.empty(config)
|
expeditions = _expeditions(config)
|
||||||
state.current_player = 0
|
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
state.phase = "draw"
|
state = make_state(
|
||||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
config,
|
||||||
state.discards[0] = [Card(color=0, rank=6)]
|
deck=[Card(color=1, rank=8)],
|
||||||
state.deck = [Card(color=1, rank=8)]
|
expeditions=expeditions,
|
||||||
|
discards=[[Card(color=0, rank=6)], []],
|
||||||
|
phase="draw",
|
||||||
|
)
|
||||||
|
|
||||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||||
|
|
||||||
@@ -106,20 +136,23 @@ def test_safe_heuristic_can_draw_discard_to_deny_opponent_when_losing() -> None:
|
|||||||
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=4)
|
config = LostCitiesConfig(n_colors=2, n_ranks=8, hand_size=4)
|
||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
|
|
||||||
state = GameState.empty(config)
|
expeditions = _expeditions(config)
|
||||||
state.current_player = 0
|
expeditions[0][1] = [Card(color=1, rank=8)]
|
||||||
state.phase = "draw"
|
expeditions[1][0] = [
|
||||||
state.deck = [Card(color=1, rank=8), Card(color=1, rank=7)]
|
|
||||||
state.hands[0] = [Card(color=0, rank=0), Card(color=0, rank=7)]
|
|
||||||
state.expeditions[0][1] = [Card(color=1, rank=8)]
|
|
||||||
state.expeditions[1][0] = [
|
|
||||||
Card(color=0, rank=0),
|
Card(color=0, rank=0),
|
||||||
Card(color=0, rank=5),
|
Card(color=0, rank=5),
|
||||||
Card(color=0, rank=6),
|
Card(color=0, rank=6),
|
||||||
Card(color=0, rank=7),
|
Card(color=0, rank=7),
|
||||||
Card(color=0, rank=8),
|
Card(color=0, rank=8),
|
||||||
]
|
]
|
||||||
state.discards[0] = [Card(color=0, rank=6)]
|
state = make_state(
|
||||||
|
config,
|
||||||
|
deck=[Card(color=1, rank=8), Card(color=1, rank=7)],
|
||||||
|
hands=[[Card(color=0, rank=0), Card(color=0, rank=7)], []],
|
||||||
|
expeditions=expeditions,
|
||||||
|
discards=[[Card(color=0, rank=6)], []],
|
||||||
|
phase="draw",
|
||||||
|
)
|
||||||
|
|
||||||
assert state.score_diff(0) < 0
|
assert state.score_diff(0) < 0
|
||||||
assert bot._act_draw(state) == draw_from_discard_action(0)
|
assert bot._act_draw(state) == draw_from_discard_action(0)
|
||||||
@@ -152,16 +185,17 @@ def test_safe_heuristic_classic_self_play_opens_expeditions() -> None:
|
|||||||
def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
|
def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
|
||||||
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
state = GameState.empty(config)
|
expeditions = _expeditions(config)
|
||||||
state.current_player = 0
|
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
state.phase = "card"
|
expeditions[0][1] = [Card(color=1, rank=4)]
|
||||||
|
expeditions[0][2] = [Card(color=2, rank=5)]
|
||||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
expeditions[0][3] = [Card(color=3, rank=6)]
|
||||||
state.expeditions[0][1] = [Card(color=1, rank=4)]
|
|
||||||
state.expeditions[0][2] = [Card(color=2, rank=5)]
|
|
||||||
state.expeditions[0][3] = [Card(color=3, rank=6)]
|
|
||||||
weak_open = Card(color=4, rank=4)
|
weak_open = Card(color=4, rank=4)
|
||||||
state.hands[0] = [weak_open, Card(color=4, rank=7), Card(color=0, rank=6)]
|
state = make_state(
|
||||||
|
config,
|
||||||
|
hands=[[weak_open, Card(color=4, rank=7), Card(color=0, rank=6)], []],
|
||||||
|
expeditions=expeditions,
|
||||||
|
)
|
||||||
state.sort_hand(0)
|
state.sort_hand(0)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
@@ -180,11 +214,16 @@ def test_safe_heuristic_avoids_opening_weak_fifth_color() -> None:
|
|||||||
def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
|
def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
|
||||||
config = LostCitiesConfig(n_colors=3, n_ranks=8, hand_size=5)
|
config = LostCitiesConfig(n_colors=3, n_ranks=8, hand_size=5)
|
||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
state = GameState.empty(config)
|
expeditions = _expeditions(config)
|
||||||
state.current_player = 0
|
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
state.phase = "card"
|
state = make_state(
|
||||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
config,
|
||||||
state.hands[0] = [Card(color=0, rank=6), Card(color=1, rank=4), Card(color=1, rank=7)]
|
hands=[
|
||||||
|
[Card(color=0, rank=6), Card(color=1, rank=4), Card(color=1, rank=7)],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
expeditions=expeditions,
|
||||||
|
)
|
||||||
state.sort_hand(0)
|
state.sort_hand(0)
|
||||||
|
|
||||||
action = bot._act_card(state)
|
action = bot._act_card(state)
|
||||||
@@ -197,15 +236,20 @@ def test_safe_heuristic_prefers_followup_on_started_expedition() -> None:
|
|||||||
def test_safe_heuristic_avoids_unopened_discard_draw_after_four_opens() -> None:
|
def test_safe_heuristic_avoids_unopened_discard_draw_after_four_opens() -> None:
|
||||||
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
config = LostCitiesConfig(n_colors=5, n_ranks=8, hand_size=8)
|
||||||
bot = SafeHeuristicBot()
|
bot = SafeHeuristicBot()
|
||||||
state = GameState.empty(config)
|
expeditions = _expeditions(config)
|
||||||
state.current_player = 0
|
expeditions[0][0] = [Card(color=0, rank=4)]
|
||||||
state.phase = "draw"
|
expeditions[0][1] = [Card(color=1, rank=4)]
|
||||||
state.deck = [Card(color=0, rank=8), Card(color=1, rank=8)]
|
expeditions[0][2] = [Card(color=2, rank=5)]
|
||||||
state.expeditions[0][0] = [Card(color=0, rank=4)]
|
expeditions[0][3] = [Card(color=3, rank=6)]
|
||||||
state.expeditions[0][1] = [Card(color=1, rank=4)]
|
discards = [[] for _ in range(config.n_colors)]
|
||||||
state.expeditions[0][2] = [Card(color=2, rank=5)]
|
discards[4] = [Card(color=4, rank=5)]
|
||||||
state.expeditions[0][3] = [Card(color=3, rank=6)]
|
state = make_state(
|
||||||
state.hands[0] = [Card(color=4, rank=4), Card(color=4, rank=7)]
|
config,
|
||||||
state.discards[4] = [Card(color=4, rank=5)]
|
deck=[Card(color=0, rank=8), Card(color=1, rank=8)],
|
||||||
|
hands=[[Card(color=4, rank=4), Card(color=4, rank=7)], []],
|
||||||
|
expeditions=expeditions,
|
||||||
|
discards=discards,
|
||||||
|
phase="draw",
|
||||||
|
)
|
||||||
|
|
||||||
assert bot._act_draw(state) == 0
|
assert bot._act_draw(state) == 0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||||
|
|
||||||
import coolrl_lost_cities.games.classic as classic
|
import coolrl_lost_cities.games.classic as classic
|
||||||
|
from tests.games.classic.helpers import make_state
|
||||||
|
|
||||||
FIXTURE_DIR = Path(classic.__file__).resolve().parent / "fixtures"
|
FIXTURE_DIR = Path(classic.__file__).resolve().parent / "fixtures"
|
||||||
|
|
||||||
@@ -85,17 +86,21 @@ def test_snapshot_roundtrip_preserves_json_state() -> None:
|
|||||||
|
|
||||||
def test_validate_invariants_detects_card_loss() -> None:
|
def test_validate_invariants_detects_card_loss() -> None:
|
||||||
state = GameState.new_game(LostCitiesConfig(seed=7))
|
state = GameState.new_game(LostCitiesConfig(seed=7))
|
||||||
state.deck.pop()
|
snapshot = state.to_snapshot()
|
||||||
|
snapshot["deck"].pop()
|
||||||
|
broken = GameState.from_snapshot(snapshot, validate=False)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="card conservation"):
|
with pytest.raises(ValueError, match="card conservation"):
|
||||||
state.validate_invariants()
|
broken.validate_invariants()
|
||||||
|
|
||||||
|
|
||||||
def test_validate_invariants_detects_bad_expedition_order() -> None:
|
def test_validate_invariants_detects_bad_expedition_order() -> None:
|
||||||
state = GameState.new_game(LostCitiesConfig(seed=8))
|
config = LostCitiesConfig(seed=8)
|
||||||
card = state.deck.pop()
|
state = make_state(
|
||||||
state.expeditions[0][card.color].extend([Card(card.color, 2), Card(card.color, 1)])
|
config,
|
||||||
state.deck.extend([Card(card.color, 2), Card(card.color, 1)])
|
deck=GameState.new_game(config).deck,
|
||||||
|
expeditions=[[[Card(0, 2), Card(0, 1)], [], [], [], []], [[], [], [], [], []]],
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="strictly increasing"):
|
with pytest.raises(ValueError, match="strictly increasing"):
|
||||||
state.validate_invariants()
|
state.validate_invariants()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
from coolrl_lost_cities.games.classic.game import Card, LostCitiesConfig
|
||||||
|
|
||||||
from coolrl_lost_cities.games.classic.env import LostCitiesEnv
|
from coolrl_lost_cities.games.classic.env import LostCitiesEnv
|
||||||
|
from tests.games.classic.helpers import make_state
|
||||||
|
|
||||||
|
|
||||||
def test_env_observation_uses_fixed_unified_mask() -> None:
|
def test_env_observation_uses_fixed_unified_mask() -> None:
|
||||||
@@ -24,11 +25,12 @@ def test_env_observation_uses_fixed_unified_mask() -> None:
|
|||||||
def test_env_step_accepts_legacy_draw_action_ids() -> None:
|
def test_env_step_accepts_legacy_draw_action_ids() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
env = LostCitiesEnv(config)
|
env = LostCitiesEnv(config)
|
||||||
env.state = GameState.empty(config)
|
env.state = make_state(
|
||||||
env.state.hands[0] = [Card(0, 1)]
|
config,
|
||||||
env.state.hands[1] = [Card(1, 1)]
|
deck=[Card(2, 1), Card(2, 2)],
|
||||||
env.state.deck = [Card(2, 1), Card(2, 2)]
|
hands=[[Card(0, 1)], [Card(1, 1)]],
|
||||||
env.state.phase = "draw"
|
phase="draw",
|
||||||
|
)
|
||||||
|
|
||||||
obs, reward, done, _ = env.step(0)
|
obs, reward, done, _ = env.step(0)
|
||||||
|
|
||||||
@@ -50,11 +52,13 @@ def test_terminal_reward_is_relative_to_actor() -> None:
|
|||||||
bonus_threshold=99,
|
bonus_threshold=99,
|
||||||
)
|
)
|
||||||
env = LostCitiesEnv(config)
|
env = LostCitiesEnv(config)
|
||||||
env.state = GameState.empty(config)
|
env.state = make_state(
|
||||||
env.state.current_player = 1
|
config,
|
||||||
env.state.phase = "draw"
|
deck=[Card(1, 1)],
|
||||||
env.state.deck = [Card(1, 1)]
|
expeditions=[[[], []], [[Card(0, 1)], []]],
|
||||||
env.state.expeditions[1][0] = [Card(0, 1)]
|
current_player=1,
|
||||||
|
phase="draw",
|
||||||
|
)
|
||||||
|
|
||||||
_, reward, done, _ = env.step(config.card_action_size)
|
_, reward, done, _ = env.step(config.card_action_size)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
from coolrl_lost_cities.games.classic.game import Card, GameState, LostCitiesConfig
|
||||||
|
|
||||||
from coolrl_lost_cities.games.classic.bots import RandomBot
|
from coolrl_lost_cities.games.classic.bots import RandomBot
|
||||||
|
from tests.games.classic.helpers import make_state
|
||||||
|
|
||||||
|
|
||||||
def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
||||||
@@ -12,8 +13,7 @@ def test_legal_mask_has_action_in_nonterminal_phases() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_empty_hand_slots_are_masked() -> None:
|
def test_empty_hand_slots_are_masked() -> None:
|
||||||
state = GameState.empty(LostCitiesConfig())
|
state = make_state(hands=[[Card(0, 1)], []])
|
||||||
state.hands[0] = [Card(0, 1)]
|
|
||||||
mask = state.legal_card_mask()
|
mask = state.legal_card_mask()
|
||||||
assert mask[0] is True
|
assert mask[0] is True
|
||||||
assert mask[1] is True
|
assert mask[1] is True
|
||||||
@@ -21,9 +21,7 @@ def test_empty_hand_slots_are_masked() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_empty_discard_pile_draw_is_illegal() -> None:
|
def test_empty_discard_pile_draw_is_illegal() -> None:
|
||||||
state = GameState.empty(LostCitiesConfig())
|
state = make_state(deck=[Card(0, 1)], phase="draw")
|
||||||
state.phase = "draw"
|
|
||||||
state.deck = [Card(0, 1)]
|
|
||||||
mask = state.legal_draw_mask()
|
mask = state.legal_draw_mask()
|
||||||
assert mask[0] is True
|
assert mask[0] is True
|
||||||
assert all(mask[1 + color] is False for color in range(state.config.n_colors))
|
assert all(mask[1 + color] is False for color in range(state.config.n_colors))
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from coolrl_lost_cities.games.classic.game import (
|
|||||||
build_deck,
|
build_deck,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from tests.games.classic.helpers import make_state
|
||||||
|
|
||||||
|
|
||||||
def test_deck_generation_count() -> None:
|
def test_deck_generation_count() -> None:
|
||||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||||
@@ -23,25 +25,27 @@ def test_initial_hands_remove_cards_from_deck() -> None:
|
|||||||
|
|
||||||
def test_play_must_be_ascending() -> None:
|
def test_play_must_be_ascending() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(
|
||||||
state.hands[0] = [Card(0, 2)]
|
config,
|
||||||
state.expeditions[0][0] = [Card(0, 4)]
|
hands=[[Card(0, 2)], []],
|
||||||
|
expeditions=[[[Card(0, 4)], [], [], [], []], [[], [], [], [], []]],
|
||||||
|
)
|
||||||
assert state.legal_card_mask()[0] is False
|
assert state.legal_card_mask()[0] is False
|
||||||
|
|
||||||
|
|
||||||
def test_handshake_after_number_forbidden() -> None:
|
def test_handshake_after_number_forbidden() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(
|
||||||
state.hands[0] = [Card(1, 0)]
|
config,
|
||||||
state.expeditions[0][1] = [Card(1, 1)]
|
hands=[[Card(1, 0)], []],
|
||||||
|
expeditions=[[[], [Card(1, 1)], [], [], []], [[], [], [], [], []]],
|
||||||
|
)
|
||||||
assert state.legal_card_mask()[0] is False
|
assert state.legal_card_mask()[0] is False
|
||||||
|
|
||||||
|
|
||||||
def test_cannot_draw_just_discarded_color() -> None:
|
def test_cannot_draw_just_discarded_color() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(config, deck=[Card(0, 1)], hands=[[Card(2, 2)], []])
|
||||||
state.hands[0] = [Card(2, 2)]
|
|
||||||
state.deck = [Card(0, 1)]
|
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
mask = state.legal_draw_mask()
|
mask = state.legal_draw_mask()
|
||||||
assert mask[1 + 2] is False
|
assert mask[1 + 2] is False
|
||||||
@@ -49,9 +53,7 @@ def test_cannot_draw_just_discarded_color() -> None:
|
|||||||
|
|
||||||
def test_drawing_just_discarded_color_is_rejected() -> None:
|
def test_drawing_just_discarded_color_is_rejected() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(config, deck=[Card(0, 1)], hands=[[Card(2, 2)], []])
|
||||||
state.hands[0] = [Card(2, 2)]
|
|
||||||
state.deck = [Card(0, 1)]
|
|
||||||
|
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
|
|
||||||
@@ -61,10 +63,11 @@ def test_drawing_just_discarded_color_is_rejected() -> None:
|
|||||||
|
|
||||||
def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
|
def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(
|
||||||
state.hands[0] = [Card(2, 2)]
|
config,
|
||||||
state.hands[1] = [Card(0, 1)]
|
deck=[Card(1, 1), Card(1, 2)],
|
||||||
state.deck = [Card(1, 1), Card(1, 2)]
|
hands=[[Card(2, 2)], [Card(0, 1)]],
|
||||||
|
)
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
state.apply_action(0)
|
state.apply_action(0)
|
||||||
assert state.current_player == 1
|
assert state.current_player == 1
|
||||||
@@ -75,10 +78,11 @@ def test_discarded_color_can_be_drawn_after_turn_advances() -> None:
|
|||||||
|
|
||||||
def test_discarded_card_is_removed_when_drawn_later() -> None:
|
def test_discarded_card_is_removed_when_drawn_later() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(
|
||||||
state.hands[0] = [Card(2, 2)]
|
config,
|
||||||
state.hands[1] = [Card(0, 1)]
|
deck=[Card(1, 1), Card(1, 2)],
|
||||||
state.deck = [Card(1, 1), Card(1, 2)]
|
hands=[[Card(2, 2)], [Card(0, 1)]],
|
||||||
|
)
|
||||||
|
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
assert state.discards[2] == [Card(2, 2)]
|
assert state.discards[2] == [Card(2, 2)]
|
||||||
@@ -95,9 +99,7 @@ def test_discarded_card_is_removed_when_drawn_later() -> None:
|
|||||||
|
|
||||||
def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
|
def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
|
||||||
config = LostCitiesConfig()
|
config = LostCitiesConfig()
|
||||||
state = GameState.empty(config)
|
state = make_state(config, deck=[Card(1, 1)], hands=[[Card(0, 1)], []])
|
||||||
state.hands[0] = [Card(0, 1)]
|
|
||||||
state.deck = [Card(1, 1)]
|
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
state.apply_action(0)
|
state.apply_action(0)
|
||||||
assert state.terminal is True
|
assert state.terminal is True
|
||||||
@@ -106,10 +108,7 @@ def test_deck_exhaustion_ends_after_last_deck_draw() -> None:
|
|||||||
|
|
||||||
def test_card_phase_can_end_game_when_no_draw_sources_exist() -> None:
|
def test_card_phase_can_end_game_when_no_draw_sources_exist() -> None:
|
||||||
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
config = LostCitiesConfig(n_colors=3, n_ranks=5, n_handshakes=1, hand_size=5)
|
||||||
state = GameState.empty(config)
|
state = make_state(config, hands=[[Card(0, 1)], [Card(1, 1)]])
|
||||||
state.hands[0] = [Card(0, 1)]
|
|
||||||
state.hands[1] = [Card(1, 1)]
|
|
||||||
state.deck = []
|
|
||||||
state.apply_action(1)
|
state.apply_action(1)
|
||||||
assert state.phase == "draw"
|
assert state.phase == "draw"
|
||||||
assert state.terminal is True
|
assert state.terminal is True
|
||||||
|
|||||||
Reference in New Issue
Block a user