mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
fabro(01KTA1FC5W5W0BHTQSV865A1H6): expand_spec (succeeded)
Fabro-Run: 01KTA1FC5W5W0BHTQSV865A1H6
Fabro-Completed: 2
Fabro-Checkpoint: 290d6dc928
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
497aaba6f2
commit
b2b2ec7ff2
2 changed files with 447 additions and 0 deletions
444
.ai/card-game-spec.md
Normal file
444
.ai/card-game-spec.md
Normal file
|
|
@ -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.
|
||||
3
status.json
Normal file
3
status.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"outcome": "succeeded"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue