diff --git a/.ai/card-game-fast-plan.md b/.ai/card-game-fast-plan.md new file mode 100644 index 000000000..d3ab8d954 --- /dev/null +++ b/.ai/card-game-fast-plan.md @@ -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` diff --git a/card-game-app/engine.py b/card-game-app/engine.py new file mode 100644 index 000000000..fea3c064b --- /dev/null +++ b/card-game-app/engine.py @@ -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 diff --git a/card-game-app/main.py b/card-game-app/main.py new file mode 100644 index 000000000..bdeb32c5b --- /dev/null +++ b/card-game-app/main.py @@ -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() diff --git a/card-game-app/test_engine.py b/card-game-app/test_engine.py new file mode 100644 index 000000000..ff24caefe --- /dev/null +++ b/card-game-app/test_engine.py @@ -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() diff --git a/status.json b/status.json new file mode 100644 index 000000000..905fb370f --- /dev/null +++ b/status.json @@ -0,0 +1,3 @@ +{ + "outcome": "succeeded" +}