고속 엔진 undo stack 추가
This commit is contained in:
@@ -5,12 +5,7 @@ serious traversal code should use the Cython `fast.pxd` API directly.
|
||||
|
||||
Deferred work:
|
||||
|
||||
1. Add an internal undo stack with `push_action()` / `pop_action()` so Python
|
||||
callers can avoid tuple allocation when they need nested search.
|
||||
2. Keep traversal legal-action generation caller-buffer based. Consider a
|
||||
reusable Python-wrapper action buffer only if wrapper profiling shows
|
||||
`legal_actions()` allocation is material.
|
||||
3. Consider direct NumPy or feature-buffer output for RL pipelines instead of
|
||||
1. Consider direct NumPy or feature-buffer output for RL pipelines instead of
|
||||
building Python lists and converting later.
|
||||
4. Consider a single contiguous allocation for state arrays after profiling the
|
||||
2. Consider a single contiguous allocation for state arrays after profiling the
|
||||
simpler separate-allocation layout.
|
||||
|
||||
@@ -43,6 +43,9 @@ cdef class FastGameState:
|
||||
cdef int* numeric_sums
|
||||
cdef int* expedition_scores
|
||||
cdef int total_scores[2]
|
||||
cdef UndoRecord* undo_stack
|
||||
cdef int undo_stack_len
|
||||
cdef int undo_stack_capacity
|
||||
|
||||
cdef public int current_player
|
||||
cdef int phase_id
|
||||
@@ -66,6 +69,9 @@ cdef class FastGameState:
|
||||
cpdef object apply_action_with_undo(self, int action_id)
|
||||
cpdef object apply_unified_action_with_undo(self, int action_id)
|
||||
cpdef undo_action(self, object undo)
|
||||
cpdef int push_action(self, int action_id)
|
||||
cpdef int push_unified_action(self, int action_id)
|
||||
cpdef int pop_action(self)
|
||||
cpdef bint can_play_encoded_card(self, int player, int card)
|
||||
cpdef int last_numeric_rank(self, int player, int color)
|
||||
cpdef int expedition_score(self, int player, int color)
|
||||
@@ -79,6 +85,9 @@ cdef class FastGameState:
|
||||
cdef void _fill_undo_c(self, int action_id, UndoRecord* undo) noexcept
|
||||
cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *
|
||||
cdef void _apply_action_unchecked_c(self, int action_id) except *
|
||||
cdef void _ensure_undo_capacity_c(self) except *
|
||||
cdef int _push_action_c(self, int action_id) except *
|
||||
cdef int _pop_action_c(self) except *
|
||||
cdef object _undo_to_tuple(self, UndoRecord* undo)
|
||||
cdef void _tuple_to_undo(self, object data, UndoRecord* undo) except *
|
||||
cdef void _apply_card_action(self, int action_id) except *
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections import Counter
|
||||
import random
|
||||
|
||||
from libc.string cimport memcpy
|
||||
from libc.stdlib cimport free, malloc
|
||||
from libc.stdlib cimport free, malloc, realloc
|
||||
|
||||
from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping
|
||||
|
||||
@@ -30,6 +30,7 @@ cdef class FastGameState:
|
||||
self.handshake_counts = NULL
|
||||
self.numeric_sums = NULL
|
||||
self.expedition_scores = NULL
|
||||
self.undo_stack = NULL
|
||||
|
||||
def __init__(self, config=None):
|
||||
config = config or LostCitiesConfig()
|
||||
@@ -57,6 +58,8 @@ cdef class FastGameState:
|
||||
free(self.numeric_sums)
|
||||
if self.expedition_scores != NULL:
|
||||
free(self.expedition_scores)
|
||||
if self.undo_stack != NULL:
|
||||
free(self.undo_stack)
|
||||
|
||||
cdef void _configure(self, object config) except *:
|
||||
self.config = config
|
||||
@@ -84,6 +87,10 @@ cdef class FastGameState:
|
||||
self.handshake_counts = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||
self.numeric_sums = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||
self.expedition_scores = <int*>malloc(2 * self.n_colors * sizeof(int))
|
||||
self.undo_stack_capacity = 2 * self.total_cards + 16
|
||||
self.undo_stack = <UndoRecord*>malloc(
|
||||
self.undo_stack_capacity * sizeof(UndoRecord)
|
||||
)
|
||||
if (
|
||||
self.deck == NULL
|
||||
or self.hands == NULL
|
||||
@@ -95,6 +102,7 @@ cdef class FastGameState:
|
||||
or self.handshake_counts == NULL
|
||||
or self.numeric_sums == NULL
|
||||
or self.expedition_scores == NULL
|
||||
or self.undo_stack == NULL
|
||||
):
|
||||
raise MemoryError()
|
||||
self._clear()
|
||||
@@ -114,6 +122,7 @@ cdef class FastGameState:
|
||||
self.discard_lens[i] = 0
|
||||
self.total_scores[0] = 0
|
||||
self.total_scores[1] = 0
|
||||
self.undo_stack_len = 0
|
||||
self.current_player = 0
|
||||
self.phase_id = _phase_card()
|
||||
self.pending_discarded_color = -1
|
||||
@@ -469,6 +478,24 @@ cdef class FastGameState:
|
||||
self._tuple_to_undo(undo, &record)
|
||||
self._undo_action_c(&record)
|
||||
|
||||
cpdef int push_action(self, int action_id):
|
||||
if self.terminal:
|
||||
raise IllegalMoveError("game is already terminal")
|
||||
if not self._is_legal_action_c(action_id):
|
||||
raise IllegalMoveError(
|
||||
f"illegal action {action_id} in phase {self.phase} "
|
||||
f"for player {self.current_player}"
|
||||
)
|
||||
return self._push_action_c(action_id)
|
||||
|
||||
cpdef int push_unified_action(self, int action_id):
|
||||
return self.push_action(self.from_unified_action(action_id))
|
||||
|
||||
cpdef int pop_action(self):
|
||||
if self.undo_stack_len <= 0:
|
||||
raise ValueError("undo stack is empty")
|
||||
return self._pop_action_c()
|
||||
|
||||
cpdef bint can_play_encoded_card(self, int player, int card):
|
||||
cdef int color = self._card_color(card)
|
||||
cdef int rank = self._card_rank(card)
|
||||
@@ -698,6 +725,37 @@ cdef class FastGameState:
|
||||
else:
|
||||
self._apply_draw_action(action_id)
|
||||
|
||||
cdef void _ensure_undo_capacity_c(self) except *:
|
||||
cdef int new_capacity
|
||||
cdef UndoRecord* grown
|
||||
if self.undo_stack_len < self.undo_stack_capacity:
|
||||
return
|
||||
new_capacity = self.undo_stack_capacity * 2
|
||||
grown = <UndoRecord*>realloc(
|
||||
self.undo_stack,
|
||||
new_capacity * sizeof(UndoRecord),
|
||||
)
|
||||
if grown == NULL:
|
||||
raise MemoryError()
|
||||
self.undo_stack = grown
|
||||
self.undo_stack_capacity = new_capacity
|
||||
|
||||
cdef int _push_action_c(self, int action_id) except *:
|
||||
self._ensure_undo_capacity_c()
|
||||
self._apply_action_with_undo_c(
|
||||
action_id,
|
||||
&self.undo_stack[self.undo_stack_len],
|
||||
)
|
||||
self.undo_stack_len += 1
|
||||
return self.undo_stack_len
|
||||
|
||||
cdef int _pop_action_c(self) except *:
|
||||
cdef int action_id
|
||||
self.undo_stack_len -= 1
|
||||
action_id = self.undo_stack[self.undo_stack_len].action_id
|
||||
self._undo_action_c(&self.undo_stack[self.undo_stack_len])
|
||||
return action_id
|
||||
|
||||
cdef object _undo_to_tuple(self, UndoRecord* undo):
|
||||
return (
|
||||
"card" if undo.phase_id == _phase_card() else "draw",
|
||||
|
||||
@@ -162,3 +162,36 @@ def test_fast_apply_undo_restores_every_legal_action() -> None:
|
||||
state.apply_unified_action(rng.choice(legal))
|
||||
steps += 1
|
||||
assert steps < 1000
|
||||
|
||||
|
||||
def test_fast_push_pop_action_restores_nested_sequence() -> None:
|
||||
config = LostCitiesConfig(
|
||||
n_colors=3,
|
||||
n_ranks=5,
|
||||
min_rank=2,
|
||||
n_handshakes=1,
|
||||
hand_size=5,
|
||||
)
|
||||
for seed in range(32):
|
||||
state = FastGameState.new_game(config, seed=seed)
|
||||
rng = random.Random(seed ^ 0x517ACC)
|
||||
before = state.to_snapshot()
|
||||
actions: list[int] = []
|
||||
|
||||
for depth in range(20):
|
||||
if state.terminal:
|
||||
break
|
||||
legal = state.unified_legal_actions()
|
||||
action = rng.choice(legal)
|
||||
actions.append(action)
|
||||
assert state.push_unified_action(action) == depth + 1
|
||||
state.validate_invariants()
|
||||
|
||||
for action in reversed(actions):
|
||||
assert state.pop_action() == state.from_unified_action(action)
|
||||
state.validate_invariants()
|
||||
|
||||
assert state.to_snapshot() == before
|
||||
|
||||
with pytest.raises(ValueError, match="undo stack is empty"):
|
||||
FastGameState.new_game(config, seed=1).pop_action()
|
||||
|
||||
Reference in New Issue
Block a user