Skip to content

Variants

patiencepilot.variants

Solitaire variant implementations.

Variant

Bases: Protocol

Protocol implemented by Solitaire rule sets.

Source code in src/patiencepilot/variants/base.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Variant(Protocol):
    """Protocol implemented by Solitaire rule sets."""

    name: ClassVar[str]

    def new_game(self, seed: Seed = None) -> GameState:
        """Return a new game state."""
        ...

    def validate_state(self, state: GameState) -> None:
        """Validate that ``state`` is coherent for this variant."""
        ...

    def legal_moves(self, state: GameState) -> tuple[Move, ...]:
        """Return legal moves available from ``state``."""
        ...

    def apply_move(self, state: GameState, move: Move) -> MoveResult:
        """Apply ``move`` and return the resulting state and effects."""
        ...

    def is_won(self, state: GameState) -> bool:
        """Return whether ``state`` is a winning state."""
        ...

new_game

new_game(seed: Seed = None) -> GameState

Return a new game state.

Source code in src/patiencepilot/variants/base.py
18
19
20
def new_game(self, seed: Seed = None) -> GameState:
    """Return a new game state."""
    ...

validate_state

validate_state(state: GameState) -> None

Validate that state is coherent for this variant.

Source code in src/patiencepilot/variants/base.py
22
23
24
def validate_state(self, state: GameState) -> None:
    """Validate that ``state`` is coherent for this variant."""
    ...

legal_moves

legal_moves(state: GameState) -> tuple[Move, ...]

Return legal moves available from state.

Source code in src/patiencepilot/variants/base.py
26
27
28
def legal_moves(self, state: GameState) -> tuple[Move, ...]:
    """Return legal moves available from ``state``."""
    ...

apply_move

apply_move(state: GameState, move: Move) -> MoveResult

Apply move and return the resulting state and effects.

Source code in src/patiencepilot/variants/base.py
30
31
32
def apply_move(self, state: GameState, move: Move) -> MoveResult:
    """Apply ``move`` and return the resulting state and effects."""
    ...

is_won

is_won(state: GameState) -> bool

Return whether state is a winning state.

Source code in src/patiencepilot/variants/base.py
34
35
36
def is_won(self, state: GameState) -> bool:
    """Return whether ``state`` is a winning state."""
    ...

KlondikeRules dataclass

Rules and options for Klondike Solitaire.

Source code in src/patiencepilot/variants/klondike.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
@dataclass(frozen=True, slots=True)
class KlondikeRules:
    """Rules and options for Klondike Solitaire."""

    draw_count: int = 1
    redeals: int | None = None

    name: ClassVar[str] = "klondike"

    def __post_init__(self) -> None:
        """Validate immutable rule options."""
        if self.draw_count < 1:
            msg = "draw_count must be at least 1"
            raise InvalidStateError(msg)
        if self.redeals is not None and self.redeals < 0:
            msg = "redeals must be non-negative or None"
            raise InvalidStateError(msg)

    def new_game(self, seed: Seed = None) -> GameState:
        """Return a new shuffled Klondike game state.

        Args:
            seed: Optional deterministic random seed.
        """
        deck = list(standard_deck())
        # Solitaire deals need deterministic game randomness for reproducible seeds.
        random.Random(seed).shuffle(deck)  # nosec B311
        return self.deal(deck)

    def deal(self, deck: Sequence[Card]) -> GameState:
        """Deal ``deck`` into a Klondike starting state.

        Args:
            deck: A complete 52-card deck. The end of the sequence is treated
                as the top of the deck.

        Raises:
            InvalidStateError: If ``deck`` is not a unique 52-card deck.
        """
        if len(deck) != len(standard_deck()) or len(set(deck)) != len(standard_deck()):
            msg = "Klondike deals require a unique 52-card deck"
            raise InvalidStateError(msg)

        cards = list(deck)
        tableau: list[tuple[StackCard, ...]] = []
        for column_index in range(N_TABLEAU_COLUMNS):
            column: list[StackCard] = []
            for row_index in range(column_index + 1):
                column.append(StackCard(card=cards.pop(), face_up=row_index == column_index))
            tableau.append(tuple(column))

        return GameState(
            foundations=tuple(() for _ in SUIT_ORDER),
            tableau=tuple(tableau),
            stock=tuple(cards),
            waste=(),
            variant=self.name,
            draw_count=self.draw_count,
            redeals_allowed=self.redeals,
        )

    def validate_state(self, state: GameState) -> None:
        """Validate that ``state`` is coherent for Klondike.

        Raises:
            InvalidStateError: If the state is malformed.
            UnsupportedVariantError: If the state belongs to another variant.
        """
        if state.variant != self.name:
            msg = f"expected variant {self.name!r}, got {state.variant!r}"
            raise UnsupportedVariantError(msg)
        if state.draw_count != self.draw_count:
            msg = f"state draw_count {state.draw_count} does not match rules draw_count {self.draw_count}"
            raise InvalidStateError(msg)
        if state.redeals_allowed != self.redeals:
            msg = f"state redeals_allowed {state.redeals_allowed!r} does not match rules redeals {self.redeals!r}"
            raise InvalidStateError(msg)
        if state.redeals_used < 0:
            msg = "redeals_used must be non-negative"
            raise InvalidStateError(msg)
        if self.redeals is not None and state.redeals_used > self.redeals:
            msg = "redeals_used cannot exceed redeals_allowed"
            raise InvalidStateError(msg)
        if len(state.foundations) != len(SUIT_ORDER):
            msg = "foundations must contain one stack per suit"
            raise InvalidStateError(msg)
        if len(state.tableau) != N_TABLEAU_COLUMNS:
            msg = f"Klondike tableau must contain {N_TABLEAU_COLUMNS} columns"
            raise InvalidStateError(msg)

        known_cards = tuple(state.iter_known_cards())
        if len(set(known_cards)) != len(known_cards):
            msg = "state contains duplicate known cards"
            raise InvalidStateError(msg)

        for suit in Suit:
            self._validate_foundation(suit, state.foundation(suit))

        for column_index, column in enumerate(state.tableau):
            for stack_card in column:
                if not isinstance(stack_card.card, Card):
                    msg = f"tableau column {column_index} contains a non-card value"
                    raise InvalidStateError(msg)

    def legal_moves(self, state: GameState) -> tuple[Move, ...]:
        """Return legal Klondike moves available from ``state``."""
        self.validate_state(state)
        moves: list[Move] = []

        if state.stock:
            moves.append(DrawFromStock())
        elif state.waste and self._can_recycle(state):
            moves.append(RecycleWaste())

        if state.waste:
            waste_card = state.waste[-1]
            if self._can_move_to_foundation(waste_card, state.foundation(waste_card.suit)):
                moves.append(WasteToFoundation())
            for destination in range(N_TABLEAU_COLUMNS):
                if self._can_place_on_tableau(waste_card, state.tableau[destination]):
                    moves.append(WasteToTableau(destination=destination))

        for source, column in enumerate(state.tableau):
            top_card = self._top_visible_card(column)
            if top_card is not None and self._can_move_to_foundation(top_card, state.foundation(top_card.suit)):
                moves.append(TableauToFoundation(source=source))

            for start_index in range(len(column)):
                moving_stack = column[start_index:]
                if not self._is_movable_tableau_stack(moving_stack):
                    continue
                moving_card = moving_stack[0].visible_card
                if moving_card is None:
                    continue
                count = len(moving_stack)
                for destination in range(N_TABLEAU_COLUMNS):
                    if source == destination:
                        continue
                    if self._can_place_on_tableau(moving_card, state.tableau[destination]):
                        moves.append(TableauToTableau(source=source, destination=destination, count=count))

        return tuple(moves)

    def apply_move(self, state: GameState, move: Move) -> MoveResult:
        """Apply a legal Klondike move and return the result.

        Raises:
            InvalidMoveError: If ``move`` is not legal from ``state``.
            InvalidStateError: If ``state`` is malformed.
        """
        self.validate_state(state)

        if isinstance(move, DrawFromStock):
            result = self._draw_from_stock(state, move)
        elif isinstance(move, RecycleWaste):
            result = self._recycle_waste(state, move)
        elif isinstance(move, WasteToFoundation):
            result = self._waste_to_foundation(state, move)
        elif isinstance(move, WasteToTableau):
            result = self._waste_to_tableau(state, move)
        elif isinstance(move, TableauToFoundation):
            result = self._tableau_to_foundation(state, move)
        elif isinstance(move, TableauToTableau):
            result = self._tableau_to_tableau(state, move)
        else:
            msg = f"unsupported Klondike move: {move!r}"
            raise InvalidMoveError(msg)

        self.validate_state(result.state)
        return result

    def is_won(self, state: GameState) -> bool:
        """Return whether all cards have been moved to the foundations."""
        self.validate_state(state)
        return all(len(state.foundation(suit)) == len(Rank) for suit in Suit)

    def _draw_from_stock(self, state: GameState, move: DrawFromStock) -> MoveResult:
        """Draw cards from stock to waste."""
        if not state.stock:
            msg = "cannot draw from an empty stock"
            raise InvalidMoveError(msg)

        stock = list(state.stock)
        waste = list(state.waste)
        drawn: list[Card] = []
        for _ in range(min(self.draw_count, len(stock))):
            card = stock.pop()
            waste.append(card)
            drawn.append(card)

        next_state = replace(state, stock=tuple(stock), waste=tuple(waste))
        return MoveResult(move=move, state=next_state, effects=(DrewStockCards(cards=tuple(drawn)),))

    def _recycle_waste(self, state: GameState, move: RecycleWaste) -> MoveResult:
        """Recycle waste into stock."""
        if state.stock:
            msg = "cannot recycle waste while stock is not empty"
            raise InvalidMoveError(msg)
        if not state.waste:
            msg = "cannot recycle an empty waste pile"
            raise InvalidMoveError(msg)
        if not self._can_recycle(state):
            msg = "redeal limit has been reached"
            raise InvalidMoveError(msg)

        next_state = replace(
            state,
            stock=tuple(reversed(state.waste)),
            waste=(),
            redeals_used=state.redeals_used + 1,
        )
        return MoveResult(move=move, state=next_state, effects=(RecycledWaste(count=len(state.waste)),))

    def _waste_to_foundation(self, state: GameState, move: WasteToFoundation) -> MoveResult:
        """Move waste top card to its foundation."""
        if not state.waste:
            msg = "cannot move from an empty waste pile"
            raise InvalidMoveError(msg)
        card = state.waste[-1]
        foundations = self._append_to_foundation(state, card)
        next_state = replace(state, foundations=foundations, waste=state.waste[:-1])
        return MoveResult(
            move=move,
            state=next_state,
            effects=(MovedCards(cards=(card,), source="waste", destination=f"foundation[{card.suit.code}]"),),
        )

    def _waste_to_tableau(self, state: GameState, move: WasteToTableau) -> MoveResult:
        """Move waste top card to a tableau column."""
        self._validate_column_index(move.destination)
        if not state.waste:
            msg = "cannot move from an empty waste pile"
            raise InvalidMoveError(msg)
        card = state.waste[-1]
        destination_column = state.tableau[move.destination]
        if not self._can_place_on_tableau(card, destination_column):
            msg = f"cannot move {card} to tableau column {move.destination}"
            raise InvalidMoveError(msg)

        tableau = self._replace_tableau_column(
            state.tableau,
            move.destination,
            (*destination_column, StackCard.visible(card)),
        )
        next_state = replace(state, tableau=tableau, waste=state.waste[:-1])
        return MoveResult(
            move=move,
            state=next_state,
            effects=(MovedCards(cards=(card,), source="waste", destination=f"tableau[{move.destination}]"),),
        )

    def _tableau_to_foundation(self, state: GameState, move: TableauToFoundation) -> MoveResult:
        """Move tableau top card to its foundation."""
        self._validate_column_index(move.source)
        source_column = state.tableau[move.source]
        card = self._top_visible_card(source_column)
        if card is None:
            msg = f"tableau column {move.source} has no visible top card"
            raise InvalidMoveError(msg)

        foundations = self._append_to_foundation(state, card)
        tableau = self._replace_tableau_column(state.tableau, move.source, source_column[:-1])
        tableau, reveal_effects = self._reveal_top_card(tableau, move.source)
        next_state = replace(state, foundations=foundations, tableau=tableau)
        return MoveResult(
            move=move,
            state=next_state,
            effects=(
                MovedCards(
                    cards=(card,),
                    source=f"tableau[{move.source}]",
                    destination=f"foundation[{card.suit.code}]",
                ),
                *reveal_effects,
            ),
        )

    def _tableau_to_tableau(self, state: GameState, move: TableauToTableau) -> MoveResult:
        """Move visible tableau cards between columns."""
        self._validate_column_index(move.source)
        self._validate_column_index(move.destination)
        if move.source == move.destination:
            msg = "source and destination tableau columns must differ"
            raise InvalidMoveError(msg)
        if move.count < 1:
            msg = "tableau move count must be at least 1"
            raise InvalidMoveError(msg)

        source_column = state.tableau[move.source]
        if move.count > len(source_column):
            msg = f"tableau column {move.source} does not contain {move.count} cards"
            raise InvalidMoveError(msg)

        moving_stack = source_column[-move.count :]
        if not self._is_movable_tableau_stack(moving_stack):
            msg = "tableau move must contain a face-up descending alternating-color stack"
            raise InvalidMoveError(msg)

        moving_card = moving_stack[0].visible_card
        if moving_card is None:
            msg = "tableau move starts with an unknown card"
            raise InvalidMoveError(msg)

        destination_column = state.tableau[move.destination]
        if not self._can_place_on_tableau(moving_card, destination_column):
            msg = f"cannot move {moving_card} to tableau column {move.destination}"
            raise InvalidMoveError(msg)

        tableau = self._replace_tableau_column(state.tableau, move.source, source_column[: -move.count])
        tableau = self._replace_tableau_column(tableau, move.destination, (*destination_column, *moving_stack))
        tableau, reveal_effects = self._reveal_top_card(tableau, move.source)
        moved_cards = tuple(stack_card.visible_card for stack_card in moving_stack)
        if any(card is None for card in moved_cards):
            msg = "tableau move contains an unknown card"
            raise InvalidMoveError(msg)
        cards = tuple(card for card in moved_cards if card is not None)
        next_state = replace(state, tableau=tableau)
        return MoveResult(
            move=move,
            state=next_state,
            effects=(
                MovedCards(
                    cards=cards,
                    source=f"tableau[{move.source}]",
                    destination=f"tableau[{move.destination}]",
                ),
                *reveal_effects,
            ),
        )

    def _append_to_foundation(self, state: GameState, card: Card) -> tuple[tuple[Card, ...], ...]:
        """Return foundations with ``card`` appended to its foundation."""
        foundation = state.foundation(card.suit)
        if not self._can_move_to_foundation(card, foundation):
            msg = f"cannot move {card} to foundation"
            raise InvalidMoveError(msg)

        foundations = list(state.foundations)
        foundations[SUIT_ORDER.index(card.suit)] = (*foundation, card)
        return tuple(foundations)

    def _reveal_top_card(
        self,
        tableau: tuple[tuple[StackCard, ...], ...],
        column_index: int,
    ) -> tuple[tuple[tuple[StackCard, ...], ...], tuple[MoveEffect, ...]]:
        """Reveal the top card in a tableau column when possible."""
        column = tableau[column_index]
        if not column or column[-1].face_up:
            return tableau, ()

        revealed_column = (*column[:-1], StackCard.visible(column[-1].card))
        return (
            self._replace_tableau_column(tableau, column_index, revealed_column),
            (RevealedTableauCard(column=column_index, card=column[-1].card),),
        )

    @staticmethod
    def _replace_tableau_column(
        tableau: tuple[tuple[StackCard, ...], ...],
        column_index: int,
        column: tuple[StackCard, ...],
    ) -> tuple[tuple[StackCard, ...], ...]:
        """Return tableau with one column replaced."""
        next_tableau = list(tableau)
        next_tableau[column_index] = column
        return tuple(next_tableau)

    @staticmethod
    def _validate_column_index(column_index: int) -> None:
        """Validate a tableau column index."""
        if column_index < 0 or column_index >= N_TABLEAU_COLUMNS:
            msg = f"tableau column index out of range: {column_index}"
            raise InvalidMoveError(msg)

    @staticmethod
    def _validate_foundation(suit: Suit, foundation: tuple[Card, ...]) -> None:
        """Validate one foundation stack."""
        for expected_rank, card in enumerate(foundation, start=Rank.ACE.value):
            if card.suit != suit or card.rank.value != expected_rank:
                msg = f"foundation {suit.code} is not ordered from ace upward"
                raise InvalidStateError(msg)

    @staticmethod
    def _top_visible_card(column: tuple[StackCard, ...]) -> Card | None:
        """Return the visible top card for a tableau column."""
        if not column:
            return None
        return column[-1].visible_card

    @staticmethod
    def _can_move_to_foundation(card: Card, foundation: tuple[Card, ...]) -> bool:
        """Return whether ``card`` can move to ``foundation``."""
        if not foundation:
            return card.rank == Rank.ACE
        top_card = foundation[-1]
        return card.suit == top_card.suit and card.rank.value == top_card.rank.value + 1

    @staticmethod
    def _can_place_on_tableau(card: Card, destination: tuple[StackCard, ...]) -> bool:
        """Return whether ``card`` can be placed on a tableau destination."""
        if not destination:
            return card.rank == Rank.KING
        top_card = destination[-1].visible_card
        if top_card is None:
            return False
        return card.color != top_card.color and card.rank.value == top_card.rank.value - 1

    @staticmethod
    def _is_movable_tableau_stack(stack: tuple[StackCard, ...]) -> bool:
        """Return whether ``stack`` is a movable face-up Klondike sequence."""
        if not stack:
            return False

        visible_cards = tuple(stack_card.visible_card for stack_card in stack)
        if any(card is None for card in visible_cards):
            return False

        cards = tuple(card for card in visible_cards if card is not None)
        return all(
            lower.color != upper.color and upper.rank.value == lower.rank.value - 1 for lower, upper in pairwise(cards)
        )

    def _can_recycle(self, state: GameState) -> bool:
        """Return whether waste can be recycled into stock."""
        return self.redeals is None or state.redeals_used < self.redeals

__post_init__

__post_init__() -> None

Validate immutable rule options.

Source code in src/patiencepilot/variants/klondike.py
41
42
43
44
45
46
47
48
def __post_init__(self) -> None:
    """Validate immutable rule options."""
    if self.draw_count < 1:
        msg = "draw_count must be at least 1"
        raise InvalidStateError(msg)
    if self.redeals is not None and self.redeals < 0:
        msg = "redeals must be non-negative or None"
        raise InvalidStateError(msg)

new_game

new_game(seed: Seed = None) -> GameState

Return a new shuffled Klondike game state.

Parameters:

Name Type Description Default
seed Seed

Optional deterministic random seed.

None
Source code in src/patiencepilot/variants/klondike.py
50
51
52
53
54
55
56
57
58
59
def new_game(self, seed: Seed = None) -> GameState:
    """Return a new shuffled Klondike game state.

    Args:
        seed: Optional deterministic random seed.
    """
    deck = list(standard_deck())
    # Solitaire deals need deterministic game randomness for reproducible seeds.
    random.Random(seed).shuffle(deck)  # nosec B311
    return self.deal(deck)

deal

deal(deck: Sequence[Card]) -> GameState

Deal deck into a Klondike starting state.

Parameters:

Name Type Description Default
deck Sequence[Card]

A complete 52-card deck. The end of the sequence is treated as the top of the deck.

required

Raises:

Type Description
InvalidStateError

If deck is not a unique 52-card deck.

Source code in src/patiencepilot/variants/klondike.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def deal(self, deck: Sequence[Card]) -> GameState:
    """Deal ``deck`` into a Klondike starting state.

    Args:
        deck: A complete 52-card deck. The end of the sequence is treated
            as the top of the deck.

    Raises:
        InvalidStateError: If ``deck`` is not a unique 52-card deck.
    """
    if len(deck) != len(standard_deck()) or len(set(deck)) != len(standard_deck()):
        msg = "Klondike deals require a unique 52-card deck"
        raise InvalidStateError(msg)

    cards = list(deck)
    tableau: list[tuple[StackCard, ...]] = []
    for column_index in range(N_TABLEAU_COLUMNS):
        column: list[StackCard] = []
        for row_index in range(column_index + 1):
            column.append(StackCard(card=cards.pop(), face_up=row_index == column_index))
        tableau.append(tuple(column))

    return GameState(
        foundations=tuple(() for _ in SUIT_ORDER),
        tableau=tuple(tableau),
        stock=tuple(cards),
        waste=(),
        variant=self.name,
        draw_count=self.draw_count,
        redeals_allowed=self.redeals,
    )

validate_state

validate_state(state: GameState) -> None

Validate that state is coherent for Klondike.

Raises:

Type Description
InvalidStateError

If the state is malformed.

UnsupportedVariantError

If the state belongs to another variant.

Source code in src/patiencepilot/variants/klondike.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def validate_state(self, state: GameState) -> None:
    """Validate that ``state`` is coherent for Klondike.

    Raises:
        InvalidStateError: If the state is malformed.
        UnsupportedVariantError: If the state belongs to another variant.
    """
    if state.variant != self.name:
        msg = f"expected variant {self.name!r}, got {state.variant!r}"
        raise UnsupportedVariantError(msg)
    if state.draw_count != self.draw_count:
        msg = f"state draw_count {state.draw_count} does not match rules draw_count {self.draw_count}"
        raise InvalidStateError(msg)
    if state.redeals_allowed != self.redeals:
        msg = f"state redeals_allowed {state.redeals_allowed!r} does not match rules redeals {self.redeals!r}"
        raise InvalidStateError(msg)
    if state.redeals_used < 0:
        msg = "redeals_used must be non-negative"
        raise InvalidStateError(msg)
    if self.redeals is not None and state.redeals_used > self.redeals:
        msg = "redeals_used cannot exceed redeals_allowed"
        raise InvalidStateError(msg)
    if len(state.foundations) != len(SUIT_ORDER):
        msg = "foundations must contain one stack per suit"
        raise InvalidStateError(msg)
    if len(state.tableau) != N_TABLEAU_COLUMNS:
        msg = f"Klondike tableau must contain {N_TABLEAU_COLUMNS} columns"
        raise InvalidStateError(msg)

    known_cards = tuple(state.iter_known_cards())
    if len(set(known_cards)) != len(known_cards):
        msg = "state contains duplicate known cards"
        raise InvalidStateError(msg)

    for suit in Suit:
        self._validate_foundation(suit, state.foundation(suit))

    for column_index, column in enumerate(state.tableau):
        for stack_card in column:
            if not isinstance(stack_card.card, Card):
                msg = f"tableau column {column_index} contains a non-card value"
                raise InvalidStateError(msg)

legal_moves

legal_moves(state: GameState) -> tuple[Move, ...]

Return legal Klondike moves available from state.

Source code in src/patiencepilot/variants/klondike.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def legal_moves(self, state: GameState) -> tuple[Move, ...]:
    """Return legal Klondike moves available from ``state``."""
    self.validate_state(state)
    moves: list[Move] = []

    if state.stock:
        moves.append(DrawFromStock())
    elif state.waste and self._can_recycle(state):
        moves.append(RecycleWaste())

    if state.waste:
        waste_card = state.waste[-1]
        if self._can_move_to_foundation(waste_card, state.foundation(waste_card.suit)):
            moves.append(WasteToFoundation())
        for destination in range(N_TABLEAU_COLUMNS):
            if self._can_place_on_tableau(waste_card, state.tableau[destination]):
                moves.append(WasteToTableau(destination=destination))

    for source, column in enumerate(state.tableau):
        top_card = self._top_visible_card(column)
        if top_card is not None and self._can_move_to_foundation(top_card, state.foundation(top_card.suit)):
            moves.append(TableauToFoundation(source=source))

        for start_index in range(len(column)):
            moving_stack = column[start_index:]
            if not self._is_movable_tableau_stack(moving_stack):
                continue
            moving_card = moving_stack[0].visible_card
            if moving_card is None:
                continue
            count = len(moving_stack)
            for destination in range(N_TABLEAU_COLUMNS):
                if source == destination:
                    continue
                if self._can_place_on_tableau(moving_card, state.tableau[destination]):
                    moves.append(TableauToTableau(source=source, destination=destination, count=count))

    return tuple(moves)

apply_move

apply_move(state: GameState, move: Move) -> MoveResult

Apply a legal Klondike move and return the result.

Raises:

Type Description
InvalidMoveError

If move is not legal from state.

InvalidStateError

If state is malformed.

Source code in src/patiencepilot/variants/klondike.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def apply_move(self, state: GameState, move: Move) -> MoveResult:
    """Apply a legal Klondike move and return the result.

    Raises:
        InvalidMoveError: If ``move`` is not legal from ``state``.
        InvalidStateError: If ``state`` is malformed.
    """
    self.validate_state(state)

    if isinstance(move, DrawFromStock):
        result = self._draw_from_stock(state, move)
    elif isinstance(move, RecycleWaste):
        result = self._recycle_waste(state, move)
    elif isinstance(move, WasteToFoundation):
        result = self._waste_to_foundation(state, move)
    elif isinstance(move, WasteToTableau):
        result = self._waste_to_tableau(state, move)
    elif isinstance(move, TableauToFoundation):
        result = self._tableau_to_foundation(state, move)
    elif isinstance(move, TableauToTableau):
        result = self._tableau_to_tableau(state, move)
    else:
        msg = f"unsupported Klondike move: {move!r}"
        raise InvalidMoveError(msg)

    self.validate_state(result.state)
    return result

is_won

is_won(state: GameState) -> bool

Return whether all cards have been moved to the foundations.

Source code in src/patiencepilot/variants/klondike.py
203
204
205
206
def is_won(self, state: GameState) -> bool:
    """Return whether all cards have been moved to the foundations."""
    self.validate_state(state)
    return all(len(state.foundation(suit)) == len(Rank) for suit in Suit)

VariantDefinition dataclass

Registered Solitaire variant plus its option conversion functions.

Source code in src/patiencepilot/variants/registry.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@dataclass(frozen=True, slots=True)
class VariantDefinition:
    """Registered Solitaire variant plus its option conversion functions."""

    name: str
    factory: VariantFactory
    state_options: StateOptionsResolver
    aliases: tuple[str, ...] = ()

    def resolve(self, options: VariantOptions | None = None) -> Variant:
        """Return a rules object for this variant.

        Args:
            options: Variant-specific configuration values.
        """
        return self.factory({} if options is None else options)

resolve

resolve(options: VariantOptions | None = None) -> Variant

Return a rules object for this variant.

Parameters:

Name Type Description Default
options VariantOptions | None

Variant-specific configuration values.

None
Source code in src/patiencepilot/variants/registry.py
42
43
44
45
46
47
48
def resolve(self, options: VariantOptions | None = None) -> Variant:
    """Return a rules object for this variant.

    Args:
        options: Variant-specific configuration values.
    """
    return self.factory({} if options is None else options)

VariantRegistry dataclass

Small immutable registry of supported Solitaire variants.

Source code in src/patiencepilot/variants/registry.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@dataclass(frozen=True, slots=True)
class VariantRegistry:
    """Small immutable registry of supported Solitaire variants."""

    definitions: tuple[VariantDefinition, ...]

    def __post_init__(self) -> None:
        """Validate registry keys."""
        keys: set[str] = set()
        for definition in self.definitions:
            for name in (definition.name, *definition.aliases):
                normalized = _normalize_variant_name(name)
                if normalized in keys:
                    msg = f"duplicate variant registry name: {name!r}"
                    raise InvalidStateError(msg)
                keys.add(normalized)

    @property
    def names(self) -> tuple[str, ...]:
        """Return canonical registered variant names."""
        return tuple(definition.name for definition in self.definitions)

    def register(self, definition: VariantDefinition) -> VariantRegistry:
        """Return a new registry that also contains ``definition``.

        Args:
            definition: Variant definition to add.
        """
        return VariantRegistry((*self.definitions, definition))

    def definition_for(self, name: str) -> VariantDefinition:
        """Return the registered definition for ``name`` or alias.

        Args:
            name: Variant name or alias.

        Raises:
            UnsupportedVariantError: If no variant is registered for ``name``.
        """
        normalized = _normalize_variant_name(name)
        for definition in self.definitions:
            if normalized == _normalize_variant_name(definition.name):
                return definition
            if any(normalized == _normalize_variant_name(alias) for alias in definition.aliases):
                return definition

        msg = f"unsupported variant: {name!r}"
        raise UnsupportedVariantError(msg)

    def resolve(self, name: str, options: VariantOptions | None = None) -> Variant:
        """Return a rules object by variant name and options.

        Args:
            name: Variant name or alias.
            options: Variant-specific configuration values.
        """
        return self.definition_for(name).resolve(options)

    def options_from_state(self, state: GameState) -> dict[str, object]:
        """Return registry options that reproduce ``state`` rules.

        Args:
            state: State whose variant metadata should be converted.
        """
        return self.definition_for(state.variant).state_options(state)

    def resolve_state(self, state: GameState) -> Variant:
        """Return rules for the variant metadata stored in ``state``.

        Args:
            state: State containing variant name and option metadata.
        """
        return self.resolve(state.variant, self.options_from_state(state))

names property

names: tuple[str, ...]

Return canonical registered variant names.

__post_init__

__post_init__() -> None

Validate registry keys.

Source code in src/patiencepilot/variants/registry.py
57
58
59
60
61
62
63
64
65
66
def __post_init__(self) -> None:
    """Validate registry keys."""
    keys: set[str] = set()
    for definition in self.definitions:
        for name in (definition.name, *definition.aliases):
            normalized = _normalize_variant_name(name)
            if normalized in keys:
                msg = f"duplicate variant registry name: {name!r}"
                raise InvalidStateError(msg)
            keys.add(normalized)

register

register(definition: VariantDefinition) -> VariantRegistry

Return a new registry that also contains definition.

Parameters:

Name Type Description Default
definition VariantDefinition

Variant definition to add.

required
Source code in src/patiencepilot/variants/registry.py
73
74
75
76
77
78
79
def register(self, definition: VariantDefinition) -> VariantRegistry:
    """Return a new registry that also contains ``definition``.

    Args:
        definition: Variant definition to add.
    """
    return VariantRegistry((*self.definitions, definition))

definition_for

definition_for(name: str) -> VariantDefinition

Return the registered definition for name or alias.

Parameters:

Name Type Description Default
name str

Variant name or alias.

required

Raises:

Type Description
UnsupportedVariantError

If no variant is registered for name.

Source code in src/patiencepilot/variants/registry.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def definition_for(self, name: str) -> VariantDefinition:
    """Return the registered definition for ``name`` or alias.

    Args:
        name: Variant name or alias.

    Raises:
        UnsupportedVariantError: If no variant is registered for ``name``.
    """
    normalized = _normalize_variant_name(name)
    for definition in self.definitions:
        if normalized == _normalize_variant_name(definition.name):
            return definition
        if any(normalized == _normalize_variant_name(alias) for alias in definition.aliases):
            return definition

    msg = f"unsupported variant: {name!r}"
    raise UnsupportedVariantError(msg)

resolve

resolve(
    name: str, options: VariantOptions | None = None
) -> Variant

Return a rules object by variant name and options.

Parameters:

Name Type Description Default
name str

Variant name or alias.

required
options VariantOptions | None

Variant-specific configuration values.

None
Source code in src/patiencepilot/variants/registry.py
100
101
102
103
104
105
106
107
def resolve(self, name: str, options: VariantOptions | None = None) -> Variant:
    """Return a rules object by variant name and options.

    Args:
        name: Variant name or alias.
        options: Variant-specific configuration values.
    """
    return self.definition_for(name).resolve(options)

options_from_state

options_from_state(state: GameState) -> dict[str, object]

Return registry options that reproduce state rules.

Parameters:

Name Type Description Default
state GameState

State whose variant metadata should be converted.

required
Source code in src/patiencepilot/variants/registry.py
109
110
111
112
113
114
115
def options_from_state(self, state: GameState) -> dict[str, object]:
    """Return registry options that reproduce ``state`` rules.

    Args:
        state: State whose variant metadata should be converted.
    """
    return self.definition_for(state.variant).state_options(state)

resolve_state

resolve_state(state: GameState) -> Variant

Return rules for the variant metadata stored in state.

Parameters:

Name Type Description Default
state GameState

State containing variant name and option metadata.

required
Source code in src/patiencepilot/variants/registry.py
117
118
119
120
121
122
123
def resolve_state(self, state: GameState) -> Variant:
    """Return rules for the variant metadata stored in ``state``.

    Args:
        state: State containing variant name and option metadata.
    """
    return self.resolve(state.variant, self.options_from_state(state))

resolve_state_variant

resolve_state_variant(
    state: GameState,
    *,
    registry: VariantRegistry | None = None,
) -> Variant

Return rules for the variant metadata stored in state.

Parameters:

Name Type Description Default
state GameState

State containing variant name and option metadata.

required
registry VariantRegistry | None

Registry to use. Defaults to the package registry.

None
Source code in src/patiencepilot/variants/registry.py
142
143
144
145
146
147
148
149
def resolve_state_variant(state: GameState, *, registry: VariantRegistry | None = None) -> Variant:
    """Return rules for the variant metadata stored in ``state``.

    Args:
        state: State containing variant name and option metadata.
        registry: Registry to use. Defaults to the package registry.
    """
    return _registry_or_default(registry).resolve_state(state)

resolve_variant

resolve_variant(
    name: str = KlondikeRules.name,
    options: VariantOptions | None = None,
    *,
    registry: VariantRegistry | None = None,
) -> Variant

Return rules for a registered variant name and options.

Parameters:

Name Type Description Default
name str

Variant name or alias. Defaults to "klondike".

name
options VariantOptions | None

Variant-specific configuration values.

None
registry VariantRegistry | None

Registry to use. Defaults to the package registry.

None
Source code in src/patiencepilot/variants/registry.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def resolve_variant(
    name: str = KlondikeRules.name,
    options: VariantOptions | None = None,
    *,
    registry: VariantRegistry | None = None,
) -> Variant:
    """Return rules for a registered variant name and options.

    Args:
        name: Variant name or alias. Defaults to ``"klondike"``.
        options: Variant-specific configuration values.
        registry: Registry to use. Defaults to the package registry.
    """
    return _registry_or_default(registry).resolve(name, options)

variant_names

variant_names(
    *, registry: VariantRegistry | None = None
) -> tuple[str, ...]

Return canonical names of registered variants.

Source code in src/patiencepilot/variants/registry.py
162
163
164
def variant_names(*, registry: VariantRegistry | None = None) -> tuple[str, ...]:
    """Return canonical names of registered variants."""
    return _registry_or_default(registry).names

variant_options_from_state

variant_options_from_state(
    state: GameState,
    *,
    registry: VariantRegistry | None = None,
) -> dict[str, object]

Return registry options that reproduce state rules.

Parameters:

Name Type Description Default
state GameState

State containing variant name and option metadata.

required
registry VariantRegistry | None

Registry to use. Defaults to the package registry.

None
Source code in src/patiencepilot/variants/registry.py
152
153
154
155
156
157
158
159
def variant_options_from_state(state: GameState, *, registry: VariantRegistry | None = None) -> dict[str, object]:
    """Return registry options that reproduce ``state`` rules.

    Args:
        state: State containing variant name and option metadata.
        registry: Registry to use. Defaults to the package registry.
    """
    return _registry_or_default(registry).options_from_state(state)