From b2b2ec7ff217e2c27b95bbea22974b6b863e9f22 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 4 Jun 2026 19:26:04 +0000 Subject: [PATCH] fabro(01KTA1FC5W5W0BHTQSV865A1H6): expand_spec (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KTA1FC5W5W0BHTQSV865A1H6 Fabro-Completed: 2 Fabro-Checkpoint: 290d6dc9289b090e248e64bc68001cc4bd6a2f33 ⚒️ Generated with [Fabro](https://fabro.sh) --- .ai/card-game-spec.md | 444 ++++++++++++++++++++++++++++++++++++++++++ status.json | 3 + 2 files changed, 447 insertions(+) create mode 100644 .ai/card-game-spec.md create mode 100644 status.json diff --git a/.ai/card-game-spec.md b/.ai/card-game-spec.md new file mode 100644 index 000000000..2bb01a57c --- /dev/null +++ b/.ai/card-game-spec.md @@ -0,0 +1,444 @@ +# Detailed Implementation Spec: Terminal-Based FreeCell Solitaire in Python + +This document defines the complete technical specifications, software architecture, game rules, and interface design for a terminal-based FreeCell Solitaire game implemented in Python 3. The game relies on the standard library `curses` module for interactive rendering and must support a headless `--smoke` mode for CI/CD test automation. + +--- + +## 1. Overview and Game Rules + +FreeCell is a solitaire card game played with a single standard 52-card deck. Unlike most other solitaire games, almost all deals are solvable because all cards are dealt face-up from the start. + +### The Board Layout +The board consists of three main areas: +1. **The Tableau**: 8 columns. + - Columns 1–4 are dealt 7 cards each. + - Columns 5–8 are dealt 6 cards each. + - All cards are face-up and overlap vertically so that only their values/suits are visible, except for the bottom-most card which is fully exposed. +2. **The FreeCells (Open Cells)**: 4 temporary storage slots. + - Each slot can hold at most one card of any rank or suit. + - Cards in FreeCells can be moved back to the Tableau or to the Foundations. +3. **The Foundations**: 4 piles, one for each suit (Spades ♠, Hearts ♥, Diamonds ♦, Clubs ♣). + - These are built up in ascending order by suit, starting from Ace (A) and ending at King (K). + - Once a card is placed in a Foundation pile, it is generally kept there (or can optionally be pulled back if required, though typical play is unidirectional). + +### Valid Moves +* **To FreeCell**: Any single exposed card (the bottom-most card of a Tableau column or a card in another FreeCell) can be moved to an empty FreeCell. +* **To Tableau (Single Card)**: + - An exposed card can be placed on top of the bottom-most card of any Tableau column if the target card has a rank exactly 1 higher and is of the **opposite color** (Red vs. Black). + - Any single exposed card can be placed in an empty Tableau column. +* **To Foundation**: + - An Ace of any suit can be moved to an empty Foundation pile. + - A card can be placed on a Foundation pile if it matches the pile's suit and has a rank exactly 1 higher than the top card of that pile (e.g., a 4♦ on a 3♦). +* **Tableau to Tableau (Multi-card Sequence or "Supermove")**: + - A valid sequence (ordered descending by rank and alternating in color, e.g., 8♥, 7♠, 6♦) can be moved together from one Tableau column to another. + - The maximum size $M$ of a sequence that can be moved depends on the number of empty FreeCells ($F$) and empty Tableau columns ($T$): + $$M = (1 + F) \times 2^T$$ + *Note: If the destination Tableau column is empty, it does not count as "empty" in the exponent $T$ of the formula, because it is the target of the move.* + +### Win and Loss Conditions +* **Win**: All 52 cards are successfully placed in the 4 Foundation piles (each containing 13 cards from Ace to King). +* **Loss / Stalemate**: No legal moves are possible between the Tableau, FreeCells, and Foundations. + +--- + +## 2. Core Data Structures (The Headless Engine) + +To guarantee that the game logic is 100% testable without `curses` or any active terminal context, all game rules, cards, and board state must be encapsulated in a pure Python engine. + +### Enums and Basic Types + +```python +from enum import Enum, auto +from typing import List, Optional, Dict, Tuple, NamedTuple +import random + +class Suit(Enum): + SPADES = "♠" + HEARTS = "♥" + DIAMONDS = "♦" + CLUBS = "♣" + + @property + def color(self) -> str: + if self in (Suit.HEARTS, Suit.DIAMONDS): + return "RED" + return "BLACK" + +class Rank(Enum): + ACE = 1 + TWO = 2 + THREE = 3 + FOUR = 4 + FIVE = 5 + SIX = 6 + SEVEN = 7 + EIGHT = 8 + NINE = 9 + TEN = 10 + JACK = 11 + QUEEN = 12 + KING = 13 + + @property + def symbol(self) -> str: + mapping = { + Rank.ACE: "A", + Rank.JACK: "J", + Rank.QUEEN: "Q", + Rank.KING: "K" + } + return mapping.get(self, str(self.value)) +``` + +### The `Card` Class + +```python +class Card: + def __init__(self, rank: Rank, suit: Suit): + self.rank: Rank = rank + self.suit: Suit = suit + + @property + def color(self) -> str: + return self.suit.color + + def is_opposite_color(self, other: "Card") -> bool: + return self.color != other.color + + def can_be_placed_on_tableau(self, other: "Card") -> bool: + """Checks if self can be placed on other (which is on top of a Tableau column).""" + return self.is_opposite_color(other) and self.rank.value == other.rank.value - 1 + + def __repr__(self) -> str: + return f"{self.rank.symbol}{self.suit.value}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Card): + return NotImplemented + return self.rank == other.rank and self.suit == other.suit +``` + +### Pile Types and Identifiers + +Move commands will refer to specific regions of the board. We define a standard naming schema for sources and destinations: +- **FreeCells**: `F1`, `F2`, `F3`, `F4` (or 0-indexed integer indices `0` to `3` mapped to `F`) +- **Foundations**: `A1`, `A2`, `A3`, `A4` (or `Foundation_Spades`, etc., mapped to `A`) +- **Tableau Columns**: `C1` to `C8` (or 0-indexed `0` to `7` mapped to `C`) + +```python +class Move(NamedTuple): + src_type: str # 'C' (Tableau), 'F' (Freecell) + src_idx: int # 0-indexed + dst_type: str # 'C' (Tableau), 'F' (Freecell), 'A' (Foundation) + dst_idx: int # 0-indexed + card_count: int = 1 # For sequence moves +``` + +### GameState Class + +```python +class GameState: + def __init__(self): + self.tableau: List[List[Card]] = [[] for _ in range(8)] + self.free_cells: List[Optional[Card]] = [None] * 4 + self.foundations: Dict[Suit, List[Card]] = { + Suit.SPADES: [], + Suit.HEARTS: [], + Suit.DIAMONDS: [], + Suit.CLUBS: [] + } + self.history: List[Tuple[List[List[Card]], List[Optional[Card]], Dict[Suit, List[Card]]]] = [] + self.redo_history: List[Tuple[List[List[Card]], List[Optional[Card]], Dict[Suit, List[Card]]]] = [] + + def deal(self, seed: Optional[int] = None) -> None: + """Generates, shuffles, and distributes a standard 52-card deck.""" + deck = [Card(rank, suit) for suit in Suit for rank in Rank] + if seed is not None: + random.seed(seed) + else: + random.seed() + random.shuffle(deck) + + self.tableau = [[] for _ in range(8)] + self.free_cells = [None] * 4 + self.foundations = {s: [] for s in Suit} + self.history.clear() + self.redo_history.clear() + + # Deal cards: 7 to columns 0-3, 6 to columns 4-7 + for idx, card in enumerate(deck): + col = idx % 8 + self.tableau[col].append(card) + + def save_state(self) -> Tuple[List[List[Card]], List[Optional[Card]], Dict[Suit, List[Card]]]: + """Creates a deep copy of current piles to push to history.""" + tableau_copy = [col.copy() for col in self.tableau] + free_cells_copy = list(self.free_cells) + foundations_copy = {suit: pile.copy() for suit, pile in self.foundations.items()} + return (tableau_copy, free_cells_copy, foundations_copy) + + def restore_state(self, state_tuple: Tuple[List[List[Card]], List[Optional[Card]], Dict[Suit, List[Card]]]) -> None: + self.tableau, self.free_cells, self.foundations = state_tuple + + def push_history(self) -> None: + self.history.append(self.save_state()) + self.redo_history.clear() + + def undo(self) -> bool: + if not self.history: + return False + self.redo_history.append(self.save_state()) + self.restore_state(self.history.pop()) + return True + + def redo(self) -> bool: + if not self.redo_history: + return False + self.history.append(self.save_state()) + self.restore_state(self.redo_history.pop()) + return True +``` + +--- + +## 3. Terminal UI Layout (using Curses) + +The UI will be formatted to fit standard terminal windows (minimum requirement: 80 columns by 24 lines). + +### ASCII Layout Blueprint + +```text +======================= TERMINAL FREECELL ======================= +Moves: 12 Time: 01:45 [H]elp [Q]uit + + [ F1 ] [ F2 ] [ F3 ] [ F4 ] [ ♠ ] [ ♥ ] [ ♦ ] [ ♣ ] + [ A♠ ] [ -- ] [ -- ] [ -- ] [ A♠ ] [ -- ] [ -- ] [ -- ] + + 1 2 3 4 5 6 7 8 +================================================================= + C1 C2 C3 C4 C5 C6 C7 C8 + K♠ 10♦ 5♣ 8♥ A♦ 7♣ Q♠ J♥ + Q♦ 9♣ 4♦ 7♠ J♣ 6♦ 10♥ + J♣ 8♦ 6♦ 5♠ + 10♥ + +================================================================= +Command: Move from (e.g. C1): _ +[Error/Notification Bar: Invalid move! Red Jack cannot go on Red Queen.] +``` + +### Layout Sections +1. **Header Bar**: Displays current move count, elapsed timer, game state notifications, and quick keys (`H` for Help, `Q` for Quit, `U` for Undo, `R` for Reset, `N` for New Game). +2. **Top Deck Row**: + - **FreeCells (Left)**: 4 slots showing empty state `[ -- ]` or the card string `[ Q♦ ]`. + - **Foundations (Right)**: 4 slots showing the suit symbol for empty stacks `[ ♠ ]`, or the top-most card of the stack `[ K♠ ]`. +3. **Tableau Columns Row**: + - Headers: `C1` to `C8`. + - Vertical stacks. Overlapping cards are printed downward. + - Selected columns or selected cards must be highlighted in reverse-video or marked with a cursor indicator (e.g., `>` prefix). +4. **Interactive Command/Status Row**: + - Prompts the user for action input if using key sequences. + - Highlights error states with high-contrast text. + +--- + +## 4. Input Handling, Navigation, and Commands + +To accommodate varying terminal capabilities and user preferences, the program will support **Command Sequences** as its primary interface. + +### Keyboard Command Input + +The prompt `Command:` accepts simple character-based coordinates to declare source and destination. + +1. **Selecting Source & Destination**: + - Keys `1` to `8` map directly to Tableau Columns `C1` to `C8`. + - Keys `q`, `w`, `e`, `r` map directly to FreeCells `F1`, `F2`, `F3`, `F4`. + - Keys `a`, `s`, `d`, `f` map directly to Foundations `A1`, `A2`, `A3`, `A4` (or users can just use `a` for automatic best-fit foundation routing). + + *Example Gameplay Interaction*: + - Pressing `1` selects Column 1. The top card is highlighted. + - Pressing `q` immediately moves that card to FreeCell 1 (if empty). + - Pressing `2` followed by `3` moves the bottom card (or valid sequence) of Column 2 to Column 3. + +2. **Command Actions**: + - `u` / `U`: Undo last move. + - `r` / `R`: Redo undone move. + - `n` / `N`: Deal a completely new game. + - `s` / `S`: Restart current game (using same shuffle seed). + - `h` / `H`: Toggle help overlay showing game controls and rules. + - `esc`: Cancel current selection. + - `q` / `Q`: Exit game. + +### Cursor-Based (Optional Secondary Interface) +If arrow keys/WASD navigation is enabled: +- Arrow keys move a highlighted cursor box across Columns 1-8, Freecells, and Foundations. +- `Space` / `Enter` selects the active stack. +- Pressing `Space` / `Enter` on another stack completes the move. + +--- + +## 5. Move and Action Validation + +The rules engine must rigidly enforce FreeCell constraints. + +### Validation Logic Flow Chart (Engine level) + +```python +def validate_move(state: GameState, move: Move) -> Tuple[bool, str]: + """ + Returns (True, "") if the move is legal, or (False, "reason") if illegal. + """ + # 1. Fetch source card(s) + src_cards = get_source_cards(state, move.src_type, move.src_idx, move.card_count) + if not src_cards: + return False, "Source is empty or invalid." + + # 2. If moving multiple cards, verify they form a valid alternating descending sequence + if len(src_cards) > 1: + if not is_valid_sequence(src_cards): + return False, "Selected cards do not form a valid alternating color descending sequence." + + # 3. Validate Destination + if move.dst_type == 'F': # Destination is FreeCell + if move.card_count > 1: + return False, "Cannot move a sequence to a FreeCell." + if state.free_cells[move.dst_idx] is not None: + return False, "Target FreeCell is occupied." + + elif move.dst_type == 'A': # Destination is Foundation + if move.card_count > 1: + return False, "Cannot move a sequence to a Foundation." + card = src_cards[0] + f_pile = state.foundations[card.suit] + if not f_pile: + if card.rank != Rank.ACE: + return False, "Foundations must start with an Ace." + else: + top_card = f_pile[-1] + if card.rank.value != top_card.rank.value + 1: + return False, f"Cannot place {card} on {top_card}. Must be next rank up." + + elif move.dst_type == 'C': # Destination is Tableau + dest_col = state.tableau[move.dst_idx] + first_src_card = src_cards[0] # The highest rank card in the sequence being moved + + if not dest_col: + # Moving sequence/card to empty tableau column + # Verify supermove capacity limit + max_allowed = get_max_movable_cards(state, target_is_empty_col=True) + if len(src_cards) > max_allowed: + return False, f"Insufficient empty FreeCells/Columns to move {len(src_cards)} cards (Max: {max_allowed})." + else: + dest_card = dest_col[-1] + if not first_src_card.can_be_placed_on_tableau(dest_card): + return False, f"Cannot place {first_src_card} on {dest_card}. Must be alternating color and rank-1." + # Verify supermove capacity limit + max_allowed = get_max_movable_cards(state, target_is_empty_col=False) + if len(src_cards) > max_allowed: + return False, f"Insufficient empty FreeCells/Columns to move {len(src_cards)} cards (Max: {max_allowed})." + + return True, "" +``` + +### Auto-Home (Quality of Life) +To minimize repetitive actions, the engine automatically checks if any exposed cards can be safely moved to foundations. +A card of rank $R$ and suit $S$ can be safely moved to its foundation if: +1. It is a legal foundation move. +2. All cards of rank $R-1$ of the **opposite color** are already in the foundation piles. +3. All cards of rank $R-2$ of the **same color** are already in the foundation piles. + +*Why?* This ensures that no remaining card in the Tableau can possibly require this card as a sequence parent (since any card that could pair with it is already safely homed). + +--- + +## 6. Smoke Mode Implementation + +To support automated test execution and code validation in continuous integration (CI) pipelines, the program must implement a **headless smoke mode**. + +### Invocation +```bash +python3 main.py --smoke +``` + +### Headless execution logic +1. Parse CLI arguments. If `--smoke` is present: + - **Do not** initialize `curses` or modify the terminal buffer. + - Instantiate the `GameState` engine. + - Seed the random generator with a fixed value (e.g., `seed=42`) to guarantee a deterministic state. + - Run `state.deal(seed=42)`. + - Verify that: + - 52 cards are distributed. + - Tableau columns have size `[7, 7, 7, 7, 6, 6, 6, 6]`. + - FreeCells are empty. + - Foundation piles are empty. + - Run a sequence of mock moves (e.g., attempt to move a valid card if possible, perform undos, verify validation logic handles both valid and invalid moves). + - Cleanly exit with return code `0` on success, or return code `1` (or throw exceptions) if assertions fail. + +```python +# Sketch of main entry-point logic +import sys + +def main(): + if "--smoke" in sys.argv: + print("Running headless smoke tests...") + try: + state = GameState() + state.deal(seed=12345) + + # Assert initial setup + assert sum(len(col) for col in state.tableau) == 52, "Tableau must contain 52 cards" + assert len(state.tableau[0]) == 7, "Column 1 must have 7 cards" + assert len(state.tableau[7]) == 6, "Column 8 must have 6 cards" + assert all(fc is None for fc in state.free_cells), "Freecells must start empty" + + # Verify move validation logic doesn't crash + # e.g., attempt an illegal move and ensure it gets rejected + invalid_move = Move('C', 0, 'C', 1, 1) + valid, reason = validate_move(state, invalid_move) + + print("Smoke tests passed successfully.") + sys.exit(0) + except Exception as e: + print(f"Smoke test failed: {e}", file=sys.stderr) + sys.exit(1) + else: + # Run standard interactive curses application + import curses + curses.wrapper(run_curses_app) +``` + +--- + +## 7. Test Strategy + +Comprehensive automated tests must validate the engine rules independently of rendering. + +### Test Architecture Blueprint + +```text +tests/ +├── __init__.py +├── test_card.py # Card attributes, comparison, color, and suitability checks +├── test_rules.py # Single-move checks, sequence checking, supermove limit calculation +├── test_state.py # Deal correctness, seed-determinism, Undo/Redo stack preservation +└── test_integration.py # Simulation of a short deterministic game sequence to verification of Win/Loss +``` + +### Key Test Categories and Mock Scenarios +1. **The Alternating Descending Rule**: + - Try placing Red Jack on Black Queen (Valid). + - Try placing Red Jack on Red Queen (Invalid). + - Try placing Red Jack on Black 10 (Invalid). +2. **The Supermove Formula Verification**: + - Setup state with $F$ empty Freecells and $T$ empty Tableau columns. + - Assert `get_max_movable_cards(state)` yields exactly the mathematical output of $(1 + F) \times 2^T$. + - Assert that trying to move a sequence of length $M+1$ gets rejected with a helpful warning message. +3. **Undo/Redo Integrity**: + - Execute a series of moves. + - Store historical snapshots. + - Assert calling `undo()` restores card values, suits, and counts perfectly. + - Assert calling `redo()` reapplies changes perfectly. +4. **Win State Execution**: + - Artificially mock all foundation stacks to contain Aces through Queens. + - Make the final 4 moves placing Kings. + - Verify `state.is_won()` is triggered exactly on the final King placement. diff --git a/status.json b/status.json new file mode 100644 index 000000000..ee6907d7f --- /dev/null +++ b/status.json @@ -0,0 +1,3 @@ +{ + "outcome": "succeeded" +} \ No newline at end of file