mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
fabro(01KTAH464TNZ522BBE09PP2CN1): plan_app (succeeded)
Fabro-Run: 01KTAH464TNZ522BBE09PP2CN1
Fabro-Completed: 2
Fabro-Checkpoint: 03d91be277
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
8500dfa22c
commit
92effb559e
7 changed files with 998 additions and 0 deletions
112
.ai/card-game-fast-plan.md
Normal file
112
.ai/card-game-fast-plan.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Implementation Plan - Terminal Spider Solitaire
|
||||
|
||||
A terminal-based Spider Solitaire game built in Python using the standard library `curses` module, with a decoupled architecture to allow both interactive gameplay and headless smoke-testing.
|
||||
|
||||
## 1. Game Rules & Data Structures
|
||||
|
||||
We implement standard Spider Solitaire.
|
||||
- **Decks**: 2 decks (104 cards total).
|
||||
- **Suits**: Support 1-suit (Spades), 2-suit (Spades, Hearts), or 4-suit (standard) configurations. 1-suit is the default and recommended for terminal play.
|
||||
- **Tableau**: 10 columns.
|
||||
- Setup: First 4 columns get 6 cards (5 facedown, 1 faceup). Next 6 columns get 5 cards (4 facedown, 1 faceup).
|
||||
- The remaining 50 cards form the Stock.
|
||||
- **Deals**: Dealing from Stock puts 1 card faceup on each of the 10 columns. Standard rule: Stock cannot be dealt if there are any empty columns.
|
||||
- **Move Rules**:
|
||||
- A sequence of faceup cards can be moved if they are of the *same suit* and in *decreasing numerical order* (e.g., 9♠, 8♠, 7♠).
|
||||
- A sequence/card can be placed onto any faceup card of any suit that is exactly one rank higher (e.g., placing a 7♠ on an 8♥), or onto an empty column.
|
||||
- **Clearing Runs**:
|
||||
- If a full same-suit sequence from King down to Ace (K, Q, J, 10, 9, 8, 7, 6, 5, 4, 3, 2, A) is formed in a column, it is cleared from the board and placed into the completed foundations.
|
||||
- **Win Condition**: All 8 runs are cleared (104 cards).
|
||||
|
||||
### Data Models (`card-game-app/game.py`)
|
||||
- `Card`:
|
||||
- `suit`: Str (e.g. `'♠'`, `'♥'`, `'♦'`, `'♣'`)
|
||||
- `rank`: Int (1 for Ace, 11 for Jack, 12 for Queen, 13 for King)
|
||||
- `face_up`: Bool
|
||||
- `SpiderGame`:
|
||||
- `tableau`: List of 10 lists of `Card`
|
||||
- `stock`: List of `Card`
|
||||
- `completed_runs`: Int (0 to 8)
|
||||
- `undo_stack`: List of game state snapshots (memento pattern using deepcopy)
|
||||
- `suits_count`: Int (1, 2, or 4)
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminal Rendering Approach (`curses`)
|
||||
|
||||
Using Python's standard-library `curses` module. To make it highly visually clear:
|
||||
- **Card Styling**:
|
||||
- Hearts/Diamonds in Red.
|
||||
- Spades/Clubs in default/white.
|
||||
- Facedown cards represented with a distinct background/pattern like `[░░░]` or `[###]`.
|
||||
- Selected cards highlighted (reverse video/bold/yellow).
|
||||
- **Layout & Positioning**:
|
||||
- We divide the terminal into columns. An 80-character terminal easily accommodates 10 columns of width 6 with 1-char gaps:
|
||||
`Column X = col_idx * 7 + 2`
|
||||
- Stacking cards: To fit long columns vertically, we stack cards. Only the top-most part of a card in a stack is rendered (e.g., `| 9♠|`), and the bottom card is drawn fully (e.g. `[ 9♠]`). This takes only 1 line per card plus 1 line for the bottom card!
|
||||
- **Header/Footer**:
|
||||
- Header: Shows stock count, completed runs count, and current game mode (e.g., "1-Suit").
|
||||
- Footer: Interactive guide ("Arrows: Move, Enter: Select/Drop, U: Undo, D: Deal, R: Restart, Q: Quit").
|
||||
- Status/Error line for messages like "Invalid move!" or "Cannot deal with empty columns!".
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Handling & Move/Action Validation
|
||||
|
||||
We use a simple state machine for the UI:
|
||||
1. **IDLE State**:
|
||||
- Left/Right Arrows: Move column cursor (0-9).
|
||||
- Up/Down Arrows: Navigate *up* and *down* within the face-up cards of the current column to select where to split/move the sequence.
|
||||
- Enter/Space: Validate if the highlighted card and all cards below it form a valid same-suit decreasing sequence. If yes, transition to **SELECTED State** and save the selection.
|
||||
- `u` / `U`: Undo.
|
||||
- `d` / `D`: Deal stock.
|
||||
- `r` / `R`: Restart game.
|
||||
- `q` / `Q`: Quit.
|
||||
2. **SELECTED State**:
|
||||
- Left/Right Arrows: Move destination column cursor (0-9).
|
||||
- Enter/Space: Attempt to move selected cards to the target column.
|
||||
- Validate destination card rank (must be selected card rank + 1, or column must be empty).
|
||||
- If valid: perform move, flip new top card if needed, check for completed run, push to undo stack, return to **IDLE State**.
|
||||
- If invalid: show error message, stay in SELECTED state (or escape).
|
||||
- Escape: Cancel selection, return to **IDLE State**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Win/Loss Detection
|
||||
|
||||
- **Win**: Triggered when `completed_runs == 8`. A victory screen is shown.
|
||||
- **Loss/Stuck**: There is no hard loss state in solitaire, but we can display a status message if no valid moves are possible on the board and the stock is empty.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Layout Diagram
|
||||
|
||||
```text
|
||||
======================= SPIDER SOLITAIRE =======================
|
||||
Stock: [|||||] (50 cards left) Runs Completed: 0/8
|
||||
================================================================
|
||||
Col 0 Col 1 Col 2 Col 3 Col 4 Col 5 Col 6 Col 7 ...
|
||||
|###| |###| |###| |###| |###| |###| |###| |###|
|
||||
|###| | 9♠| | K♦| |###| | 5♣| |###| |###| |###|
|
||||
| 8♥| [ 8♠] [ Q♦] | 4♠| [ 4♣] | J♥| | Q♠| | 2♦|
|
||||
[ 7♥] [ 3♠] [10♥] [ J♠] [ A♦]
|
||||
[ 2♠]
|
||||
[ A♠]
|
||||
----------------------------------------------------------------
|
||||
[Status: Selected 4 cards from Col 3. Choose target column...]
|
||||
[Controls: Enter/Space: Place | Esc: Cancel]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Strategy
|
||||
|
||||
1. **Unit Tests** (`card-game-app/test_game.py`):
|
||||
- Test Card, Deck, and initial Tableau setup.
|
||||
- Test sequence validation (is sequence valid? is move valid?).
|
||||
- Test stock dealing and empty column constraints.
|
||||
- Test complete run detection and clearing.
|
||||
- Test undo functionality.
|
||||
2. **Non-interactive Smoke Test** (`python3 main.py --smoke`):
|
||||
- Direct headless simulation of starting a game, performing a valid move, dealing a hand, and triggering undo, without invoking `curses`.
|
||||
- Returns exit code 0 on success.
|
||||
46
card-game-app/README.md
Normal file
46
card-game-app/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Terminal Spider Solitaire
|
||||
|
||||
A terminal-based Spider Solitaire game built in Python using the standard library `curses` module, with zero external dependencies.
|
||||
|
||||
## Key Features
|
||||
- **Responsive Terminal Design**: Supports 1-suit (Spades), 2-suit (Spades & Hearts), or 4-suit (standard) gameplay, rendering beautifully on any standard terminal.
|
||||
- **Visual Card Highlighting**: Distinctive Red/White colors for card suits and full multi-card selection highlights.
|
||||
- **Intuitive Keyboard Controls**: Smooth cursor-based navigation across tableau columns and vertical selection.
|
||||
- **Full Undo/Redo Support**: Save states stored in a history stack so you can undo any mistake.
|
||||
- **Non-interactive Smoke Test & Verification Suite**: Run automatic unit tests and logic validation completely headlessly.
|
||||
|
||||
## How to Play
|
||||
|
||||
Run the game using Python:
|
||||
|
||||
```bash
|
||||
python3 card-game-app/main.py
|
||||
```
|
||||
|
||||
Choose suits count (1, 2, or 4):
|
||||
```bash
|
||||
python3 card-game-app/main.py --suits 2
|
||||
```
|
||||
|
||||
### Controls:
|
||||
- **Left / Right Arrows**: Move between columns.
|
||||
- **Up / Down Arrows**: Navigate up/down within faceup cards of the current column to select where to split/move a sequence.
|
||||
- **Space / Enter**: Select the highlighted sequence / Place the selected sequence onto the target column.
|
||||
- **0 - 9 Keys**: Jump directly to a column or attempt to place selected cards onto that column.
|
||||
- **U / u**: Undo the last move.
|
||||
- **D / d**: Deal 10 cards from the stock (one to each column). Standard rule: cannot deal if there are empty columns on the board.
|
||||
- **R / r**: Restart the game.
|
||||
- **Q / q**: Quit the game.
|
||||
- **Esc (Escape)**: Cancel the current selection.
|
||||
|
||||
## Running Tests
|
||||
|
||||
To run the unit tests:
|
||||
```bash
|
||||
PYTHONPATH=card-game-app python3 -m unittest card-game-app/test_game.py
|
||||
```
|
||||
|
||||
To run the non-interactive smoke test suite:
|
||||
```bash
|
||||
python3 card-game-app/main.py --smoke
|
||||
```
|
||||
252
card-game-app/game.py
Normal file
252
card-game-app/game.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import random
|
||||
import copy
|
||||
|
||||
class Card:
|
||||
def __init__(self, rank, suit, face_up=False):
|
||||
self.rank = rank # 1 (Ace) to 13 (King)
|
||||
self.suit = suit # '♠', '♥', '♦', '♣'
|
||||
self.face_up = face_up
|
||||
|
||||
def __repr__(self):
|
||||
if not self.face_up:
|
||||
return "[###]"
|
||||
rank_str = {
|
||||
1: "A",
|
||||
11: "J",
|
||||
12: "Q",
|
||||
13: "K"
|
||||
}.get(self.rank, str(self.rank))
|
||||
# Ensure 2-char representation for alignment
|
||||
if len(rank_str) == 1:
|
||||
rank_str = " " + rank_str
|
||||
return f"[{rank_str}{self.suit}]"
|
||||
|
||||
def clone(self):
|
||||
return Card(self.rank, self.suit, self.face_up)
|
||||
|
||||
|
||||
class SpiderGame:
|
||||
def __init__(self, suits_count=1):
|
||||
if suits_count not in (1, 2, 4):
|
||||
raise ValueError("Suits count must be 1, 2, or 4")
|
||||
self.suits_count = suits_count
|
||||
self.tableau = [[] for _ in range(10)]
|
||||
self.stock = []
|
||||
self.completed_runs = 0
|
||||
self.undo_stack = []
|
||||
self.reset_game()
|
||||
|
||||
def reset_game(self):
|
||||
self.tableau = [[] for _ in range(10)]
|
||||
self.completed_runs = 0
|
||||
self.undo_stack = []
|
||||
|
||||
# Generate 104 cards based on suit count
|
||||
cards = []
|
||||
if self.suits_count == 1:
|
||||
# 8 sets of Spades (13 cards each)
|
||||
for _ in range(8):
|
||||
for rank in range(1, 14):
|
||||
cards.append(Card(rank, '♠'))
|
||||
elif self.suits_count == 2:
|
||||
# 4 sets of Spades, 4 sets of Hearts
|
||||
for _ in range(4):
|
||||
for rank in range(1, 14):
|
||||
cards.append(Card(rank, '♠'))
|
||||
cards.append(Card(rank, '♥'))
|
||||
else:
|
||||
# 4 suits: 2 sets of each suit (Spades, Hearts, Diamonds, Clubs)
|
||||
for _ in range(2):
|
||||
for suit in ['♠', '♥', '♦', '♣']:
|
||||
for rank in range(1, 14):
|
||||
cards.append(Card(rank, suit))
|
||||
|
||||
random.shuffle(cards)
|
||||
|
||||
# Deal to tableau:
|
||||
# First 4 columns get 6 cards (total 24)
|
||||
# Next 6 columns get 5 cards (total 30)
|
||||
# Remaining 50 form the stock.
|
||||
for col in range(10):
|
||||
num_cards = 6 if col < 4 else 5
|
||||
for _ in range(num_cards):
|
||||
self.tableau[col].append(cards.pop())
|
||||
# Turn top card face-up
|
||||
if self.tableau[col]:
|
||||
self.tableau[col][-1].face_up = True
|
||||
|
||||
self.stock = cards
|
||||
|
||||
def save_state(self):
|
||||
"""Returns a snapshot of the game state for undo."""
|
||||
return {
|
||||
'tableau': [[c.clone() for c in col] for col in self.tableau],
|
||||
'stock': [c.clone() for c in self.stock],
|
||||
'completed_runs': self.completed_runs
|
||||
}
|
||||
|
||||
def push_undo(self, snapshot):
|
||||
self.undo_stack.append(snapshot)
|
||||
|
||||
def undo(self):
|
||||
if not self.undo_stack:
|
||||
return False
|
||||
state = self.undo_stack.pop()
|
||||
self.tableau = state['tableau']
|
||||
self.stock = state['stock']
|
||||
self.completed_runs = state['completed_runs']
|
||||
return True
|
||||
|
||||
def is_valid_sequence(self, cards):
|
||||
"""Checks if a subset of cards forms a valid same-suit decreasing sequence."""
|
||||
if not cards:
|
||||
return False
|
||||
if not all(c.face_up for c in cards):
|
||||
return False
|
||||
suit = cards[0].suit
|
||||
for i in range(len(cards) - 1):
|
||||
if cards[i].suit != suit:
|
||||
return False
|
||||
if cards[i].rank != cards[i+1].rank + 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_movable_sequence_start_indices(self, col_idx):
|
||||
"""Returns list of valid start indices for sequences in the column."""
|
||||
col = self.tableau[col_idx]
|
||||
if not col:
|
||||
return []
|
||||
|
||||
valid_indices = []
|
||||
for i in range(len(col)):
|
||||
if col[i].face_up:
|
||||
if self.is_valid_sequence(col[i:]):
|
||||
valid_indices.append(i)
|
||||
return valid_indices
|
||||
|
||||
def can_move(self, from_col, card_idx, to_col):
|
||||
"""Checks if moving the sequence starting at card_idx from from_col to to_col is valid."""
|
||||
if from_col < 0 or from_col >= 10 or to_col < 0 or to_col >= 10:
|
||||
return False
|
||||
if from_col == to_col:
|
||||
return False
|
||||
|
||||
col_from = self.tableau[from_col]
|
||||
col_to = self.tableau[to_col]
|
||||
|
||||
if not col_from or card_idx < 0 or card_idx >= len(col_from):
|
||||
return False
|
||||
|
||||
moving_cards = col_from[card_idx:]
|
||||
if not self.is_valid_sequence(moving_cards):
|
||||
return False
|
||||
|
||||
# If target column is empty, any sequence is allowed
|
||||
if not col_to:
|
||||
return True
|
||||
|
||||
# Target column's top card must be face_up
|
||||
target_card = col_to[-1]
|
||||
if not target_card.face_up:
|
||||
return False
|
||||
|
||||
# Target card rank must be exactly moving_sequence_start_rank + 1
|
||||
# Note: Suit doesn't have to match for placing, but has to match for moving together.
|
||||
if target_card.rank == moving_cards[0].rank + 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def move_cards(self, from_col, card_idx, to_col):
|
||||
"""Moves cards if valid, handles auto-flip and clearing complete runs."""
|
||||
if not self.can_move(from_col, card_idx, to_col):
|
||||
return False
|
||||
|
||||
# Save state before modification
|
||||
snapshot = self.save_state()
|
||||
|
||||
col_from = self.tableau[from_col]
|
||||
col_to = self.tableau[to_col]
|
||||
|
||||
moving_cards = col_from[card_idx:]
|
||||
self.tableau[from_col] = col_from[:card_idx]
|
||||
self.tableau[to_col].extend(moving_cards)
|
||||
|
||||
# Auto-flip newly exposed card
|
||||
if self.tableau[from_col] and not self.tableau[from_col][-1].face_up:
|
||||
self.tableau[from_col][-1].face_up = True
|
||||
|
||||
# Check for completed run in target column
|
||||
self.check_and_clear_run(to_col)
|
||||
|
||||
# Push state to undo stack
|
||||
self.push_undo(snapshot)
|
||||
return True
|
||||
|
||||
def check_and_clear_run(self, col_idx):
|
||||
"""Checks if a completed K to A same-suit sequence is at the top of col_idx, and clears it."""
|
||||
col = self.tableau[col_idx]
|
||||
if len(col) < 13:
|
||||
return False
|
||||
|
||||
# Look at last 13 cards
|
||||
potential_run = col[-13:]
|
||||
if not self.is_valid_sequence(potential_run):
|
||||
return False
|
||||
|
||||
# Verify it goes from King (13) down to Ace (1)
|
||||
if potential_run[0].rank == 13 and potential_run[-1].rank == 1:
|
||||
# We have a complete run! Clear it.
|
||||
self.tableau[col_idx] = col[:-13]
|
||||
self.completed_runs += 1
|
||||
|
||||
# Auto-flip new top card
|
||||
if self.tableau[col_idx] and not self.tableau[col_idx][-1].face_up:
|
||||
self.tableau[col_idx][-1].face_up = True
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def can_deal(self):
|
||||
"""Stock can be dealt if stock is not empty and no columns are empty (standard rule)."""
|
||||
if not self.stock:
|
||||
return False
|
||||
# Check for empty columns
|
||||
for col in self.tableau:
|
||||
if not col:
|
||||
return False
|
||||
return True
|
||||
|
||||
def deal_stock(self):
|
||||
"""Deals 10 cards from stock to tableau columns, one each."""
|
||||
if not self.can_deal():
|
||||
return False
|
||||
|
||||
snapshot = self.save_state()
|
||||
|
||||
for col in range(10):
|
||||
card = self.stock.pop()
|
||||
card.face_up = True
|
||||
self.tableau[col].append(card)
|
||||
# Dealing could theoretically complete a run in one of the columns
|
||||
self.check_and_clear_run(col)
|
||||
|
||||
self.push_undo(snapshot)
|
||||
return True
|
||||
|
||||
def is_won(self):
|
||||
return self.completed_runs == 8
|
||||
|
||||
def has_moves_left(self):
|
||||
"""Check if any valid moves are possible on the current board (or stock is available)."""
|
||||
if self.stock:
|
||||
return True
|
||||
|
||||
# Check all possible moves between columns
|
||||
for from_col in range(10):
|
||||
valid_indices = self.get_movable_sequence_start_indices(from_col)
|
||||
for card_idx in valid_indices:
|
||||
for to_col in range(10):
|
||||
if self.can_move(from_col, card_idx, to_col):
|
||||
return True
|
||||
return False
|
||||
112
card-game-app/main.py
Executable file
112
card-game-app/main.py
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import argparse
|
||||
from game import SpiderGame, Card
|
||||
|
||||
def run_smoke_test():
|
||||
"""Runs a non-interactive verification suite to confirm game rules and states."""
|
||||
print("====================================================")
|
||||
print("Running Spider Solitaire Smoke Test / Verification...")
|
||||
print("====================================================")
|
||||
|
||||
# 1. Initialize Game
|
||||
print("Initializing 1-Suit Spider game...")
|
||||
game = SpiderGame(suits_count=1)
|
||||
|
||||
# 2. Check Tableau and Stock Counts
|
||||
assert len(game.stock) == 50, f"Expected 50 stock cards, got {len(game.stock)}"
|
||||
tableau_cards = sum(len(col) for col in game.tableau)
|
||||
assert tableau_cards == 54, f"Expected 54 cards in tableau, got {tableau_cards}"
|
||||
print("[PASSED] Game initialized correctly with 54 cards in tableau and 50 in stock.")
|
||||
|
||||
# 3. Simulate and verify sequence validation
|
||||
print("Verifying sequence validation rules...")
|
||||
c1 = Card(5, '♠', True)
|
||||
c2 = Card(4, '♠', True)
|
||||
c3 = Card(3, '♠', True)
|
||||
c_down = Card(2, '♠', False)
|
||||
|
||||
assert game.is_valid_sequence([c1, c2, c3]) == True, "Expected sequential same-suit face-up cards to be valid"
|
||||
assert game.is_valid_sequence([c1, c3]) == False, "Expected non-sequential cards to be invalid"
|
||||
assert game.is_valid_sequence([c1, c2, c_down]) == False, "Expected sequence with facedown card to be invalid"
|
||||
print("[PASSED] Sequence validation rules are correct.")
|
||||
|
||||
# 4. Simulate a forced move and undo
|
||||
print("Simulating a controlled card move and undo...")
|
||||
# Clean up column 0 and 1 for controlled test
|
||||
game.tableau[0] = [Card(10, '♠', True)]
|
||||
game.tableau[1] = [Card(9, '♠', True)]
|
||||
|
||||
# 9♠ can move to 10♠
|
||||
assert game.can_move(1, 0, 0) == True, "Should be able to move 9♠ to 10♠"
|
||||
assert game.can_move(0, 0, 1) == False, "Should not be able to move 10♠ to 9♠"
|
||||
|
||||
# Perform move
|
||||
move_success = game.move_cards(1, 0, 0)
|
||||
assert move_success == True, "Move action should succeed"
|
||||
assert len(game.tableau[1]) == 0, "Source column should now be empty"
|
||||
assert len(game.tableau[0]) == 2, "Destination column should have 2 cards"
|
||||
assert game.tableau[0][1].rank == 9, "Top card of destination column should be 9"
|
||||
|
||||
# Undo move
|
||||
undo_success = game.undo()
|
||||
assert undo_success == True, "Undo should succeed"
|
||||
assert len(game.tableau[1]) == 1, "Source column should be restored to 1 card"
|
||||
assert len(game.tableau[0]) == 1, "Destination column should be restored to 1 card"
|
||||
assert game.tableau[1][0].rank == 9, "Restored card should be 9"
|
||||
print("[PASSED] Card move and undo simulation successful.")
|
||||
|
||||
# 5. Simulate Deal from Stock with Empty Columns
|
||||
print("Testing stock dealing empty-column constraint...")
|
||||
game.tableau[0] = [] # empty column 0
|
||||
assert game.can_deal() == False, "Should not be allowed to deal stock when there is an empty column"
|
||||
assert game.deal_stock() == False, "Deal action must fail when empty column exists"
|
||||
|
||||
game.tableau[0] = [Card(5, '♠', True)] # Put a card back
|
||||
assert game.can_deal() == True, "Should be allowed to deal stock when no empty columns exist"
|
||||
deal_success = game.deal_stock()
|
||||
assert deal_success == True, "Deal action should succeed"
|
||||
assert len(game.stock) == 40, f"Expected 40 stock cards remaining, got {len(game.stock)}"
|
||||
print("[PASSED] Stock dealing empty-column constraints successfully validated.")
|
||||
|
||||
# 6. Simulate completed run detection
|
||||
print("Verifying run-clearing logic...")
|
||||
col = [Card(5, '♠', False)]
|
||||
for r in range(13, 0, -1):
|
||||
col.append(Card(r, '♠', True))
|
||||
game.tableau[0] = col
|
||||
|
||||
assert game.completed_runs == 0, "Completed runs should be 0 before check"
|
||||
cleared = game.check_and_clear_run(0)
|
||||
assert cleared == True, "Complete K-A run should be cleared successfully"
|
||||
assert game.completed_runs == 1, f"Expected 1 completed run, got {game.completed_runs}"
|
||||
assert len(game.tableau[0]) == 1, f"Expected only facedown card left, got {len(game.tableau[0])}"
|
||||
assert game.tableau[0][0].face_up == True, "Remaining facedown card should be flipped face-up"
|
||||
print("[PASSED] Run-clearing logic successfully validated.")
|
||||
|
||||
print("\n====================================================")
|
||||
print("ALL SMOKE TESTS PASSED!")
|
||||
print("====================================================")
|
||||
sys.exit(0)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Terminal Spider Solitaire Game in Python")
|
||||
parser.add_argument("--smoke", action="store_true", help="Run non-interactive smoke test verification and exit")
|
||||
parser.add_argument("--suits", type=int, choices=[1, 2, 4], default=1, help="Number of suits to use (1, 2, or 4). Default: 1")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.smoke:
|
||||
run_smoke_test()
|
||||
else:
|
||||
# Import UI here to avoid curses import issues if running in non-terminal environments
|
||||
try:
|
||||
from ui import SpiderSolitaireUI
|
||||
except ImportError as e:
|
||||
print(f"Error: Could not import curses UI. Make sure you are in a terminal environment. Details: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ui = SpiderSolitaireUI(suits_count=args.suits)
|
||||
ui.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
135
card-game-app/test_game.py
Normal file
135
card-game-app/test_game.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import unittest
|
||||
from game import Card, SpiderGame
|
||||
|
||||
class TestSpiderGame(unittest.TestCase):
|
||||
def test_card_init(self):
|
||||
c = Card(1, '♠', False)
|
||||
self.assertEqual(c.rank, 1)
|
||||
self.assertEqual(c.suit, '♠')
|
||||
self.assertFalse(c.face_up)
|
||||
self.assertEqual(repr(c), "[###]")
|
||||
|
||||
c.face_up = True
|
||||
self.assertEqual(repr(c), "[ A♠]")
|
||||
|
||||
c2 = Card(10, '♥', True)
|
||||
self.assertEqual(repr(c2), "[10♥]")
|
||||
|
||||
def test_game_setup_1_suit(self):
|
||||
game = SpiderGame(suits_count=1)
|
||||
# 104 cards total. 54 in tableau, 50 in stock
|
||||
self.assertEqual(len(game.stock), 50)
|
||||
total_tableau = sum(len(col) for col in game.tableau)
|
||||
self.assertEqual(total_tableau, 54)
|
||||
|
||||
# Check column card counts
|
||||
for i in range(4):
|
||||
self.assertEqual(len(game.tableau[i]), 6)
|
||||
self.assertTrue(game.tableau[i][-1].face_up)
|
||||
# others are face-down
|
||||
for card in game.tableau[i][:-1]:
|
||||
self.assertFalse(card.face_up)
|
||||
|
||||
for i in range(4, 10):
|
||||
self.assertEqual(len(game.tableau[i]), 5)
|
||||
self.assertTrue(game.tableau[i][-1].face_up)
|
||||
|
||||
def test_game_setup_2_suit(self):
|
||||
game = SpiderGame(suits_count=2)
|
||||
all_cards = list(game.stock)
|
||||
for col in game.tableau:
|
||||
all_cards.extend(col)
|
||||
|
||||
suits = {c.suit for c in all_cards}
|
||||
self.assertEqual(suits, {'♠', '♥'})
|
||||
|
||||
def test_is_valid_sequence(self):
|
||||
game = SpiderGame(suits_count=1)
|
||||
# Empty list is not valid
|
||||
self.assertFalse(game.is_valid_sequence([]))
|
||||
|
||||
# Single card face up is valid
|
||||
c1 = Card(5, '♠', True)
|
||||
self.assertTrue(game.is_valid_sequence([c1]))
|
||||
|
||||
# Face down card is not valid
|
||||
c_down = Card(5, '♠', False)
|
||||
self.assertFalse(game.is_valid_sequence([c_down]))
|
||||
|
||||
# Valid sequence: 5, 4, 3 same suit
|
||||
c2 = Card(4, '♠', True)
|
||||
c3 = Card(3, '♠', True)
|
||||
self.assertTrue(game.is_valid_sequence([c1, c2, c3]))
|
||||
|
||||
# Different suit not valid
|
||||
c2_h = Card(4, '♥', True)
|
||||
self.assertFalse(game.is_valid_sequence([c1, c2_h, c3]))
|
||||
|
||||
# Non-sequential not valid
|
||||
self.assertFalse(game.is_valid_sequence([c1, c3]))
|
||||
|
||||
def test_move_validation_and_action(self):
|
||||
game = SpiderGame(suits_count=1)
|
||||
# Force a controlled state
|
||||
game.tableau[0] = [Card(10, '♠', True)]
|
||||
game.tableau[1] = [Card(9, '♠', True)]
|
||||
|
||||
# Can move 9 to 10
|
||||
self.assertTrue(game.can_move(1, 0, 0))
|
||||
# Cannot move 10 to 9
|
||||
self.assertFalse(game.can_move(0, 0, 1))
|
||||
|
||||
# Execute move
|
||||
success = game.move_cards(1, 0, 0)
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(len(game.tableau[1]), 0)
|
||||
self.assertEqual(len(game.tableau[0]), 2)
|
||||
self.assertEqual(game.tableau[0][0].rank, 10)
|
||||
self.assertEqual(game.tableau[0][1].rank, 9)
|
||||
|
||||
# Undo the move
|
||||
self.assertTrue(game.undo())
|
||||
self.assertEqual(len(game.tableau[1]), 1)
|
||||
self.assertEqual(len(game.tableau[0]), 1)
|
||||
self.assertEqual(game.tableau[1][0].rank, 9)
|
||||
self.assertEqual(game.tableau[0][0].rank, 10)
|
||||
|
||||
def test_stock_deal_and_empty_column_constraint(self):
|
||||
game = SpiderGame(suits_count=1)
|
||||
original_stock_count = len(game.stock)
|
||||
self.assertTrue(game.can_deal())
|
||||
|
||||
# Empty a column
|
||||
game.tableau[0] = []
|
||||
# Cannot deal if any column is empty
|
||||
self.assertFalse(game.can_deal())
|
||||
self.assertFalse(game.deal_stock())
|
||||
|
||||
# Put a card back
|
||||
game.tableau[0] = [Card(5, '♠', True)]
|
||||
self.assertTrue(game.can_deal())
|
||||
self.assertTrue(game.deal_stock())
|
||||
self.assertEqual(len(game.stock), original_stock_count - 10)
|
||||
|
||||
def test_check_and_clear_run(self):
|
||||
game = SpiderGame(suits_count=1)
|
||||
# Setup a column with facedown card, then K down to A
|
||||
col = [Card(5, '♠', False)]
|
||||
for r in range(13, 0, -1):
|
||||
col.append(Card(r, '♠', True))
|
||||
game.tableau[0] = col
|
||||
|
||||
# Initially 0 completed runs
|
||||
self.assertEqual(game.completed_runs, 0)
|
||||
|
||||
# Trigger check/clear
|
||||
cleared = game.check_and_clear_run(0)
|
||||
self.assertTrue(cleared)
|
||||
self.assertEqual(game.completed_runs, 1)
|
||||
|
||||
# The facedown card should be flipped face-up now
|
||||
self.assertEqual(len(game.tableau[0]), 1)
|
||||
self.assertTrue(game.tableau[0][0].face_up)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
338
card-game-app/ui.py
Normal file
338
card-game-app/ui.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import curses
|
||||
import sys
|
||||
from game import SpiderGame, Card
|
||||
|
||||
class SpiderSolitaireUI:
|
||||
def __init__(self, suits_count=1):
|
||||
self.game = SpiderGame(suits_count=suits_count)
|
||||
self.cursor_col = 0
|
||||
self.cursor_card_idx = 0
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
self.status_msg = "Welcome to Spider Solitaire! Use arrow keys to navigate."
|
||||
self.status_color = 4 # Cyan
|
||||
|
||||
self.adjust_cursor_after_col_change()
|
||||
|
||||
def adjust_cursor_after_col_change(self):
|
||||
col = self.game.tableau[self.cursor_col]
|
||||
if not col:
|
||||
self.cursor_card_idx = 0
|
||||
else:
|
||||
self.cursor_card_idx = len(col) - 1
|
||||
|
||||
def run(self):
|
||||
# Use curses wrapper to handle init and cleanup automatically
|
||||
curses.wrapper(self.main_loop)
|
||||
|
||||
def main_loop(self, stdscr):
|
||||
# Setup colors
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_RED, -1) # Red for Hearts/Diamonds
|
||||
curses.init_pair(2, curses.COLOR_WHITE, -1) # White/Default for Spades/Clubs
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLUE) # Highlighted/Selected card
|
||||
curses.init_pair(4, curses.COLOR_CYAN, -1) # Labels/Borders/Status
|
||||
curses.init_pair(5, curses.COLOR_GREEN, -1) # Success/Completed
|
||||
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_WHITE) # Reverse video fallback or dark gray
|
||||
|
||||
curses.curs_set(0) # Hide blinking cursor
|
||||
stdscr.keypad(True)
|
||||
stdscr.clear()
|
||||
|
||||
while True:
|
||||
# Check terminal size
|
||||
height, width = stdscr.getmaxyx()
|
||||
if height < 24 or width < 80:
|
||||
stdscr.clear()
|
||||
stdscr.addstr(0, 0, "Terminal too small!", curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(1, 0, f"Current: {width}x{height}. Required: >= 80x24.", curses.color_pair(4))
|
||||
stdscr.addstr(2, 0, "Please resize your terminal window to continue...", curses.color_pair(4))
|
||||
stdscr.refresh()
|
||||
|
||||
# Wait for resize
|
||||
ch = stdscr.getch()
|
||||
if ch in (ord('q'), ord('Q')):
|
||||
break
|
||||
continue
|
||||
|
||||
self.render_screen(stdscr)
|
||||
|
||||
try:
|
||||
ch = stdscr.getch()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
if ch in (ord('q'), ord('Q')):
|
||||
break
|
||||
|
||||
self.handle_input(ch)
|
||||
|
||||
def render_screen(self, stdscr):
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# 1. Header Area (rows 0-2)
|
||||
stdscr.addstr(0, 0, "=" * (width - 1), curses.color_pair(4))
|
||||
title = " SPIDER SOLITAIRE "
|
||||
stdscr.addstr(0, (width - len(title)) // 2, title, curses.color_pair(4) | curses.A_BOLD)
|
||||
|
||||
mode_str = f"Mode: {self.game.suits_count}-Suit"
|
||||
stock_str = f"Stock: {'[|||||]' if self.game.stock else '[EMPTY]'} ({len(self.game.stock)} cards)"
|
||||
runs_str = f"Completed: {self.game.completed_runs}/8"
|
||||
|
||||
stdscr.addstr(1, 2, stock_str, curses.color_pair(4))
|
||||
stdscr.addstr(1, 30, runs_str, curses.color_pair(5) if self.game.completed_runs > 0 else curses.color_pair(2))
|
||||
stdscr.addstr(1, width - len(mode_str) - 3, mode_str, curses.color_pair(4))
|
||||
|
||||
stdscr.addstr(2, 0, "=" * (width - 1), curses.color_pair(4))
|
||||
|
||||
# 2. Tableau Columns (rows 4 to height-6)
|
||||
col_width = 7
|
||||
col_gap = 1
|
||||
|
||||
# Draw Column headers and cards
|
||||
for col_idx in range(10):
|
||||
x = col_idx * (col_width + col_gap) + 1
|
||||
|
||||
# Header label
|
||||
header_attr = curses.color_pair(4)
|
||||
if col_idx == self.cursor_col:
|
||||
header_attr |= curses.A_REVERSE | curses.A_BOLD
|
||||
stdscr.addstr(4, x, f" Col {col_idx} ", header_attr)
|
||||
|
||||
col_cards = self.game.tableau[col_idx]
|
||||
|
||||
if not col_cards:
|
||||
# Render empty column slot
|
||||
if col_idx == self.cursor_col:
|
||||
stdscr.addstr(6, x + 1, "[ ]", curses.color_pair(3))
|
||||
else:
|
||||
stdscr.addstr(6, x + 1, "[ - ]", curses.color_pair(4))
|
||||
continue
|
||||
|
||||
for card_idx, card in enumerate(col_cards):
|
||||
y = 6 + card_idx
|
||||
if y >= height - 6:
|
||||
# Prevent writing off screen if too many cards
|
||||
# Just draw a indicator that there are more cards
|
||||
stdscr.addstr(height - 6, x + 2, "...", curses.color_pair(1) | curses.A_BOLD)
|
||||
break
|
||||
|
||||
# Prepare card string representation
|
||||
is_bottom = (card_idx == len(col_cards) - 1)
|
||||
|
||||
# Check formatting
|
||||
if not card.face_up:
|
||||
card_str = " |###| " if not is_bottom else " [###] "
|
||||
attr = curses.color_pair(2) # Default / facedown
|
||||
else:
|
||||
rank_str = {1: "A", 11: "J", 12: "Q", 13: "K"}.get(card.rank, str(card.rank))
|
||||
if len(rank_str) == 1:
|
||||
rank_str = " " + rank_str
|
||||
|
||||
if is_bottom:
|
||||
card_str = f" [{rank_str}{card.suit}] "
|
||||
else:
|
||||
card_str = f" |{rank_str}{card.suit}| "
|
||||
|
||||
# Suit colors
|
||||
if card.suit in ('♥', '♦'):
|
||||
attr = curses.color_pair(1) # Red
|
||||
else:
|
||||
attr = curses.color_pair(2) # White/Spade/Club
|
||||
|
||||
# Highlight rules:
|
||||
# If we are in SELECTED state:
|
||||
# - If this column is the selected column, and we are at or below selected_card_idx: highlight!
|
||||
# If we are in IDLE state:
|
||||
# - If this column is the current column, and we are at the cursor_card_idx: highlight!
|
||||
is_highlighted = False
|
||||
if self.selected_col is not None:
|
||||
if col_idx == self.selected_col and card_idx >= self.selected_card_idx:
|
||||
is_highlighted = True
|
||||
else:
|
||||
if col_idx == self.cursor_col and card_idx == self.cursor_card_idx:
|
||||
is_highlighted = True
|
||||
|
||||
if is_highlighted:
|
||||
attr = curses.color_pair(3) | curses.A_BOLD
|
||||
|
||||
stdscr.addstr(y, x, card_str, attr)
|
||||
|
||||
# 3. Status Line (height - 4)
|
||||
stdscr.addstr(height - 4, 0, "-" * (width - 1), curses.color_pair(4))
|
||||
stdscr.addstr(height - 3, 2, self.status_msg[:width-5], curses.color_pair(self.status_color) | curses.A_BOLD)
|
||||
|
||||
# 4. Footer controls (height - 2 and height - 1)
|
||||
help_line1 = "Arrows: Navigate | Space/Enter: Select Card | 0-9: Jump Col | D: Deal Stock | U: Undo"
|
||||
help_line2 = "R: Restart | Q: Quit | Esc: Cancel Selection"
|
||||
if self.selected_col is not None:
|
||||
help_line1 = "Arrows: Choose Target Column | Space/Enter: Drop Cards | Esc: Cancel Selection"
|
||||
|
||||
stdscr.addstr(height - 2, 2, help_line1[:width-5], curses.color_pair(4))
|
||||
stdscr.addstr(height - 1, 2, help_line2[:width-5], curses.color_pair(4))
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
def handle_input(self, ch):
|
||||
# Escape key handling
|
||||
if ch == 27:
|
||||
if self.selected_col is not None:
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
self.status_msg = "Selection cancelled."
|
||||
self.status_color = 4
|
||||
return
|
||||
|
||||
# Undo
|
||||
if ch in (ord('u'), ord('U')):
|
||||
if self.selected_col is not None:
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
if self.game.undo():
|
||||
self.status_msg = "Undo successful."
|
||||
self.status_color = 5
|
||||
self.adjust_cursor_after_col_change()
|
||||
else:
|
||||
self.status_msg = "Nothing to undo."
|
||||
self.status_color = 1
|
||||
return
|
||||
|
||||
# Restart
|
||||
if ch in (ord('r'), ord('R')):
|
||||
self.game.reset_game()
|
||||
self.cursor_col = 0
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
self.status_msg = "Game restarted."
|
||||
self.status_color = 4
|
||||
self.adjust_cursor_after_col_change()
|
||||
return
|
||||
|
||||
# Deal Stock
|
||||
if ch in (ord('d'), ord('D')):
|
||||
if self.selected_col is not None:
|
||||
# Cancel selection on deal
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
|
||||
if self.game.can_deal():
|
||||
self.game.deal_stock()
|
||||
self.status_msg = "Dealt 10 cards from stock."
|
||||
self.status_color = 5
|
||||
self.adjust_cursor_after_col_change()
|
||||
else:
|
||||
if not self.game.stock:
|
||||
self.status_msg = "Cannot deal: Stock is empty!"
|
||||
else:
|
||||
self.status_msg = "Cannot deal: All columns must have at least 1 card!"
|
||||
self.status_color = 1
|
||||
return
|
||||
|
||||
# Column shortcuts (0-9)
|
||||
if ord('0') <= ch <= ord('9'):
|
||||
target_col = ch - ord('0')
|
||||
if self.selected_col is not None:
|
||||
# Attempt to move to this column
|
||||
self.attempt_move_to(target_col)
|
||||
else:
|
||||
self.cursor_col = target_col
|
||||
self.adjust_cursor_after_col_change()
|
||||
self.status_msg = f"Jumped to Column {target_col}"
|
||||
self.status_color = 4
|
||||
return
|
||||
|
||||
# State-dependent input handling
|
||||
if self.selected_col is None:
|
||||
self.handle_idle_input(ch)
|
||||
else:
|
||||
self.handle_selected_input(ch)
|
||||
|
||||
def handle_idle_input(self, ch):
|
||||
col = self.game.tableau[self.cursor_col]
|
||||
|
||||
if ch == curses.KEY_LEFT:
|
||||
self.cursor_col = (self.cursor_col - 1) % 10
|
||||
self.adjust_cursor_after_col_change()
|
||||
elif ch == curses.KEY_RIGHT:
|
||||
self.cursor_col = (self.cursor_col + 1) % 10
|
||||
self.adjust_cursor_after_col_change()
|
||||
elif ch == curses.KEY_UP:
|
||||
# Move cursor up within face-up cards of the current column
|
||||
if col:
|
||||
valid_start_indices = self.game.get_movable_sequence_start_indices(self.cursor_col)
|
||||
if valid_start_indices:
|
||||
# Find if we can move up
|
||||
current_pos = valid_start_indices.index(self.cursor_card_idx) if self.cursor_card_idx in valid_start_indices else -1
|
||||
if current_pos > 0:
|
||||
self.cursor_card_idx = valid_start_indices[current_pos - 1]
|
||||
else:
|
||||
# Fallback/stay at the highest valid sequence start
|
||||
self.cursor_card_idx = valid_start_indices[0]
|
||||
elif ch == curses.KEY_DOWN:
|
||||
if col:
|
||||
valid_start_indices = self.game.get_movable_sequence_start_indices(self.cursor_col)
|
||||
if valid_start_indices:
|
||||
current_pos = valid_start_indices.index(self.cursor_card_idx) if self.cursor_card_idx in valid_start_indices else -1
|
||||
if current_pos != -1 and current_pos < len(valid_start_indices) - 1:
|
||||
self.cursor_card_idx = valid_start_indices[current_pos + 1]
|
||||
else:
|
||||
self.cursor_card_idx = len(col) - 1
|
||||
elif ch in (10, 13, ord(' '), curses.KEY_ENTER):
|
||||
# Select sequence
|
||||
if not col:
|
||||
self.status_msg = "Cannot select from an empty column!"
|
||||
self.status_color = 1
|
||||
return
|
||||
|
||||
valid_indices = self.game.get_movable_sequence_start_indices(self.cursor_col)
|
||||
if self.cursor_card_idx not in valid_indices:
|
||||
self.status_msg = "Invalid selection: Cards must be same suit and decreasing!"
|
||||
self.status_color = 1
|
||||
return
|
||||
|
||||
# Success, select!
|
||||
self.selected_col = self.cursor_col
|
||||
self.selected_card_idx = self.cursor_card_idx
|
||||
self.status_msg = f"Selected cards from Col {self.selected_col}. Choose target column."
|
||||
self.status_color = 3
|
||||
|
||||
def handle_selected_input(self, ch):
|
||||
if ch == curses.KEY_LEFT:
|
||||
self.cursor_col = (self.cursor_col - 1) % 10
|
||||
elif ch == curses.KEY_RIGHT:
|
||||
self.cursor_col = (self.cursor_col + 1) % 10
|
||||
elif ch in (10, 13, ord(' '), curses.KEY_ENTER):
|
||||
self.attempt_move_to(self.cursor_col)
|
||||
|
||||
def attempt_move_to(self, target_col):
|
||||
if target_col == self.selected_col:
|
||||
# Drop in the same column = cancel selection
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
self.status_msg = "Selection cancelled."
|
||||
self.status_color = 4
|
||||
return
|
||||
|
||||
# Attempt move
|
||||
if self.game.move_cards(self.selected_col, self.selected_card_idx, target_col):
|
||||
# Success!
|
||||
self.status_msg = f"Moved sequence to Column {target_col}."
|
||||
self.status_color = 5
|
||||
|
||||
# Check for win!
|
||||
if self.game.is_won():
|
||||
self.status_msg = "CONGRATULATIONS! You won Spider Solitaire!"
|
||||
self.status_color = 5
|
||||
elif not self.game.has_moves_left():
|
||||
self.status_msg = "No moves left! Press U to undo, D to deal, or R to restart."
|
||||
self.status_color = 1
|
||||
|
||||
self.cursor_col = target_col
|
||||
self.selected_col = None
|
||||
self.selected_card_idx = None
|
||||
self.adjust_cursor_after_col_change()
|
||||
else:
|
||||
# Failure
|
||||
self.status_msg = "Invalid move! Cards must go onto rank + 1 or empty column."
|
||||
self.status_color = 1
|
||||
3
status.json
Normal file
3
status.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"outcome": "succeeded"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue