fabro(01KTA1FC5W5W0BHTQSV865A1H6): impl_setup (succeeded)

Fabro-Run: 01KTA1FC5W5W0BHTQSV865A1H6
Fabro-Completed: 3
Fabro-Checkpoint: a86ad0bae2

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 19:28:25 +00:00
parent b2b2ec7ff2
commit 0f62c1d76f
12 changed files with 410 additions and 1 deletions

31
card-game-app/README.md Normal file
View file

@ -0,0 +1,31 @@
# Terminal FreeCell Solitaire
A terminal-based FreeCell Solitaire game implemented in Python 3 using `curses`.
## Installation and Setup
Ensure you have Python 3.8+ installed.
To install dependencies for testing:
```bash
pip install -e ".[dev]"
```
## Running the Game
To run the interactive TUI game:
```bash
python3 main.py
```
To run headless smoke tests:
```bash
python3 main.py --smoke
```
## Running Tests
To run the automated tests:
```bash
pytest
```

40
card-game-app/main.py Normal file
View file

@ -0,0 +1,40 @@
import os
import sys
# Ensure src directory is in Python path for easy execution
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "src")))
from card_game_tui.engine import GameState, Move, validate_move
from card_game_tui.tui import run_curses_app
def main():
if "--smoke" in sys.argv:
print("Running headless smoke tests...")
try:
state = GameState()
state.deal(seed=42)
# 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
# Attempt an illegal move and ensure it gets rejected
invalid_move = Move('C', 0, 'C', 1, 1)
valid, reason = validate_move(state, invalid_move)
assert not valid, "Expected illegal move to be rejected"
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)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "card-game-tui"
version = "0.1.0"
description = "A terminal-based FreeCell solitaire game in Python"
readme = "README.md"
requires-python = ">=3.8"
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
]
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q"
testpaths = [
"tests",
]
pythonpath = ["src"]

View file

@ -0,0 +1,12 @@
from .engine import Suit, Rank, Card, Move, GameState, validate_move
from .tui import run_curses_app
__all__ = [
"Suit",
"Rank",
"Card",
"Move",
"GameState",
"validate_move",
"run_curses_app",
]

View file

@ -0,0 +1,217 @@
from enum import Enum
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))
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
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
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
def get_source_cards(state: GameState, src_type: str, src_idx: int, card_count: int) -> List[Card]:
if src_type == 'C':
col = state.tableau[src_idx]
if len(col) < card_count:
return []
return col[-card_count:]
elif src_type == 'F':
if card_count != 1:
return []
card = state.free_cells[src_idx]
return [card] if card is not None else []
return []
def is_valid_sequence(cards: List[Card]) -> bool:
if not cards:
return False
for i in range(len(cards) - 1):
curr = cards[i]
nxt = cards[i + 1]
if not nxt.can_be_placed_on_tableau(curr):
return False
return True
def get_max_movable_cards(state: GameState, target_is_empty_col: bool) -> int:
F = sum(1 for fc in state.free_cells if fc is None)
T = sum(1 for col in state.tableau if not col)
if target_is_empty_col and T > 0:
T -= 1
return (1 + F) * (2 ** T)
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, ""

View file

@ -0,0 +1,9 @@
def run_curses_app(stdscr) -> None:
"""
Placeholder for the interactive curses application.
"""
stdscr.clear()
stdscr.addstr(0, 0, "Terminal FreeCell Solitaire (TUI)")
stdscr.addstr(2, 0, "Press any key to exit...")
stdscr.refresh()
stdscr.getch()

View file

@ -0,0 +1 @@
# Tests for Card Game TUI

View file

@ -0,0 +1,29 @@
from card_game_tui.engine import Card, Rank, Suit
def test_card_properties():
card = Card(Rank.ACE, Suit.SPADES)
assert card.rank == Rank.ACE
assert card.suit == Suit.SPADES
assert card.color == "BLACK"
assert repr(card) == "A♠"
def test_card_opposite_color():
card_spades = Card(Rank.ACE, Suit.SPADES)
card_hearts = Card(Rank.TWO, Suit.HEARTS)
card_clubs = Card(Rank.THREE, Suit.CLUBS)
assert card_spades.is_opposite_color(card_hearts)
assert not card_spades.is_opposite_color(card_clubs)
def test_can_be_placed_on_tableau():
card_red_jack = Card(Rank.JACK, Suit.HEARTS)
card_black_queen = Card(Rank.QUEEN, Suit.SPADES)
card_red_queen = Card(Rank.QUEEN, Suit.DIAMONDS)
card_black_ten = Card(Rank.TEN, Suit.CLUBS)
# Red Jack can be placed on Black Queen
assert card_red_jack.can_be_placed_on_tableau(card_black_queen)
# Red Jack cannot be placed on Red Queen (same color)
assert not card_red_jack.can_be_placed_on_tableau(card_red_queen)
# Red Jack cannot be placed on Black Ten (wrong rank order)
assert not card_red_jack.can_be_placed_on_tableau(card_black_ten)

View file

@ -0,0 +1,6 @@
from card_game_tui.engine import GameState
def test_integration_setup():
state = GameState()
state.deal(seed=123)
assert sum(len(col) for col in state.tableau) == 52

View file

@ -0,0 +1,10 @@
from card_game_tui.engine import GameState, Move, validate_move, Card, Rank, Suit
def test_validate_move_initial_illegal():
state = GameState()
state.deal(seed=42)
# Moving card from empty FreeCell is illegal
move = Move('F', 0, 'C', 0, 1)
valid, reason = validate_move(state, move)
assert not valid
assert "Source is empty or invalid" in reason

View file

@ -0,0 +1,30 @@
from card_game_tui.engine import GameState
def test_deal():
state = GameState()
state.deal(seed=42)
assert sum(len(col) for col in state.tableau) == 52
assert len(state.tableau[0]) == 7
assert len(state.tableau[7]) == 6
assert all(fc is None for fc in state.free_cells)
def test_undo_redo():
state = GameState()
state.deal(seed=42)
# Push history
state.push_history()
# Modify state
card = state.tableau[0].pop()
state.free_cells[0] = card
# Undo
assert state.undo()
assert state.free_cells[0] is None
assert len(state.tableau[0]) == 7
# Redo
assert state.redo()
assert state.free_cells[0] == card
assert len(state.tableau[0]) == 6

View file

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