From b8296f3b8056d6d27f796b29efb93eced54980a1 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 4 Jun 2026 23:34:28 +0000 Subject: [PATCH] fabro(01KTAFMYFYDMRRZ16YQVTW183J): plan_app (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KTAFMYFYDMRRZ16YQVTW183J Fabro-Completed: 2 Fabro-Checkpoint: 35f04a1493ee2bc2abe77faa6e6f2c1c5630aea8 ⚒️ Generated with [Fabro](https://fabro.sh) --- .ai/card-game-fast-plan.md | 106 ++++++++ card-game-app/game.py | 421 +++++++++++++++++++++++++++++++ card-game-app/main.py | 254 +++++++++++++++++++ card-game-app/tests/test_game.py | 148 +++++++++++ status.json | 3 + 5 files changed, 932 insertions(+) create mode 100644 .ai/card-game-fast-plan.md create mode 100644 card-game-app/game.py create mode 100644 card-game-app/main.py create mode 100644 card-game-app/tests/test_game.py create mode 100644 status.json diff --git a/.ai/card-game-fast-plan.md b/.ai/card-game-fast-plan.md new file mode 100644 index 000000000..1609add29 --- /dev/null +++ b/.ai/card-game-fast-plan.md @@ -0,0 +1,106 @@ +# Klondike Solitaire - Implementation Plan + +This document outlines the concise plan for implementing a terminal-based Klondike Solitaire game in Python under `card-game-app/`. + +--- + +## 1. Game Rules & Data Structures + +We will implement standard **Klondike Solitaire** (Draw 1 variation). + +### Data Models (`card_game_app/game.py` or similar) +- **Suit (Enum)**: `HEARTS` (Red), `DIAMONDS` (Red), `CLUBS` (Black), `SPADES` (Black). +- **Rank (Enum or Int)**: 1 (Ace) to 13 (King). +- **Card**: + - `suit`: Suit + - `rank`: int + - `face_up`: bool + - Methods: `color()` -> `'red' | 'black'`, `__repr__()` -> e.g. `"10♦"` or `"A♠"`. +- **Pile Types**: + - `Stock`: Draw pile (face-down cards). + - `Waste`: Discard/reveal pile (face-up cards). + - `Foundation`: 4 piles, build up by suit from Ace to King. + - `Tableau`: 7 piles, build down by alternating color. +- **GameState**: + - `stock`: List[Card] + - `waste`: List[Card] + - `foundations`: Dict[Suit, List[Card]] (or list of 4 piles) + - `tableaus`: List[List[Card]] (7 piles) + - `selected_pile`: Optional index/reference + - `selected_card_idx`: Optional index in that pile for stack moves. + +--- + +## 2. Terminal Rendering with `curses` + +The UI will be drawn using the standard Python `curses` module. + +### Core UI Components +- **Layout**: + - **Top Row**: Stock, Waste, spacing, and 4 Foundations. + - **Bottom Row**: 7 Tableau columns displaying cards stacked vertically. + - **Footer**: Keybindings help text, move status messages, or win/loss announcements. +- **Rendering Elements**: + - Face-down cards drawn as `[ █ ]` (blue/cyan background block or custom pattern). + - Face-up cards drawn with colored suit symbols: red for `♥`/`♦` and white/default for `♠`/`♣`. + - Selected cards/piles highlighted using reverse-video or blinking attribute (`curses.A_REVERSE`). + - Active cursor highlighted in yellow/cyan. +- **Color Pairs Setup**: + - Pair 1: Red text on Black (Hearts/Diamonds) + - Pair 2: White text on Black (Spades/Clubs) + - Pair 3: Blue/Cyan on Black (Face-down card back / Board outlines) + - Pair 4: Yellow/Black (Cursor / Help text) + +--- + +## 3. Input Handling & Navigation + +We will implement a standard **Cursor-Based Selection System**: +- **Movement**: + - Arrow keys or `WASD` to move the cursor between the different piles (Stock, Waste, Foundations 1-4, Tableaus 1-7). + - When on a Tableau, `Up`/`Down` keys navigate the cursor up/down the face-up cards to select a partial stack to move. +- **Interaction**: + - `Space` / `Enter`: + - On **Stock**: Draws a card to Waste. If Stock is empty, recycles Waste back to Stock. + - On **other piles**: + - If no pile is currently selected, selects the pile (and the card under the cursor). + - If a pile *is* selected, attempts to move the selected card/stack to the target pile. + - `Esc` / `c`: Cancels current selection. + - `q`: Quits the game. + - `r`: Restarts/re-shuffles. +- **Move Validation**: + - Moving to **Foundation**: Only a single card. Must be Ace on empty foundation, or rank+1 of the same suit. + - Moving to **Tableau**: Can be a stack. Bottom card of the moving stack must be opposite color and rank-1 of the target Tableau's top card. If target Tableau is empty, only a King can be placed. + - Auto-flip: If a move leaves a Tableau with a face-down card on top, it is automatically flipped face-up. + +--- + +## 4. Win/Loss Detection + +- **Win Condition**: All 4 foundations contain 13 cards (total 52 cards). +- **Loss / No Moves Condition**: Game doesn't force end on lock (since cards can be moved back and forth), but can show a hint if no moves are possible. +- When won, show a beautiful success overlay and prompt for restart. + +--- + +## 5. Test Strategy & Smoke Verification + +### Unit Tests +- Testing standard moves: Stock draw, Stock recycle, valid/invalid Tableau moves, valid/invalid Foundation moves. +- Testing auto-reveal of top Tableau cards. +- Testing complete win-state detection. + +### Smoke Verification (`python3 main.py --smoke`) +- A non-interactive mode that initializes a seed-based game state, executes a valid move sequence, asserts the outcomes, and prints a success message before exiting with `0`. This allows automated verification in environments without a TTY or curses terminal. + +--- + +## 6. Implementation File Structure + +``` +card-game-app/ +├── main.py # Entrypoint (CLI args, --smoke, starts curses) +├── game.py # Core game logic engine +└── tests/ + └── test_game.py # Complete test suite +``` diff --git a/card-game-app/game.py b/card-game-app/game.py new file mode 100644 index 000000000..f7a219706 --- /dev/null +++ b/card-game-app/game.py @@ -0,0 +1,421 @@ +import random +from typing import List, Dict, Optional, Tuple + +class Card: + SUITS = { + 'H': '♥', # Hearts (Red) + 'D': '♦', # Diamonds (Red) + 'C': '♣', # Clubs (Black) + 'S': '♠' # Spades (Black) + } + + RANKS = { + 1: 'A', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', + 8: '8', 9: '9', 10: '10', 11: 'J', 12: 'Q', 13: 'K' + } + + def __init__(self, suit: str, rank: int, face_up: bool = False): + if suit not in self.SUITS: + raise ValueError(f"Invalid suit: {suit}") + if rank not in self.RANKS: + raise ValueError(f"Invalid rank: {rank}") + self.suit = suit + self.rank = rank + self.face_up = face_up + + @property + def color(self) -> str: + return 'red' if self.suit in ('H', 'D') else 'black' + + @property + def suit_symbol(self) -> str: + return self.SUITS[self.suit] + + @property + def rank_symbol(self) -> str: + return self.RANKS[self.rank] + + def __repr__(self) -> str: + status = "↑" if self.face_up else "↓" + return f"{self.rank_symbol}{self.suit_symbol}{status}" + + def to_string(self) -> str: + if self.face_up: + return f"{self.rank_symbol}{self.suit_symbol}" + return "[ █ ]" + + +class GameState: + def __init__(self, seed: Optional[int] = None): + self.seed = seed + self.stock: List[Card] = [] + self.waste: List[Card] = [] + # Foundations: 4 piles, indexed 0 to 3. Each starts empty. + # Associated with H, D, C, S respectively. + self.foundations: List[List[Card]] = [[], [], [], []] + self.foundation_suits = ['H', 'D', 'C', 'S'] + # Tableaus: 7 piles. + self.tableaus: List[List[Card]] = [[] for _ in range(7)] + + # Cursor and Selection State + # row: 0 (top row: Stock, Waste, spacer, F0-F3), 1 (bottom row: T0-T6) + # col: 0 to 6 + self.cursor_row = 1 + self.cursor_col = 0 + # For tableau columns, we can select a specific card index + self.cursor_card_idx = 0 + + # Selection: tuple of (row, col, card_idx) or None + self.selected: Optional[Tuple[int, int, int]] = None + + self.message = "Welcome to Solitaire! Use arrows to navigate, Space/Enter to select/move." + self.reset() + + def reset(self): + # Create deck + deck = [] + for suit in ['H', 'D', 'C', 'S']: + for rank in range(1, 14): + deck.append(Card(suit, rank, face_up=False)) + + if self.seed is not None: + random.seed(self.seed) + else: + random.seed() + random.shuffle(deck) + + # Deal Tableaus + # T0 gets 1 card, T1 gets 2, ..., T6 gets 7 + self.tableaus = [[] for _ in range(7)] + for i in range(7): + for j in range(i + 1): + card = deck.pop() + if j == i: + card.face_up = True + self.tableaus[i].append(card) + + # Remaining cards go to stock + self.stock = deck + self.waste = [] + self.foundations = [[], [], [], []] + + # Reset selection and cursor + self.cursor_row = 1 + self.cursor_col = 0 + self.cursor_card_idx = len(self.tableaus[0]) - 1 if self.tableaus[0] else 0 + self.selected = None + self.message = "Game reset. New shuffle!" + + def get_first_face_up_idx(self, tableau_idx: int) -> int: + tableau = self.tableaus[tableau_idx] + for idx, card in enumerate(tableau): + if card.face_up: + return idx + return -1 + + def move_cursor(self, direction: str): + """ + Moves the cursor around the 2x7 grid. + Row 0: 0=Stock, 1=Waste, 2=Spacer, 3=F0, 4=F1, 5=F2, 6=F3 + Row 1: 0=Tableau 0, 1=Tableau 1, ..., 6=Tableau 6 + """ + if direction == 'left': + new_col = self.cursor_col - 1 + if self.cursor_row == 0 and new_col == 2: + new_col = 1 # Skip spacer going left + if new_col < 0: + new_col = 0 + self.cursor_col = new_col + # Update card index if entering Tableau + if self.cursor_row == 1: + t_len = len(self.tableaus[self.cursor_col]) + self.cursor_card_idx = max(0, t_len - 1) + + elif direction == 'right': + new_col = self.cursor_col + 1 + if self.cursor_row == 0 and new_col == 2: + new_col = 3 # Skip spacer going right + if new_col > 6: + new_col = 6 + self.cursor_col = new_col + # Update card index if entering Tableau + if self.cursor_row == 1: + t_len = len(self.tableaus[self.cursor_col]) + self.cursor_card_idx = max(0, t_len - 1) + + elif direction == 'up': + if self.cursor_row == 1: + # If on a tableau, try to move UP the face-up stack first + first_face_up = self.get_first_face_up_idx(self.cursor_col) + if first_face_up != -1 and self.cursor_card_idx > first_face_up: + self.cursor_card_idx -= 1 + else: + # Move to top row + self.cursor_row = 0 + if self.cursor_col == 2: + self.cursor_col = 1 # Don't land on spacer + else: + # Already on row 0, do nothing + pass + + elif direction == 'down': + if self.cursor_row == 0: + self.cursor_row = 1 + t_len = len(self.tableaus[self.cursor_col]) + self.cursor_card_idx = max(0, t_len - 1) + else: + # Already on row 1, try to move DOWN the face-up stack + t_len = len(self.tableaus[self.cursor_col]) + if self.cursor_card_idx < t_len - 1: + self.cursor_card_idx += 1 + + def select_or_move(self): + """ + Handles Space/Enter selection logic. + """ + row = self.cursor_row + col = self.cursor_col + + # 1. Clicking on Stock (Row 0, Col 0) + if row == 0 and col == 0: + self.selected = None # Clear any active selection + self.draw_card() + return + + # 2. Clicking on Spacer (Row 0, Col 2) -> Do nothing + if row == 0 and col == 2: + return + + # 3. If no active selection, try to select + if self.selected is None: + if row == 0: + if col == 1: # Waste + if self.waste: + self.selected = (row, col, len(self.waste) - 1) + self.message = "Selected Waste card. Choose destination." + else: + self.message = "Waste is empty!" + elif col >= 3: # Foundations + f_idx = col - 3 + if self.foundations[f_idx]: + self.selected = (row, col, len(self.foundations[f_idx]) - 1) + self.message = f"Selected top of Foundation {self.foundation_suits[f_idx]}. Choose destination." + else: + self.message = "Foundation is empty!" + elif row == 1: # Tableaus + t_idx = col + t_len = len(self.tableaus[t_idx]) + if t_len > 0: + # Ensure cursor card index is valid and face-up + first_face_up = self.get_first_face_up_idx(t_idx) + if first_face_up != -1 and self.cursor_card_idx >= first_face_up: + self.selected = (row, col, self.cursor_card_idx) + card = self.tableaus[t_idx][self.cursor_card_idx] + self.message = f"Selected {card.rank_symbol}{card.suit_symbol} from Tableau {t_idx + 1}." + else: + # Fallback to top card if cursor card index was somehow out of sync or face-down + self.selected = (row, col, t_len - 1) + card = self.tableaus[t_idx][-1] + self.message = f"Selected {card.rank_symbol}{card.suit_symbol} from Tableau {t_idx + 1}." + else: + self.message = "Tableau is empty!" + + # 4. If there is an active selection, try to perform the move + else: + src_row, src_col, src_card_idx = self.selected + + # If selecting the same pile/card, deselect + if src_row == row and src_col == col: + self.selected = None + self.message = "Selection canceled." + return + + success = False + + # Case A: Source is Waste + if src_row == 0 and src_col == 1: + if row == 1: # To Tableau + success = self.move_waste_to_tableau(col) + elif row == 0 and col >= 3: # To Foundation + success = self.move_waste_to_foundation(col - 3) + + # Case B: Source is Foundation + elif src_row == 0 and src_col >= 3: + src_f_idx = src_col - 3 + if row == 1: # To Tableau + success = self.move_foundation_to_tableau(src_f_idx, col) + + # Case C: Source is Tableau + elif src_row == 1: + if row == 1: # To Tableau + success = self.move_tableau_to_tableau(src_col, src_card_idx, col) + elif row == 0 and col >= 3: # To Foundation + # Ensure we are only moving the top single card to Foundation + if src_card_idx == len(self.tableaus[src_col]) - 1: + success = self.move_tableau_to_foundation(src_col, col - 3) + else: + self.message = "Can only move the single top card of a Tableau to Foundation!" + self.selected = None + return + + if success: + self.message = "Valid move completed!" + else: + self.message = "Invalid move!" + + self.selected = None + # Update cursor card index for tableaus to the top card + if self.cursor_row == 1: + t_len = len(self.tableaus[self.cursor_col]) + self.cursor_card_idx = max(0, t_len - 1) + + # --- Core Move Operations --- + + def draw_card(self) -> bool: + """Draws a card from stock to waste, or recycles waste if stock is empty.""" + if self.stock: + card = self.stock.pop() + card.face_up = True + self.waste.append(card) + self.message = f"Drew {card.rank_symbol}{card.suit_symbol}." + return True + elif self.waste: + # Recycle waste back to stock + # Reverse waste, turn face_down + self.stock = list(reversed(self.waste)) + for card in self.stock: + card.face_up = False + self.waste = [] + # and immediately draw the first card + card = self.stock.pop() + card.face_up = True + self.waste.append(card) + self.message = "Recycled waste pile back to stock and drew first card." + return True + else: + self.message = "No cards left in Stock or Waste!" + return False + + def move_waste_to_tableau(self, dest_idx: int) -> bool: + if not self.waste: + return False + card = self.waste[-1] + dest_pile = self.tableaus[dest_idx] + + if not dest_pile: + # Empty tableau accepts only Kings + if card.rank == 13: + dest_pile.append(self.waste.pop()) + return True + else: + dest_card = dest_pile[-1] + if dest_card.face_up and card.color != dest_card.color and card.rank == dest_card.rank - 1: + dest_pile.append(self.waste.pop()) + return True + return False + + def move_waste_to_foundation(self, f_idx: int) -> bool: + if not self.waste: + return False + card = self.waste[-1] + f_suit = self.foundation_suits[f_idx] + dest_pile = self.foundations[f_idx] + + if card.suit != f_suit: + return False + + if not dest_pile: + # Empty foundation accepts only Ace + if card.rank == 1: + dest_pile.append(self.waste.pop()) + return True + else: + top_card = dest_pile[-1] + if card.rank == top_card.rank + 1: + dest_pile.append(self.waste.pop()) + return True + return False + + def move_foundation_to_tableau(self, f_idx: int, dest_idx: int) -> bool: + f_pile = self.foundations[f_idx] + if not f_pile: + return False + card = f_pile[-1] + dest_pile = self.tableaus[dest_idx] + + if not dest_pile: + # Empty tableau accepts only Kings + if card.rank == 13: + dest_pile.append(f_pile.pop()) + return True + else: + dest_card = dest_pile[-1] + if dest_card.face_up and card.color != dest_card.color and card.rank == dest_card.rank - 1: + dest_pile.append(f_pile.pop()) + return True + return False + + def move_tableau_to_tableau(self, src_idx: int, card_idx: int, dest_idx: int) -> bool: + src_pile = self.tableaus[src_idx] + dest_pile = self.tableaus[dest_idx] + + if not src_pile or card_idx >= len(src_pile): + return False + + moving_stack = src_pile[card_idx:] + # Ensure all moving cards are face up + if not all(c.face_up for c in moving_stack): + return False + + first_moving_card = moving_stack[0] + + if not dest_pile: + # Empty tableau accepts only Kings + if first_moving_card.rank == 13: + self.tableaus[dest_idx].extend(moving_stack) + del src_pile[card_idx:] + self.auto_flip_top(src_idx) + return True + else: + dest_card = dest_pile[-1] + if dest_card.face_up and first_moving_card.color != dest_card.color and first_moving_card.rank == dest_card.rank - 1: + self.tableaus[dest_idx].extend(moving_stack) + del src_pile[card_idx:] + self.auto_flip_top(src_idx) + return True + return False + + def move_tableau_to_foundation(self, src_idx: int, f_idx: int) -> bool: + src_pile = self.tableaus[src_idx] + if not src_pile: + return False + card = src_pile[-1] + f_suit = self.foundation_suits[f_idx] + dest_pile = self.foundations[f_idx] + + if card.suit != f_suit: + return False + + if not dest_pile: + # Empty foundation accepts only Ace + if card.rank == 1: + dest_pile.append(src_pile.pop()) + self.auto_flip_top(src_idx) + return True + else: + top_card = dest_pile[-1] + if card.rank == top_card.rank + 1: + dest_pile.append(src_pile.pop()) + self.auto_flip_top(src_idx) + return True + return False + + def auto_flip_top(self, tableau_idx: int): + """If a tableau top card is face down, flip it face up.""" + pile = self.tableaus[tableau_idx] + if pile and not pile[-1].face_up: + pile[-1].face_up = True + + def check_win(self) -> bool: + """The game is won if all four foundations have 13 cards.""" + return all(len(f) == 13 for f in self.foundations) diff --git a/card-game-app/main.py b/card-game-app/main.py new file mode 100644 index 000000000..126a7edab --- /dev/null +++ b/card-game-app/main.py @@ -0,0 +1,254 @@ +import sys +import argparse +import curses +import locale +from game import GameState, Card + +# Set locale to support unicode suit symbols (♥, ♦, ♣, ♠, █) +try: + locale.setlocale(locale.LC_ALL, '') +except Exception: + pass # Fallback to default locale if not supported + +def run_smoke_test(): + """Runs a non-interactive simulation of the game and verifies correctness.""" + print("Running Solitaire smoke test...") + + # Initialize game with a fixed seed + state = GameState(seed=12345) + + # Assert initial setup + assert len(state.stock) == 24, f"Expected 24 stock cards, got {len(state.stock)}" + assert len(state.waste) == 0, "Expected empty waste pile initially" + assert sum(len(t) for t in state.tableaus) == 28, "Expected 28 cards in tableaus" + + # Verify top card of Tableau 0 is face up + assert state.tableaus[0][-1].face_up, "Top card of Tableau 0 should be face up" + + # Action 1: Draw card + success = state.draw_card() + assert success, "Failed to draw card from stock" + assert len(state.stock) == 23, f"Expected 23 stock cards, got {len(state.stock)}" + assert len(state.waste) == 1, f"Expected 1 card in waste, got {len(state.waste)}" + assert state.waste[-1].face_up, "Drawn card should be face up" + + # Action 2: Reset / Restart + state.reset() + assert len(state.stock) == 24, "Expected stock reset to 24" + assert len(state.waste) == 0, "Expected waste reset to 0" + + print("Solitaire smoke test passed successfully!") + sys.exit(0) + +# Curses UI Rendering and Logic + +def draw_game(stdscr, state: GameState): + stdscr.erase() + + # Get terminal dimensions + max_y, max_x = stdscr.getmaxyx() + + if max_y < 24 or max_x < 80: + stdscr.addstr(0, 0, "Terminal too small!", curses.color_pair(1) | curses.A_BOLD) + stdscr.addstr(1, 0, f"Current: {max_x}x{max_y}. Required: 80x24 minimum.", curses.A_BOLD) + stdscr.addstr(2, 0, "Please resize your terminal window to continue...", curses.A_DIM) + stdscr.refresh() + return + + # Draw Header + title = "♠ ♥ ♣ ♦ KLONDIKE SOLITAIRE ♦ ♣ ♥ ♠" + stdscr.addstr(0, (80 - len(title)) // 2, title, curses.color_pair(4) | curses.A_BOLD) + stdscr.addstr(1, 0, "─" * 80, curses.color_pair(3)) + + # Column configuration + col_width = 10 + start_x = 4 + + # --- Draw Row 0 (Stock, Waste, Spacer, Foundations) --- + + # Stock (Col 0) + stock_x = start_x + 0 * col_width + stdscr.addstr(2, stock_x, "[ Stock ]", curses.color_pair(3)) + stock_str = f"[ █ {len(state.stock):2d}]" if state.stock else "[ X ]" + is_cursor = (state.cursor_row == 0 and state.cursor_col == 0) + is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 0) + + attr = curses.color_pair(3) + if is_cursor: + attr |= curses.A_REVERSE + if is_selected: + attr |= curses.A_BLINK + stdscr.addstr(3, stock_x + 1, stock_str, attr) + + # Waste (Col 1) + waste_x = start_x + 1 * col_width + stdscr.addstr(2, waste_x, "[ Waste ]", curses.color_pair(3)) + is_cursor = (state.cursor_row == 0 and state.cursor_col == 1) + is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 1) + + if state.waste: + top_card = state.waste[-1] + card_str = f"[ {top_card.rank_symbol:>2}{top_card.suit_symbol} ]" + color_pair = 1 if top_card.color == 'red' else 2 + attr = curses.color_pair(color_pair) + if is_cursor: + attr |= curses.A_REVERSE + if is_selected: + attr |= curses.A_UNDERLINE + stdscr.addstr(3, waste_x + 1, card_str, attr) + else: + attr = curses.color_pair(3) + if is_cursor: + attr |= curses.A_REVERSE + stdscr.addstr(3, waste_x + 1, "[ Empty ]", attr) + + # Spacer (Col 2) -> just draw blank or placeholder + spacer_x = start_x + 2 * col_width + stdscr.addstr(2, spacer_x, " ") + stdscr.addstr(3, spacer_x, " ") + + # Foundations (Col 3 to 6) + for i in range(4): + f_suit = state.foundation_suits[i] + f_symbol = Card.SUITS[f_suit] + f_x = start_x + (3 + i) * col_width + stdscr.addstr(2, f_x, f"[ F{i+1} {f_symbol} ]", curses.color_pair(3)) + + is_cursor = (state.cursor_row == 0 and state.cursor_col == 3 + i) + is_selected = (state.selected is not None and state.selected[0] == 0 and state.selected[1] == 3 + i) + + f_pile = state.foundations[i] + if f_pile: + top_card = f_pile[-1] + card_str = f"[ {top_card.rank_symbol:>2}{top_card.suit_symbol} ]" + color_pair = 1 if top_card.color == 'red' else 2 + attr = curses.color_pair(color_pair) + if is_cursor: + attr |= curses.A_REVERSE + if is_selected: + attr |= curses.A_UNDERLINE + stdscr.addstr(3, f_x + 1, card_str, attr) + else: + color_pair = 1 if f_suit in ('H', 'D') else 2 + attr = curses.color_pair(color_pair) + if is_cursor: + attr |= curses.A_REVERSE + stdscr.addstr(3, f_x + 1, f"[ {f_symbol} ]", attr) + + # --- Draw Row 1 (Tableaus 0 to 6) --- + + stdscr.addstr(5, 0, "─" * 80, curses.color_pair(3)) + + for col in range(7): + t_x = start_x + col * col_width + stdscr.addstr(6, t_x, f"[ T{col+1} ]", curses.color_pair(3)) + + pile = state.tableaus[col] + if not pile: + # Draw empty placeholder + is_cursor = (state.cursor_row == 1 and state.cursor_col == col) + attr = curses.color_pair(3) + if is_cursor: + attr |= curses.A_REVERSE + stdscr.addstr(7, t_x + 1, "[ Empty ]", attr) + else: + for idx, card in enumerate(pile): + is_cursor = (state.cursor_row == 1 and state.cursor_col == col and state.cursor_card_idx == idx) + is_selected = (state.selected is not None and state.selected[0] == 1 and state.selected[1] == col and idx >= state.selected[2]) + + card_y = 7 + idx + # If too deep for screen, cap rendering and show indicators + if card_y >= max_y - 4: + stdscr.addstr(max_y - 4, t_x + 1, "[ ... ]", curses.color_pair(3)) + break + + if card.face_up: + card_str = f"[ {card.rank_symbol:>2}{card.suit_symbol} ]" + color_pair = 1 if card.color == 'red' else 2 + attr = curses.color_pair(color_pair) + else: + card_str = "[ █ ]" + attr = curses.color_pair(3) + + if is_selected: + attr |= curses.A_UNDERLINE + if is_cursor: + attr |= curses.A_REVERSE + + stdscr.addstr(card_y, t_x + 1, card_str, attr) + + # --- Draw Footer / Information --- + + # Win State banner + if state.check_win(): + win_msg = "★★★ CONGRATULATIONS! YOU WON THE GAME! Press 'R' to play again. ★★★" + stdscr.addstr(max_y - 3, (80 - len(win_msg)) // 2, win_msg, curses.color_pair(4) | curses.A_BOLD | curses.A_BLINK) + else: + # Action/Message line + stdscr.addstr(max_y - 3, 2, "Status: " + state.message[:75], curses.color_pair(4) | curses.A_BOLD) + + # Keyboard helper + legend = "[Arrows/WASD] Move [Space/Enter] Select/Move [Esc/C] Cancel [R] Restart [Q] Quit" + stdscr.addstr(max_y - 2, (80 - len(legend)) // 2, legend, curses.A_DIM) + + stdscr.refresh() + +def main_loop(stdscr): + # Initialize Curses Colors + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_RED, -1) # Red suits + curses.init_pair(2, curses.COLOR_WHITE, -1) # Black suits + curses.init_pair(3, curses.COLOR_CYAN, -1) # Blue/Cyan borders & back + curses.init_pair(4, curses.COLOR_YELLOW, -1) # Yellow cursor / highlights + + # Hide standard cursor block + try: + curses.curs_set(0) + except Exception: + pass + + state = GameState() + + while True: + draw_game(stdscr, state) + + try: + ch = stdscr.getch() + except KeyboardInterrupt: + break + + if ch == -1: + continue + + # Standardize inputs (support arrow keys, WASD, and letters) + if ch == ord('q') or ch == ord('Q'): + break + elif ch == ord('r') or ch == ord('R'): + state.reset() + elif ch in (curses.KEY_LEFT, ord('a'), ord('A')): + state.move_cursor('left') + elif ch in (curses.KEY_RIGHT, ord('d'), ord('D')): + state.move_cursor('right') + elif ch in (curses.KEY_UP, ord('w'), ord('W')): + state.move_cursor('up') + elif ch in (curses.KEY_DOWN, ord('s'), ord('S')): + state.move_cursor('down') + elif ch in (ord(' '), ord('\n'), curses.KEY_ENTER): + state.select_or_move() + elif ch in (27, ord('c'), ord('C')): # 27 is Esc + state.selected = None + state.message = "Selection canceled." + +def main(): + parser = argparse.ArgumentParser(description="Terminal Klondike Solitaire Game") + parser.add_argument("--smoke", action="store_true", help="Run non-interactive smoke test and exit") + args = parser.parse_args() + + if args.smoke: + run_smoke_test() + else: + # Start the interactive curses application + curses.wrapper(main_loop) + +if __name__ == "__main__": + main() diff --git a/card-game-app/tests/test_game.py b/card-game-app/tests/test_game.py new file mode 100644 index 000000000..d1245755d --- /dev/null +++ b/card-game-app/tests/test_game.py @@ -0,0 +1,148 @@ +import unittest +from game import Card, GameState + +class TestSolitaireGame(unittest.TestCase): + + def test_card_properties(self): + card1 = Card('H', 1, face_up=True) # Ace of Hearts + card2 = Card('S', 13, face_up=False) # King of Spades + + self.assertEqual(card1.color, 'red') + self.assertEqual(card1.suit_symbol, '♥') + self.assertEqual(card1.rank_symbol, 'A') + self.assertEqual(card1.to_string(), "A♥") + + self.assertEqual(card2.color, 'black') + self.assertEqual(card2.suit_symbol, '♠') + self.assertEqual(card2.rank_symbol, 'K') + self.assertEqual(card2.to_string(), "[ █ ]") + + def test_initial_state(self): + state = GameState(seed=42) + # Total cards = 52 + total_tableau_cards = sum(len(t) for t in state.tableaus) + self.assertEqual(total_tableau_cards, 28) # 1+2+3+4+5+6+7 = 28 + self.assertEqual(len(state.stock), 24) # 52 - 28 = 24 + self.assertEqual(len(state.waste), 0) + self.assertTrue(all(len(f) == 0 for f in state.foundations)) + + # Check that top tableau cards are face-up and lower ones are face-down + for i in range(7): + t = state.tableaus[i] + for j in range(i): + self.assertFalse(t[j].face_up) + self.assertTrue(t[i].face_up) + + def test_draw_and_recycle_stock(self): + state = GameState(seed=42) + initial_stock_size = len(state.stock) + + # Draw all stock cards + for i in range(initial_stock_size): + self.assertTrue(state.draw_card()) + + self.assertEqual(len(state.stock), 0) + self.assertEqual(len(state.waste), initial_stock_size) + self.assertTrue(all(c.face_up for c in state.waste)) + + # Draw when empty should recycle + self.assertTrue(state.draw_card()) + self.assertEqual(len(state.stock), initial_stock_size - 1) + self.assertEqual(len(state.waste), 1) + self.assertTrue(state.waste[-1].face_up) + self.assertTrue(all(not c.face_up for c in state.stock)) + + def test_move_waste_to_tableau_valid_and_invalid(self): + state = GameState(seed=42) + + # Let's set up a predictable waste and tableau top card for testing + # Waste top: Red Q (Hearts, 12) + state.waste = [Card('H', 12, face_up=True)] + + # Destination Tableau top card is Black K (Spades, 13) + state.tableaus[0] = [Card('S', 13, face_up=True)] + + # Move Q to K is valid (Red 12 on Black 13) + success = state.move_waste_to_tableau(0) + self.assertTrue(success) + self.assertEqual(len(state.waste), 0) + self.assertEqual(state.tableaus[0][-1].rank, 12) + + # Destination Tableau is now Red 12 on top. Let's put Black Jack (11) on waste + state.waste = [Card('C', 11, face_up=True)] + success = state.move_waste_to_tableau(0) + self.assertTrue(success) + self.assertEqual(len(state.waste), 0) + self.assertEqual(state.tableaus[0][-1].rank, 11) + + # Try to move non-matching card: Black 10 (Spades, 10) on Black Jack (11) (invalid, should be opposite color) + state.waste = [Card('S', 10, face_up=True)] + success = state.move_waste_to_tableau(0) + self.assertFalse(success) + self.assertEqual(len(state.waste), 1) + + def test_move_to_empty_tableau_only_king(self): + state = GameState(seed=42) + state.tableaus[0] = [] # empty + + # Try putting a Queen on empty tableau + state.waste = [Card('H', 12, face_up=True)] + success = state.move_waste_to_tableau(0) + self.assertFalse(success) + + # Put a King on empty tableau + state.waste = [Card('D', 13, face_up=True)] + success = state.move_waste_to_tableau(0) + self.assertTrue(success) + self.assertEqual(len(state.tableaus[0]), 1) + self.assertEqual(state.tableaus[0][0].rank, 13) + + def test_move_tableau_to_tableau_stack(self): + state = GameState(seed=42) + # Tableau 0: Black K (Spades, 13) + state.tableaus[0] = [Card('S', 13, face_up=True)] + + # Tableau 1: Red Q (Hearts, 12) -> Black J (Clubs, 11) + state.tableaus[1] = [Card('H', 12, face_up=True), Card('C', 11, face_up=True)] + + # Move the entire stack [Q, J] onto K + success = state.move_tableau_to_tableau(1, 0, 0) + self.assertTrue(success) + self.assertEqual(len(state.tableaus[1]), 0) + self.assertEqual(len(state.tableaus[0]), 3) + self.assertEqual([c.rank for c in state.tableaus[0]], [13, 12, 11]) + + def test_move_to_foundation(self): + state = GameState(seed=42) + # Foundation 0 is 'H' (Hearts) + # Try putting a 2 of Hearts on empty foundation (should fail, needs Ace) + state.waste = [Card('H', 2, face_up=True)] + success = state.move_waste_to_foundation(0) + self.assertFalse(success) + + # Put Ace of Hearts (1) + state.waste = [Card('H', 1, face_up=True)] + success = state.move_waste_to_foundation(0) + self.assertTrue(success) + self.assertEqual(len(state.foundations[0]), 1) + + # Now put 2 of Hearts (2) + state.waste = [Card('H', 2, face_up=True)] + success = state.move_waste_to_foundation(0) + self.assertTrue(success) + self.assertEqual(len(state.foundations[0]), 2) + self.assertEqual(state.foundations[0][-1].rank, 2) + + def test_check_win(self): + state = GameState(seed=42) + self.assertFalse(state.check_win()) + + # Fill foundations to win + for i, suit in enumerate(state.foundation_suits): + for rank in range(1, 14): + state.foundations[i].append(Card(suit, rank, face_up=True)) + + self.assertTrue(state.check_win()) + +if __name__ == '__main__': + unittest.main() 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" +}