fabro(01KT9YT14FHDYA4VTV9DSFG0FY): expand_spec (succeeded)

Fabro-Run: 01KT9YT14FHDYA4VTV9DSFG0FY
Fabro-Completed: 2
Fabro-Checkpoint: 84b9439e7b

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 18:43:33 +00:00
parent 497aaba6f2
commit dd57c4b4a0
2 changed files with 365 additions and 0 deletions

362
.ai/card-game-spec.md Normal file
View file

@ -0,0 +1,362 @@
# Technical Specification: Terminal-Based FreeCell Solitaire in Python
This document specifies the architecture, domain logic, user interface, and testing strategy for a terminal-based FreeCell Solitaire game built with Python.
The application is split into two completely decoupled layers:
1. **Core Domain Model / Game Engine**: Fully testable, framework-independent, pure-Python representations of cards, piles, moves, validation, and game state.
2. **Curses-based Terminal UI (TUI)**: A visual presentation layer built on the standard-library `curses` module, handling input polling, terminal events (like resizing), and colorized rendering.
---
## 1. Game Rules & Mechanics
FreeCell is a solitaire card game played with a single standard 52-card deck. All cards are dealt face-up at the start.
### 1.1 Table Layout
The game screen consists of three main areas:
- **Free Cells (4 cells)**: Temporary holding spaces. Each cell can hold at most one card of any suit and rank.
- **Foundations (4 piles)**: Destination piles built up by suit from Ace (rank 1) through King (rank 13). Winning the game requires placing all 52 cards here.
- **Tableau (8 columns)**: The main play area. Columns 1–4 are dealt 7 cards each; columns 5–8 are dealt 6 cards each.
### 1.2 Movement Rules
- **Moving to a Free Cell**: Any single card can be moved to an empty Free Cell.
- **Moving to a Foundation**:
- An **Ace** can be moved to an empty Foundation pile.
- A card of rank $R$ and suit $S$ can be placed on a Foundation pile if the current top card of that pile is of suit $S$ and rank $R - 1$.
- **Moving to a Tableau Column**:
- A single card of rank $R$ and color $C$ can be placed on top of a tableau card of rank $R+1$ and the *opposite* color (e.g., a Black 7 on a Red 8).
- An empty tableau column can accept any single card.
- **Sequence Moves (Multiple Cards)**:
- A sequence of cards that is properly ordered (descending ranks and alternating colors) can be moved together from one tableau column to another.
- The maximum size of a sequence that can be moved depends on the number of empty Free Cells and empty Tableau columns available as temporary landing zones.
- **Formula for Max Move Size**:
$$\text{Max Cards} = (1 + \text{Empty Free Cells}) \times 2^{\text{Empty Tableau Columns}}$$
*Exception*: If the destination column is an empty tableau column, that empty column does not count as "empty" in the exponent of the formula (as it is the final destination, not a temporary transit zone).
### 1.3 Automatic Moves ("Auto-Homing")
To reduce tedious manual moves, the engine should automatically move cards to the Foundations if they can no longer be used as transition cards for lower-ranked cards. A card of rank $R$ and suit $S$ can safely be "auto-homed" to its Foundation if:
1. It is a legal move (it is on top of a tableau/freecell and matches $R-1$ of its foundation).
2. All cards of the *opposite color* with rank $R-1$ or lower have already been placed in the foundations. This ensures that no card still needs to be built on top of this card in the tableaus.
---
## 2. Core Domain Architecture (Non-UI)
All domain logic must reside in classes that do not import or assume a `curses` environment. This ensures unit tests can run in headless CI environments.
```
+-------------------------------------------------------------+
| Domain Model |
| |
| +----------------+ +---------------+ |
| | Card |---> | Deck | |
| +----------------+ +---------------+ |
| | |
| v |
| +----------------+ |
| | GameState | <--- Handles validation, moves, undo |
| +----------------+ |
+-------------------------------------------------------------+
```
### 2.1 Domain Types & Data Structures
#### Card Representation
```python
from dataclasses import dataclass
from enum import Enum, auto
class Suit(Enum):
HEARTS = "H"
DIAMONDS = "D"
CLUBS = "C"
SPADES = "S"
@property
def color(self) -> str:
return "RED" if self in (Suit.HEARTS, Suit.DIAMONDS) else "BLACK"
@property
def symbol(self) -> str:
# Unicode symbols for attractive rendering
return {
Suit.HEARTS: "♥",
Suit.DIAMONDS: "♦",
Suit.CLUBS: "♣",
Suit.SPADES: "♠"
}[self]
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 label(self) -> str:
if self.value == 1: return "A"
if self.value == 11: return "J"
if self.value == 12: return "Q"
if self.value == 13: return "K"
return str(self.value)
@dataclass(frozen=True)
class Card:
suit: Suit
rank: Rank
def __repr__(self) -> str:
return f"{self.rank.label}{self.suit.symbol}"
```
#### Deck & Setup
```python
import random
from typing import List
class Deck:
def __init__(self, seed: int = None):
self.cards = [Card(suit, rank) for suit in Suit for rank in Rank]
if seed is not None:
random.seed(seed)
random.shuffle(self.cards)
def deal(self) -> List[List[Card]]:
"""Deals 52 cards into 8 columns."""
tableaus: List[List[Card]] = [[] for _ in range(8)]
for i, card in enumerate(self.cards):
tableaus[i % 8].append(card)
return tableaus
```
#### Move Representation
To support an robust Undo/Redo stack, each move is captured as a detailed state transition:
```python
from typing import Union
class LocationType(Enum):
TABLEAU = auto()
FREECELL = auto()
FOUNDATION = auto()
@dataclass(frozen=True)
class Position:
type: LocationType
index: int # 0-7 for Tableau, 0-3 for FreeCell, 0-3 for Foundation
@dataclass
class MoveRecord:
from_pos: Position
to_pos: Position
cards: List[Card] # Captured for single or sequence moves
auto_moves: List['MoveRecord'] = None # Nested moves triggered by auto-homing
```
### 2.2 Core Engine (`GameState`)
The state engine encapsulates all state, mutations, validation, and history.
```python
class GameState:
def __init__(self, seed: int = None):
self.tableaus: List[List[Card]] = Deck(seed).deal()
self.freecells: List[Union[Card, None]] = [None] * 4
self.foundations: dict[Suit, List[Card]] = {suit: [] for suit in Suit}
self.undo_stack: List[MoveRecord] = []
self.redo_stack: List[MoveRecord] = []
def get_card_at(self, position: Position) -> Union[Card, None]:
if position.type == LocationType.FREECELL:
return self.freecells[position.index]
elif position.type == LocationType.FOUNDATION:
pile = self.foundations[list(Suit)[position.index]]
return pile[-1] if pile else None
elif position.type == LocationType.TABLEAU:
col = self.tableaus[position.index]
return col[-1] if col else None
return None
def validate_move(self, from_pos: Position, to_pos: Position, count: int = 1) -> bool:
"""
Calculates whether moving 'count' cards from from_pos to to_pos is legal.
"""
# 1. Basic boundary and type validations...
# 2. Extract card(s) to be moved...
# 3. Check target constraints (descending rank, alternate color for tableaus; suit-match ascending for foundation)
# 4. Check capacity constraints if sequence move (using the formula)
pass
def execute_move(self, from_pos: Position, to_pos: Position, count: int = 1) -> bool:
"""
Executes a move, saves it to the undo stack, runs auto-homing, and clears the redo stack.
"""
if not self.validate_move(from_pos, to_pos, count):
return False
# Perform move logic, handle nested auto-moves, append to undo_stack...
return True
def undo(self) -> bool:
"""Reverts the last move, including nested auto-homing steps."""
if not self.undo_stack:
return False
# Revert changes using move records and push to redo stack...
return True
def check_win(self) -> bool:
"""Returns True if all 52 cards are in the Foundations."""
return all(len(self.foundations[suit]) == 13 for suit in Suit)
```
---
## 3. UI Layout & Visual Design
The UI will be formatted to fit standard $80 \times 24$ terminal windows, but will dynamically adapt if larger screen space is available.
### 3.1 Terminal ASCII / Unicode Grid Layout
```
+-----------------------------------------------------------------------------+
| FREECELL SOLITAIRE [SEED: 49281] |
| |
| Free Cells Foundations |
| [A] [B] [C] [D] [♥] [♦] [♣] [♠] |
| +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ |
| | 5♣| | | | | | | | A♥| | | | | | | |
| +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ |
| |
| Tableau Columns |
| [1] [2] [3] [4] [5] [6] [7] [8] |
| +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ |
| | K♠| | J♦| | Q♣| | 9♦| | 8♣| | 6♥| | 10♦| | A♠| |
| | Q♦| | 10♣| | 3♦| | 8♥| | 7♦| | 5♦| | 9♠| | | |
| | J♣| | | | 2♠| | | | 6♠| | | | | | | |
| | 10♦| | | | | | | | 5♥| | | | | | | |
| +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ |
| |
|-----------------------------------------------------------------------------|
| MSG: Selected [1] K♠. Choose destination column or cell. |
| KEYS: [Arrows] Navigate [Space] Select/Drop [U] Undo [R] Restart [Q] Quit|
+-----------------------------------------------------------------------------+
```
### 3.2 Visual Components and Curses Rendering Strategy
1. **Card Rendering**:
- Empty slots (cells, tableaus, foundations) are rendered with dashed borders `· · ·` or generic outlines `[ ]`.
- Cards are rendered with a white background/colored foreground using curses color pairs:
- **Red Cards (Hearts, Diamonds)**: Red text on a white or black background.
- **Black Cards (Clubs, Spades)**: Bold white or cyan text on a black background (or black text on a white background).
2. **Double-Buffered Screen**:
- Use `curses.newwin()` for distinct sub-regions (Top panel, Main game table, Status bar) to organize layout.
- Run calculations and render elements using memory-efficient double-buffering via `stdscr.noutrefresh()` and a final `curses.doupdate()`.
3. **Cursor Navigation & Selection**:
- **Keyboard Cursor**: An active selection box/cursor highlighting a single pile/cell is moved via the **Arrow Keys** (or WASD).
- **Selection State**:
- First press of `Space` or `Enter` on a non-empty slot highlights the card(s) to move (visual "lift" state).
- Moving the cursor to another column and pressing `Space` / `Enter` attempts the move.
- Pressing `Escape` cancels the active selection.
---
## 4. Input Handling & UI Interaction
### 4.1 Curses Main Loop & Input States
```
+----------------------+
| Game Loop |
+----------------------+
|
v
[ Wait for Key ]
|
+--------------+--------------+
| |
v v
[ Action Key ] [ Navigation Key ]
(Q, U, R, ESC, etc.) (Arrows, Tab, WASD)
| |
v v
Handle Command Move Cursor Highlight
| |
+--------------+--------------+
|
v
[ Update State ]
|
v
[ Redraw UI ]
```
### 4.2 Keybindings Map
| Key | Action |
| --- | --- |
| `Left / Right / Up / Down` | Navigate cursor across cells, foundations, and tableaus |
| `Space / Enter` | Select card at cursor / Move selected card to cursor target |
| `Escape` | Clear active card selection |
| `u / U` | Undo last move (supports infinite undo back to the start) |
| `r / R` | Restart current game (with same seed/shuffling) |
| `n / N` | Start a completely new game with a random seed |
| `q / Q` | Exit the game gracefully (presents confirm dialog) |
---
## 5. Non-Interactive Smoke Mode
To ensure the CLI can be verified programmatically without invoking an interactive full-screen curses environment, the application must support a non-interactive setup probe via `--smoke`:
```bash
python3 main.py --smoke
```
### 5.1 Smoke Mode Requirements:
- Must not call `curses.initscr()`, `curses.wrapper()`, or perform terminal modifications.
- Must initialize the domain engine (`GameState`) with a deterministic test seed.
- Must execute a tiny mock transaction sequence (e.g., attempt 2 valid moves and 1 invalid move, validating assertion outcomes).
- Must print a plain-text confirmation of the test result to stdout.
- Must exit with status code `0` on success, or a non-zero exit code if an import or validation error occurs.
---
## 6. Testing Strategy
Maintaining 100% testability on rules guarantees that bugs are never hidden behind UI refresh issues.
### 6.1 Unit Tests (Headless)
All game rule constraints must be tested inside a robust suite of unit tests (e.g. using `unittest` or `pytest`):
- **Deck Verification**: Ensure 52 unique cards are present; verify that deterministic seeding generates identical layouts.
- **Move Validations**:
- Valid and invalid moves to empty and occupied free cells.
- Valid sequence building on Tableau (alternating color, consecutive ranks).
- Multi-card tableau-to-tableau sequence validation obeying the formula: $(1 + \text{empty cells}) \times 2^{\text{empty tableaus}}$.
- Valid and invalid foundation building (Aces first, ordered ascending by suit).
- **Auto-Homing Logic**: Verify that cards are only auto-homed when all cards of the opposite color of smaller rank have already been homed.
- **Undo/Redo System**: Run a multi-step sequence, trigger undo, verify state completely matches initial configuration.
- **Win Detection**: Construct a state where all but 1 card is homed, verify final homing triggers winning state flag.
### 6.2 Curses Integration Tests
For the UI layer, tests can mock the terminal dimensions and standard input keys using Python's `unittest.mock`:
- Mock `curses.getch` and verify the cursor coordinates state shifts correctly.
- Verify screen boundary logic prevents cursor overflow out of valid coordinates (e.g. going left of FreeCell column 0 or below the lowest card in a Tableau).
---
## 7. Implementation Milestones
1. **Milestone 1**: Implement pure-domain `Card`, `Deck`, `Position`, and `GameState` engines.
2. **Milestone 2**: Write unit tests proving full game validation logic and automated auto-homing.
3. **Milestone 3**: Implement `--smoke` mode in CLI entrypoint and verify CI execution.
4. **Milestone 4**: Setup curses framework window structure, color palettes, and draw static cards.
5. **Milestone 5**: Implement interactive keyboard cursor, card selection mechanics, and execution hook.
6. **Milestone 6**: Complete Undo-stack integration and terminal resize layout adjustment handler.

3
status.json Normal file
View file

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