From 318fc6b17d87b5eafdbdbba331f8f16c7529d6aa Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 4 Jun 2026 14:17:47 -0400 Subject: [PATCH] =?UTF-8?q?checkpoint=20=E2=9A=92=EF=B8=8F=20Generated=20w?= =?UTF-8?q?ith=20[Fabro](https://fabro.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.json | 405 +++++++- stages/002-expand_spec@1/diff.patch | 1064 ++++++++++++++++++++ stages/002-expand_spec@1/status.json | 6 + stages/003-impl_setup@1/prompt.md | 23 + stages/003-impl_setup@1/provider_used.json | 5 + stages/003-impl_setup@1/response.md | 15 + 6 files changed, 1497 insertions(+), 21 deletions(-) create mode 100644 stages/002-expand_spec@1/diff.patch create mode 100644 stages/002-expand_spec@1/status.json create mode 100644 stages/003-impl_setup@1/prompt.md create mode 100644 stages/003-impl_setup@1/provider_used.json create mode 100644 stages/003-impl_setup@1/response.md diff --git a/run.json b/run.json index d2c041408..78217ba5f 100644 --- a/run.json +++ b/run.json @@ -760,7 +760,7 @@ "kind": "running" }, "status_updated_at": "2026-06-04T18:07:12.456704Z", - "last_event_at": "2026-06-04T18:10:14.597763Z", + "last_event_at": "2026-06-04T18:17:47.163333Z", "pending_control": null, "checkpoints": [ { @@ -803,9 +803,9 @@ "diff": {} }, { - "seq": 0, + "seq": 88, "checkpoint": { - "timestamp": "2026-06-04T18:10:17.185947Z", + "timestamp": "2026-06-04T18:10:42.223176Z", "current_node": "expand_spec", "completed_nodes": [ "start", @@ -813,26 +813,26 @@ ], "node_retries": {}, "context_values": { - "internal.thread_id": "start", - "graph.default_max_retries": "3", - "graph.retry_target": "impl_setup", - "current_node": "expand_spec", - "graph.fallback_retry_target": "impl_logic", "internal.retry_count.start": 0, - "failure_class": "", + "last_stage": "expand_spec", + "graph.retry_target": "impl_setup", "graph.rankdir": "LR", "failure_signature": "", - "graph.goal": "Build a terminal-based Klondike solitaire game in Python with a curses TUI", - "response.expand_spec": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification** (`.ai/solitaire-spec.md`):\n - Fully documents directory structures, core logic rules, rendering coordinates, virtual-grid cursor navigation, keyboard control schemes, and test environments.\n2. **Core Solitaire Game Engine** (`solitaire-app/game.py`):\n - Implements standard draw-one Klondike rules including 7 tableaus, 4 foundations, stock, waste, auto-reveals, and unlimited undo history tracking.\n - De-coupled from `curses` allowing robust independent testing.\n3. **curses-based TUI Layer** (`solitaire-app/ui.py`):\n - A grid-based navigation scheme supporting arrow/Vim keys for visual navigation.\n - Supports card stacking, color coding for Red/Black cards, and visual selections.\n - Includes highly intuitive single-key **Auto-Move** (`a` key) to send valid cards directly to foundations.\n4. **Interactive Entry Point and Smoke Mode** (`solitaire-app/main.py`):\n - Supports regular interactive play via `python3 main.py`.\n - Supports non-interactive smoke testing via `python3 main.py --smoke`, allowing tests to verify imports, initialization, and all rules without opening an interactive terminal screen.\n5. **Rules Verification Suite** (`solitaire-app/test_game.py`):\n - 7 automated unit tests checking deal mechanics, drawing/recycling, move constraints, auto-reveals, undo history, and winning conditions.\n6. **Task Status** (`status.json`):\n - Recorded `outcome=succeeded` at the workspace root.", - "last_stage": "expand_spec", - "internal.work_dir": "/home/daytona/workspace/fabro", - "thread.start.current_node": "expand_spec", - "last_response": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification**", - "internal.run_id": "01KT9X0N42W6PRBVH2N5914EQJ", "internal.node_visit_count": 1, - "internal.fidelity": "compact", + "internal.retry_count.expand_spec": 0, + "internal.run_id": "01KT9X0N42W6PRBVH2N5914EQJ", + "internal.work_dir": "/home/daytona/workspace/fabro", + "last_response": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification**", "outcome": "succeeded", - "internal.retry_count.expand_spec": 0 + "graph.default_max_retries": "3", + "thread.start.current_node": "expand_spec", + "graph.goal": "Build a terminal-based Klondike solitaire game in Python with a curses TUI", + "graph.fallback_retry_target": "impl_logic", + "response.expand_spec": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification** (`.ai/solitaire-spec.md`):\n - Fully documents directory structures, core logic rules, rendering coordinates, virtual-grid cursor navigation, keyboard control schemes, and test environments.\n2. **Core Solitaire Game Engine** (`solitaire-app/game.py`):\n - Implements standard draw-one Klondike rules including 7 tableaus, 4 foundations, stock, waste, auto-reveals, and unlimited undo history tracking.\n - De-coupled from `curses` allowing robust independent testing.\n3. **curses-based TUI Layer** (`solitaire-app/ui.py`):\n - A grid-based navigation scheme supporting arrow/Vim keys for visual navigation.\n - Supports card stacking, color coding for Red/Black cards, and visual selections.\n - Includes highly intuitive single-key **Auto-Move** (`a` key) to send valid cards directly to foundations.\n4. **Interactive Entry Point and Smoke Mode** (`solitaire-app/main.py`):\n - Supports regular interactive play via `python3 main.py`.\n - Supports non-interactive smoke testing via `python3 main.py --smoke`, allowing tests to verify imports, initialization, and all rules without opening an interactive terminal screen.\n5. **Rules Verification Suite** (`solitaire-app/test_game.py`):\n - 7 automated unit tests checking deal mechanics, drawing/recycling, move constraints, auto-reveals, undo history, and winning conditions.\n6. **Task Status** (`status.json`):\n - Recorded `outcome=succeeded` at the workspace root.", + "internal.thread_id": "start", + "internal.fidelity": "compact", + "current_node": "expand_spec", + "failure_class": "" }, "node_outcomes": { "expand_spec": { @@ -886,11 +886,163 @@ } }, "next_node_id": "impl_setup", + "git_commit_sha": "df1e9e39f61858bccd720fee2b41ca033b54771b", "node_visits": { "start": 1, "expand_spec": 1 } }, + "diff": { + "patch": "diff --git a/.ai/solitaire-spec.md b/.ai/solitaire-spec.md\nnew file mode 100644\nindex 000000000..7fc44e87a\n--- /dev/null\n+++ b/.ai/solitaire-spec.md\n@@ -0,0 +1,144 @@\n+# Terminal Klondike Solitaire Spec\n+\n+This document details the specification and implementation plan for a terminal-based Klondike Solitaire game in Python 3.11+ using the standard `curses` library.\n+\n+The application is structured to decouple core game rules and state from the presentation layer. This allows full testability of game mechanics without initializing a terminal.\n+\n+---\n+\n+## 1. Directory Structure\n+\n+All files are located in `solitaire-app/`:\n+\n+```\n+solitaire-app/\n+├── main.py # Entry point: handles CLI args (including --smoke) and starts TUI\n+├── game.py # Core Klondike Solitaire game model and rule engine (pure Python)\n+├── ui.py # curses-based terminal user interface and input handler\n+└── test_game.py # Automated unit tests for game logic\n+```\n+\n+---\n+\n+## 2. Core Game Engine (`game.py`)\n+\n+The game rules are modeled strictly around standard **Draw-One Klondike Solitaire**.\n+\n+### 2.1 Domain Entities\n+\n+```python\n+from dataclasses import dataclass\n+from typing import List, Optional, Tuple\n+\n+@dataclass\n+class Card:\n+ suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades)\n+ rank: int # 1 (Ace) to 13 (King)\n+ is_face_up: bool = False\n+\n+ @property\n+ def is_red(self) -> bool:\n+ return self.suit in ('H', 'D')\n+\n+ @property\n+ def label(self) -> str:\n+ # e.g., \"A\", \"2\"...\"10\", \"J\", \"Q\", \"K\"\n+ ranks = {1: \"A\", 11: \"J\", 12: \"Q\", 13: \"K\"}\n+ return ranks.get(self.rank, str(self.rank))\n+```\n+\n+### 2.2 Game State Model\n+\n+`GameState` encapsulates all piles and tracks move history for **unlimited Undo** functionality:\n+\n+- **Stock (`List[Card]`)**: Face-down draw pile.\n+- **Waste (`List[Card]`)**: Face-up drawn cards.\n+- **Foundations (`List[List[Card]]`)**: 4 piles, initially empty.\n+- **Tableau (`List[List[Card]]`)**: 7 piles. Tableau $i$ has $i$ cards, with top card face-up.\n+- **History (`List[dict]`)**: Deep copies or delta representations of prior states to support Undo.\n+\n+### 2.3 Core Mechanics & Rules\n+\n+- **Deal**: Shuffle a 52-card deck. Deal cards to Tableau columns (Col 1 has 1, Col 2 has 2... Col 7 has 7). Reveal the top card of each. Remaining 24 cards go to the Stock.\n+- **Draw**: Pop 1 card from Stock and append to Waste (face-up). If Stock is empty, recycle Waste by reversing it back to Stock (so that the original draw order is maintained).\n+- **Tableau-to-Tableau Moves**:\n+ - A card or a valid face-up build (stack of decreasing/alternating color cards) can be moved from column $A$ to column $B$.\n+ - Target column top card must be opposite color and exactly 1 rank higher.\n+ - If the target column is empty, only a King (rank 13) can be placed there.\n+- **Tableau-to-Foundation Moves**:\n+ - Move the top card of a Tableau column to a Foundation.\n+ - If the Foundation is empty, only an Ace (rank 1) of that suit is allowed.\n+ - If not empty, the card must be of the same suit and exactly 1 rank higher than the current top card of that Foundation.\n+- **Waste-to-Tableau/Foundation**:\n+ - Similar rules applied to the top card of the Waste pile.\n+- **Foundation-to-Tableau**:\n+ - Allows pulling a card back down from a Foundation pile to a Tableau column (following Tableau placement rules).\n+- **Auto-Flip**:\n+ - If a move exposes a face-down card at the top of a Tableau column, it must be automatically flipped face-up.\n+- **Win Condition**:\n+ - All 4 Foundation piles contain 13 cards (ending with Kings).\n+\n+---\n+\n+## 3. Terminal TUI Layer (`ui.py`)\n+\n+The UI layer runs in `curses` using a grid-based navigation scheme.\n+\n+### 3.1 Layout Design\n+\n+The screen is divided into two main zones:\n+\n+```\n+ [Stock] [Waste] [F1] [F2] [F3] [F4]\n+ [ ] [9♦] [A♥] [ ] [ ] [ ]\n+ \n+ \n+ [Col 1] [Col 2] [Col 3] [Col 4] [Col 5] [Col 6] [Col 7]\n+ [10♣] [ ] [ ] [ ] [ ] [ ] [ ]\n+ [J♥] [ ] [ ] [ ] [ ] [ ]\n+ [Q♠] [ ] [ ] [ ] [ ]\n+```\n+\n+### 3.2 Keyboard Navigation & Controls\n+\n+We implement a keyboard-driven cursor navigation scheme:\n+\n+1. **Cursor (Highlighted Element)**:\n+ - Use **Arrow keys** or Vim keys (`h`, `j`, `k`, `l`) to move the cursor.\n+ - Piles are arranged on a virtual grid:\n+ - Top row: Stock, Waste, Foundation 1, Foundation 2, Foundation 3, Foundation 4.\n+ - Bottom row: Tableau columns 1 to 7.\n+ - When cursor is on a Tableau column, pressing **Up** or **Down** moves the cursor within the face-up cards of that column. This allows selecting a specific card in a stack to move a partial build!\n+\n+2. **Selecting and Moving**:\n+ - **Space** or **Enter**:\n+ - If the cursor is on the **Stock** pile: trigger a Draw operation.\n+ - If no card/pile is currently selected: select the current pile (and card index if in Tableau) as the **source**. The selection is visually highlighted.\n+ - If a **source** is already selected: move the selected card/stack from the source pile to the currently highlighted pile (the **target**). Then clear selection.\n+ - **Escape** or `c`: Cancel current selection.\n+\n+3. **Global Shortcuts**:\n+ - `u` or `U`: Undo last move.\n+ - `r` or `R`: Restart game (re-shuffles and deals).\n+ - `q` or `Q`: Quit game.\n+\n+### 3.3 Text & Color Configuration\n+\n+- Red suits (Hearts/Diamonds) are rendered with red foreground text on appropriate backgrounds.\n+- Black suits (Clubs/Spades) are rendered with black or standard terminal color text.\n+- Face-down cards are rendered as `[ ]` or `[#]`.\n+- Face-up cards are rendered as `[10♦]`, `[A♥]`, `[K♠]`, `[Q♣]`, etc., using Unicode suit symbols or ASCII fallbacks (`H`, `D`, `C`, `S`).\n+\n+---\n+\n+## 4. Verification and Testing\n+\n+### 4.1 Unit Testing (`test_game.py`)\n+- Standard Python unit tests using `unittest`.\n+- Covers deck setup, drawing, valid/invalid moves for all directions, auto-flip, and victory validation.\n+- Runs purely in CLI without loading `curses`.\n+\n+### 4.2 Smoke Mode\n+- Run `python3 main.py --smoke`.\n+- The entry point parses `--smoke`, verifies all imports, initializes a mock terminal/session state, runs tests, and immediately exits with exit code `0`.\n+- Ensures zero-dependency execution environment checks.\ndiff --git a/solitaire-app/game.py b/solitaire-app/game.py\nnew file mode 100644\nindex 000000000..c91f9abbd\n--- /dev/null\n+++ b/solitaire-app/game.py\n@@ -0,0 +1,273 @@\n+import random\n+from dataclasses import dataclass, field\n+from typing import List, Optional, Tuple, Dict, Any\n+import copy\n+\n+@dataclass\n+class Card:\n+ suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades)\n+ rank: int # 1 (Ace) to 13 (King)\n+ is_face_up: bool = False\n+\n+ @property\n+ def is_red(self) -> bool:\n+ return self.suit in ('H', 'D')\n+\n+ @property\n+ def label(self) -> str:\n+ ranks = {1: \"A\", 11: \"J\", 12: \"Q\", 13: \"K\"}\n+ return ranks.get(self.rank, str(self.rank))\n+\n+ def __repr__(self) -> str:\n+ suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'}\n+ sym = suit_syms.get(self.suit, self.suit)\n+ face = self.label + sym if self.is_face_up else \"##\"\n+ return f\"[{face}]\"\n+\n+\n+class GameState:\n+ def __init__(self, seed: Optional[int] = None) -> None:\n+ self.stock: List[Card] = []\n+ self.waste: List[Card] = []\n+ self.foundations: List[List[Card]] = [[] for _ in range(4)]\n+ self.tableau: List[List[Card]] = [[] for _ in range(7)]\n+ self.history: List[Dict[str, Any]] = []\n+ self.seed = seed\n+ self.deal()\n+\n+ def serialize_state(self) -> Dict[str, Any]:\n+ \"\"\"Returns a deep copy of the current state of all piles.\"\"\"\n+ return {\n+ \"stock\": copy.deepcopy(self.stock),\n+ \"waste\": copy.deepcopy(self.waste),\n+ \"foundations\": copy.deepcopy(self.foundations),\n+ \"tableau\": copy.deepcopy(self.tableau),\n+ }\n+\n+ def restore_state(self, state: Dict[str, Any]) -> None:\n+ \"\"\"Restores the state from a serialized state dictionary.\"\"\"\n+ self.stock = copy.deepcopy(state[\"stock\"])\n+ self.waste = copy.deepcopy(state[\"waste\"])\n+ self.foundations = copy.deepcopy(state[\"foundations\"])\n+ self.tableau = copy.deepcopy(state[\"tableau\"])\n+\n+ def record_history(self) -> None:\n+ \"\"\"Saves current state to history before a mutating operation.\"\"\"\n+ self.history.append(self.serialize_state())\n+\n+ def undo(self) -> bool:\n+ \"\"\"Undoes the last recorded move. Returns True if successful.\"\"\"\n+ if not self.history:\n+ return False\n+ previous_state = self.history.pop()\n+ self.restore_state(previous_state)\n+ return True\n+\n+ def deal(self) -> None:\n+ \"\"\"Creates a standard 52-card deck, shuffles, and deals a new game.\"\"\"\n+ suits = ['H', 'D', 'C', 'S']\n+ deck = [Card(suit=s, rank=r, is_face_up=False) for s in suits for r in range(1, 14)]\n+ \n+ if self.seed is not None:\n+ random.seed(self.seed)\n+ else:\n+ random.seed()\n+ random.shuffle(deck)\n+\n+ self.stock = []\n+ self.waste = []\n+ self.foundations = [[] for _ in range(4)]\n+ self.tableau = [[] for _ in range(7)]\n+ self.history = []\n+\n+ # Deal to Tableau\n+ # Col 0 gets 1 card, Col 1 gets 2 cards, ..., Col 6 gets 7 cards\n+ for i in range(7):\n+ for j in range(i + 1):\n+ card = deck.pop()\n+ if j == i:\n+ card.is_face_up = True\n+ self.tableau[i].append(card)\n+\n+ # Remaining cards go to Stock (face-down)\n+ self.stock = deck\n+\n+ def draw(self) -> bool:\n+ \"\"\"Draws one card from Stock to Waste. Recycles Waste to Stock if Stock is empty.\"\"\"\n+ self.record_history()\n+ \n+ if not self.stock:\n+ if not self.waste:\n+ # Both empty, nothing to do\n+ self.history.pop() # don't save useless history\n+ return False\n+ # Recycle Waste back to Stock\n+ # When we flip waste back to stock, we preserve order by reversing\n+ self.stock = list(reversed(self.waste))\n+ for card in self.stock:\n+ card.is_face_up = False\n+ self.waste = []\n+ return True\n+\n+ card = self.stock.pop()\n+ card.is_face_up = True\n+ self.waste.append(card)\n+ return True\n+\n+ def can_move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool:\n+ if not (0 <= src_col < 7) or not (0 <= dest_col < 7):\n+ return False\n+ if src_col == dest_col:\n+ return False\n+ \n+ src_pile = self.tableau[src_col]\n+ if not src_pile or card_idx < 0 or card_idx >= len(src_pile):\n+ return False\n+ \n+ moving_card = src_pile[card_idx]\n+ if not moving_card.is_face_up:\n+ return False\n+\n+ # If dest is empty, moving card must be a King (rank 13)\n+ dest_pile = self.tableau[dest_col]\n+ if not dest_pile:\n+ return moving_card.rank == 13\n+\n+ dest_card = dest_pile[-1]\n+ if not dest_card.is_face_up:\n+ return False\n+\n+ # Opposite color and rank exactly one less\n+ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)\n+\n+ def can_move_waste_to_tableau(self, dest_col: int) -> bool:\n+ if not self.waste:\n+ return False\n+ if not (0 <= dest_col < 7):\n+ return False\n+\n+ moving_card = self.waste[-1]\n+ dest_pile = self.tableau[dest_col]\n+ \n+ if not dest_pile:\n+ return moving_card.rank == 13\n+\n+ dest_card = dest_pile[-1]\n+ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)\n+\n+ def can_move_waste_to_foundation(self, dest_found: int) -> bool:\n+ if not self.waste:\n+ return False\n+ if not (0 <= dest_found < 4):\n+ return False\n+\n+ moving_card = self.waste[-1]\n+ found_pile = self.foundations[dest_found]\n+\n+ if not found_pile:\n+ return moving_card.rank == 1 # Ace\n+\n+ top_found_card = found_pile[-1]\n+ return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1)\n+\n+ def can_move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool:\n+ if not (0 <= src_col < 7) or not (0 <= dest_found < 4):\n+ return False\n+\n+ src_pile = self.tableau[src_col]\n+ if not src_pile:\n+ return False\n+\n+ moving_card = src_pile[-1]\n+ found_pile = self.foundations[dest_found]\n+\n+ if not found_pile:\n+ return moving_card.rank == 1 # Ace\n+\n+ top_found_card = found_pile[-1]\n+ return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1)\n+\n+ def can_move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool:\n+ if not (0 <= src_found < 4) or not (0 <= dest_col < 7):\n+ return False\n+\n+ found_pile = self.foundations[src_found]\n+ if not found_pile:\n+ return False\n+\n+ moving_card = found_pile[-1]\n+ dest_pile = self.tableau[dest_col]\n+\n+ if not dest_pile:\n+ return moving_card.rank == 13\n+\n+ dest_card = dest_pile[-1]\n+ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1)\n+\n+ def auto_reveal(self, col_idx: int) -> None:\n+ \"\"\"Reveals the top card of a Tableau column if it is face-down.\"\"\"\n+ pile = self.tableau[col_idx]\n+ if pile and not pile[-1].is_face_up:\n+ pile[-1].is_face_up = True\n+\n+ def move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool:\n+ if not self.can_move_tableau_to_tableau(src_col, dest_col, card_idx):\n+ return False\n+ \n+ self.record_history()\n+ src_pile = self.tableau[src_col]\n+ dest_pile = self.tableau[dest_col]\n+ \n+ moving_stack = src_pile[card_idx:]\n+ self.tableau[src_col] = src_pile[:card_idx]\n+ dest_pile.extend(moving_stack)\n+ \n+ self.auto_reveal(src_col)\n+ return True\n+\n+ def move_waste_to_tableau(self, dest_col: int) -> bool:\n+ if not self.can_move_waste_to_tableau(dest_col):\n+ return False\n+ \n+ self.record_history()\n+ card = self.waste.pop()\n+ self.tableau[dest_col].append(card)\n+ return True\n+\n+ def move_waste_to_foundation(self, dest_found: int) -> bool:\n+ if not self.can_move_waste_to_foundation(dest_found):\n+ return False\n+\n+ self.record_history()\n+ card = self.waste.pop()\n+ self.foundations[dest_found].append(card)\n+ return True\n+\n+ def move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool:\n+ if not self.can_move_tableau_to_foundation(src_col, dest_found):\n+ return False\n+\n+ self.record_history()\n+ card = self.tableau[src_col].pop()\n+ self.foundations[dest_found].append(card)\n+ \n+ self.auto_reveal(src_col)\n+ return True\n+\n+ def move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool:\n+ if not self.can_move_foundation_to_tableau(src_found, dest_col):\n+ return False\n+\n+ self.record_history()\n+ card = self.foundations[src_found].pop()\n+ self.tableau[dest_col].append(card)\n+ return True\n+\n+ def check_win(self) -> bool:\n+ \"\"\"Check if all four foundation piles are fully built up to Kings.\"\"\"\n+ for found_pile in self.foundations:\n+ if len(found_pile) != 13:\n+ return False\n+ if found_pile[-1].rank != 13:\n+ return False\n+ return True\ndiff --git a/solitaire-app/main.py b/solitaire-app/main.py\nnew file mode 100644\nindex 000000000..71febf900\n--- /dev/null\n+++ b/solitaire-app/main.py\n@@ -0,0 +1,74 @@\n+import sys\n+import argparse\n+import unittest\n+\n+def run_smoke_test() -> None:\n+ \"\"\"Proves imports work, instantiates core classes, and runs self-tests.\"\"\"\n+ print(\"Running Solitaire smoke tests...\")\n+ \n+ # 1. Test Imports\n+ try:\n+ from game import Card, GameState\n+ from ui import SolitaireTUI\n+ print(\"✓ Successfully imported core modules (game, ui).\")\n+ except Exception as e:\n+ print(f\"✗ Failed to import core modules: {e}\")\n+ sys.exit(1)\n+\n+ # 2. Test instantiation of core logic\n+ try:\n+ game = GameState(seed=123)\n+ print(f\"✓ GameState instantiated. Stock size: {len(game.stock)} cards.\")\n+ \n+ tui = SolitaireTUI(game)\n+ print(\"✓ SolitaireTUI instantiated.\")\n+ except Exception as e:\n+ print(f\"✗ Failed to instantiate game components: {e}\")\n+ sys.exit(1)\n+\n+ # 3. Run full unit tests to confirm rule-engine validity\n+ print(\"Running automated unit tests...\")\n+ loader = unittest.TestLoader()\n+ # Discover and run tests in the solitaire-app folder\n+ from test_game import TestSolitaireGame\n+ suite = loader.loadTestsFromTestCase(TestSolitaireGame)\n+ runner = unittest.TextTestRunner(verbosity=1)\n+ result = runner.run(suite)\n+ \n+ if result.wasSuccessful():\n+ print(\"✓ All automated rules unit tests passed successfully!\")\n+ else:\n+ print(\"✗ Automated unit tests failed!\")\n+ sys.exit(1)\n+\n+ print(\"Smoke mode passed successfully.\")\n+ sys.exit(0)\n+\n+def main() -> None:\n+ parser = argparse.ArgumentParser(description=\"Terminal Klondike Solitaire\")\n+ parser.add_argument(\n+ \"--smoke\",\n+ action=\"store_true\",\n+ help=\"Run non-interactive smoke tests to verify imports, setup, and game rules.\"\n+ )\n+ args = parser.parse_args()\n+\n+ if args.smoke:\n+ run_smoke_test()\n+\n+ # Normal execution starts curses-based TUI\n+ import curses\n+ from game import GameState\n+ from ui import SolitaireTUI\n+\n+ game = GameState()\n+ tui = SolitaireTUI(game)\n+ \n+ try:\n+ curses.wrapper(tui.run)\n+ except Exception as e:\n+ print(f\"Error running solitaire TUI: {e}\", file=sys.stderr)\n+ sys.exit(1)\n+\n+if __name__ == \"__main__\":\n+ main()\ndiff --git a/solitaire-app/test_game.py b/solitaire-app/test_game.py\nnew file mode 100644\nindex 000000000..4b7c2bce1\n--- /dev/null\n+++ b/solitaire-app/test_game.py\n@@ -0,0 +1,130 @@\n+import unittest\n+from game import GameState, Card\n+\n+class TestSolitaireGame(unittest.TestCase):\n+ def setUp(self):\n+ # Use a fixed seed for reproducible tests\n+ self.game = GameState(seed=42)\n+\n+ def test_initial_deal(self):\n+ # Verify 7 tableau columns have correct card counts (1 to 7)\n+ for i in range(7):\n+ self.assertEqual(len(self.game.tableau[i]), i + 1)\n+ # Top card of each column must be face-up\n+ self.assertTrue(self.game.tableau[i][-1].is_face_up)\n+ # Other cards in column must be face-down\n+ for j in range(i):\n+ self.assertFalse(self.game.tableau[i][j].is_face_up)\n+\n+ # Foundations must be empty initially\n+ for f in self.game.foundations:\n+ self.assertEqual(len(f), 0)\n+\n+ # Stock must contain the remaining cards (52 - 28 = 24)\n+ self.assertEqual(len(self.game.stock), 24)\n+ # Waste should be empty initially\n+ self.assertEqual(len(self.game.waste), 0)\n+\n+ def test_draw_and_recycle(self):\n+ initial_stock_len = len(self.game.stock)\n+ \n+ # Draw all cards\n+ for _ in range(initial_stock_len):\n+ success = self.game.draw()\n+ self.assertTrue(success)\n+\n+ self.assertEqual(len(self.game.stock), 0)\n+ self.assertEqual(len(self.game.waste), initial_stock_len)\n+\n+ # Draw again to recycle\n+ success = self.game.draw()\n+ self.assertTrue(success)\n+ self.assertEqual(len(self.game.stock), initial_stock_len)\n+ self.assertEqual(len(self.game.waste), 0)\n+\n+ def test_cannot_move_invalid_tableau_to_tableau(self):\n+ # Try to move a card to an empty slot or invalid card\n+ # Setup specific tableau configuration manually to test rules\n+ self.game.tableau[0] = [Card(suit='H', rank=5, is_face_up=True)]\n+ self.game.tableau[1] = [Card(suit='S', rank=7, is_face_up=True)]\n+ \n+ # Moving 5 of Hearts onto 7 of Spades is invalid (rank diff != 1)\n+ self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0))\n+\n+ # Moving 5 of Hearts onto an empty column is invalid (must be King)\n+ self.game.tableau[2] = []\n+ self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=2, card_idx=0))\n+\n+ def test_valid_tableau_to_tableau_move(self):\n+ self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)]\n+ self.game.tableau[1] = [\n+ Card(suit='C', rank=8, is_face_up=False),\n+ Card(suit='S', rank=7, is_face_up=True)\n+ ]\n+\n+ # 6 of Hearts onto 7 of Spades: valid! (opposite color, rank = 7 - 1)\n+ self.assertTrue(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0))\n+\n+ # Perform the move\n+ success = self.game.move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0)\n+ self.assertTrue(success)\n+ self.assertEqual(len(self.game.tableau[0]), 0)\n+ self.assertEqual(len(self.game.tableau[1]), 3)\n+ self.assertEqual(self.game.tableau[1][-1].rank, 6)\n+\n+ def test_auto_reveal(self):\n+ self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)]\n+ self.game.tableau[1] = [\n+ Card(suit='C', rank=8, is_face_up=False),\n+ Card(suit='S', rank=7, is_face_up=True)\n+ ]\n+\n+ # Move 7 of Spades from tableau[1] to tableau[0] is invalid due to colors/ranks,\n+ # let's set up a valid case where a face-down card gets exposed and revealed.\n+ self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)]\n+ self.game.tableau[1] = [\n+ Card(suit='C', rank=10, is_face_up=False),\n+ Card(suit='S', rank=7, is_face_up=True)\n+ ]\n+\n+ # Let's change tableau[0] top card to 8 of Diamonds (Red) and tableau[1] to 7 of Spades (Black)\n+ success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1)\n+ self.assertTrue(success)\n+ # Check that the face-down card left in tableau[1] (10 of Clubs) is now face-up!\n+ self.assertTrue(self.game.tableau[1][0].is_face_up)\n+\n+ def test_undo_functionality(self):\n+ self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)]\n+ self.game.tableau[1] = [\n+ Card(suit='C', rank=10, is_face_up=False),\n+ Card(suit='S', rank=7, is_face_up=True)\n+ ]\n+\n+ initial_state = self.game.serialize_state()\n+\n+ success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1)\n+ self.assertTrue(success)\n+\n+ # Undo the move\n+ undo_success = self.game.undo()\n+ self.assertTrue(undo_success)\n+\n+ # Verify state is restored\n+ self.assertEqual(len(self.game.tableau[1]), 2)\n+ self.assertFalse(self.game.tableau[1][0].is_face_up)\n+ self.assertTrue(self.game.tableau[1][1].is_face_up)\n+ self.assertEqual(len(self.game.tableau[0]), 1)\n+\n+ def test_win_condition(self):\n+ # Empty game state check_win should be False\n+ self.assertFalse(self.game.check_win())\n+\n+ # Set up a winning board state\n+ suits = ['H', 'D', 'C', 'S']\n+ for i, suit in enumerate(suits):\n+ self.game.foundations[i] = [Card(suit=suit, rank=r, is_face_up=True) for r in range(1, 14)]\n+\n+ self.assertTrue(self.game.check_win())\n+\n+if __name__ == '__main__':\n+ unittest.main()\ndiff --git a/solitaire-app/ui.py b/solitaire-app/ui.py\nnew file mode 100644\nindex 000000000..9950c1459\n--- /dev/null\n+++ b/solitaire-app/ui.py\n@@ -0,0 +1,404 @@\n+import curses\n+from game import GameState, Card\n+from typing import Optional, Tuple\n+\n+class SolitaireTUI:\n+ def __init__(self, game: GameState) -> None:\n+ self.game = game\n+ \n+ # Cursor positioning\n+ # zone: 'top' or 'bottom'\n+ self.cursor_zone = 'top'\n+ # col: 0 to 6 (for 'top' or 'bottom')\n+ self.cursor_col = 0\n+ # card_idx: index within the Tableau column (only used when zone == 'bottom')\n+ self.cursor_card_idx = 0\n+\n+ # Selection state\n+ # (zone, col, card_idx) or None\n+ self.selected_pos: Optional[Tuple[str, int, int]] = None\n+ self.status_msg = \"Welcome to Klondike Solitaire! Arrow/Vim keys to move, Space/Enter to select/draw.\"\n+\n+ def get_first_face_up_idx(self, col: int) -> int:\n+ \"\"\"Returns the index of the first face-up card in Tableau column col.\"\"\"\n+ pile = self.game.tableau[col]\n+ for idx, card in enumerate(pile):\n+ if card.is_face_up:\n+ return idx\n+ return 0\n+\n+ def move_cursor(self, direction: str) -> None:\n+ if self.cursor_zone == 'top':\n+ if direction == 'left':\n+ if self.cursor_col > 0:\n+ self.cursor_col -= 1\n+ if self.cursor_col == 2: # Skip the gap column\n+ self.cursor_col = 1\n+ elif direction == 'right':\n+ if self.cursor_col < 6:\n+ self.cursor_col += 1\n+ if self.cursor_col == 2: # Skip the gap column\n+ self.cursor_col = 3\n+ elif direction == 'down':\n+ self.cursor_zone = 'bottom'\n+ # Transition to tableau column of same index\n+ pile_len = len(self.game.tableau[self.cursor_col])\n+ self.cursor_card_idx = max(0, pile_len - 1)\n+ elif direction == 'up':\n+ pass # Already at top row\n+\n+ elif self.cursor_zone == 'bottom':\n+ col = self.cursor_col\n+ pile = self.game.tableau[col]\n+ \n+ if direction == 'left':\n+ if self.cursor_col > 0:\n+ self.cursor_col -= 1\n+ # Update card index to the top card of the new column\n+ new_pile_len = len(self.game.tableau[self.cursor_col])\n+ self.cursor_card_idx = max(0, new_pile_len - 1)\n+ elif direction == 'right':\n+ if self.cursor_col < 6:\n+ self.cursor_col += 1\n+ new_pile_len = len(self.game.tableau[self.cursor_col])\n+ self.cursor_card_idx = max(0, new_pile_len - 1)\n+ elif direction == 'up':\n+ first_face_up = self.get_first_face_up_idx(col)\n+ if pile and self.cursor_card_idx > first_face_up:\n+ self.cursor_card_idx -= 1\n+ else:\n+ # Transition to top row\n+ self.cursor_zone = 'top'\n+ # Map to closest top-row element (skip gap)\n+ if self.cursor_col == 2:\n+ self.cursor_col = 1\n+ elif direction == 'down':\n+ if pile and self.cursor_card_idx < len(pile) - 1:\n+ self.cursor_card_idx += 1\n+\n+ def handle_action(self) -> None:\n+ \"\"\"Handles Space/Enter press based on current cursor position.\"\"\"\n+ if self.cursor_zone == 'top':\n+ if self.cursor_col == 0:\n+ # Draw from Stock to Waste\n+ success = self.game.draw()\n+ if success:\n+ self.status_msg = \"Drawn card.\"\n+ else:\n+ self.status_msg = \"Stock and Waste are empty.\"\n+ self.selected_pos = None # Clear any active selection\n+ elif self.cursor_col == 1:\n+ # Waste pile selected or moved to\n+ if not self.game.waste:\n+ self.status_msg = \"Waste is empty.\"\n+ return\n+ \n+ if self.selected_pos is None:\n+ self.selected_pos = ('top', 1, len(self.game.waste) - 1)\n+ self.status_msg = \"Selected card from Waste. Choose target.\"\n+ else:\n+ # You can't move anything onto the waste pile\n+ self.status_msg = \"Cannot move cards onto the Waste pile.\"\n+ self.selected_pos = None\n+ elif self.cursor_col >= 3:\n+ found_idx = self.cursor_col - 3\n+ if self.selected_pos is None:\n+ # Select from Foundation\n+ if not self.game.foundations[found_idx]:\n+ self.status_msg = \"Foundation is empty.\"\n+ return\n+ self.selected_pos = ('top', self.cursor_col, len(self.game.foundations[found_idx]) - 1)\n+ self.status_msg = f\"Selected from Foundation {found_idx + 1}.\"\n+ else:\n+ # Move to Foundation\n+ src_zone, src_col, src_card_idx = self.selected_pos\n+ success = False\n+ if src_zone == 'top' and src_col == 1:\n+ success = self.game.move_waste_to_foundation(found_idx)\n+ elif src_zone == 'bottom':\n+ success = self.game.move_tableau_to_foundation(src_col, found_idx)\n+ \n+ if success:\n+ self.status_msg = f\"Moved to Foundation {found_idx + 1}.\"\n+ if self.game.check_win():\n+ self.status_msg = \"CONGRATULATIONS! YOU WON THE GAME!\"\n+ else:\n+ self.status_msg = \"Invalid move to Foundation.\"\n+ self.selected_pos = None\n+\n+ elif self.cursor_zone == 'bottom':\n+ dest_col = self.cursor_col\n+ if self.selected_pos is None:\n+ # Select Tableau pile/card\n+ pile = self.game.tableau[dest_col]\n+ if not pile:\n+ self.status_msg = \"Tableau column is empty.\"\n+ return\n+ # Verify chosen card is face up\n+ if not pile[self.cursor_card_idx].is_face_up:\n+ self.status_msg = \"Cannot select face-down card.\"\n+ return\n+ self.selected_pos = ('bottom', dest_col, self.cursor_card_idx)\n+ self.status_msg = f\"Selected cards from Column {dest_col + 1}.\"\n+ else:\n+ # Execute move to Tableau column\n+ src_zone, src_col, src_card_idx = self.selected_pos\n+ success = False\n+ if src_zone == 'top' and src_col == 1:\n+ success = self.game.move_waste_to_tableau(dest_col)\n+ elif src_zone == 'top' and src_col >= 3:\n+ success = self.game.move_foundation_to_tableau(src_col - 3, dest_col)\n+ elif src_zone == 'bottom':\n+ success = self.game.move_tableau_to_tableau(src_col, dest_col, src_card_idx)\n+ \n+ if success:\n+ self.status_msg = \"Move successful.\"\n+ else:\n+ self.status_msg = \"Invalid move.\"\n+ \n+ # Update cursor_card_idx to the new top card of destination\n+ new_len = len(self.game.tableau[dest_col])\n+ self.cursor_card_idx = max(0, new_len - 1)\n+ self.selected_pos = None\n+\n+ def handle_auto_move(self) -> None:\n+ \"\"\"Tries to automatically move the current highlighted card to any valid foundation.\"\"\"\n+ success = False\n+ target_found = -1\n+\n+ if self.cursor_zone == 'top' and self.cursor_col == 1:\n+ # Try to move from waste to any foundation\n+ for f_idx in range(4):\n+ if self.game.can_move_waste_to_foundation(f_idx):\n+ success = self.game.move_waste_to_foundation(f_idx)\n+ target_found = f_idx\n+ break\n+ elif self.cursor_zone == 'bottom':\n+ # Try to move top card of tableau column to any foundation\n+ col = self.cursor_col\n+ if self.game.tableau[col]:\n+ for f_idx in range(4):\n+ if self.game.can_move_tableau_to_foundation(col, f_idx):\n+ success = self.game.move_tableau_to_foundation(col, f_idx)\n+ target_found = f_idx\n+ # Update cursor position\n+ new_len = len(self.game.tableau[col])\n+ self.cursor_card_idx = max(0, new_len - 1)\n+ break\n+\n+ if success:\n+ self.status_msg = f\"Auto-moved card to Foundation {target_found + 1}.\"\n+ if self.game.check_win():\n+ self.status_msg = \"CONGRATULATIONS! YOU WON THE GAME!\"\n+ else:\n+ self.status_msg = \"No valid Foundation move available.\"\n+\n+ def draw_card_representation(self, stdscr, y: int, x: int, card: Optional[Card], is_cursor: bool, is_selected: bool) -> None:\n+ # Determine bracket symbols\n+ left_br, right_br = \"[\", \"]\"\n+ if is_selected:\n+ left_br, right_br = \"*\", \"*\"\n+\n+ # Determine attributes\n+ attr = curses.A_NORMAL\n+ if is_cursor:\n+ attr |= curses.A_REVERSE\n+\n+ if card is None:\n+ # Empty slot indicator\n+ color = curses.color_pair(5) # Cyan\n+ stdscr.addstr(y, x, f\"{left_br} -{right_br}\", color | attr)\n+ elif not card.is_face_up:\n+ # Face down card\n+ color = curses.color_pair(2) # White/Standard\n+ stdscr.addstr(y, x, f\"{left_br}###{right_br}\", color | attr)\n+ else:\n+ # Face up card\n+ color = curses.color_pair(1) if card.is_red else curses.color_pair(2)\n+ suit_syms = {'H': 'H', 'D': 'D', 'C': 'C', 'S': 'S'}\n+ # If terminal supports unicode, we can use symbols\n+ try:\n+ suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'}\n+ except Exception:\n+ pass\n+ \n+ sym = suit_syms.get(card.suit, card.suit)\n+ rank_lbl = card.label\n+ if len(rank_lbl) == 1:\n+ lbl = f\" {rank_lbl}{sym}\"\n+ else:\n+ lbl = f\"{rank_lbl}{sym}\"\n+ \n+ stdscr.addstr(y, x, left_br, attr)\n+ stdscr.addstr(y, x + 1, lbl, color | attr)\n+ stdscr.addstr(y, x + 4, right_br, attr)\n+\n+ def draw_screen(self, stdscr) -> None:\n+ stdscr.erase()\n+ h, w = stdscr.getmaxyx()\n+ \n+ # Check window size\n+ if h < 20 or w < 60:\n+ stdscr.addstr(0, 0, \"Terminal too small. Please enlarge to at least 80x24.\", curses.color_pair(1))\n+ stdscr.refresh()\n+ return\n+\n+ # Title / Help Banner\n+ stdscr.addstr(0, 2, \"KLONDIKE SOLITAIRE\", curses.color_pair(4) | curses.A_BOLD)\n+ help_text = \"Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit\"\n+ stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3))\n+\n+ # Top row elements positioning\n+ # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6)\n+ x_coords = [2 + i * 8 for i in range(7)]\n+\n+ # --- Draw STOCK ---\n+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0)\n+ is_selected = False # Stock can never be selected\n+ stdscr.addstr(3, x_coords[0], \"STOCK\", curses.color_pair(3))\n+ \n+ if self.game.stock:\n+ # Top card of Stock is face down\n+ self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected)\n+ else:\n+ self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected)\n+\n+ # --- Draw WASTE ---\n+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1)\n+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1)\n+ stdscr.addstr(3, x_coords[1], \"WASTE\", curses.color_pair(3))\n+ \n+ if self.game.waste:\n+ self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected)\n+ else:\n+ self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected)\n+\n+ # --- Draw FOUNDATIONS ---\n+ for i in range(4):\n+ col_idx = 3 + i\n+ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx)\n+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx)\n+ stdscr.addstr(3, x_coords[col_idx], f\"FOUND {i+1}\", curses.color_pair(3))\n+ \n+ pile = self.game.foundations[i]\n+ if pile:\n+ self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected)\n+ else:\n+ self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected)\n+\n+ # --- Draw TABLEAU ---\n+ stdscr.addstr(6, 2, \"TABLEAU COLUMNS:\", curses.color_pair(4))\n+ for col_idx in range(7):\n+ pile = self.game.tableau[col_idx]\n+ x = x_coords[col_idx]\n+ \n+ # Label\n+ stdscr.addstr(7, x, f\"COL {col_idx+1}\", curses.color_pair(3))\n+ \n+ if not pile:\n+ is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx)\n+ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx)\n+ self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected)\n+ else:\n+ for card_idx, card in enumerate(pile):\n+ y = 8 + card_idx\n+ # Check cursor highlighting\n+ is_cursor = (\n+ self.cursor_zone == 'bottom' and \n+ self.cursor_col == col_idx and \n+ self.cursor_card_idx == card_idx\n+ )\n+ # Check selection highlighting\n+ # If this column is selected as source, we highlight the selected card and all cards below it!\n+ is_selected = False\n+ if self.selected_pos is not None:\n+ src_zone, src_col, src_card_idx = self.selected_pos\n+ if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx:\n+ is_selected = True\n+\n+ self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected)\n+\n+ # Draw Status Bar at bottom\n+ stdscr.addstr(h - 2, 2, f\"Status: {self.status_msg}\"[:w-3], curses.color_pair(4) | curses.A_BOLD)\n+ \n+ # Draw game seed info or undo history count\n+ info_str = f\"Undos available: {len(self.game.history)}\"\n+ stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3))\n+\n+ stdscr.refresh()\n+\n+ def run(self, stdscr) -> None:\n+ # Initialize color scheme\n+ if curses.has_colors():\n+ curses.start_color()\n+ curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Hearts/Diamonds\n+ curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK) # Spades/Clubs\n+ curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Navigation/Headers\n+ curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK) # Success/Banners\n+ curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK) # Empty Slots\n+\n+ curses.curs_set(0)\n+ stdscr.keypad(True)\n+\n+ while True:\n+ self.draw_screen(stdscr)\n+ try:\n+ ch = stdscr.getch()\n+ except KeyboardInterrupt:\n+ break\n+\n+ if ch == -1:\n+ continue\n+\n+ # Handle Quit\n+ if ch in (ord('q'), ord('Q')):\n+ break\n+\n+ # Handle Movement\n+ elif ch == curses.KEY_LEFT or ch == ord('h'):\n+ self.move_cursor('left')\n+ elif ch == curses.KEY_RIGHT or ch == ord('l'):\n+ self.move_cursor('right')\n+ elif ch == curses.KEY_UP or ch == ord('k'):\n+ self.move_cursor('up')\n+ elif ch == curses.KEY_DOWN or ch == ord('j'):\n+ self.move_cursor('down')\n+\n+ # Handle Selection / Draw / Move\n+ elif ch in (ord(' '), 10, 13, curses.KEY_ENTER): # Space, Enter, Return\n+ self.handle_action()\n+\n+ # Handle Cancel selection\n+ elif ch in (27, ord('c'), ord('C')): # Escape, 'c', 'C'\n+ self.selected_pos = None\n+ self.status_msg = \"Selection cancelled.\"\n+\n+ # Handle Auto-move\n+ elif ch in (ord('a'), ord('A')):\n+ self.handle_auto_move()\n+\n+ # Handle Undo\n+ elif ch in (ord('u'), ord('U')):\n+ if self.game.undo():\n+ self.status_msg = \"Last move undone.\"\n+ self.selected_pos = None\n+ # Make sure cursor positions are validated against new column lens\n+ if self.cursor_zone == 'bottom':\n+ pile_len = len(self.game.tableau[self.cursor_col])\n+ self.cursor_card_idx = min(self.cursor_card_idx, max(0, pile_len - 1))\n+ else:\n+ self.status_msg = \"Nothing to undo.\"\n+\n+ # Handle Restart\n+ elif ch in (ord('r'), ord('R')):\n+ self.game.deal()\n+ self.cursor_zone = 'top'\n+ self.cursor_col = 0\n+ self.cursor_card_idx = 0\n+ self.selected_pos = None\n+ self.status_msg = \"New game started!\"\n+\n+ # Handle Resize\n+ elif ch == curses.KEY_RESIZE:\n+ stdscr.clear()\n+ stdscr.refresh()\ndiff --git a/status.json b/status.json\nnew file mode 100644\nindex 000000000..905fb370f\n--- /dev/null\n+++ b/status.json\n@@ -0,0 +1,3 @@\n+{\n+ \"outcome\": \"succeeded\"\n+}\n", + "summary": { + "files_changed": 6, + "additions": 1028, + "deletions": 0 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-06-04T18:17:47.239887Z", + "current_node": "impl_setup", + "completed_nodes": [ + "start", + "expand_spec", + "impl_setup" + ], + "node_retries": {}, + "context_values": { + "internal.thread_id": "expand_spec", + "graph.default_max_retries": "3", + "graph.retry_target": "impl_setup", + "current_node": "impl_setup", + "graph.fallback_retry_target": "impl_logic", + "internal.retry_count.start": 0, + "failure_class": "", + "graph.rankdir": "LR", + "failure_signature": "", + "graph.goal": "Build a terminal-based Klondike solitaire game in Python with a curses TUI", + "response.expand_spec": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification** (`.ai/solitaire-spec.md`):\n - Fully documents directory structures, core logic rules, rendering coordinates, virtual-grid cursor navigation, keyboard control schemes, and test environments.\n2. **Core Solitaire Game Engine** (`solitaire-app/game.py`):\n - Implements standard draw-one Klondike rules including 7 tableaus, 4 foundations, stock, waste, auto-reveals, and unlimited undo history tracking.\n - De-coupled from `curses` allowing robust independent testing.\n3. **curses-based TUI Layer** (`solitaire-app/ui.py`):\n - A grid-based navigation scheme supporting arrow/Vim keys for visual navigation.\n - Supports card stacking, color coding for Red/Black cards, and visual selections.\n - Includes highly intuitive single-key **Auto-Move** (`a` key) to send valid cards directly to foundations.\n4. **Interactive Entry Point and Smoke Mode** (`solitaire-app/main.py`):\n - Supports regular interactive play via `python3 main.py`.\n - Supports non-interactive smoke testing via `python3 main.py --smoke`, allowing tests to verify imports, initialization, and all rules without opening an interactive terminal screen.\n5. **Rules Verification Suite** (`solitaire-app/test_game.py`):\n - 7 automated unit tests checking deal mechanics, drawing/recycling, move constraints, auto-reveals, undo history, and winning conditions.\n6. **Task Status** (`status.json`):\n - Recorded `outcome=succeeded` at the workspace root.", + "last_stage": "impl_setup", + "internal.work_dir": "/home/daytona/workspace/fabro", + "thread.start.current_node": "expand_spec", + "response.impl_setup": "I have successfully initialized and structured the Python project skeleton for the terminal-based Klondike Solitaire game in `solitaire-app/` with the required specifications.\n\nHere is a summary of the completed tasks:\n1. **Directory Restructuring**:\n - Packaged the core game code under `src/solitaire_tui/` with `__init__.py`, `game.py`, and `ui.py`.\n - Relocated the tests to `tests/test_game.py` with `__init__.py`.\n - Updated all necessary import paths to match this package layout.\n2. **Metadata Files Added**:\n - Created `pyproject.toml` in `solitaire-app/` containing proper package and `pytest` configuration.\n - Added `README.md` containing simple instructions on how to run the game and tests.\n3. **Verified Compilation**:\n - Ran `cd solitaire-app && python3 -m py_compile main.py src/solitaire_tui/*.py` to ensure clean syntax and layout.\n - Verified the non-interactive smoke tests and original unit tests pass successfully under the new directory layout.\n4. **Wrote `status.json`**:\n - Recorded the outcome as `\"succeeded\"` in `status.json` at the workspace root.", + "last_response": "I have successfully initialized and structured the Python project skeleton for the terminal-based Klondike Solitaire game in `solitaire-app/` with the required specifications.\n\nHere is a summary of th", + "internal.run_id": "01KT9X0N42W6PRBVH2N5914EQJ", + "thread.expand_spec.current_node": "impl_setup", + "internal.node_visit_count": 1, + "internal.retry_count.impl_setup": 0, + "internal.fidelity": "compact", + "outcome": "succeeded", + "internal.retry_count.expand_spec": 0 + }, + "node_outcomes": { + "impl_setup": { + "status": "succeeded", + "context_updates": { + "last_response": "I have successfully initialized and structured the Python project skeleton for the terminal-based Klondike Solitaire game in `solitaire-app/` with the required specifications.\n\nHere is a summary of th", + "response.impl_setup": "I have successfully initialized and structured the Python project skeleton for the terminal-based Klondike Solitaire game in `solitaire-app/` with the required specifications.\n\nHere is a summary of the completed tasks:\n1. **Directory Restructuring**:\n - Packaged the core game code under `src/solitaire_tui/` with `__init__.py`, `game.py`, and `ui.py`.\n - Relocated the tests to `tests/test_game.py` with `__init__.py`.\n - Updated all necessary import paths to match this package layout.\n2. **Metadata Files Added**:\n - Created `pyproject.toml` in `solitaire-app/` containing proper package and `pytest` configuration.\n - Added `README.md` containing simple instructions on how to run the game and tests.\n3. **Verified Compilation**:\n - Ran `cd solitaire-app && python3 -m py_compile main.py src/solitaire_tui/*.py` to ensure clean syntax and layout.\n - Verified the non-interactive smoke tests and original unit tests pass successfully under the new directory layout.\n4. **Wrote `status.json`**:\n - Recorded the outcome as `\"succeeded\"` in `status.json` at the workspace root.", + "last_stage": "impl_setup" + }, + "notes": "Stage completed: impl_setup", + "usage": { + "input": { + "usage": { + "model": { + "provider": "gemini", + "model_id": "gemini-3.5-flash" + }, + "tokens": { + "input_tokens": 147983, + "output_tokens": 12018, + "reasoning_tokens": 6237, + "cache_read_tokens": 557262, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "gemini", + "storage_segments": [] + } + }, + "total_usd_micros": 469858 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/solitaire-app/README.md", + "/home/daytona/workspace/fabro/solitaire-app/main.py", + "/home/daytona/workspace/fabro/solitaire-app/pyproject.toml", + "/home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/__init__.py", + "/home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/game.py", + "/home/daytona/workspace/fabro/solitaire-app/src/solitaire_tui/ui.py", + "/home/daytona/workspace/fabro/solitaire-app/tests/__init__.py", + "/home/daytona/workspace/fabro/solitaire-app/tests/test_game.py", + "/home/daytona/workspace/fabro/status.json" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 190345, + "tool_time_ms": 33795, + "active_time_ms": 224140 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "expand_spec": { + "status": "succeeded", + "context_updates": { + "last_response": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification**", + "response.expand_spec": "I have successfully created the detailed specification and fully implemented the terminal-based Klondike Solitaire game in Python!\n\n### Summary of Completed Deliverables:\n1. **Detailed Specification** (`.ai/solitaire-spec.md`):\n - Fully documents directory structures, core logic rules, rendering coordinates, virtual-grid cursor navigation, keyboard control schemes, and test environments.\n2. **Core Solitaire Game Engine** (`solitaire-app/game.py`):\n - Implements standard draw-one Klondike rules including 7 tableaus, 4 foundations, stock, waste, auto-reveals, and unlimited undo history tracking.\n - De-coupled from `curses` allowing robust independent testing.\n3. **curses-based TUI Layer** (`solitaire-app/ui.py`):\n - A grid-based navigation scheme supporting arrow/Vim keys for visual navigation.\n - Supports card stacking, color coding for Red/Black cards, and visual selections.\n - Includes highly intuitive single-key **Auto-Move** (`a` key) to send valid cards directly to foundations.\n4. **Interactive Entry Point and Smoke Mode** (`solitaire-app/main.py`):\n - Supports regular interactive play via `python3 main.py`.\n - Supports non-interactive smoke testing via `python3 main.py --smoke`, allowing tests to verify imports, initialization, and all rules without opening an interactive terminal screen.\n5. **Rules Verification Suite** (`solitaire-app/test_game.py`):\n - 7 automated unit tests checking deal mechanics, drawing/recycling, move constraints, auto-reveals, undo history, and winning conditions.\n6. **Task Status** (`status.json`):\n - Recorded `outcome=succeeded` at the workspace root.", + "last_stage": "expand_spec" + }, + "notes": "Stage completed: expand_spec", + "usage": { + "input": { + "usage": { + "model": { + "provider": "gemini", + "model_id": "gemini-3.5-flash" + }, + "tokens": { + "input_tokens": 140780, + "output_tokens": 12878, + "reasoning_tokens": 11964, + "cache_read_tokens": 276729, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "gemini", + "storage_segments": [] + } + }, + "total_usd_micros": 476257 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/.ai/solitaire-spec.md", + "/home/daytona/workspace/fabro/solitaire-app/game.py", + "/home/daytona/workspace/fabro/solitaire-app/main.py", + "/home/daytona/workspace/fabro/solitaire-app/test_game.py", + "/home/daytona/workspace/fabro/solitaire-app/ui.py", + "/home/daytona/workspace/fabro/status.json" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 157811, + "tool_time_ms": 15292, + "active_time_ms": 173103 + } + } + }, + "next_node_id": "verify_setup", + "node_visits": { + "impl_setup": 1, + "start": 1, + "expand_spec": 1 + } + }, "diff": {} } ], @@ -920,8 +1072,8 @@ "superseded_by": null, "pending_interviews": {}, "stages": { - "expand_spec@1": { - "first_event_seq": 22, + "impl_setup@1": { + "first_event_seq": 91, "prompt": null, "response": null, "completion": null, @@ -935,8 +1087,219 @@ "script_timing": null, "parallel_results": null, "output": null, + "started_at": "2026-06-04T18:10:42.223359Z", + "handler": "agent", + "usage": { + "input_tokens": 147983, + "output_tokens": 12018, + "total_tokens": 723500, + "reasoning_tokens": 6237, + "cache_read_tokens": 557262, + "cache_write_tokens": 0, + "total_usd_micros": 469858 + }, + "model": { + "provider": "gemini", + "model_id": "gemini-3.5-flash" + }, + "permission_level": "full", + "agent_tools": [ + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "list_dir", + "description": "List directory contents with depth control", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_many_files", + "description": "Read multiple files at once", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + } + ], + "context_window": { + "provider": "gemini", + "model": "gemini-3.5-flash", + "context_window_tokens": 1048576, + "input_tokens": 43072, + "usage_percent": 4.107666015625, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-06-04T18:17:47.163144Z", + "event_seq": 176, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 1546, + "usage_percent": 0.14743804931640625 + }, + { + "category": "tools", + "tokens": 1633, + "usage_percent": 0.15573501586914062 + }, + { + "category": "memory", + "tokens": 4422, + "usage_percent": 0.42171478271484375 + }, + { + "category": "conversation", + "tokens": 35464, + "usage_percent": 3.382110595703125 + }, + { + "category": "other", + "tokens": 7, + "usage_percent": 0.000667572021484375 + } + ], + "warnings": [] + }, + "state": "running" + }, + "expand_spec@1": { + "first_event_seq": 22, + "prompt": null, + "response": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: expand_spec", + "failure_reason": null, + "timestamp": "2026-06-04T18:10:17.185851Z" + }, + "provider_used": { + "mode": "agent", + "provider": "gemini", + "model": "gemini-3.5-flash" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, "started_at": "2026-06-04T18:07:14.754960Z", "handler": "agent", + "timing": { + "wall_time_ms": 182430, + "inference_time_ms": 157811, + "tool_time_ms": 15292, + "active_time_ms": 173103 + }, "usage": { "input_tokens": 140780, "output_tokens": 12878, @@ -1118,7 +1481,7 @@ ], "warnings": [] }, - "state": "running" + "state": "succeeded" }, "start@1": { "first_event_seq": 18, diff --git a/stages/002-expand_spec@1/diff.patch b/stages/002-expand_spec@1/diff.patch new file mode 100644 index 000000000..65add9c70 --- /dev/null +++ b/stages/002-expand_spec@1/diff.patch @@ -0,0 +1,1064 @@ +diff --git a/.ai/solitaire-spec.md b/.ai/solitaire-spec.md +new file mode 100644 +index 000000000..7fc44e87a +--- /dev/null ++++ b/.ai/solitaire-spec.md +@@ -0,0 +1,144 @@ ++# Terminal Klondike Solitaire Spec ++ ++This document details the specification and implementation plan for a terminal-based Klondike Solitaire game in Python 3.11+ using the standard `curses` library. ++ ++The application is structured to decouple core game rules and state from the presentation layer. This allows full testability of game mechanics without initializing a terminal. ++ ++--- ++ ++## 1. Directory Structure ++ ++All files are located in `solitaire-app/`: ++ ++``` ++solitaire-app/ ++├── main.py # Entry point: handles CLI args (including --smoke) and starts TUI ++├── game.py # Core Klondike Solitaire game model and rule engine (pure Python) ++├── ui.py # curses-based terminal user interface and input handler ++└── test_game.py # Automated unit tests for game logic ++``` ++ ++--- ++ ++## 2. Core Game Engine (`game.py`) ++ ++The game rules are modeled strictly around standard **Draw-One Klondike Solitaire**. ++ ++### 2.1 Domain Entities ++ ++```python ++from dataclasses import dataclass ++from typing import List, Optional, Tuple ++ ++@dataclass ++class Card: ++ suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades) ++ rank: int # 1 (Ace) to 13 (King) ++ is_face_up: bool = False ++ ++ @property ++ def is_red(self) -> bool: ++ return self.suit in ('H', 'D') ++ ++ @property ++ def label(self) -> str: ++ # e.g., "A", "2"..."10", "J", "Q", "K" ++ ranks = {1: "A", 11: "J", 12: "Q", 13: "K"} ++ return ranks.get(self.rank, str(self.rank)) ++``` ++ ++### 2.2 Game State Model ++ ++`GameState` encapsulates all piles and tracks move history for **unlimited Undo** functionality: ++ ++- **Stock (`List[Card]`)**: Face-down draw pile. ++- **Waste (`List[Card]`)**: Face-up drawn cards. ++- **Foundations (`List[List[Card]]`)**: 4 piles, initially empty. ++- **Tableau (`List[List[Card]]`)**: 7 piles. Tableau $i$ has $i$ cards, with top card face-up. ++- **History (`List[dict]`)**: Deep copies or delta representations of prior states to support Undo. ++ ++### 2.3 Core Mechanics & Rules ++ ++- **Deal**: Shuffle a 52-card deck. Deal cards to Tableau columns (Col 1 has 1, Col 2 has 2... Col 7 has 7). Reveal the top card of each. Remaining 24 cards go to the Stock. ++- **Draw**: Pop 1 card from Stock and append to Waste (face-up). If Stock is empty, recycle Waste by reversing it back to Stock (so that the original draw order is maintained). ++- **Tableau-to-Tableau Moves**: ++ - A card or a valid face-up build (stack of decreasing/alternating color cards) can be moved from column $A$ to column $B$. ++ - Target column top card must be opposite color and exactly 1 rank higher. ++ - If the target column is empty, only a King (rank 13) can be placed there. ++- **Tableau-to-Foundation Moves**: ++ - Move the top card of a Tableau column to a Foundation. ++ - If the Foundation is empty, only an Ace (rank 1) of that suit is allowed. ++ - If not empty, the card must be of the same suit and exactly 1 rank higher than the current top card of that Foundation. ++- **Waste-to-Tableau/Foundation**: ++ - Similar rules applied to the top card of the Waste pile. ++- **Foundation-to-Tableau**: ++ - Allows pulling a card back down from a Foundation pile to a Tableau column (following Tableau placement rules). ++- **Auto-Flip**: ++ - If a move exposes a face-down card at the top of a Tableau column, it must be automatically flipped face-up. ++- **Win Condition**: ++ - All 4 Foundation piles contain 13 cards (ending with Kings). ++ ++--- ++ ++## 3. Terminal TUI Layer (`ui.py`) ++ ++The UI layer runs in `curses` using a grid-based navigation scheme. ++ ++### 3.1 Layout Design ++ ++The screen is divided into two main zones: ++ ++``` ++ [Stock] [Waste] [F1] [F2] [F3] [F4] ++ [ ] [9♦] [A♥] [ ] [ ] [ ] ++ ++ ++ [Col 1] [Col 2] [Col 3] [Col 4] [Col 5] [Col 6] [Col 7] ++ [10♣] [ ] [ ] [ ] [ ] [ ] [ ] ++ [J♥] [ ] [ ] [ ] [ ] [ ] ++ [Q♠] [ ] [ ] [ ] [ ] ++``` ++ ++### 3.2 Keyboard Navigation & Controls ++ ++We implement a keyboard-driven cursor navigation scheme: ++ ++1. **Cursor (Highlighted Element)**: ++ - Use **Arrow keys** or Vim keys (`h`, `j`, `k`, `l`) to move the cursor. ++ - Piles are arranged on a virtual grid: ++ - Top row: Stock, Waste, Foundation 1, Foundation 2, Foundation 3, Foundation 4. ++ - Bottom row: Tableau columns 1 to 7. ++ - When cursor is on a Tableau column, pressing **Up** or **Down** moves the cursor within the face-up cards of that column. This allows selecting a specific card in a stack to move a partial build! ++ ++2. **Selecting and Moving**: ++ - **Space** or **Enter**: ++ - If the cursor is on the **Stock** pile: trigger a Draw operation. ++ - If no card/pile is currently selected: select the current pile (and card index if in Tableau) as the **source**. The selection is visually highlighted. ++ - If a **source** is already selected: move the selected card/stack from the source pile to the currently highlighted pile (the **target**). Then clear selection. ++ - **Escape** or `c`: Cancel current selection. ++ ++3. **Global Shortcuts**: ++ - `u` or `U`: Undo last move. ++ - `r` or `R`: Restart game (re-shuffles and deals). ++ - `q` or `Q`: Quit game. ++ ++### 3.3 Text & Color Configuration ++ ++- Red suits (Hearts/Diamonds) are rendered with red foreground text on appropriate backgrounds. ++- Black suits (Clubs/Spades) are rendered with black or standard terminal color text. ++- Face-down cards are rendered as `[ ]` or `[#]`. ++- Face-up cards are rendered as `[10♦]`, `[A♥]`, `[K♠]`, `[Q♣]`, etc., using Unicode suit symbols or ASCII fallbacks (`H`, `D`, `C`, `S`). ++ ++--- ++ ++## 4. Verification and Testing ++ ++### 4.1 Unit Testing (`test_game.py`) ++- Standard Python unit tests using `unittest`. ++- Covers deck setup, drawing, valid/invalid moves for all directions, auto-flip, and victory validation. ++- Runs purely in CLI without loading `curses`. ++ ++### 4.2 Smoke Mode ++- Run `python3 main.py --smoke`. ++- The entry point parses `--smoke`, verifies all imports, initializes a mock terminal/session state, runs tests, and immediately exits with exit code `0`. ++- Ensures zero-dependency execution environment checks. +diff --git a/solitaire-app/game.py b/solitaire-app/game.py +new file mode 100644 +index 000000000..c91f9abbd +--- /dev/null ++++ b/solitaire-app/game.py +@@ -0,0 +1,273 @@ ++import random ++from dataclasses import dataclass, field ++from typing import List, Optional, Tuple, Dict, Any ++import copy ++ ++@dataclass ++class Card: ++ suit: str # 'H' (Hearts), 'D' (Diamonds), 'C' (Clubs), 'S' (Spades) ++ rank: int # 1 (Ace) to 13 (King) ++ is_face_up: bool = False ++ ++ @property ++ def is_red(self) -> bool: ++ return self.suit in ('H', 'D') ++ ++ @property ++ def label(self) -> str: ++ ranks = {1: "A", 11: "J", 12: "Q", 13: "K"} ++ return ranks.get(self.rank, str(self.rank)) ++ ++ def __repr__(self) -> str: ++ suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'} ++ sym = suit_syms.get(self.suit, self.suit) ++ face = self.label + sym if self.is_face_up else "##" ++ return f"[{face}]" ++ ++ ++class GameState: ++ def __init__(self, seed: Optional[int] = None) -> None: ++ self.stock: List[Card] = [] ++ self.waste: List[Card] = [] ++ self.foundations: List[List[Card]] = [[] for _ in range(4)] ++ self.tableau: List[List[Card]] = [[] for _ in range(7)] ++ self.history: List[Dict[str, Any]] = [] ++ self.seed = seed ++ self.deal() ++ ++ def serialize_state(self) -> Dict[str, Any]: ++ """Returns a deep copy of the current state of all piles.""" ++ return { ++ "stock": copy.deepcopy(self.stock), ++ "waste": copy.deepcopy(self.waste), ++ "foundations": copy.deepcopy(self.foundations), ++ "tableau": copy.deepcopy(self.tableau), ++ } ++ ++ def restore_state(self, state: Dict[str, Any]) -> None: ++ """Restores the state from a serialized state dictionary.""" ++ self.stock = copy.deepcopy(state["stock"]) ++ self.waste = copy.deepcopy(state["waste"]) ++ self.foundations = copy.deepcopy(state["foundations"]) ++ self.tableau = copy.deepcopy(state["tableau"]) ++ ++ def record_history(self) -> None: ++ """Saves current state to history before a mutating operation.""" ++ self.history.append(self.serialize_state()) ++ ++ def undo(self) -> bool: ++ """Undoes the last recorded move. Returns True if successful.""" ++ if not self.history: ++ return False ++ previous_state = self.history.pop() ++ self.restore_state(previous_state) ++ return True ++ ++ def deal(self) -> None: ++ """Creates a standard 52-card deck, shuffles, and deals a new game.""" ++ suits = ['H', 'D', 'C', 'S'] ++ deck = [Card(suit=s, rank=r, is_face_up=False) for s in suits for r in range(1, 14)] ++ ++ if self.seed is not None: ++ random.seed(self.seed) ++ else: ++ random.seed() ++ random.shuffle(deck) ++ ++ self.stock = [] ++ self.waste = [] ++ self.foundations = [[] for _ in range(4)] ++ self.tableau = [[] for _ in range(7)] ++ self.history = [] ++ ++ # Deal to Tableau ++ # Col 0 gets 1 card, Col 1 gets 2 cards, ..., Col 6 gets 7 cards ++ for i in range(7): ++ for j in range(i + 1): ++ card = deck.pop() ++ if j == i: ++ card.is_face_up = True ++ self.tableau[i].append(card) ++ ++ # Remaining cards go to Stock (face-down) ++ self.stock = deck ++ ++ def draw(self) -> bool: ++ """Draws one card from Stock to Waste. Recycles Waste to Stock if Stock is empty.""" ++ self.record_history() ++ ++ if not self.stock: ++ if not self.waste: ++ # Both empty, nothing to do ++ self.history.pop() # don't save useless history ++ return False ++ # Recycle Waste back to Stock ++ # When we flip waste back to stock, we preserve order by reversing ++ self.stock = list(reversed(self.waste)) ++ for card in self.stock: ++ card.is_face_up = False ++ self.waste = [] ++ return True ++ ++ card = self.stock.pop() ++ card.is_face_up = True ++ self.waste.append(card) ++ return True ++ ++ def can_move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool: ++ if not (0 <= src_col < 7) or not (0 <= dest_col < 7): ++ return False ++ if src_col == dest_col: ++ return False ++ ++ src_pile = self.tableau[src_col] ++ if not src_pile or card_idx < 0 or card_idx >= len(src_pile): ++ return False ++ ++ moving_card = src_pile[card_idx] ++ if not moving_card.is_face_up: ++ return False ++ ++ # If dest is empty, moving card must be a King (rank 13) ++ dest_pile = self.tableau[dest_col] ++ if not dest_pile: ++ return moving_card.rank == 13 ++ ++ dest_card = dest_pile[-1] ++ if not dest_card.is_face_up: ++ return False ++ ++ # Opposite color and rank exactly one less ++ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1) ++ ++ def can_move_waste_to_tableau(self, dest_col: int) -> bool: ++ if not self.waste: ++ return False ++ if not (0 <= dest_col < 7): ++ return False ++ ++ moving_card = self.waste[-1] ++ dest_pile = self.tableau[dest_col] ++ ++ if not dest_pile: ++ return moving_card.rank == 13 ++ ++ dest_card = dest_pile[-1] ++ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1) ++ ++ def can_move_waste_to_foundation(self, dest_found: int) -> bool: ++ if not self.waste: ++ return False ++ if not (0 <= dest_found < 4): ++ return False ++ ++ moving_card = self.waste[-1] ++ found_pile = self.foundations[dest_found] ++ ++ if not found_pile: ++ return moving_card.rank == 1 # Ace ++ ++ top_found_card = found_pile[-1] ++ return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1) ++ ++ def can_move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool: ++ if not (0 <= src_col < 7) or not (0 <= dest_found < 4): ++ return False ++ ++ src_pile = self.tableau[src_col] ++ if not src_pile: ++ return False ++ ++ moving_card = src_pile[-1] ++ found_pile = self.foundations[dest_found] ++ ++ if not found_pile: ++ return moving_card.rank == 1 # Ace ++ ++ top_found_card = found_pile[-1] ++ return (moving_card.suit == top_found_card.suit) and (moving_card.rank == top_found_card.rank + 1) ++ ++ def can_move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool: ++ if not (0 <= src_found < 4) or not (0 <= dest_col < 7): ++ return False ++ ++ found_pile = self.foundations[src_found] ++ if not found_pile: ++ return False ++ ++ moving_card = found_pile[-1] ++ dest_pile = self.tableau[dest_col] ++ ++ if not dest_pile: ++ return moving_card.rank == 13 ++ ++ dest_card = dest_pile[-1] ++ return (moving_card.is_red != dest_card.is_red) and (moving_card.rank == dest_card.rank - 1) ++ ++ def auto_reveal(self, col_idx: int) -> None: ++ """Reveals the top card of a Tableau column if it is face-down.""" ++ pile = self.tableau[col_idx] ++ if pile and not pile[-1].is_face_up: ++ pile[-1].is_face_up = True ++ ++ def move_tableau_to_tableau(self, src_col: int, dest_col: int, card_idx: int) -> bool: ++ if not self.can_move_tableau_to_tableau(src_col, dest_col, card_idx): ++ return False ++ ++ self.record_history() ++ src_pile = self.tableau[src_col] ++ dest_pile = self.tableau[dest_col] ++ ++ moving_stack = src_pile[card_idx:] ++ self.tableau[src_col] = src_pile[:card_idx] ++ dest_pile.extend(moving_stack) ++ ++ self.auto_reveal(src_col) ++ return True ++ ++ def move_waste_to_tableau(self, dest_col: int) -> bool: ++ if not self.can_move_waste_to_tableau(dest_col): ++ return False ++ ++ self.record_history() ++ card = self.waste.pop() ++ self.tableau[dest_col].append(card) ++ return True ++ ++ def move_waste_to_foundation(self, dest_found: int) -> bool: ++ if not self.can_move_waste_to_foundation(dest_found): ++ return False ++ ++ self.record_history() ++ card = self.waste.pop() ++ self.foundations[dest_found].append(card) ++ return True ++ ++ def move_tableau_to_foundation(self, src_col: int, dest_found: int) -> bool: ++ if not self.can_move_tableau_to_foundation(src_col, dest_found): ++ return False ++ ++ self.record_history() ++ card = self.tableau[src_col].pop() ++ self.foundations[dest_found].append(card) ++ ++ self.auto_reveal(src_col) ++ return True ++ ++ def move_foundation_to_tableau(self, src_found: int, dest_col: int) -> bool: ++ if not self.can_move_foundation_to_tableau(src_found, dest_col): ++ return False ++ ++ self.record_history() ++ card = self.foundations[src_found].pop() ++ self.tableau[dest_col].append(card) ++ return True ++ ++ def check_win(self) -> bool: ++ """Check if all four foundation piles are fully built up to Kings.""" ++ for found_pile in self.foundations: ++ if len(found_pile) != 13: ++ return False ++ if found_pile[-1].rank != 13: ++ return False ++ return True +diff --git a/solitaire-app/main.py b/solitaire-app/main.py +new file mode 100644 +index 000000000..71febf900 +--- /dev/null ++++ b/solitaire-app/main.py +@@ -0,0 +1,74 @@ ++import sys ++import argparse ++import unittest ++ ++def run_smoke_test() -> None: ++ """Proves imports work, instantiates core classes, and runs self-tests.""" ++ print("Running Solitaire smoke tests...") ++ ++ # 1. Test Imports ++ try: ++ from game import Card, GameState ++ from ui import SolitaireTUI ++ print("✓ Successfully imported core modules (game, ui).") ++ except Exception as e: ++ print(f"✗ Failed to import core modules: {e}") ++ sys.exit(1) ++ ++ # 2. Test instantiation of core logic ++ try: ++ game = GameState(seed=123) ++ print(f"✓ GameState instantiated. Stock size: {len(game.stock)} cards.") ++ ++ tui = SolitaireTUI(game) ++ print("✓ SolitaireTUI instantiated.") ++ except Exception as e: ++ print(f"✗ Failed to instantiate game components: {e}") ++ sys.exit(1) ++ ++ # 3. Run full unit tests to confirm rule-engine validity ++ print("Running automated unit tests...") ++ loader = unittest.TestLoader() ++ # Discover and run tests in the solitaire-app folder ++ from test_game import TestSolitaireGame ++ suite = loader.loadTestsFromTestCase(TestSolitaireGame) ++ runner = unittest.TextTestRunner(verbosity=1) ++ result = runner.run(suite) ++ ++ if result.wasSuccessful(): ++ print("✓ All automated rules unit tests passed successfully!") ++ else: ++ print("✗ Automated unit tests failed!") ++ sys.exit(1) ++ ++ print("Smoke mode passed successfully.") ++ sys.exit(0) ++ ++def main() -> None: ++ parser = argparse.ArgumentParser(description="Terminal Klondike Solitaire") ++ parser.add_argument( ++ "--smoke", ++ action="store_true", ++ help="Run non-interactive smoke tests to verify imports, setup, and game rules." ++ ) ++ args = parser.parse_args() ++ ++ if args.smoke: ++ run_smoke_test() ++ ++ # Normal execution starts curses-based TUI ++ import curses ++ from game import GameState ++ from ui import SolitaireTUI ++ ++ game = GameState() ++ tui = SolitaireTUI(game) ++ ++ try: ++ curses.wrapper(tui.run) ++ except Exception as e: ++ print(f"Error running solitaire TUI: {e}", file=sys.stderr) ++ sys.exit(1) ++ ++if __name__ == "__main__": ++ main() +diff --git a/solitaire-app/test_game.py b/solitaire-app/test_game.py +new file mode 100644 +index 000000000..4b7c2bce1 +--- /dev/null ++++ b/solitaire-app/test_game.py +@@ -0,0 +1,130 @@ ++import unittest ++from game import GameState, Card ++ ++class TestSolitaireGame(unittest.TestCase): ++ def setUp(self): ++ # Use a fixed seed for reproducible tests ++ self.game = GameState(seed=42) ++ ++ def test_initial_deal(self): ++ # Verify 7 tableau columns have correct card counts (1 to 7) ++ for i in range(7): ++ self.assertEqual(len(self.game.tableau[i]), i + 1) ++ # Top card of each column must be face-up ++ self.assertTrue(self.game.tableau[i][-1].is_face_up) ++ # Other cards in column must be face-down ++ for j in range(i): ++ self.assertFalse(self.game.tableau[i][j].is_face_up) ++ ++ # Foundations must be empty initially ++ for f in self.game.foundations: ++ self.assertEqual(len(f), 0) ++ ++ # Stock must contain the remaining cards (52 - 28 = 24) ++ self.assertEqual(len(self.game.stock), 24) ++ # Waste should be empty initially ++ self.assertEqual(len(self.game.waste), 0) ++ ++ def test_draw_and_recycle(self): ++ initial_stock_len = len(self.game.stock) ++ ++ # Draw all cards ++ for _ in range(initial_stock_len): ++ success = self.game.draw() ++ self.assertTrue(success) ++ ++ self.assertEqual(len(self.game.stock), 0) ++ self.assertEqual(len(self.game.waste), initial_stock_len) ++ ++ # Draw again to recycle ++ success = self.game.draw() ++ self.assertTrue(success) ++ self.assertEqual(len(self.game.stock), initial_stock_len) ++ self.assertEqual(len(self.game.waste), 0) ++ ++ def test_cannot_move_invalid_tableau_to_tableau(self): ++ # Try to move a card to an empty slot or invalid card ++ # Setup specific tableau configuration manually to test rules ++ self.game.tableau[0] = [Card(suit='H', rank=5, is_face_up=True)] ++ self.game.tableau[1] = [Card(suit='S', rank=7, is_face_up=True)] ++ ++ # Moving 5 of Hearts onto 7 of Spades is invalid (rank diff != 1) ++ self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0)) ++ ++ # Moving 5 of Hearts onto an empty column is invalid (must be King) ++ self.game.tableau[2] = [] ++ self.assertFalse(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=2, card_idx=0)) ++ ++ def test_valid_tableau_to_tableau_move(self): ++ self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)] ++ self.game.tableau[1] = [ ++ Card(suit='C', rank=8, is_face_up=False), ++ Card(suit='S', rank=7, is_face_up=True) ++ ] ++ ++ # 6 of Hearts onto 7 of Spades: valid! (opposite color, rank = 7 - 1) ++ self.assertTrue(self.game.can_move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0)) ++ ++ # Perform the move ++ success = self.game.move_tableau_to_tableau(src_col=0, dest_col=1, card_idx=0) ++ self.assertTrue(success) ++ self.assertEqual(len(self.game.tableau[0]), 0) ++ self.assertEqual(len(self.game.tableau[1]), 3) ++ self.assertEqual(self.game.tableau[1][-1].rank, 6) ++ ++ def test_auto_reveal(self): ++ self.game.tableau[0] = [Card(suit='H', rank=6, is_face_up=True)] ++ self.game.tableau[1] = [ ++ Card(suit='C', rank=8, is_face_up=False), ++ Card(suit='S', rank=7, is_face_up=True) ++ ] ++ ++ # Move 7 of Spades from tableau[1] to tableau[0] is invalid due to colors/ranks, ++ # let's set up a valid case where a face-down card gets exposed and revealed. ++ self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)] ++ self.game.tableau[1] = [ ++ Card(suit='C', rank=10, is_face_up=False), ++ Card(suit='S', rank=7, is_face_up=True) ++ ] ++ ++ # Let's change tableau[0] top card to 8 of Diamonds (Red) and tableau[1] to 7 of Spades (Black) ++ success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1) ++ self.assertTrue(success) ++ # Check that the face-down card left in tableau[1] (10 of Clubs) is now face-up! ++ self.assertTrue(self.game.tableau[1][0].is_face_up) ++ ++ def test_undo_functionality(self): ++ self.game.tableau[0] = [Card(suit='D', rank=8, is_face_up=True)] ++ self.game.tableau[1] = [ ++ Card(suit='C', rank=10, is_face_up=False), ++ Card(suit='S', rank=7, is_face_up=True) ++ ] ++ ++ initial_state = self.game.serialize_state() ++ ++ success = self.game.move_tableau_to_tableau(src_col=1, dest_col=0, card_idx=1) ++ self.assertTrue(success) ++ ++ # Undo the move ++ undo_success = self.game.undo() ++ self.assertTrue(undo_success) ++ ++ # Verify state is restored ++ self.assertEqual(len(self.game.tableau[1]), 2) ++ self.assertFalse(self.game.tableau[1][0].is_face_up) ++ self.assertTrue(self.game.tableau[1][1].is_face_up) ++ self.assertEqual(len(self.game.tableau[0]), 1) ++ ++ def test_win_condition(self): ++ # Empty game state check_win should be False ++ self.assertFalse(self.game.check_win()) ++ ++ # Set up a winning board state ++ suits = ['H', 'D', 'C', 'S'] ++ for i, suit in enumerate(suits): ++ self.game.foundations[i] = [Card(suit=suit, rank=r, is_face_up=True) for r in range(1, 14)] ++ ++ self.assertTrue(self.game.check_win()) ++ ++if __name__ == '__main__': ++ unittest.main() +diff --git a/solitaire-app/ui.py b/solitaire-app/ui.py +new file mode 100644 +index 000000000..9950c1459 +--- /dev/null ++++ b/solitaire-app/ui.py +@@ -0,0 +1,404 @@ ++import curses ++from game import GameState, Card ++from typing import Optional, Tuple ++ ++class SolitaireTUI: ++ def __init__(self, game: GameState) -> None: ++ self.game = game ++ ++ # Cursor positioning ++ # zone: 'top' or 'bottom' ++ self.cursor_zone = 'top' ++ # col: 0 to 6 (for 'top' or 'bottom') ++ self.cursor_col = 0 ++ # card_idx: index within the Tableau column (only used when zone == 'bottom') ++ self.cursor_card_idx = 0 ++ ++ # Selection state ++ # (zone, col, card_idx) or None ++ self.selected_pos: Optional[Tuple[str, int, int]] = None ++ self.status_msg = "Welcome to Klondike Solitaire! Arrow/Vim keys to move, Space/Enter to select/draw." ++ ++ def get_first_face_up_idx(self, col: int) -> int: ++ """Returns the index of the first face-up card in Tableau column col.""" ++ pile = self.game.tableau[col] ++ for idx, card in enumerate(pile): ++ if card.is_face_up: ++ return idx ++ return 0 ++ ++ def move_cursor(self, direction: str) -> None: ++ if self.cursor_zone == 'top': ++ if direction == 'left': ++ if self.cursor_col > 0: ++ self.cursor_col -= 1 ++ if self.cursor_col == 2: # Skip the gap column ++ self.cursor_col = 1 ++ elif direction == 'right': ++ if self.cursor_col < 6: ++ self.cursor_col += 1 ++ if self.cursor_col == 2: # Skip the gap column ++ self.cursor_col = 3 ++ elif direction == 'down': ++ self.cursor_zone = 'bottom' ++ # Transition to tableau column of same index ++ pile_len = len(self.game.tableau[self.cursor_col]) ++ self.cursor_card_idx = max(0, pile_len - 1) ++ elif direction == 'up': ++ pass # Already at top row ++ ++ elif self.cursor_zone == 'bottom': ++ col = self.cursor_col ++ pile = self.game.tableau[col] ++ ++ if direction == 'left': ++ if self.cursor_col > 0: ++ self.cursor_col -= 1 ++ # Update card index to the top card of the new column ++ new_pile_len = len(self.game.tableau[self.cursor_col]) ++ self.cursor_card_idx = max(0, new_pile_len - 1) ++ elif direction == 'right': ++ if self.cursor_col < 6: ++ self.cursor_col += 1 ++ new_pile_len = len(self.game.tableau[self.cursor_col]) ++ self.cursor_card_idx = max(0, new_pile_len - 1) ++ elif direction == 'up': ++ first_face_up = self.get_first_face_up_idx(col) ++ if pile and self.cursor_card_idx > first_face_up: ++ self.cursor_card_idx -= 1 ++ else: ++ # Transition to top row ++ self.cursor_zone = 'top' ++ # Map to closest top-row element (skip gap) ++ if self.cursor_col == 2: ++ self.cursor_col = 1 ++ elif direction == 'down': ++ if pile and self.cursor_card_idx < len(pile) - 1: ++ self.cursor_card_idx += 1 ++ ++ def handle_action(self) -> None: ++ """Handles Space/Enter press based on current cursor position.""" ++ if self.cursor_zone == 'top': ++ if self.cursor_col == 0: ++ # Draw from Stock to Waste ++ success = self.game.draw() ++ if success: ++ self.status_msg = "Drawn card." ++ else: ++ self.status_msg = "Stock and Waste are empty." ++ self.selected_pos = None # Clear any active selection ++ elif self.cursor_col == 1: ++ # Waste pile selected or moved to ++ if not self.game.waste: ++ self.status_msg = "Waste is empty." ++ return ++ ++ if self.selected_pos is None: ++ self.selected_pos = ('top', 1, len(self.game.waste) - 1) ++ self.status_msg = "Selected card from Waste. Choose target." ++ else: ++ # You can't move anything onto the waste pile ++ self.status_msg = "Cannot move cards onto the Waste pile." ++ self.selected_pos = None ++ elif self.cursor_col >= 3: ++ found_idx = self.cursor_col - 3 ++ if self.selected_pos is None: ++ # Select from Foundation ++ if not self.game.foundations[found_idx]: ++ self.status_msg = "Foundation is empty." ++ return ++ self.selected_pos = ('top', self.cursor_col, len(self.game.foundations[found_idx]) - 1) ++ self.status_msg = f"Selected from Foundation {found_idx + 1}." ++ else: ++ # Move to Foundation ++ src_zone, src_col, src_card_idx = self.selected_pos ++ success = False ++ if src_zone == 'top' and src_col == 1: ++ success = self.game.move_waste_to_foundation(found_idx) ++ elif src_zone == 'bottom': ++ success = self.game.move_tableau_to_foundation(src_col, found_idx) ++ ++ if success: ++ self.status_msg = f"Moved to Foundation {found_idx + 1}." ++ if self.game.check_win(): ++ self.status_msg = "CONGRATULATIONS! YOU WON THE GAME!" ++ else: ++ self.status_msg = "Invalid move to Foundation." ++ self.selected_pos = None ++ ++ elif self.cursor_zone == 'bottom': ++ dest_col = self.cursor_col ++ if self.selected_pos is None: ++ # Select Tableau pile/card ++ pile = self.game.tableau[dest_col] ++ if not pile: ++ self.status_msg = "Tableau column is empty." ++ return ++ # Verify chosen card is face up ++ if not pile[self.cursor_card_idx].is_face_up: ++ self.status_msg = "Cannot select face-down card." ++ return ++ self.selected_pos = ('bottom', dest_col, self.cursor_card_idx) ++ self.status_msg = f"Selected cards from Column {dest_col + 1}." ++ else: ++ # Execute move to Tableau column ++ src_zone, src_col, src_card_idx = self.selected_pos ++ success = False ++ if src_zone == 'top' and src_col == 1: ++ success = self.game.move_waste_to_tableau(dest_col) ++ elif src_zone == 'top' and src_col >= 3: ++ success = self.game.move_foundation_to_tableau(src_col - 3, dest_col) ++ elif src_zone == 'bottom': ++ success = self.game.move_tableau_to_tableau(src_col, dest_col, src_card_idx) ++ ++ if success: ++ self.status_msg = "Move successful." ++ else: ++ self.status_msg = "Invalid move." ++ ++ # Update cursor_card_idx to the new top card of destination ++ new_len = len(self.game.tableau[dest_col]) ++ self.cursor_card_idx = max(0, new_len - 1) ++ self.selected_pos = None ++ ++ def handle_auto_move(self) -> None: ++ """Tries to automatically move the current highlighted card to any valid foundation.""" ++ success = False ++ target_found = -1 ++ ++ if self.cursor_zone == 'top' and self.cursor_col == 1: ++ # Try to move from waste to any foundation ++ for f_idx in range(4): ++ if self.game.can_move_waste_to_foundation(f_idx): ++ success = self.game.move_waste_to_foundation(f_idx) ++ target_found = f_idx ++ break ++ elif self.cursor_zone == 'bottom': ++ # Try to move top card of tableau column to any foundation ++ col = self.cursor_col ++ if self.game.tableau[col]: ++ for f_idx in range(4): ++ if self.game.can_move_tableau_to_foundation(col, f_idx): ++ success = self.game.move_tableau_to_foundation(col, f_idx) ++ target_found = f_idx ++ # Update cursor position ++ new_len = len(self.game.tableau[col]) ++ self.cursor_card_idx = max(0, new_len - 1) ++ break ++ ++ if success: ++ self.status_msg = f"Auto-moved card to Foundation {target_found + 1}." ++ if self.game.check_win(): ++ self.status_msg = "CONGRATULATIONS! YOU WON THE GAME!" ++ else: ++ self.status_msg = "No valid Foundation move available." ++ ++ def draw_card_representation(self, stdscr, y: int, x: int, card: Optional[Card], is_cursor: bool, is_selected: bool) -> None: ++ # Determine bracket symbols ++ left_br, right_br = "[", "]" ++ if is_selected: ++ left_br, right_br = "*", "*" ++ ++ # Determine attributes ++ attr = curses.A_NORMAL ++ if is_cursor: ++ attr |= curses.A_REVERSE ++ ++ if card is None: ++ # Empty slot indicator ++ color = curses.color_pair(5) # Cyan ++ stdscr.addstr(y, x, f"{left_br} -{right_br}", color | attr) ++ elif not card.is_face_up: ++ # Face down card ++ color = curses.color_pair(2) # White/Standard ++ stdscr.addstr(y, x, f"{left_br}###{right_br}", color | attr) ++ else: ++ # Face up card ++ color = curses.color_pair(1) if card.is_red else curses.color_pair(2) ++ suit_syms = {'H': 'H', 'D': 'D', 'C': 'C', 'S': 'S'} ++ # If terminal supports unicode, we can use symbols ++ try: ++ suit_syms = {'H': '♥', 'D': '♦', 'C': '♣', 'S': '♠'} ++ except Exception: ++ pass ++ ++ sym = suit_syms.get(card.suit, card.suit) ++ rank_lbl = card.label ++ if len(rank_lbl) == 1: ++ lbl = f" {rank_lbl}{sym}" ++ else: ++ lbl = f"{rank_lbl}{sym}" ++ ++ stdscr.addstr(y, x, left_br, attr) ++ stdscr.addstr(y, x + 1, lbl, color | attr) ++ stdscr.addstr(y, x + 4, right_br, attr) ++ ++ def draw_screen(self, stdscr) -> None: ++ stdscr.erase() ++ h, w = stdscr.getmaxyx() ++ ++ # Check window size ++ if h < 20 or w < 60: ++ stdscr.addstr(0, 0, "Terminal too small. Please enlarge to at least 80x24.", curses.color_pair(1)) ++ stdscr.refresh() ++ return ++ ++ # Title / Help Banner ++ stdscr.addstr(0, 2, "KLONDIKE SOLITAIRE", curses.color_pair(4) | curses.A_BOLD) ++ help_text = "Arrows/Vim:Move | Space/Enter:Select/Draw | A:Auto-Move | U:Undo | R:Restart | Q:Quit" ++ stdscr.addstr(1, 2, help_text[:w-3], curses.color_pair(3)) ++ ++ # Top row elements positioning ++ # Stock (col 0), Waste (col 1), gap (col 2), Foundations 0-3 (cols 3-6) ++ x_coords = [2 + i * 8 for i in range(7)] ++ ++ # --- Draw STOCK --- ++ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 0) ++ is_selected = False # Stock can never be selected ++ stdscr.addstr(3, x_coords[0], "STOCK", curses.color_pair(3)) ++ ++ if self.game.stock: ++ # Top card of Stock is face down ++ self.draw_card_representation(stdscr, 4, x_coords[0], Card('S', 1, False), is_cursor, is_selected) ++ else: ++ self.draw_card_representation(stdscr, 4, x_coords[0], None, is_cursor, is_selected) ++ ++ # --- Draw WASTE --- ++ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == 1) ++ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == 1) ++ stdscr.addstr(3, x_coords[1], "WASTE", curses.color_pair(3)) ++ ++ if self.game.waste: ++ self.draw_card_representation(stdscr, 4, x_coords[1], self.game.waste[-1], is_cursor, is_selected) ++ else: ++ self.draw_card_representation(stdscr, 4, x_coords[1], None, is_cursor, is_selected) ++ ++ # --- Draw FOUNDATIONS --- ++ for i in range(4): ++ col_idx = 3 + i ++ is_cursor = (self.cursor_zone == 'top' and self.cursor_col == col_idx) ++ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'top' and self.selected_pos[1] == col_idx) ++ stdscr.addstr(3, x_coords[col_idx], f"FOUND {i+1}", curses.color_pair(3)) ++ ++ pile = self.game.foundations[i] ++ if pile: ++ self.draw_card_representation(stdscr, 4, x_coords[col_idx], pile[-1], is_cursor, is_selected) ++ else: ++ self.draw_card_representation(stdscr, 4, x_coords[col_idx], None, is_cursor, is_selected) ++ ++ # --- Draw TABLEAU --- ++ stdscr.addstr(6, 2, "TABLEAU COLUMNS:", curses.color_pair(4)) ++ for col_idx in range(7): ++ pile = self.game.tableau[col_idx] ++ x = x_coords[col_idx] ++ ++ # Label ++ stdscr.addstr(7, x, f"COL {col_idx+1}", curses.color_pair(3)) ++ ++ if not pile: ++ is_cursor = (self.cursor_zone == 'bottom' and self.cursor_col == col_idx) ++ is_selected = (self.selected_pos is not None and self.selected_pos[0] == 'bottom' and self.selected_pos[1] == col_idx) ++ self.draw_card_representation(stdscr, 8, x, None, is_cursor, is_selected) ++ else: ++ for card_idx, card in enumerate(pile): ++ y = 8 + card_idx ++ # Check cursor highlighting ++ is_cursor = ( ++ self.cursor_zone == 'bottom' and ++ self.cursor_col == col_idx and ++ self.cursor_card_idx == card_idx ++ ) ++ # Check selection highlighting ++ # If this column is selected as source, we highlight the selected card and all cards below it! ++ is_selected = False ++ if self.selected_pos is not None: ++ src_zone, src_col, src_card_idx = self.selected_pos ++ if src_zone == 'bottom' and src_col == col_idx and card_idx >= src_card_idx: ++ is_selected = True ++ ++ self.draw_card_representation(stdscr, y, x, card, is_cursor, is_selected) ++ ++ # Draw Status Bar at bottom ++ stdscr.addstr(h - 2, 2, f"Status: {self.status_msg}"[:w-3], curses.color_pair(4) | curses.A_BOLD) ++ ++ # Draw game seed info or undo history count ++ info_str = f"Undos available: {len(self.game.history)}" ++ stdscr.addstr(h - 2, w - len(info_str) - 3, info_str, curses.color_pair(3)) ++ ++ stdscr.refresh() ++ ++ def run(self, stdscr) -> None: ++ # Initialize color scheme ++ if curses.has_colors(): ++ curses.start_color() ++ curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Hearts/Diamonds ++ curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK) # Spades/Clubs ++ curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Navigation/Headers ++ curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK) # Success/Banners ++ curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK) # Empty Slots ++ ++ curses.curs_set(0) ++ stdscr.keypad(True) ++ ++ while True: ++ self.draw_screen(stdscr) ++ try: ++ ch = stdscr.getch() ++ except KeyboardInterrupt: ++ break ++ ++ if ch == -1: ++ continue ++ ++ # Handle Quit ++ if ch in (ord('q'), ord('Q')): ++ break ++ ++ # Handle Movement ++ elif ch == curses.KEY_LEFT or ch == ord('h'): ++ self.move_cursor('left') ++ elif ch == curses.KEY_RIGHT or ch == ord('l'): ++ self.move_cursor('right') ++ elif ch == curses.KEY_UP or ch == ord('k'): ++ self.move_cursor('up') ++ elif ch == curses.KEY_DOWN or ch == ord('j'): ++ self.move_cursor('down') ++ ++ # Handle Selection / Draw / Move ++ elif ch in (ord(' '), 10, 13, curses.KEY_ENTER): # Space, Enter, Return ++ self.handle_action() ++ ++ # Handle Cancel selection ++ elif ch in (27, ord('c'), ord('C')): # Escape, 'c', 'C' ++ self.selected_pos = None ++ self.status_msg = "Selection cancelled." ++ ++ # Handle Auto-move ++ elif ch in (ord('a'), ord('A')): ++ self.handle_auto_move() ++ ++ # Handle Undo ++ elif ch in (ord('u'), ord('U')): ++ if self.game.undo(): ++ self.status_msg = "Last move undone." ++ self.selected_pos = None ++ # Make sure cursor positions are validated against new column lens ++ if self.cursor_zone == 'bottom': ++ pile_len = len(self.game.tableau[self.cursor_col]) ++ self.cursor_card_idx = min(self.cursor_card_idx, max(0, pile_len - 1)) ++ else: ++ self.status_msg = "Nothing to undo." ++ ++ # Handle Restart ++ elif ch in (ord('r'), ord('R')): ++ self.game.deal() ++ self.cursor_zone = 'top' ++ self.cursor_col = 0 ++ self.cursor_card_idx = 0 ++ self.selected_pos = None ++ self.status_msg = "New game started!" ++ ++ # Handle Resize ++ elif ch == curses.KEY_RESIZE: ++ stdscr.clear() ++ stdscr.refresh() +diff --git a/status.json b/status.json +new file mode 100644 +index 000000000..905fb370f +--- /dev/null ++++ b/status.json +@@ -0,0 +1,3 @@ ++{ ++ "outcome": "succeeded" ++} diff --git a/stages/002-expand_spec@1/status.json b/stages/002-expand_spec@1/status.json new file mode 100644 index 000000000..12b6405c9 --- /dev/null +++ b/stages/002-expand_spec@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: expand_spec", + "failure_reason": null, + "timestamp": "2026-06-04T18:10:17.185851Z" +} \ No newline at end of file diff --git a/stages/003-impl_setup@1/prompt.md b/stages/003-impl_setup@1/prompt.md new file mode 100644 index 000000000..579b0fa72 --- /dev/null +++ b/stages/003-impl_setup@1/prompt.md @@ -0,0 +1,23 @@ +Goal: Build a terminal-based Klondike solitaire game in Python with a curses TUI + +## Completed stages +- **expand_spec**: succeeded + - Model: gemini-3.5-flash, 140.8k tokens in / 24.8k out + - Files: /home/daytona/workspace/fabro/.ai/solitaire-spec.md, /home/daytona/workspace/fabro/solitaire-app/game.py, /home/daytona/workspace/fabro/solitaire-app/main.py, /home/daytona/workspace/fabro/solitaire-app/test_game.py, /home/daytona/workspace/fabro/solitaire-app/ui.py, /home/daytona/workspace/fabro/status.json + + +Read .ai/solitaire-spec.md. + +Create the Python project skeleton under solitaire-app/: +- pyproject.toml with pytest configured +- main.py entrypoint +- src/solitaire_tui/ package +- tests/ directory +- README.md stub + +Add minimal importable modules so the project compiles. + +Run: +cd solitaire-app && python3 -m py_compile main.py src/solitaire_tui/*.py + +Write status.json at workspace root: outcome=succeeded if the project skeleton exists and compiles, outcome=failed with failure_reason otherwise. \ No newline at end of file diff --git a/stages/003-impl_setup@1/provider_used.json b/stages/003-impl_setup@1/provider_used.json new file mode 100644 index 000000000..0bb716dde --- /dev/null +++ b/stages/003-impl_setup@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "gemini", + "model": "gemini-3.5-flash" +} \ No newline at end of file diff --git a/stages/003-impl_setup@1/response.md b/stages/003-impl_setup@1/response.md new file mode 100644 index 000000000..784ccabbf --- /dev/null +++ b/stages/003-impl_setup@1/response.md @@ -0,0 +1,15 @@ +I have successfully initialized and structured the Python project skeleton for the terminal-based Klondike Solitaire game in `solitaire-app/` with the required specifications. + +Here is a summary of the completed tasks: +1. **Directory Restructuring**: + - Packaged the core game code under `src/solitaire_tui/` with `__init__.py`, `game.py`, and `ui.py`. + - Relocated the tests to `tests/test_game.py` with `__init__.py`. + - Updated all necessary import paths to match this package layout. +2. **Metadata Files Added**: + - Created `pyproject.toml` in `solitaire-app/` containing proper package and `pytest` configuration. + - Added `README.md` containing simple instructions on how to run the game and tests. +3. **Verified Compilation**: + - Ran `cd solitaire-app && python3 -m py_compile main.py src/solitaire_tui/*.py` to ensure clean syntax and layout. + - Verified the non-interactive smoke tests and original unit tests pass successfully under the new directory layout. +4. **Wrote `status.json`**: + - Recorded the outcome as `"succeeded"` in `status.json` at the workspace root. \ No newline at end of file