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