nodes/game_state.py¶

Part of Klondike Solitaire.

  1"""Pure-logic Klondike game state.
  2
  3Tableau (7 columns) + foundations (4 piles) + stock + waste (a.k.a. talon),
  4the same shape as the upstream C implementation's ``src/solitaire.c``
  5(https://github.com/zaccnz/solitaire). Move objects are immutable records that
  6can be inverted for undo. No rendering, no node references -- everything works
  7on ``CardId`` values so the state can be JSONed for save+load.
  8
  9Klondike rules:
 10
 11  - Tableau: descending rank, alternating colour (red on black, black on red).
 12  - Empty tableau column accepts a King only.
 13  - Foundation: ascending rank, same suit. Empty foundation accepts an Ace.
 14  - Stock -> waste: deals one card at a time (this port plays "draw 1").
 15  - When stock is empty, clicking it returns the waste back to stock.
 16  - Win: all 52 cards on the foundations.
 17
 18Standard (non-Vegas) scoring, never below zero: +10 onto a foundation, +5 from
 19the waste to a tableau column, +5 for turning a tableau card face-up, -15 back
 20off a foundation, -100 for recycling the waste. Each move records the score
 21delta it actually applied, so undo reverses scoring exactly.
 22"""
 23
 24from __future__ import annotations
 25
 26import random
 27from dataclasses import dataclass, field
 28
 29from .card_textures import CardId, make_full_deck
 30
 31
 32@dataclass
 33class CardState:
 34    """A card and its face-up flag. Equality is by (rank, suit) only."""
 35
 36    card: CardId
 37    face_up: bool = False
 38
 39    def __eq__(self, other: object) -> bool:
 40        return isinstance(other, CardState) and self.card == other.card
 41
 42    def __hash__(self) -> int:
 43        return hash(self.card)
 44
 45
 46# Move locations
 47TABLEAU = "tableau"
 48FOUNDATION = "foundation"
 49WASTE = "waste"
 50STOCK = "stock"
 51
 52
 53@dataclass(frozen=True)
 54class Move:
 55    """An undoable move. ``count`` is the number of cards relocated; for
 56    stock-cycle moves it's how many were dealt to the waste."""
 57
 58    kind: str  # "card" or "stock_cycle" or "stock_recycle"
 59    src: str  # TABLEAU/FOUNDATION/WASTE/STOCK
 60    src_idx: int  # column index for tableau/foundation, -1 otherwise
 61    dst: str
 62    dst_idx: int
 63    count: int = 1
 64    revealed: bool = False  # True if this move flipped a hidden tableau card
 65    score_delta: int = 0  # score actually applied (post-clamp), so undo can reverse it
 66
 67
 68@dataclass
 69class GameState:
 70    """Live state of a Klondike game."""
 71
 72    seed: int = 0
 73    tableau: list[list[CardState]] = field(default_factory=lambda: [[] for _ in range(7)])
 74    foundations: list[list[CardState]] = field(default_factory=lambda: [[] for _ in range(4)])
 75    stock: list[CardState] = field(default_factory=list)
 76    waste: list[CardState] = field(default_factory=list)
 77    history: list[Move] = field(default_factory=list)
 78    score: int = 0
 79    moves: int = 0
 80
 81    # ------------------------------------------------------------------ deal
 82    @classmethod
 83    def new_game(cls, seed: int | None = None) -> GameState:
 84        rng = random.Random(seed)
 85        deck = [CardState(c, False) for c in make_full_deck()]
 86        rng.shuffle(deck)
 87        gs = cls(seed=seed if seed is not None else 0)
 88        idx = 0
 89        for col in range(7):
 90            for row in range(col + 1):
 91                cs = deck[idx]
 92                cs.face_up = row == col  # only the top card flipped
 93                gs.tableau[col].append(cs)
 94                idx += 1
 95        # Remaining cards are stock (face down)
 96        while idx < len(deck):
 97            deck[idx].face_up = False
 98            gs.stock.append(deck[idx])
 99            idx += 1
100        return gs
101
102    # -------------------------------------------------------------- queries
103    @property
104    def is_won(self) -> bool:
105        return all(len(f) == 13 for f in self.foundations)
106
107    def top_of(self, kind: str, idx: int = 0) -> CardState | None:
108        pile = self._pile(kind, idx)
109        return pile[-1] if pile else None
110
111    def _pile(self, kind: str, idx: int) -> list[CardState]:
112        if kind == TABLEAU:
113            return self.tableau[idx]
114        if kind == FOUNDATION:
115            return self.foundations[idx]
116        if kind == WASTE:
117            return self.waste
118        if kind == STOCK:
119            return self.stock
120        raise ValueError(f"Unknown pile kind: {kind}")
121
122    # ----------------------------------------------------------- validation
123    def can_move_to_tableau(self, moving: CardState, dst_col: int) -> bool:
124        col = self.tableau[dst_col]
125        if not col:
126            return moving.card.rank == "K"
127        top = col[-1]
128        if not top.face_up:
129            return False
130        return moving.card.value == top.card.value - 1 and moving.card.is_red != top.card.is_red
131
132    def can_move_to_foundation(self, moving: CardState, dst_idx: int) -> bool:
133        f = self.foundations[dst_idx]
134        if not f:
135            return moving.card.rank == "A"
136        top = f[-1]
137        return moving.card.suit == top.card.suit and moving.card.value == top.card.value + 1
138
139    # -------------------------------------------------------------- scoring
140    def _add_score(self, delta: int) -> int:
141        """Apply ``delta``, clamped so the score never goes negative.
142
143        Returns the delta actually applied, which is what the ``Move`` stores:
144        subtracting it in :meth:`undo` restores the previous score exactly.
145        """
146        new = max(0, self.score + delta)
147        applied = new - self.score
148        self.score = new
149        return applied
150
151    @staticmethod
152    def _move_score(src: str, dst: str, revealed: bool) -> int:
153        delta = 0
154        if dst == FOUNDATION:
155            delta += 10
156        elif dst == TABLEAU and src == WASTE:
157            delta += 5
158        if src == FOUNDATION:
159            delta -= 15
160        if revealed:
161            delta += 5
162        return delta
163
164    # ------------------------------------------------------------- mutation
165    def deal_from_stock(self) -> bool:
166        """Move one card from stock to waste, or recycle waste -> stock if empty."""
167        if self.stock:
168            cs = self.stock.pop()
169            cs.face_up = True
170            self.waste.append(cs)
171            self.history.append(Move(kind="stock_cycle", src=STOCK, src_idx=-1, dst=WASTE, dst_idx=-1, count=1))
172            self.moves += 1
173            return True
174        if self.waste:
175            # Recycle: return waste face-down to stock in reverse order
176            count = len(self.waste)
177            while self.waste:
178                cs = self.waste.pop()
179                cs.face_up = False
180                self.stock.append(cs)
181            self.history.append(
182                Move(
183                    kind="stock_recycle",
184                    src=WASTE,
185                    src_idx=-1,
186                    dst=STOCK,
187                    dst_idx=-1,
188                    count=count,
189                    score_delta=self._add_score(-100),
190                )
191            )
192            self.moves += 1
193            return True
194        return False
195
196    def move_cards(self, src: str, src_idx: int, dst: str, dst_idx: int, count: int = 1) -> bool:
197        """Validate and execute a card move. Returns True on success.
198
199        For tableau-to-tableau moves, ``count`` may be > 1 (multi-card stack
200        move). All other moves are single cards. The bottom-most moved card
201        must satisfy the destination rule.
202        """
203        if dst == STOCK or src == STOCK:
204            return False  # stock moves go through deal_from_stock
205
206        src_pile = self._pile(src, src_idx)
207        if len(src_pile) < count:
208            return False
209        moving = src_pile[-count]
210        if not moving.face_up:
211            return False
212        # All cards in a multi-card source slice must be face up
213        if any(not c.face_up for c in src_pile[-count:]):
214            return False
215
216        if dst == TABLEAU:
217            if not self.can_move_to_tableau(moving, dst_idx):
218                return False
219        elif dst == FOUNDATION:
220            if count != 1 or not self.can_move_to_foundation(moving, dst_idx):
221                return False
222        else:
223            return False
224
225        # Execute
226        moved = src_pile[-count:]
227        del src_pile[-count:]
228        self._pile(dst, dst_idx).extend(moved)
229
230        # Reveal if this exposed a hidden tableau card
231        revealed = False
232        if src == TABLEAU and src_pile and not src_pile[-1].face_up:
233            src_pile[-1].face_up = True
234            revealed = True
235
236        self.history.append(
237            Move(
238                kind="card",
239                src=src,
240                src_idx=src_idx,
241                dst=dst,
242                dst_idx=dst_idx,
243                count=count,
244                revealed=revealed,
245                score_delta=self._add_score(self._move_score(src, dst, revealed)),
246            )
247        )
248        self.moves += 1
249        return True
250
251    def undo(self) -> bool:
252        """Reverse the last move. Stock recycle/cycle and card moves are all
253        undoable. Returns False if the history is empty."""
254        if not self.history:
255            return False
256        m = self.history.pop()
257
258        if m.kind == "stock_cycle":
259            cs = self.waste.pop()
260            cs.face_up = False
261            self.stock.append(cs)
262        elif m.kind == "stock_recycle":
263            # Move stock back to waste face-up, in reverse pop order
264            while self.stock:
265                cs = self.stock.pop()
266                cs.face_up = True
267                self.waste.append(cs)
268        elif m.kind == "card":
269            # If this move revealed a card, hide it again first
270            src_pile = self._pile(m.src, m.src_idx)
271            if m.revealed and src_pile:
272                src_pile[-1].face_up = False
273            dst_pile = self._pile(m.dst, m.dst_idx)
274            cards = dst_pile[-m.count :]
275            del dst_pile[-m.count :]
276            src_pile.extend(cards)
277        self.score = max(0, self.score - m.score_delta)
278        self.moves += 1
279        return True
280
281    # ------------------------------------------------------------ auto-find
282    def find_destination(self, src: str, src_idx: int, count: int = 1) -> tuple[str, int] | None:
283        """Find a legal destination for the topmost ``count`` cards from src.
284
285        Returns ``(dst_kind, dst_idx)`` or ``None``. Foundations preferred for
286        single-card moves; otherwise scans tableau columns left-to-right.
287        """
288        pile = self._pile(src, src_idx)
289        if len(pile) < count:
290            return None
291        moving = pile[-count]
292        if count == 1:
293            for i in range(4):
294                if self.can_move_to_foundation(moving, i):
295                    return (FOUNDATION, i)
296        for i in range(7):
297            if i == src_idx and src == TABLEAU:
298                continue
299            if self.can_move_to_tableau(moving, i):
300                return (TABLEAU, i)
301        return None
302
303    # ------------------------------------------------------------- save IO
304    def to_dict(self) -> dict:
305        def pile(p):
306            return [(cs.card.rank, cs.card.suit, cs.face_up) for cs in p]
307
308        return {
309            "seed": self.seed,
310            "tableau": [pile(c) for c in self.tableau],
311            "foundations": [pile(f) for f in self.foundations],
312            "stock": pile(self.stock),
313            "waste": pile(self.waste),
314            "history": [
315                {
316                    "kind": m.kind,
317                    "src": m.src,
318                    "src_idx": m.src_idx,
319                    "dst": m.dst,
320                    "dst_idx": m.dst_idx,
321                    "count": m.count,
322                    "revealed": m.revealed,
323                    "score_delta": m.score_delta,
324                }
325                for m in self.history
326            ],
327            "score": self.score,
328            "moves": self.moves,
329        }
330
331    @classmethod
332    def from_dict(cls, data: dict) -> GameState:
333        def unpile(rows):
334            return [CardState(CardId(r, s), bool(face)) for (r, s, face) in rows]
335
336        gs = cls(seed=int(data.get("seed", 0)))
337        gs.tableau = [unpile(c) for c in data["tableau"]]
338        gs.foundations = [unpile(f) for f in data["foundations"]]
339        gs.stock = unpile(data["stock"])
340        gs.waste = unpile(data["waste"])
341        gs.history = [
342            Move(
343                kind=m["kind"],
344                src=m["src"],
345                src_idx=int(m["src_idx"]),
346                dst=m["dst"],
347                dst_idx=int(m["dst_idx"]),
348                count=int(m.get("count", 1)),
349                revealed=bool(m.get("revealed", False)),
350                score_delta=int(m.get("score_delta", 0)),
351            )
352            for m in data.get("history", [])
353        ]
354        gs.score = int(data.get("score", 0))
355        gs.moves = int(data.get("moves", 0))
356        return gs