고속 엔진 안전성 보강

This commit is contained in:
2026-05-06 21:56:59 +09:00
parent 2e38fc6454
commit a349cf34ed
4 changed files with 172 additions and 20 deletions
@@ -88,7 +88,7 @@ cdef class FastGameState:
cdef void _undo_draw_action_c(self, UndoRecord* undo) except *
cdef void _recompute_score_caches(self) noexcept
cdef int _score_from_summary_c(self, int length, int handshakes, int numeric_sum) noexcept
cdef bint _has_any_legal_draw(self)
cdef bint _has_any_legal_draw(self) noexcept
cdef int _hand_index(self, int player, int slot)
cdef int _expedition_len_index(self, int player, int color)
cdef int _expedition_index(self, int player, int color, int index)
@@ -4,6 +4,7 @@
from collections import Counter
import random
from libc.string cimport memcpy
from libc.stdlib cimport free, malloc
from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping
@@ -137,6 +138,10 @@ cdef class FastGameState:
config = config or LostCitiesConfig()
config.validate()
encoded = [_encode_card_snapshot(card, config) for card in deck]
if len(encoded) != int(config.deck_size):
raise ValueError(
f"deck length must be {config.deck_size}, got {len(encoded)}"
)
if Counter(encoded) != Counter(_build_encoded_deck(config)):
raise ValueError("deck must contain exactly the cards defined by config")
@@ -166,6 +171,10 @@ cdef class FastGameState:
cdef list cards
cards = [_encode_card_snapshot(card, config) for card in snapshot["deck"]]
if len(cards) > state.total_cards:
raise ValueError(
f"deck snapshot exceeds capacity {state.total_cards}: {len(cards)}"
)
state.deck_len = len(cards)
for index, card in enumerate(cards):
state.deck[index] = <int>card
@@ -174,6 +183,11 @@ cdef class FastGameState:
cards = [
_encode_card_snapshot(card, config) for card in snapshot["hands"][player]
]
if len(cards) > state.hand_size:
raise ValueError(
f"hand {player} snapshot exceeds hand_size "
f"{state.hand_size}: {len(cards)}"
)
state.hand_lens[player] = len(cards)
for index, card in enumerate(cards):
state.hands[state._hand_index(player, index)] = <int>card
@@ -184,12 +198,22 @@ cdef class FastGameState:
_encode_card_snapshot(card, config)
for card in snapshot["expeditions"][player][color]
]
if len(cards) > state.cards_per_color:
raise ValueError(
f"expedition {player}/{color} snapshot exceeds capacity "
f"{state.cards_per_color}: {len(cards)}"
)
state.expedition_lens[state._expedition_len_index(player, color)] = len(cards)
for index, card in enumerate(cards):
state.expeditions[state._expedition_index(player, color, index)] = <int>card
for color in range(state.n_colors):
cards = [_encode_card_snapshot(card, config) for card in snapshot["discards"][color]]
if len(cards) > state.cards_per_color:
raise ValueError(
f"discard {color} snapshot exceeds capacity "
f"{state.cards_per_color}: {len(cards)}"
)
state.discard_lens[color] = len(cards)
for index, card in enumerate(cards):
state.discards[state._discard_index(color, index)] = <int>card
@@ -275,26 +299,43 @@ cdef class FastGameState:
cpdef FastGameState clone(self):
cdef FastGameState other = FastGameState(self.config)
cdef int i
other.deck_len = self.deck_len
for i in range(self.deck_len):
other.deck[i] = self.deck[i]
for i in range(2 * self.hand_size):
other.hands[i] = self.hands[i]
memcpy(other.deck, self.deck, self.deck_len * sizeof(int))
memcpy(other.hands, self.hands, 2 * self.hand_size * sizeof(int))
other.hand_lens[0] = self.hand_lens[0]
other.hand_lens[1] = self.hand_lens[1]
for i in range(2 * self.n_colors * self.cards_per_color):
other.expeditions[i] = self.expeditions[i]
for i in range(2 * self.n_colors):
other.expedition_lens[i] = self.expedition_lens[i]
other.last_numeric_ranks[i] = self.last_numeric_ranks[i]
other.handshake_counts[i] = self.handshake_counts[i]
other.numeric_sums[i] = self.numeric_sums[i]
other.expedition_scores[i] = self.expedition_scores[i]
for i in range(self.n_colors * self.cards_per_color):
other.discards[i] = self.discards[i]
for i in range(self.n_colors):
other.discard_lens[i] = self.discard_lens[i]
memcpy(
other.expeditions,
self.expeditions,
2 * self.n_colors * self.cards_per_color * sizeof(int),
)
memcpy(
other.expedition_lens,
self.expedition_lens,
2 * self.n_colors * sizeof(int),
)
memcpy(
other.discards,
self.discards,
self.n_colors * self.cards_per_color * sizeof(int),
)
memcpy(other.discard_lens, self.discard_lens, self.n_colors * sizeof(int))
memcpy(
other.last_numeric_ranks,
self.last_numeric_ranks,
2 * self.n_colors * sizeof(int),
)
memcpy(
other.handshake_counts,
self.handshake_counts,
2 * self.n_colors * sizeof(int),
)
memcpy(other.numeric_sums, self.numeric_sums, 2 * self.n_colors * sizeof(int))
memcpy(
other.expedition_scores,
self.expedition_scores,
2 * self.n_colors * sizeof(int),
)
other.total_scores[0] = self.total_scores[0]
other.total_scores[1] = self.total_scores[1]
other.current_player = self.current_player
@@ -312,7 +353,7 @@ cdef class FastGameState:
return mask
for slot in range(self.hand_lens[self.current_player]):
card = self.hands[self._hand_index(self.current_player, slot)]
mask[2 * slot] = self.can_play_encoded_card(self.current_player, card)
mask[2 * slot] = self._can_play_encoded_card_c(self.current_player, card)
mask[2 * slot + 1] = True
return mask
@@ -453,14 +494,51 @@ cdef class FastGameState:
def validate_invariants(self):
self.config.validate()
cdef int player
cdef int color
cdef int index
cdef int length
cdef int card
cdef int rank
cdef int last_rank
cdef bint seen_numeric
if self.current_player not in (0, 1):
raise ValueError("current_player must be 0 or 1")
if self.phase_id not in (_phase_card(), _phase_draw()):
raise ValueError("invalid phase")
if self.deck_len < 0 or self.deck_len > self.total_cards:
raise ValueError("deck length out of range")
if self.pending_discarded_color >= self.n_colors:
raise ValueError("pending_discarded_color is out of range")
if self.hand_lens[0] > self.hand_size or self.hand_lens[1] > self.hand_size:
raise ValueError("hand exceeds hand_size")
for color in range(self.n_colors):
if self.discard_lens[color] < 0 or self.discard_lens[color] > self.cards_per_color:
raise ValueError("discard length out of range")
for player in range(2):
if self.hand_lens[player] < 0:
raise ValueError("hand length out of range")
for color in range(self.n_colors):
length = self.expedition_lens[self._expedition_len_index(player, color)]
if length < 0 or length > self.cards_per_color:
raise ValueError("expedition length out of range")
seen_numeric = False
last_rank = 0
for index in range(length):
card = self.expeditions[self._expedition_index(player, color, index)]
if self._card_color(card) != color:
raise ValueError("expedition contains wrong color")
rank = self._card_rank(card)
if rank < 0 or rank > self.n_ranks:
raise ValueError("card rank out of range")
if rank == 0:
if seen_numeric:
raise ValueError("expedition has handshake after number")
else:
seen_numeric = True
if rank <= last_rank:
raise ValueError("expedition is not strictly increasing")
last_rank = rank
if Counter(_all_cards_from_snapshot(self.to_snapshot())) != Counter(
_build_encoded_deck(self.config)
):
@@ -698,6 +776,8 @@ cdef class FastGameState:
self.discard_lens[color] += 1
self.pending_discarded_color = color
self.phase_id = _phase_draw()
# Defensive terminal branch for externally constructed states where the
# deck was already empty before the card phase action.
if self.deck_len == 0 and not self._has_any_legal_draw():
self.terminal = True
@@ -842,7 +922,7 @@ cdef class FastGameState:
score += self.bonus_amount
return score
cdef bint _has_any_legal_draw(self):
cdef bint _has_any_legal_draw(self) noexcept:
cdef int color
if self.deck_len > 0:
return True