fabro(01KTAFMYJW6ATWQB4KFNSNW37T): plan_app (succeeded)

Fabro-Run: 01KTAFMYJW6ATWQB4KFNSNW37T
Fabro-Completed: 2
Fabro-Checkpoint: b79ae37b2a

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

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

@ -0,0 +1,155 @@
# Spider Solitaire Terminal Game Implementation Plan
This plan outlines the architecture, rules, UI design, and testing strategy for a terminal-based Spider Solitaire game built with Python and the standard `curses` library. All source files will be located in the `card-game-app/` directory.
---
## 1. Game Rules & Logic (Spider Solitaire)
### Card & Deck Representation
- **Standard Spider Solitaire** uses **2 decks (104 cards)**.
- **Difficulty / Suit Configurations**:
- **1 Suit (Easy)**: All cards are Spades (♠). (104 Spades)
- **2 Suits (Medium)**: Spades (♠) and Hearts (♥). (52 Spades, 52 Hearts)
- **4 Suits (Hard)**: Spades (♠), Hearts (♥), Diamonds (♦), and Clubs (♣). (26 of each)
- **Ranks**: King (K), Queen (Q), Jack (J), 10, 9, 8, 7, 6, 5, 4, 3, 2, Ace (A).
### Initial Deal / Tableau Setup
- **10 Tableau Columns**:
- Columns 1-4: 6 cards each (5 face-down, 1 face-up at the bottom).
- Columns 5-10: 5 cards each (4 face-down, 1 face-up at the bottom).
- Total dealt initially: 54 cards.
- **Stock Pile**:
- Remaining 50 cards are kept in the stock.
- Dealt in 5 rounds of 10 cards each (1 card to each column).
- **Constraint**: Dealing from the stock is only allowed if **no column is empty** (standard rule, though some variants allow dealing with empty columns; we will enforce standard rules or make it configurable).
### Card Movement Rules
- **Moving a Card or Sequence**:
- Any single face-up card can be moved to another column if the destination card's rank is exactly **one higher** than the card being moved. Suit does not matter for single card moves. (e.g., Any Jack can be placed on any Queen).
- A sequence of cards can be moved *together* only if:
1. They are in descending rank order (e.g., J, 10, 9, 8).
2. They are of the **same suit** (e.g., all Spades).
- Any face-up card or valid sequence can be moved to an **empty column**.
- **Revealing Cards**:
- If a move leaves a facedown card at the bottom of a column, that card is automatically flipped face-up.
### Clearing Sequences (Win Condition)
- When a complete sequence of King down to Ace (K, Q, J, 10, 9, 8, 7, 6, 5, 4, 3, 2, A) of the **same suit** is formed in a column, it is automatically removed from the Tableau and placed in the Completed pile.
- **Game Win**: When all 8 completed sequences (104 cards) are removed.
- **Game Loss**: No more valid moves, the stock is empty, and the board is in a locked/unplayable state. (Usually, the user decides to resign, but we can detect gridlock if needed).
---
## 2. Core Data Structures (`card-game-app/engine.py`)
We will design a clean, object-oriented state engine decouple-able from `curses` to facilitate unit testing and the `--smoke` non-interactive test run.
### `Card`
```python
class Card:
def __init__(self, rank: int, suit: str, face_up: bool = False):
self.rank = rank # 1 (Ace) to 13 (King)
self.suit = suit # 'S' (Spades), 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs)
self.face_up = face_up
```
### `GameState`
- **`tableau`**: `List[List[Card]]` - 10 columns.
- **`stock`**: `List[Card]` - Decks/remaining cards.
- **`completed_sequences`**: `int` - Count of removed sequences (0 to 8).
- **`history`**: `List[Memento]` - For Undo functionality.
- **`score`**: `int` - Starts at 500. Each move subtracts 1 point. Completing a sequence adds 100 points.
### Key Operations
- `deal_initial()`: Shuffles and populates the tableau and stock.
- `deal_from_stock()`: Deals 1 card to each column.
- `can_move(from_col, card_idx, to_col)`: Validates if a move is legal.
- `move_cards(from_col, card_idx, to_col)`: Executes the move, flips newly exposed bottom cards, and automatically extracts completed sequences.
- `undo()`: Reverts the last state.
- `check_win()`: Returns `True` if `completed_sequences == 8`.
---
## 3. Terminal Rendering via Curses (`card-game-app/ui.py`)
Using the standard-library `curses` module, we will implement a full-screen, responsive interface.
### Layout Design
```
[SPIDER SOLITAIRE] Score: 495 Moves: 5 Suits: 1-Suit (S)
==================================================================================
Stock: [ [50] ] Completed: [K♠] [K♠] [ ] [ ] [ ] [ ] [ ] [ ]
Col 1 Col 2 Col 3 Col 4 Col 5 Col 6 Col 7 Col 8 Col 9 Col 10
----- ----- ----- ----- ----- ----- ----- ----- ----- ------
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
[ ] [ ] [ ] [ ] 10♠ [ ] [ ] [ ] [ ] [ ]
J♠ 9♥ [ ] [ ] [ ] [ ] [ ] [ ] [ ]
10♠ 8♦ K♣ [ ] [ ] [ ] [ ] [ ]
7♦ Q♣ [ ] [ ] [ ] [ ]
5♠ 2♦ [ ] [ ]
A♦
==================================================================================
Controls: [Arrow keys / Tab] Move cursor [Space/Enter] Select card/column
[S] Deal Stock [U] Undo [R] New Game [Q] Quit
```
### Visual Representation of Cards
- Face-down card: `[░░░]` or blue block.
- Face-up card: Rank + Suit symbol. Examples: `A♠`, `10♥`, `Q♦`, `K♣`.
- Color schemes:
- Spades/Clubs: White or default color.
- Hearts/Diamonds: Red text (`curses.color_pair` with red foreground).
- Selected card/sequence: Highlighted background (Reverse video or yellow background).
### Cursor & Selection Mechanics
- **Grid-based selection / Keyboard cursor**:
- The player moves a cursor (highlighted cell or arrow pointer) across columns.
- Pressing `SPACE` or `ENTER` on a column selects the deepest movable sequence.
- Moving the cursor to another column and pressing `SPACE`/`ENTER` attempts the move.
- Alternative: Keyboard column shortcut keys (e.g., Press `1` through `0` to select source column, then press destination column). We will provide **both** cursor-based navigations and quick hotkeys for smooth UX.
---
## 4. Input Handling & Actions
| Input Key | Action |
| --- | --- |
| `LEFT` / `RIGHT` or `H` / `L` | Navigate left/right across columns |
| `UP` / `DOWN` or `K` / `J` | Navigate up/down within a column to select the starting card of a sequence |
| `SPACE` / `ENTER` | Select starting card of sequence / Drop sequence onto target column |
| `S` | Deal a round from stock |
| `U` | Undo last move |
| `R` | Restart / New Game (prompts for difficulty: 1, 2, or 4 suits) |
| `Q` / `ESC` | Exit game |
---
## 5. Non-Interactive Demo Verification (`--smoke`)
To satisfy the verification requirements without prompting for curses terminal initialization, `python3 main.py --smoke` will run a programmatic simulation of the solitaire game engine:
1. Initialize a 1-suit Spider solitaire game.
2. Verify the card count in columns (54) and stock (50).
3. Find a legal move in the initial dealt state, execute it, and verify that columns and score updated.
4. Deal from stock and verify stock size decreases by 10 and columns increase.
5. Perform an undo and verify correctness.
6. Print a JSON report of the execution status and exit with code `0`.
---
## 6. Testing Strategy
### Unit Tests (`card-game-app/test_engine.py`)
We will write lightweight and automated unit tests for:
- Card model initialization and representation.
- Complete deck shuffling and dealing proportions.
- Move validation rules (successes and various invalid move rejections).
- Automatic extraction and clearing of complete K-to-A sequences.
- Stock deals and its pre-requisites (no empty columns).
- Undo/redo correctness.
We can execute unit tests using standard library `unittest` or `pytest`:
`python3 -m unittest card-game-app/test_engine.py`

335
card-game-app/engine.py Normal file
View file

@ -0,0 +1,335 @@
import random
import copy
# Ranks mapping for display
RANK_NAMES = {
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'
}
SUIT_SYMBOLS = {
'S': '♠', # Spades
'H': '♥', # Hearts
'D': '♦', # Diamonds
'C': '♣' # Clubs
}
class Card:
def __init__(self, rank: int, suit: str, face_up: bool = False):
self.rank = rank # 1 (Ace) to 13 (King)
self.suit = suit # 'S', 'H', 'D', 'C'
self.face_up = face_up
def __repr__(self):
status = "up" if self.face_up else "down"
return f"{RANK_NAMES[self.rank]}{SUIT_SYMBOLS[self.suit]} ({status})"
def display_str(self) -> str:
if self.face_up:
return f"{RANK_NAMES[self.rank]}{SUIT_SYMBOLS[self.suit]}"
return "[░░░]"
def to_dict(self):
return {
'rank': self.rank,
'suit': self.suit,
'face_up': self.face_up
}
@classmethod
def from_dict(cls, data):
return cls(data['rank'], data['suit'], data['face_up'])
class GameState:
def __init__(self, difficulty: int = 1):
"""
difficulty: 1 (1-Suit: Spades), 2 (2-Suit: Spades, Hearts), 4 (4-Suit: Standard)
"""
if difficulty not in (1, 2, 4):
difficulty = 1
self.difficulty = difficulty
self.tableau = [[] for _ in range(10)] # 10 columns
self.stock = [] # stock pile
self.completed_sequences = 0 # Count of completed K-A runs (0-8)
self.completed_suits = [] # Track exact suits of completed runs
self.score = 500 # Standard starting score
self.moves_count = 0
self.history = [] # Undo history
self.initialize_game()
def initialize_game(self):
# Determine suits to use based on difficulty
if self.difficulty == 1:
suits = ['S'] * 8
elif self.difficulty == 2:
suits = ['S', 'H'] * 4
else:
suits = ['S', 'H', 'D', 'C'] * 2
# Create 104 cards (8 full 13-card runs)
deck = []
for suit in suits:
for rank in range(1, 14):
deck.append(Card(rank, suit, face_up=False))
# Shuffle deck
random.shuffle(deck)
# Distribute cards to 10 columns
# Columns 0-3: 6 cards each (5 face down, 1 face up)
# Columns 4-9: 5 cards each (4 face down, 1 face up)
self.tableau = [[] for _ in range(10)]
for i in range(10):
num_cards = 6 if i < 4 else 5
for _ in range(num_cards):
card = deck.pop()
self.tableau[i].append(card)
# Turn top card face up
if self.tableau[i]:
self.tableau[i][-1].face_up = True
# Remaining 50 cards go to stock
self.stock = deck
self.completed_sequences = 0
self.completed_suits = []
self.score = 500
self.moves_count = 0
self.history = []
def save_state_to_history(self):
"""Save a deep-ish copy of state to allow undo"""
state_copy = {
'tableau': [[Card(c.rank, c.suit, c.face_up) for c in col] for col in self.tableau],
'stock': [Card(c.rank, c.suit, c.face_up) for c in self.stock],
'completed_sequences': self.completed_sequences,
'completed_suits': list(self.completed_suits),
'score': self.score,
'moves_count': self.moves_count
}
self.history.append(state_copy)
def undo(self) -> bool:
"""Revert to the last saved state"""
if not self.history:
return False
prev_state = self.history.pop()
self.tableau = prev_state['tableau']
self.stock = prev_state['stock']
self.completed_sequences = prev_state['completed_sequences']
self.completed_suits = prev_state['completed_suits']
self.score = prev_state['score']
self.moves_count = prev_state['moves_count']
return True
def can_deal_from_stock(self) -> bool:
"""
Stock deals 10 cards.
Standard rules: stock cannot be dealt if any column is empty.
Must also have at least 10 cards left in the stock.
"""
if len(self.stock) < 10:
return False
for col in self.tableau:
if not col:
return False
return True
def deal_from_stock(self) -> bool:
"""Deals 1 card to each of the 10 columns."""
if not self.can_deal_from_stock():
return False
self.save_state_to_history()
# Deal 10 cards
for col_idx in range(10):
card = self.stock.pop()
card.face_up = True
self.tableau[col_idx].append(card)
# After deal, check for any newly completed sequences in columns
self.check_and_clear_all_completed_sequences()
self.score -= 1
self.moves_count += 1
return True
def get_movable_sequence_start_indices(self, col_idx: int) -> list:
"""
Returns a list of starting indices of all valid movable sequences in a column.
A sequence is movable if:
1. All cards in the sequence are face_up.
2. The cards are in consecutive descending ranks (e.g. 7, 6, 5).
3. All cards in the sequence have the SAME suit.
"""
col = self.tableau[col_idx]
if not col:
return []
movable_indices = []
n = len(col)
# Check from the bottom-most card upwards
for start_idx in range(n - 1, -1, -1):
# If the starting card is not face-up, we cannot start a sequence here
if not col[start_idx].face_up:
break
# Verify sequence from start_idx to the end of the column
is_valid = True
current_suit = col[start_idx].suit
for i in range(start_idx, n - 1):
card1 = col[i]
card2 = col[i+1]
# Conditions: same suit, and rank of card2 is exactly card1 - 1
if not card2.face_up or card2.suit != current_suit or card2.rank != card1.rank - 1:
is_valid = False
break
if is_valid:
movable_indices.append(start_idx)
else:
# If a sequence from start_idx is not valid, any larger sequence containing it won't be valid either
break
# Return indices sorted ascending (e.g., from top of sequence down to bottom)
return sorted(movable_indices)
def can_move(self, from_col: int, start_idx: int, to_col: int) -> bool:
"""
Validates if moving the sequence starting at start_idx from from_col to to_col is legal.
"""
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]
# Valid range check
if not col_from or start_idx < 0 or start_idx >= len(col_from):
return False
# Is the sequence itself valid (descending, same suit, all face up)?
valid_starts = self.get_movable_sequence_start_indices(from_col)
if start_idx not in valid_starts:
return False
# Can it be placed on target column?
if not col_to:
# Empty column can accept any valid sequence
return True
# Target column is not empty; top card must be rank of moving_card + 1 (suit doesn't matter)
target_card = col_to[-1]
moving_card = col_from[start_idx]
if target_card.rank == moving_card.rank + 1:
return True
return False
def move_cards(self, from_col: int, start_idx: int, to_col: int) -> bool:
"""Executes a move from from_col to to_col, handling score, revealing, and completions."""
if not self.can_move(from_col, start_idx, to_col):
return False
self.save_state_to_history()
col_from = self.tableau[from_col]
col_to = self.tableau[to_col]
# Extract sequence
moving_cards = col_from[start_idx:]
self.tableau[from_col] = col_from[:start_idx]
# Place on target
col_to.extend(moving_cards)
# Flip the new bottom card of the source column if it's facedown
if self.tableau[from_col] and not self.tableau[from_col][-1].face_up:
self.tableau[from_col][-1].face_up = True
# Check for sequence completions across all columns
self.check_and_clear_all_completed_sequences()
self.score -= 1
self.moves_count += 1
return True
def check_and_clear_all_completed_sequences(self):
"""
Scan all 10 columns. If the bottom 13 cards of a column form a complete
descending same-suit sequence from King (13) down to Ace (1), remove them
and increment completed count.
Repeat until no more completed sequences are found.
"""
cleared_any = True
while cleared_any:
cleared_any = False
for col_idx in range(10):
col = self.tableau[col_idx]
if len(col) < 13:
continue
# Check bottom 13 cards
candidate_cards = col[-13:]
# Check if all 13 cards are face_up, same suit, and descending from 13 to 1
suit = candidate_cards[0].suit
is_completed = True
for i, card in enumerate(candidate_cards):
expected_rank = 13 - i
if not card.face_up or card.suit != suit or card.rank != expected_rank:
is_completed = False
break
if is_completed:
# Remove the completed sequence
self.tableau[col_idx] = col[:-13]
self.completed_sequences += 1
self.completed_suits.append(suit)
self.score += 100
# Reveal the newly exposed bottom card of the column
if self.tableau[col_idx] and not self.tableau[col_idx][-1].face_up:
self.tableau[col_idx][-1].face_up = True
cleared_any = True
break # Restart scan since tableau state has changed
def has_any_moves(self) -> bool:
"""
Detects if there is any valid move available on the board.
Does not check stock deals (stock deal is always an option if stock not empty).
"""
# If stock is not empty, there is a potential action (even if we need to clear empty cols first)
if len(self.stock) >= 10:
return True
# Check all possible from/to column combinations
for from_col in range(10):
valid_starts = self.get_movable_sequence_start_indices(from_col)
for start_idx in valid_starts:
for to_col in range(10):
if from_col == to_col:
continue
if self.can_move(from_col, start_idx, to_col):
return True
return False
def is_won(self) -> bool:
return self.completed_sequences == 8

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

@ -0,0 +1,432 @@
import sys
import os
import json
# Add current directory to path to ensure relative imports work reliably
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from engine import GameState, Card, RANK_NAMES, SUIT_SYMBOLS
def run_smoke_test():
print("Running non-interactive smoke test verification...")
# 1. Initialize a 1-suit Spider solitaire game
state = GameState(difficulty=1)
# 2. Verify the card count in columns (54) and stock (50)
total_tableau = sum(len(col) for col in state.tableau)
print(f"Tableau card count: {total_tableau} (Expected: 54)")
assert total_tableau == 54, f"Tableau card count must be 54, got {total_tableau}"
stock_count = len(state.stock)
print(f"Stock card count: {stock_count} (Expected: 50)")
assert stock_count == 50, f"Stock card count must be 50, got {stock_count}"
# 3. Force-create a valid move in the initial state to ensure 100% determinism
# Set top of col 0 to Q of Spades (12) and top of col 1 to J of Spades (11)
state.tableau[0][-1] = Card(12, 'S', face_up=True)
state.tableau[1][-1] = Card(11, 'S', face_up=True)
idx_col1 = len(state.tableau[1]) - 1
print("Checking move validation...")
can_m = state.can_move(1, idx_col1, 0)
print(f"Can move J onto Q? {can_m}")
assert can_m, "Deterministic move should be valid"
print("Executing move...")
success = state.move_cards(1, idx_col1, 0)
print(f"Move success? {success}")
assert success, "Move execution must succeed"
# Verify columns and score updated
assert len(state.tableau[1]) == 5, f"Col 1 should have 5 cards, got {len(state.tableau[1])}"
assert len(state.tableau[0]) == 7, f"Col 0 should have 7 cards, got {len(state.tableau[0])}"
assert state.tableau[0][-1].rank == 11, "Col 0 top card should be J"
assert state.tableau[0][-2].rank == 12, "Col 0 second top card should be Q"
assert state.score == 499, f"Score should be 499, got {state.score}"
assert state.moves_count == 1, f"Moves count should be 1, got {state.moves_count}"
# 4. Deal from stock and verify stock size decreases by 10 and columns increase
print("Dealing from stock...")
deal_ok = state.deal_from_stock()
print(f"Deal success? {deal_ok}")
assert deal_ok, "Deal from stock must succeed"
assert len(state.stock) == 40, f"Stock size should be 40, got {len(state.stock)}"
assert len(state.tableau[0]) == 8, f"Col 0 should now have 8 cards, got {len(state.tableau[0])}"
assert state.score == 498, f"Score should be 498, got {state.score}"
assert state.moves_count == 2, f"Moves count should be 2, got {state.moves_count}"
# 5. Perform an undo and verify correctness
print("Undoing deal...")
undo_ok = state.undo()
print(f"Undo success? {undo_ok}")
assert undo_ok, "Undo must succeed"
assert len(state.stock) == 50, f"Stock size should return to 50, got {len(state.stock)}"
assert len(state.tableau[0]) == 7, f"Col 0 should return to 7 cards, got {len(state.tableau[0])}"
assert state.score == 499, f"Score should return to 499, got {state.score}"
assert state.moves_count == 1, f"Moves count should return to 1, got {state.moves_count}"
# 6. Print JSON report of the execution status and exit with code 0
report = {
"outcome": "succeeded",
"assertions_verified": True,
"tableau_cards": total_tableau,
"stock_cards": len(state.stock),
"smoke_test_passed": True
}
print(json.dumps(report, indent=2))
sys.exit(0)
def get_card_color_pair(card, is_selected, is_cursor):
is_red = card.suit in ('H', 'D')
if is_cursor:
return 4 if is_red else 3
elif is_selected:
return 6 if is_red else 5
else:
return 1 if is_red else 2
def confirm_action(stdscr, prompt_text):
height, width = stdscr.getmaxyx()
stdscr.move(height - 3, 0)
stdscr.clrtoeol()
stdscr.addstr(height - 3, 2, f"{prompt_text} (y/n): ", curses.A_BOLD | curses.color_pair(8))
stdscr.refresh()
while True:
ch = stdscr.getch()
if ch in (ord('y'), ord('Y')):
return True
elif ch in (ord('n'), ord('N'), 27):
return False
def choose_difficulty(stdscr):
height, width = stdscr.getmaxyx()
stdscr.move(height - 3, 0)
stdscr.clrtoeol()
stdscr.addstr(height - 3, 2, "Select Difficulty: [1] 1-Suit (Easy) [2] 2-Suit (Medium) [4] 4-Suit (Hard) (ESC to cancel): ", curses.A_BOLD | curses.color_pair(8))
stdscr.refresh()
while True:
ch = stdscr.getch()
if ch == ord('1'):
return 1
elif ch == ord('2'):
return 2
elif ch == ord('4'):
return 4
elif ch == 27: # ESC
return None
def play_game(stdscr):
# Hide standard cursor
try:
curses.curs_set(0)
except Exception:
pass
# Init colors
import curses
try:
curses.start_color()
curses.use_default_colors()
bg = -1
except Exception:
bg = curses.COLOR_BLACK
curses.init_pair(1, curses.COLOR_RED, bg) # Red card
curses.init_pair(2, curses.COLOR_WHITE, bg) # Black card
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_CYAN) # Cursor (Black)
curses.init_pair(4, curses.COLOR_RED, curses.COLOR_CYAN) # Cursor (Red)
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Selected (Black)
curses.init_pair(6, curses.COLOR_RED, curses.COLOR_YELLOW) # Selected (Red)
curses.init_pair(7, curses.COLOR_GREEN, bg) # Status success
curses.init_pair(8, curses.COLOR_YELLOW, bg) # Status warn
# Start default game
difficulty = 1
state = GameState(difficulty)
cursor_col = 0
cursor_row = len(state.tableau[cursor_col]) - 1
selected_col = None
selected_card_idx = None
status_message = "🕷️ Welcome to Spider Solitaire! Use Arrow keys to move cursor, Enter/Space to select."
status_is_error = False
while True:
stdscr.erase()
height, width = stdscr.getmaxyx()
# Guard for small screen sizes
if width < 80 or height < 22:
stdscr.addstr(0, 0, "Terminal screen is too small!")
stdscr.addstr(1, 0, f"Current: {width}x{height} (Required: at least 80x22)")
stdscr.addstr(3, 0, "Please enlarge your terminal or press 'q' to Quit.")
stdscr.refresh()
ch = stdscr.getch()
if ch in (ord('q'), ord('Q'), 27):
break
continue
# 1. Header Row
stdscr.addstr(0, 2, "🕷️ SPIDER SOLITAIRE", curses.A_BOLD | curses.color_pair(7))
diff_str = {1: "1-Suit (Easy)", 2: "2-Suit (Medium)", 4: "4-Suit (Hard)"}[state.difficulty]
summary_str = f"Score: {state.score:<4} Moves: {state.moves_count:<4} Difficulty: {diff_str}"
stdscr.addstr(0, width - len(summary_str) - 2, summary_str, curses.A_BOLD)
stdscr.addstr(1, 0, "=" * width)
# 2. Stock & Foundation Row
num_deals = len(state.stock) // 10
stock_desc = "Stock: "
stdscr.addstr(3, 2, stock_desc)
for d in range(5):
if d < num_deals:
stdscr.addstr(3, 2 + len(stock_desc) + d * 6, "[░░░]", curses.color_pair(2))
else:
stdscr.addstr(3, 2 + len(stock_desc) + d * 6, "[ ]", curses.A_DIM)
stdscr.addstr(3, 2 + len(stock_desc) + 5 * 6, f" ({num_deals} deals left)", curses.A_DIM)
completed_desc = "Completed: "
stdscr.addstr(3, width - 48, completed_desc)
for i in range(8):
comp_x = width - 48 + len(completed_desc) + i * 5
if i < len(state.completed_suits):
suit = state.completed_suits[i]
suit_sym = SUIT_SYMBOLS[suit]
color_p = 1 if suit in ('H', 'D') else 2
stdscr.addstr(3, comp_x, f"[K{suit_sym}]", curses.color_pair(color_p) | curses.A_BOLD)
else:
stdscr.addstr(3, comp_x, "[ ]", curses.A_DIM)
stdscr.addstr(4, 0, "-" * width)
# 3. Tableau Rendering
for col_idx in range(10):
col_x = col_idx * 8 + 1
col_cards = state.tableau[col_idx]
# Label
col_lbl_style = curses.A_BOLD
if cursor_col == col_idx and selected_col is None:
col_lbl_style |= curses.A_UNDERLINE
stdscr.addstr(5, col_x, f" Col {col_idx+1:<2}", col_lbl_style)
stdscr.addstr(6, col_x, "------")
# Get counts and partition
num_fd = sum(1 for c in col_cards if not c.face_up)
y = 7
if num_fd > 0:
stdscr.addstr(y, col_x, f"[░x{num_fd}]", curses.color_pair(2))
y += 1
if not col_cards:
# Column is empty
if cursor_col == col_idx:
stdscr.addstr(y, col_x, "[ - ]", curses.color_pair(3))
else:
stdscr.addstr(y, col_x, "[---]", curses.A_DIM)
else:
for card_idx, card in enumerate(col_cards):
if not card.face_up:
continue
is_cur = (col_idx == cursor_col and card_idx == cursor_row)
is_sel = (selected_col == col_idx and card_idx >= selected_card_idx)
pair = get_card_color_pair(card, is_sel, is_cur)
card_str = f"[{RANK_NAMES[card.rank]:>2}{SUIT_SYMBOLS[card.suit]}]"
draw_row = 7 + (1 if num_fd > 0 else 0) + (card_idx - num_fd)
if draw_row < height - 5:
stdscr.addstr(draw_row, col_x, card_str, curses.color_pair(pair))
# 4. Footer & Control Panel
stdscr.addstr(height - 5, 0, "=" * width)
status_style = curses.color_pair(8) if status_is_error else curses.color_pair(7)
stdscr.addstr(height - 4, 2, status_message[:width-4], status_style | curses.A_BOLD)
instr_str = "Arrows/WASD: Move Cursor | Enter/Space: Select/Move | S: Deal Stock | U: Undo | R: Restart | Q: Quit"
stdscr.addstr(height - 2, 2, instr_str[:width-4], curses.A_DIM)
stdscr.refresh()
# User input
ch = stdscr.getch()
# Navigation
if ch in (curses.KEY_LEFT, ord('h'), ord('H'), ord('a'), ord('A')):
cursor_col = (cursor_col - 1) % 10
col_cards = state.tableau[cursor_col]
cursor_row = max(0, len(col_cards) - 1)
status_message = f"Column {cursor_col + 1} selected."
status_is_error = False
elif ch in (curses.KEY_RIGHT, ord('l'), ord('L'), ord('d'), ord('D')):
cursor_col = (cursor_col + 1) % 10
col_cards = state.tableau[cursor_col]
cursor_row = max(0, len(col_cards) - 1)
status_message = f"Column {cursor_col + 1} selected."
status_is_error = False
elif ch in (curses.KEY_UP, ord('k'), ord('K'), ord('w'), ord('W')):
if selected_col is None:
col_cards = state.tableau[cursor_col]
num_fd = sum(1 for c in col_cards if not c.face_up)
if len(col_cards) > 0:
cursor_row = max(num_fd, cursor_row - 1)
else:
status_message = "Locked on selected sequence. Choose destination column and press Enter/Space."
status_is_error = True
elif ch in (curses.KEY_DOWN, ord('j'), ord('J'), ord('s'), ord('S')) and ch not in (ord('s'), ord('S')):
if selected_col is None:
col_cards = state.tableau[cursor_col]
if len(col_cards) > 0:
cursor_row = min(len(col_cards) - 1, cursor_row + 1)
else:
status_message = "Locked on selected sequence. Choose destination column and press Enter/Space."
status_is_error = True
elif ch in (ord(' '), 10, 13, curses.KEY_ENTER):
if selected_col is None:
# Select sequence
col_cards = state.tableau[cursor_col]
if not col_cards:
status_message = "Cannot select from an empty column!"
status_is_error = True
else:
valid_starts = state.get_movable_sequence_start_indices(cursor_col)
if cursor_row in valid_starts:
selected_col = cursor_col
selected_card_idx = cursor_row
status_message = f"Selected cards from Col {selected_col + 1}. Choose target column and press Enter."
status_is_error = False
else:
status_message = "Invalid selection! Cards must be descending and of the same suit."
status_is_error = True
else:
# Attempt move
if cursor_col == selected_col:
# Deselect
selected_col = None
selected_card_idx = None
status_message = "Selection cleared."
status_is_error = False
else:
success = state.move_cards(selected_col, selected_card_idx, cursor_col)
if success:
selected_col = None
selected_card_idx = None
status_message = "Moved successfully!"
status_is_error = False
# Set cursor row to bottom of new column
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
# Check Win
if state.is_won():
stdscr.erase()
stdscr.addstr(height // 2 - 2, (width - 40) // 2, "🎉 CONGRATULATIONS! YOU WON! 🎉", curses.A_BOLD | curses.color_pair(7))
stdscr.addstr(height // 2, (width - 30) // 2, f"Final Score: {state.score}", curses.A_BOLD)
stdscr.addstr(height // 2 + 1, (width - 30) // 2, f"Total Moves: {state.moves_count}", curses.A_BOLD)
stdscr.addstr(height // 2 + 3, (width - 40) // 2, "Press any key to exit...", curses.A_DIM)
stdscr.refresh()
stdscr.getch()
break
else:
status_message = "Invalid move! Target card must be 1 rank higher than selected card."
status_is_error = True
elif ch in (ord('c'), ord('C'), 27): # ESC or C clears selection
if selected_col is not None:
selected_col = None
selected_card_idx = None
status_message = "Selection cleared."
status_is_error = False
else:
# Prompt Quit on Esc if nothing is selected
if confirm_action(stdscr, "Are you sure you want to quit?"):
break
else:
status_message = "Quit cancelled."
status_is_error = False
elif ch in (ord('s'), ord('S')):
if not state.can_deal_from_stock():
if len(state.stock) < 10:
status_message = "Stock is empty!"
else:
status_message = "Cannot deal: all empty columns must be filled first!"
status_is_error = True
else:
success = state.deal_from_stock()
if success:
selected_col = None
selected_card_idx = None
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
status_message = "Dealt 10 cards from the stock!"
status_is_error = False
elif ch in (ord('u'), ord('U')):
if state.undo():
selected_col = None
selected_card_idx = None
cursor_row = max(0, len(state.tableau[cursor_col]) - 1)
status_message = "Last move undone."
status_is_error = False
else:
status_message = "Nothing to undo!"
status_is_error = True
elif ch in (ord('r'), ord('R')):
if confirm_action(stdscr, "Are you sure you want to restart?"):
diff = choose_difficulty(stdscr)
if diff is not None:
difficulty = diff
state = GameState(difficulty)
cursor_col = 0
cursor_row = len(state.tableau[cursor_col]) - 1
selected_col = None
selected_card_idx = None
status_message = f"Started a new {difficulty}-Suit game!"
status_is_error = False
else:
status_message = "Restart cancelled."
status_is_error = False
elif ch in (ord('q'), ord('Q')):
if confirm_action(stdscr, "Are you sure you want to quit?"):
break
else:
status_message = "Quit cancelled."
status_is_error = False
elif ch == curses.KEY_RESIZE:
# Re-read terminal dimensions next loop
pass
def main():
if len(sys.argv) > 1 and sys.argv[1] == '--smoke':
run_smoke_test()
else:
import curses
from curses import wrapper
try:
wrapper(play_game)
except KeyboardInterrupt:
print("\nGame exited.")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,173 @@
import unittest
from engine import GameState, Card, RANK_NAMES, SUIT_SYMBOLS
class TestSpiderSolitaireEngine(unittest.TestCase):
def test_initialization_1_suit(self):
state = GameState(difficulty=1)
# Check initial totals
self.assertEqual(len(state.stock), 50)
total_tableau_cards = sum(len(col) for col in state.tableau)
self.assertEqual(total_tableau_cards, 54)
self.assertEqual(state.completed_sequences, 0)
self.assertEqual(state.score, 500)
self.assertEqual(state.moves_count, 0)
# Columns 1-4 (indices 0-3) should have 6 cards, last is face-up
for i in range(4):
self.assertEqual(len(state.tableau[i]), 6)
self.assertTrue(state.tableau[i][-1].face_up)
self.assertFalse(state.tableau[i][0].face_up)
# Columns 5-10 (indices 4-9) should have 5 cards, last is face-up
for i in range(4, 10):
self.assertEqual(len(state.tableau[i]), 5)
self.assertTrue(state.tableau[i][-1].face_up)
self.assertFalse(state.tableau[i][0].face_up)
# Verify all cards are Spades ('S')
for col in state.tableau:
for card in col:
self.assertEqual(card.suit, 'S')
for card in state.stock:
self.assertEqual(card.suit, 'S')
def test_initialization_2_suit(self):
state = GameState(difficulty=2)
suits = set()
for col in state.tableau:
for card in col:
suits.add(card.suit)
for card in state.stock:
suits.add(card.suit)
self.assertEqual(suits, {'S', 'H'})
def test_initialization_4_suit(self):
state = GameState(difficulty=4)
suits = set()
for col in state.tableau:
for card in col:
suits.add(card.suit)
for card in state.stock:
suits.add(card.suit)
self.assertEqual(suits, {'S', 'H', 'D', 'C'})
def test_can_deal_from_stock_restrictions(self):
state = GameState(difficulty=1)
# Initially, all columns have cards, so deal should be allowed
self.assertTrue(state.can_deal_from_stock())
# If we empty a column, deal is blocked
state.tableau[0] = []
self.assertFalse(state.can_deal_from_stock())
def test_deal_from_stock_execution(self):
state = GameState(difficulty=1)
initial_stock_len = len(state.stock)
self.assertTrue(state.deal_from_stock())
self.assertEqual(len(state.stock), initial_stock_len - 10)
self.assertEqual(state.score, 499)
self.assertEqual(state.moves_count, 1)
for col in state.tableau:
self.assertTrue(col[-1].face_up)
def test_movable_sequence_start_indices(self):
state = GameState(difficulty=1)
# Construct a known column state:
# facedown, facedown, 8S (faceup), 7S (faceup), 6S (faceup)
state.tableau[0] = [
Card(10, 'S', face_up=False),
Card(9, 'S', face_up=False),
Card(8, 'S', face_up=True),
Card(7, 'S', face_up=True),
Card(6, 'S', face_up=True),
]
indices = state.get_movable_sequence_start_indices(0)
# Expected movable starts are indices 2, 3, 4 (because [8,7,6], [7,6], [6] are all valid descending)
self.assertEqual(indices, [2, 3, 4])
# If ranks don't match, sequence breaks
state.tableau[0] = [
Card(8, 'S', face_up=True),
Card(6, 'S', face_up=True), # Break descending order
Card(5, 'S', face_up=True),
]
indices = state.get_movable_sequence_start_indices(0)
self.assertEqual(indices, [1, 2]) # 6, 5 is valid sequence, but 8 is broken
# If suits don't match, sequence breaks (even with descending ranks)
state.tableau[0] = [
Card(8, 'S', face_up=True),
Card(7, 'H', face_up=True), # Suit break
Card(6, 'H', face_up=True),
]
indices = state.get_movable_sequence_start_indices(0)
self.assertEqual(indices, [1, 2]) # 7H, 6H is valid, but 8S is broken because of suit
def test_move_cards_validation_and_execution(self):
state = GameState(difficulty=1)
# Col 0: 6S (face_up)
# Col 1: 7S (face_up)
state.tableau[0] = [Card(10, 'S', False), Card(6, 'S', True)]
state.tableau[1] = [Card(10, 'S', False), Card(7, 'S', True)]
# Move 6S on top of 7S
self.assertTrue(state.can_move(0, 1, 1))
self.assertTrue(state.move_cards(0, 1, 1))
# Check results
self.assertEqual(len(state.tableau[0]), 1)
# The facedown 10S in Col 0 should have been flipped faceup
self.assertTrue(state.tableau[0][0].face_up)
# Col 1 should now have 7S, 6S
self.assertEqual(len(state.tableau[1]), 3)
self.assertEqual(state.tableau[1][-2].rank, 7)
self.assertEqual(state.tableau[1][-1].rank, 6)
# Move details
self.assertEqual(state.score, 499)
self.assertEqual(state.moves_count, 1)
def test_undo_functionality(self):
state = GameState(difficulty=1)
state.tableau[0] = [Card(10, 'S', False), Card(6, 'S', True)]
state.tableau[1] = [Card(10, 'S', False), Card(7, 'S', True)]
# Move
state.move_cards(0, 1, 1)
self.assertEqual(state.moves_count, 1)
self.assertEqual(state.score, 499)
# Undo
self.assertTrue(state.undo())
self.assertEqual(state.moves_count, 0)
self.assertEqual(state.score, 500)
self.assertEqual(len(state.tableau[0]), 2)
self.assertFalse(state.tableau[0][0].face_up)
self.assertTrue(state.tableau[0][1].face_up)
self.assertEqual(len(state.tableau[1]), 2)
def test_sequence_completion_and_clearing(self):
state = GameState(difficulty=1)
# Construct a complete run of King down to Ace
run = [Card(rank, 'S', face_up=True) for rank in range(13, 0, -1)]
# Put it in col 0 with 2 facedown cards underneath
state.tableau[0] = [
Card(2, 'S', face_up=False),
Card(3, 'S', face_up=False),
] + run
# Trigger completion check (ordinarily done inside moves/deals, but we call it directly here)
state.check_and_clear_all_completed_sequences()
# Completed sequences should be 1
self.assertEqual(state.completed_sequences, 1)
# The run of 13 cards should be removed from col 0
self.assertEqual(len(state.tableau[0]), 2)
# The top card of col 0 should now be faceup
self.assertTrue(state.tableau[0][-1].face_up)
# Score increases by 100 for completed sequence
self.assertEqual(state.score, 600)
if __name__ == '__main__':
unittest.main()

3
status.json Normal file
View file

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