fabro(01KTAFMYFYDMRRZ16YQVTW183J): plan_app (succeeded)

Fabro-Run: 01KTAFMYFYDMRRZ16YQVTW183J
Fabro-Completed: 2
Fabro-Checkpoint: 35f04a1493

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 23:34:28 +00:00
parent 8500dfa22c
commit b8296f3b80
5 changed files with 932 additions and 0 deletions

106
.ai/card-game-fast-plan.md Normal file
View file

@ -0,0 +1,106 @@
# Klondike Solitaire - Implementation Plan
This document outlines the concise plan for implementing a terminal-based Klondike Solitaire game in Python under `card-game-app/`.
---
## 1. Game Rules & Data Structures
We will implement standard **Klondike Solitaire** (Draw 1 variation).
### Data Models (`card_game_app/game.py` or similar)
- **Suit (Enum)**: `HEARTS` (Red), `DIAMONDS` (Red), `CLUBS` (Black), `SPADES` (Black).
- **Rank (Enum or Int)**: 1 (Ace) to 13 (King).
- **Card**:
- `suit`: Suit
- `rank`: int
- `face_up`: bool
- Methods: `color()` -> `'red' | 'black'`, `__repr__()` -> e.g. `"10♦"` or `"A♠"`.
- **Pile Types**:
- `Stock`: Draw pile (face-down cards).
- `Waste`: Discard/reveal pile (face-up cards).
- `Foundation`: 4 piles, build up by suit from Ace to King.
- `Tableau`: 7 piles, build down by alternating color.
- **GameState**:
- `stock`: List[Card]
- `waste`: List[Card]
- `foundations`: Dict[Suit, List[Card]] (or list of 4 piles)
- `tableaus`: List[List[Card]] (7 piles)
- `selected_pile`: Optional index/reference
- `selected_card_idx`: Optional index in that pile for stack moves.
---
## 2. Terminal Rendering with `curses`
The UI will be drawn using the standard Python `curses` module.
### Core UI Components
- **Layout**:
- **Top Row**: Stock, Waste, spacing, and 4 Foundations.
- **Bottom Row**: 7 Tableau columns displaying cards stacked vertically.
- **Footer**: Keybindings help text, move status messages, or win/loss announcements.
- **Rendering Elements**:
- Face-down cards drawn as `[ █ ]` (blue/cyan background block or custom pattern).
- Face-up cards drawn with colored suit symbols: red for `♥`/`♦` and white/default for `♠`/`♣`.
- Selected cards/piles highlighted using reverse-video or blinking attribute (`curses.A_REVERSE`).
- Active cursor highlighted in yellow/cyan.
- **Color Pairs Setup**:
- Pair 1: Red text on Black (Hearts/Diamonds)
- Pair 2: White text on Black (Spades/Clubs)
- Pair 3: Blue/Cyan on Black (Face-down card back / Board outlines)
- Pair 4: Yellow/Black (Cursor / Help text)
---
## 3. Input Handling & Navigation
We will implement a standard **Cursor-Based Selection System**:
- **Movement**:
- Arrow keys or `WASD` to move the cursor between the different piles (Stock, Waste, Foundations 1-4, Tableaus 1-7).
- When on a Tableau, `Up`/`Down` keys navigate the cursor up/down the face-up cards to select a partial stack to move.
- **Interaction**:
- `Space` / `Enter`:
- On **Stock**: Draws a card to Waste. If Stock is empty, recycles Waste back to Stock.
- On **other piles**:
- If no pile is currently selected, selects the pile (and the card under the cursor).
- If a pile *is* selected, attempts to move the selected card/stack to the target pile.
- `Esc` / `c`: Cancels current selection.
- `q`: Quits the game.
- `r`: Restarts/re-shuffles.
- **Move Validation**:
- Moving to **Foundation**: Only a single card. Must be Ace on empty foundation, or rank+1 of the same suit.
- Moving to **Tableau**: Can be a stack. Bottom card of the moving stack must be opposite color and rank-1 of the target Tableau's top card. If target Tableau is empty, only a King can be placed.
- Auto-flip: If a move leaves a Tableau with a face-down card on top, it is automatically flipped face-up.
---
## 4. Win/Loss Detection
- **Win Condition**: All 4 foundations contain 13 cards (total 52 cards).
- **Loss / No Moves Condition**: Game doesn't force end on lock (since cards can be moved back and forth), but can show a hint if no moves are possible.
- When won, show a beautiful success overlay and prompt for restart.
---
## 5. Test Strategy & Smoke Verification
### Unit Tests
- Testing standard moves: Stock draw, Stock recycle, valid/invalid Tableau moves, valid/invalid Foundation moves.
- Testing auto-reveal of top Tableau cards.
- Testing complete win-state detection.
### Smoke Verification (`python3 main.py --smoke`)
- A non-interactive mode that initializes a seed-based game state, executes a valid move sequence, asserts the outcomes, and prints a success message before exiting with `0`. This allows automated verification in environments without a TTY or curses terminal.
---
## 6. Implementation File Structure
```
card-game-app/
├── main.py # Entrypoint (CLI args, --smoke, starts curses)
├── game.py # Core game logic engine
└── tests/
└── test_game.py # Complete test suite
```

421
card-game-app/game.py Normal file
View file

@ -0,0 +1,421 @@
import random
from typing import List, Dict, Optional, Tuple
class Card:
SUITS = {
'H': '♥', # Hearts (Red)
'D': '♦', # Diamonds (Red)
'C': '♣', # Clubs (Black)
'S': '♠' # Spades (Black)
}
RANKS = {
1: 'A', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7',
8: '8', 9: '9', 10: '10', 11: 'J', 12: 'Q', 13: 'K'
}
def __init__(self, suit: str, rank: int, face_up: bool = False):
if suit not in self.SUITS:
raise ValueError(f"Invalid suit: {suit}")
if rank not in self.RANKS:
raise ValueError(f"Invalid rank: {rank}")
self.suit = suit
self.rank = rank
self.face_up = face_up
@property
def color(self) -> str:
return 'red' if self.suit in ('H', 'D') else 'black'
@property
def suit_symbol(self) -> str:
return self.SUITS[self.suit]
@property
def rank_symbol(self) -> str:
return self.RANKS[self.rank]
def __repr__(self) -> str:
status = "↑" if self.face_up else "↓"
return f"{self.rank_symbol}{self.suit_symbol}{status}"
def to_string(self) -> str:
if self.face_up:
return f"{self.rank_symbol}{self.suit_symbol}"
return "[ █ ]"
class GameState:
def __init__(self, seed: Optional[int] = None):
self.seed = seed
self.stock: List[Card] = []
self.waste: List[Card] = []
# Foundations: 4 piles, indexed 0 to 3. Each starts empty.
# Associated with H, D, C, S respectively.
self.foundations: List[List[Card]] = [[], [], [], []]
self.foundation_suits = ['H', 'D', 'C', 'S']
# Tableaus: 7 piles.
self.tableaus: List[List[Card]] = [[] for _ in range(7)]
# Cursor and Selection State
# row: 0 (top row: Stock, Waste, spacer, F0-F3), 1 (bottom row: T0-T6)
# col: 0 to 6
self.cursor_row = 1
self.cursor_col = 0
# For tableau columns, we can select a specific card index
self.cursor_card_idx = 0
# Selection: tuple of (row, col, card_idx) or None
self.selected: Optional[Tuple[int, int, int]] = None
self.message = "Welcome to Solitaire! Use arrows to navigate, Space/Enter to select/move."
self.reset()
def reset(self):
# Create deck
deck = []
for suit in ['H', 'D', 'C', 'S']:
for rank in range(1, 14):
deck.append(Card(suit, rank, face_up=False))
if self.seed is not None:
random.seed(self.seed)
else:
random.seed()
random.shuffle(deck)
# Deal Tableaus
# T0 gets 1 card, T1 gets 2, ..., T6 gets 7
self.tableaus = [[] for _ in range(7)]
for i in range(7):
for j in range(i + 1):
card = deck.pop()
if j == i:
card.face_up = True
self.tableaus[i].append(card)
# Remaining cards go to stock
self.stock = deck
self.waste = []
self.foundations = [[], [], [], []]
# Reset selection and cursor
self.cursor_row = 1
self.cursor_col = 0
self.cursor_card_idx = len(self.tableaus[0]) - 1 if self.tableaus[0] else 0
self.selected = None
self.message = "Game reset. New shuffle!"
def get_first_face_up_idx(self, tableau_idx: int) -> int:
tableau = self.tableaus[tableau_idx]
for idx, card in enumerate(tableau):
if card.face_up:
return idx
return -1
def move_cursor(self, direction: str):
"""
Moves the cursor around the 2x7 grid.
Row 0: 0=Stock, 1=Waste, 2=Spacer, 3=F0, 4=F1, 5=F2, 6=F3
Row 1: 0=Tableau 0, 1=Tableau 1, ..., 6=Tableau 6
"""
if direction == 'left':
new_col = self.cursor_col - 1
if self.cursor_row == 0 and new_col == 2:
new_col = 1 # Skip spacer going left
if new_col < 0:
new_col = 0
self.cursor_col = new_col
# Update card index if entering Tableau
if self.cursor_row == 1:
t_len = len(self.tableaus[self.cursor_col])
self.cursor_card_idx = max(0, t_len - 1)
elif direction == 'right':
new_col = self.cursor_col + 1
if self.cursor_row == 0 and new_col == 2:
new_col = 3 # Skip spacer going right
if new_col > 6:
new_col = 6
self.cursor_col = new_col
# Update card index if entering Tableau
if self.cursor_row == 1:
t_len = len(self.tableaus[self.cursor_col])
self.cursor_card_idx = max(0, t_len - 1)
elif direction == 'up':
if self.cursor_row == 1:
# If on a tableau, try to move UP the face-up stack first
first_face_up = self.get_first_face_up_idx(self.cursor_col)
if first_face_up != -1 and self.cursor_card_idx > first_face_up:
self.cursor_card_idx -= 1
else:
# Move to top row
self.cursor_row = 0
if self.cursor_col == 2:
self.cursor_col = 1 # Don't land on spacer
else:
# Already on row 0, do nothing
pass
elif direction == 'down':
if self.cursor_row == 0:
self.cursor_row = 1
t_len = len(self.tableaus[self.cursor_col])
self.cursor_card_idx = max(0, t_len - 1)
else:
# Already on row 1, try to move DOWN the face-up stack
t_len = len(self.tableaus[self.cursor_col])
if self.cursor_card_idx < t_len - 1:
self.cursor_card_idx += 1
def select_or_move(self):
"""
Handles Space/Enter selection logic.
"""
row = self.cursor_row
col = self.cursor_col
# 1. Clicking on Stock (Row 0, Col 0)
if row == 0 and col == 0:
self.selected = None # Clear any active selection
self.draw_card()
return
# 2. Clicking on Spacer (Row 0, Col 2) -> Do nothing
if row == 0 and col == 2:
return
# 3. If no active selection, try to select
if self.selected is None:
if row == 0:
if col == 1: # Waste
if self.waste:
self.selected = (row, col, len(self.waste) - 1)
self.message = "Selected Waste card. Choose destination."
else:
self.message = "Waste is empty!"
elif col >= 3: # Foundations
f_idx = col - 3
if self.foundations[f_idx]:
self.selected = (row, col, len(self.foundations[f_idx]) - 1)
self.message = f"Selected top of Foundation {self.foundation_suits[f_idx]}. Choose destination."
else:
self.message = "Foundation is empty!"
elif row == 1: # Tableaus
t_idx = col
t_len = len(self.tableaus[t_idx])
if t_len > 0:
# Ensure cursor card index is valid and face-up
first_face_up = self.get_first_face_up_idx(t_idx)
if first_face_up != -1 and self.cursor_card_idx >= first_face_up:
self.selected = (row, col, self.cursor_card_idx)
card = self.tableaus[t_idx][self.cursor_card_idx]
self.message = f"Selected {card.rank_symbol}{card.suit_symbol} from Tableau {t_idx + 1}."
else:
# Fallback to top card if cursor card index was somehow out of sync or face-down
self.selected = (row, col, t_len - 1)
card = self.tableaus[t_idx][-1]
self.message = f"Selected {card.rank_symbol}{card.suit_symbol} from Tableau {t_idx + 1}."
else:
self.message = "Tableau is empty!"
# 4. If there is an active selection, try to perform the move
else:
src_row, src_col, src_card_idx = self.selected
# If selecting the same pile/card, deselect
if src_row == row and src_col == col:
self.selected = None
self.message = "Selection canceled."
return
success = False
# Case A: Source is Waste
if src_row == 0 and src_col == 1:
if row == 1: # To Tableau
success = self.move_waste_to_tableau(col)
elif row == 0 and col >= 3: # To Foundation
success = self.move_waste_to_foundation(col - 3)
# Case B: Source is Foundation
elif src_row == 0 and src_col >= 3:
src_f_idx = src_col - 3
if row == 1: # To Tableau
success = self.move_foundation_to_tableau(src_f_idx, col)
# Case C: Source is Tableau
elif src_row == 1:
if row == 1: # To Tableau
success = self.move_tableau_to_tableau(src_col, src_card_idx, col)
elif row == 0 and col >= 3: # To Foundation
# Ensure we are only moving the top single card to Foundation
if src_card_idx == len(self.tableaus[src_col]) - 1:
success = self.move_tableau_to_foundation(src_col, col - 3)
else:
self.message = "Can only move the single top card of a Tableau to Foundation!"
self.selected = None
return
if success:
self.message = "Valid move completed!"
else:
self.message = "Invalid move!"
self.selected = None
# Update cursor card index for tableaus to the top card
if self.cursor_row == 1:
t_len = len(self.tableaus[self.cursor_col])
self.cursor_card_idx = max(0, t_len - 1)
# --- Core Move Operations ---
def draw_card(self) -> bool:
"""Draws a card from stock to waste, or recycles waste if stock is empty."""
if self.stock:
card = self.stock.pop()
card.face_up = True
self.waste.append(card)
self.message = f"Drew {card.rank_symbol}{card.suit_symbol}."
return True
elif self.waste:
# Recycle waste back to stock
# Reverse waste, turn face_down
self.stock = list(reversed(self.waste))
for card in self.stock:
card.face_up = False
self.waste = []
# and immediately draw the first card
card = self.stock.pop()
card.face_up = True
self.waste.append(card)
self.message = "Recycled waste pile back to stock and drew first card."
return True
else:
self.message = "No cards left in Stock or Waste!"
return False
def move_waste_to_tableau(self, dest_idx: int) -> bool:
if not self.waste:
return False
card = self.waste[-1]
dest_pile = self.tableaus[dest_idx]
if not dest_pile:
# Empty tableau accepts only Kings
if card.rank == 13:
dest_pile.append(self.waste.pop())
return True
else:
dest_card = dest_pile[-1]
if dest_card.face_up and card.color != dest_card.color and card.rank == dest_card.rank - 1:
dest_pile.append(self.waste.pop())
return True
return False
def move_waste_to_foundation(self, f_idx: int) -> bool:
if not self.waste:
return False
card = self.waste[-1]
f_suit = self.foundation_suits[f_idx]
dest_pile = self.foundations[f_idx]
if card.suit != f_suit:
return False
if not dest_pile:
# Empty foundation accepts only Ace
if card.rank == 1:
dest_pile.append(self.waste.pop())
return True
else:
top_card = dest_pile[-1]
if card.rank == top_card.rank + 1:
dest_pile.append(self.waste.pop())
return True
return False
def move_foundation_to_tableau(self, f_idx: int, dest_idx: int) -> bool:
f_pile = self.foundations[f_idx]
if not f_pile:
return False
card = f_pile[-1]
dest_pile = self.tableaus[dest_idx]
if not dest_pile:
# Empty tableau accepts only Kings
if card.rank == 13:
dest_pile.append(f_pile.pop())
return True
else:
dest_card = dest_pile[-1]
if dest_card.face_up and card.color != dest_card.color and card.rank == dest_card.rank - 1:
dest_pile.append(f_pile.pop())
return True
return False
def move_tableau_to_tableau(self, src_idx: int, card_idx: int, dest_idx: int) -> bool:
src_pile = self.tableaus[src_idx]
dest_pile = self.tableaus[dest_idx]
if not src_pile or card_idx >= len(src_pile):
return False
moving_stack = src_pile[card_idx:]
# Ensure all moving cards are face up
if not all(c.face_up for c in moving_stack):
return False
first_moving_card = moving_stack[0]
if not dest_pile:
# Empty tableau accepts only Kings
if first_moving_card.rank == 13:
self.tableaus[dest_idx].extend(moving_stack)
del src_pile[card_idx:]
self.auto_flip_top(src_idx)
return True
else:
dest_card = dest_pile[-1]
if dest_card.face_up and first_moving_card.color != dest_card.color and first_moving_card.rank == dest_card.rank - 1:
self.tableaus[dest_idx].extend(moving_stack)
del src_pile[card_idx:]
self.auto_flip_top(src_idx)
return True
return False
def move_tableau_to_foundation(self, src_idx: int, f_idx: int) -> bool:
src_pile = self.tableaus[src_idx]
if not src_pile:
return False
card = src_pile[-1]
f_suit = self.foundation_suits[f_idx]
dest_pile = self.foundations[f_idx]
if card.suit != f_suit:
return False
if not dest_pile:
# Empty foundation accepts only Ace
if card.rank == 1:
dest_pile.append(src_pile.pop())
self.auto_flip_top(src_idx)
return True
else:
top_card = dest_pile[-1]
if card.rank == top_card.rank + 1:
dest_pile.append(src_pile.pop())
self.auto_flip_top(src_idx)
return True
return False
def auto_flip_top(self, tableau_idx: int):
"""If a tableau top card is face down, flip it face up."""
pile = self.tableaus[tableau_idx]
if pile and not pile[-1].face_up:
pile[-1].face_up = True
def check_win(self) -> bool:
"""The game is won if all four foundations have 13 cards."""
return all(len(f) == 13 for f in self.foundations)

254
card-game-app/main.py Normal file
View file

@ -0,0 +1,254 @@
import sys
import argparse
import curses
import locale
from game import GameState, Card
# Set locale to support unicode suit symbols (♥, ♦, ♣, ♠, █)
try:
locale.setlocale(locale.LC_ALL, '')
except Exception:
pass # Fallback to default locale if not supported
def run_smoke_test():
"""Runs a non-interactive simulation of the game and verifies correctness."""
print("Running Solitaire smoke test...")
# Initialize game with a fixed seed
state = GameState(seed=12345)
# Assert initial setup
assert len(state.stock) == 24, f"Expected 24 stock cards, got {len(state.stock)}"
assert len(state.waste) == 0, "Expected empty waste pile initially"
assert sum(len(t) for t in state.tableaus) == 28, "Expected 28 cards in tableaus"
# Verify top card of Tableau 0 is face up
assert state.tableaus[0][-1].face_up, "Top card of Tableau 0 should be face up"
# Action 1: Draw card
success = state.draw_card()
assert success, "Failed to draw card from stock"
assert len(state.stock) == 23, f"Expected 23 stock cards, got {len(state.stock)}"
assert len(state.waste) == 1, f"Expected 1 card in waste, got {len(state.waste)}"
assert state.waste[-1].face_up, "Drawn card should be face up"
# Action 2: Reset / Restart
state.reset()
assert len(state.stock) == 24, "Expected stock reset to 24"
assert len(state.waste) == 0, "Expected waste reset to 0"
print("Solitaire smoke test passed successfully!")
sys.exit(0)
# Curses UI Rendering and Logic
def draw_game(stdscr, state: GameState):
stdscr.erase()
# Get terminal dimensions
max_y, max_x = stdscr.getmaxyx()
if max_y < 24 or max_x < 80:
stdscr.addstr(0, 0, "Terminal too small!", curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(1, 0, f"Current: {max_x}x{max_y}. Required: 80x24 minimum.", curses.A_BOLD)
stdscr.addstr(2, 0, "Please resize your terminal window to continue...", curses.A_DIM)
stdscr.refresh()
return
# Draw Header
title = "♠ ♥ ♣ ♦ KLONDIKE SOLITAIRE ♦ ♣ ♥ ♠"
stdscr.addstr(0, (80 - len(title)) // 2, title, curses.color_pair(4) | curses.A_BOLD)
stdscr.addstr(1, 0, "─" * 80, curses.color_pair(3))
# Column configuration
col_width = 10
start_x = 4
# --- Draw Row 0 (Stock, Waste, Spacer, Foundations) ---
# Stock (Col 0)
stock_x = start_x + 0 * col_width
stdscr.addstr(2, stock_x, "[ Stock ]", curses.color_pair(3))
stock_str = f"[ █ {len(state.stock):2d}]" if state.stock else "[ X ]"
is_cursor = (state.cursor_row == 0 and state.cursor_col == 0)
is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 0)
attr = curses.color_pair(3)
if is_cursor:
attr |= curses.A_REVERSE
if is_selected:
attr |= curses.A_BLINK
stdscr.addstr(3, stock_x + 1, stock_str, attr)
# Waste (Col 1)
waste_x = start_x + 1 * col_width
stdscr.addstr(2, waste_x, "[ Waste ]", curses.color_pair(3))
is_cursor = (state.cursor_row == 0 and state.cursor_col == 1)
is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 1)
if state.waste:
top_card = state.waste[-1]
card_str = f"[ {top_card.rank_symbol:>2}{top_card.suit_symbol} ]"
color_pair = 1 if top_card.color == 'red' else 2
attr = curses.color_pair(color_pair)
if is_cursor:
attr |= curses.A_REVERSE
if is_selected:
attr |= curses.A_UNDERLINE
stdscr.addstr(3, waste_x + 1, card_str, attr)
else:
attr = curses.color_pair(3)
if is_cursor:
attr |= curses.A_REVERSE
stdscr.addstr(3, waste_x + 1, "[ Empty ]", attr)
# Spacer (Col 2) -> just draw blank or placeholder
spacer_x = start_x + 2 * col_width
stdscr.addstr(2, spacer_x, " ")
stdscr.addstr(3, spacer_x, " ")
# Foundations (Col 3 to 6)
for i in range(4):
f_suit = state.foundation_suits[i]
f_symbol = Card.SUITS[f_suit]
f_x = start_x + (3 + i) * col_width
stdscr.addstr(2, f_x, f"[ F{i+1} {f_symbol} ]", curses.color_pair(3))
is_cursor = (state.cursor_row == 0 and state.cursor_col == 3 + i)
is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 3 + i)
f_pile = state.foundations[i]
if f_pile:
top_card = f_pile[-1]
card_str = f"[ {top_card.rank_symbol:>2}{top_card.suit_symbol} ]"
color_pair = 1 if top_card.color == 'red' else 2
attr = curses.color_pair(color_pair)
if is_cursor:
attr |= curses.A_REVERSE
if is_selected:
attr |= curses.A_UNDERLINE
stdscr.addstr(3, f_x + 1, card_str, attr)
else:
color_pair = 1 if f_suit in ('H', 'D') else 2
attr = curses.color_pair(color_pair)
if is_cursor:
attr |= curses.A_REVERSE
stdscr.addstr(3, f_x + 1, f"[ {f_symbol} ]", attr)
# --- Draw Row 1 (Tableaus 0 to 6) ---
stdscr.addstr(5, 0, "─" * 80, curses.color_pair(3))
for col in range(7):
t_x = start_x + col * col_width
stdscr.addstr(6, t_x, f"[ T{col+1} ]", curses.color_pair(3))
pile = state.tableaus[col]
if not pile:
# Draw empty placeholder
is_cursor = (state.cursor_row == 1 and state.cursor_col == col)
attr = curses.color_pair(3)
if is_cursor:
attr |= curses.A_REVERSE
stdscr.addstr(7, t_x + 1, "[ Empty ]", attr)
else:
for idx, card in enumerate(pile):
is_cursor = (state.cursor_row == 1 and state.cursor_col == col and state.cursor_card_idx == idx)
is_selected = (state.selected is not None and state.selected[0] == 1 and state.selected[1] == col and idx >= state.selected[2])
card_y = 7 + idx
# If too deep for screen, cap rendering and show indicators
if card_y >= max_y - 4:
stdscr.addstr(max_y - 4, t_x + 1, "[ ... ]", curses.color_pair(3))
break
if card.face_up:
card_str = f"[ {card.rank_symbol:>2}{card.suit_symbol} ]"
color_pair = 1 if card.color == 'red' else 2
attr = curses.color_pair(color_pair)
else:
card_str = "[ █ ]"
attr = curses.color_pair(3)
if is_selected:
attr |= curses.A_UNDERLINE
if is_cursor:
attr |= curses.A_REVERSE
stdscr.addstr(card_y, t_x + 1, card_str, attr)
# --- Draw Footer / Information ---
# Win State banner
if state.check_win():
win_msg = "★★★ CONGRATULATIONS! YOU WON THE GAME! Press 'R' to play again. ★★★"
stdscr.addstr(max_y - 3, (80 - len(win_msg)) // 2, win_msg, curses.color_pair(4) | curses.A_BOLD | curses.A_BLINK)
else:
# Action/Message line
stdscr.addstr(max_y - 3, 2, "Status: " + state.message[:75], curses.color_pair(4) | curses.A_BOLD)
# Keyboard helper
legend = "[Arrows/WASD] Move [Space/Enter] Select/Move [Esc/C] Cancel [R] Restart [Q] Quit"
stdscr.addstr(max_y - 2, (80 - len(legend)) // 2, legend, curses.A_DIM)
stdscr.refresh()
def main_loop(stdscr):
# Initialize Curses Colors
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_RED, -1) # Red suits
curses.init_pair(2, curses.COLOR_WHITE, -1) # Black suits
curses.init_pair(3, curses.COLOR_CYAN, -1) # Blue/Cyan borders & back
curses.init_pair(4, curses.COLOR_YELLOW, -1) # Yellow cursor / highlights
# Hide standard cursor block
try:
curses.curs_set(0)
except Exception:
pass
state = GameState()
while True:
draw_game(stdscr, state)
try:
ch = stdscr.getch()
except KeyboardInterrupt:
break
if ch == -1:
continue
# Standardize inputs (support arrow keys, WASD, and letters)
if ch == ord('q') or ch == ord('Q'):
break
elif ch == ord('r') or ch == ord('R'):
state.reset()
elif ch in (curses.KEY_LEFT, ord('a'), ord('A')):
state.move_cursor('left')
elif ch in (curses.KEY_RIGHT, ord('d'), ord('D')):
state.move_cursor('right')
elif ch in (curses.KEY_UP, ord('w'), ord('W')):
state.move_cursor('up')
elif ch in (curses.KEY_DOWN, ord('s'), ord('S')):
state.move_cursor('down')
elif ch in (ord(' '), ord('\n'), curses.KEY_ENTER):
state.select_or_move()
elif ch in (27, ord('c'), ord('C')): # 27 is Esc
state.selected = None
state.message = "Selection canceled."
def main():
parser = argparse.ArgumentParser(description="Terminal Klondike Solitaire Game")
parser.add_argument("--smoke", action="store_true", help="Run non-interactive smoke test and exit")
args = parser.parse_args()
if args.smoke:
run_smoke_test()
else:
# Start the interactive curses application
curses.wrapper(main_loop)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,148 @@
import unittest
from game import Card, GameState
class TestSolitaireGame(unittest.TestCase):
def test_card_properties(self):
card1 = Card('H', 1, face_up=True) # Ace of Hearts
card2 = Card('S', 13, face_up=False) # King of Spades
self.assertEqual(card1.color, 'red')
self.assertEqual(card1.suit_symbol, '♥')
self.assertEqual(card1.rank_symbol, 'A')
self.assertEqual(card1.to_string(), "A♥")
self.assertEqual(card2.color, 'black')
self.assertEqual(card2.suit_symbol, '♠')
self.assertEqual(card2.rank_symbol, 'K')
self.assertEqual(card2.to_string(), "[ █ ]")
def test_initial_state(self):
state = GameState(seed=42)
# Total cards = 52
total_tableau_cards = sum(len(t) for t in state.tableaus)
self.assertEqual(total_tableau_cards, 28) # 1+2+3+4+5+6+7 = 28
self.assertEqual(len(state.stock), 24) # 52 - 28 = 24
self.assertEqual(len(state.waste), 0)
self.assertTrue(all(len(f) == 0 for f in state.foundations))
# Check that top tableau cards are face-up and lower ones are face-down
for i in range(7):
t = state.tableaus[i]
for j in range(i):
self.assertFalse(t[j].face_up)
self.assertTrue(t[i].face_up)
def test_draw_and_recycle_stock(self):
state = GameState(seed=42)
initial_stock_size = len(state.stock)
# Draw all stock cards
for i in range(initial_stock_size):
self.assertTrue(state.draw_card())
self.assertEqual(len(state.stock), 0)
self.assertEqual(len(state.waste), initial_stock_size)
self.assertTrue(all(c.face_up for c in state.waste))
# Draw when empty should recycle
self.assertTrue(state.draw_card())
self.assertEqual(len(state.stock), initial_stock_size - 1)
self.assertEqual(len(state.waste), 1)
self.assertTrue(state.waste[-1].face_up)
self.assertTrue(all(not c.face_up for c in state.stock))
def test_move_waste_to_tableau_valid_and_invalid(self):
state = GameState(seed=42)
# Let's set up a predictable waste and tableau top card for testing
# Waste top: Red Q (Hearts, 12)
state.waste = [Card('H', 12, face_up=True)]
# Destination Tableau top card is Black K (Spades, 13)
state.tableaus[0] = [Card('S', 13, face_up=True)]
# Move Q to K is valid (Red 12 on Black 13)
success = state.move_waste_to_tableau(0)
self.assertTrue(success)
self.assertEqual(len(state.waste), 0)
self.assertEqual(state.tableaus[0][-1].rank, 12)
# Destination Tableau is now Red 12 on top. Let's put Black Jack (11) on waste
state.waste = [Card('C', 11, face_up=True)]
success = state.move_waste_to_tableau(0)
self.assertTrue(success)
self.assertEqual(len(state.waste), 0)
self.assertEqual(state.tableaus[0][-1].rank, 11)
# Try to move non-matching card: Black 10 (Spades, 10) on Black Jack (11) (invalid, should be opposite color)
state.waste = [Card('S', 10, face_up=True)]
success = state.move_waste_to_tableau(0)
self.assertFalse(success)
self.assertEqual(len(state.waste), 1)
def test_move_to_empty_tableau_only_king(self):
state = GameState(seed=42)
state.tableaus[0] = [] # empty
# Try putting a Queen on empty tableau
state.waste = [Card('H', 12, face_up=True)]
success = state.move_waste_to_tableau(0)
self.assertFalse(success)
# Put a King on empty tableau
state.waste = [Card('D', 13, face_up=True)]
success = state.move_waste_to_tableau(0)
self.assertTrue(success)
self.assertEqual(len(state.tableaus[0]), 1)
self.assertEqual(state.tableaus[0][0].rank, 13)
def test_move_tableau_to_tableau_stack(self):
state = GameState(seed=42)
# Tableau 0: Black K (Spades, 13)
state.tableaus[0] = [Card('S', 13, face_up=True)]
# Tableau 1: Red Q (Hearts, 12) -> Black J (Clubs, 11)
state.tableaus[1] = [Card('H', 12, face_up=True), Card('C', 11, face_up=True)]
# Move the entire stack [Q, J] onto K
success = state.move_tableau_to_tableau(1, 0, 0)
self.assertTrue(success)
self.assertEqual(len(state.tableaus[1]), 0)
self.assertEqual(len(state.tableaus[0]), 3)
self.assertEqual([c.rank for c in state.tableaus[0]], [13, 12, 11])
def test_move_to_foundation(self):
state = GameState(seed=42)
# Foundation 0 is 'H' (Hearts)
# Try putting a 2 of Hearts on empty foundation (should fail, needs Ace)
state.waste = [Card('H', 2, face_up=True)]
success = state.move_waste_to_foundation(0)
self.assertFalse(success)
# Put Ace of Hearts (1)
state.waste = [Card('H', 1, face_up=True)]
success = state.move_waste_to_foundation(0)
self.assertTrue(success)
self.assertEqual(len(state.foundations[0]), 1)
# Now put 2 of Hearts (2)
state.waste = [Card('H', 2, face_up=True)]
success = state.move_waste_to_foundation(0)
self.assertTrue(success)
self.assertEqual(len(state.foundations[0]), 2)
self.assertEqual(state.foundations[0][-1].rank, 2)
def test_check_win(self):
state = GameState(seed=42)
self.assertFalse(state.check_win())
# Fill foundations to win
for i, suit in enumerate(state.foundation_suits):
for rank in range(1, 14):
state.foundations[i].append(Card(suit, rank, face_up=True))
self.assertTrue(state.check_win())
if __name__ == '__main__':
unittest.main()

3
status.json Normal file
View file

@ -0,0 +1,3 @@
{
"outcome": "succeeded"
}