From 2629fbdd9945a472ea800817022378df26a37d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 6 May 2026 22:02:04 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=A0=EC=86=8D=20=EC=97=94=EC=A7=84=20undo?= =?UTF-8?q?=20stack=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/fast-engine-next-optimizations.md | 9 +-- .../games/classic/engines/fast.pxd | 9 +++ .../games/classic/engines/fast.pyx | 60 ++++++++++++++++++- .../games/classic/engines/test_fast_parity.py | 33 ++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/docs/fast-engine-next-optimizations.md b/docs/fast-engine-next-optimizations.md index ac8236b..95a97a8 100644 --- a/docs/fast-engine-next-optimizations.md +++ b/docs/fast-engine-next-optimizations.md @@ -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. diff --git a/src/coolrl_lost_cities/games/classic/engines/fast.pxd b/src/coolrl_lost_cities/games/classic/engines/fast.pxd index fbfd553..7e4e40d 100644 --- a/src/coolrl_lost_cities/games/classic/engines/fast.pxd +++ b/src/coolrl_lost_cities/games/classic/engines/fast.pxd @@ -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 * diff --git a/src/coolrl_lost_cities/games/classic/engines/fast.pyx b/src/coolrl_lost_cities/games/classic/engines/fast.pyx index 6acbb86..46fecbc 100644 --- a/src/coolrl_lost_cities/games/classic/engines/fast.pyx +++ b/src/coolrl_lost_cities/games/classic/engines/fast.pyx @@ -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 = malloc(2 * self.n_colors * sizeof(int)) self.numeric_sums = malloc(2 * self.n_colors * sizeof(int)) self.expedition_scores = malloc(2 * self.n_colors * sizeof(int)) + self.undo_stack_capacity = 2 * self.total_cards + 16 + self.undo_stack = 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 = 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", diff --git a/tests/games/classic/engines/test_fast_parity.py b/tests/games/classic/engines/test_fast_parity.py index 9f6748a..368d8a5 100644 --- a/tests/games/classic/engines/test_fast_parity.py +++ b/tests/games/classic/engines/test_fast_parity.py @@ -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()