checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 14:44:58 -04:00
parent 84b9439e7b
commit 74d93db932
5 changed files with 784 additions and 14 deletions

386
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,378 @@
diff --git a/.ai/card-game-spec.md b/.ai/card-game-spec.md
new file mode 100644
index 000000000..ccc4bc1b0
--- /dev/null
+++ b/.ai/card-game-spec.md
@@ -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.
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

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Stage completed: expand_spec",
"failure_reason": null,
"timestamp": "2026-06-04T18:43:25.384693Z"
}

View file

@ -0,0 +1,23 @@
Goal: Build a terminal-based FreeCell solitaire game in Python
## Completed stages
- **expand_spec**: succeeded
- Model: gemini-3.5-flash, 85.9k tokens in / 8.3k out
- Files: /home/daytona/workspace/fabro/.ai/card-game-spec.md, /home/daytona/workspace/fabro/status.json
Read .ai/card-game-spec.md.
Create the Python project skeleton under card-game-app/:
- pyproject.toml with pytest configured
- main.py entrypoint
- src/card_game_tui/ package
- tests/ directory
- README.md stub
Add minimal importable modules so the project compiles.
Run:
cd card-game-app && python3 -m py_compile main.py src/card_game_tui/*.py
Write status.json at workspace root: outcome=succeeded if the project skeleton exists and compiles, outcome=failed with failure_reason otherwise.

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "gemini",
"model": "gemini-3.5-flash"
}