diff --git a/run.json b/run.json index a31096af8..cdcb9877d 100644 --- a/run.json +++ b/run.json @@ -354,7 +354,7 @@ "kind": "running" }, "status_updated_at": "2026-06-04T20:25:12.497247Z", - "last_event_at": "2026-06-04T20:30:22.356369Z", + "last_event_at": "2026-06-04T20:34:26.709132Z", "pending_control": null, "checkpoints": [ { @@ -396,9 +396,9 @@ "diff": {} }, { - "seq": 0, + "seq": 152, "checkpoint": { - "timestamp": "2026-06-04T20:30:22.463793Z", + "timestamp": "2026-06-04T20:30:44.434425Z", "current_node": "plan_app", "completed_nodes": [ "start", @@ -406,10 +406,109 @@ ], "node_retries": {}, "context_values": { - "graph.rankdir": "LR", - "last_response": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outline", - "graph.retry_target": "implement_app", "last_stage": "plan_app", + "thread.start.current_node": "plan_app", + "failure_signature": "", + "internal.node_visit_count": 1, + "internal.retry_count.plan_app": 0, + "current_node": "plan_app", + "graph.default_max_retries": "2", + "last_response": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outline", + "response.plan_app": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outlined full FreeCell rules, the game loop state machine, and data structures.\n - Desired standard-library `curses` terminal layout and color rendering schemes (red/black card highlighting).\n - Elaborated move validation rules including the sequence limit formula $M = (F + 1) \\times 2^E$.\n - Drafted victory detection, automatic safe-card collection logic, and the test strategy.\n\n2. **Game Engine (`card-game-app/engine.py`)**:\n - Implemented `Card` and `FreeCellGame` models.\n - Designed exact FreeCell rules, safe recursive auto-collection, multiple card sequence moves, and a full undo history stack.\n\n3. **Interactive Terminal Game & Smoke Verification (`card-game-app/main.py`)**:\n - Built an interactive, color-coded, live-updating curses interface with real-time timers and clean stats.\n - Built a comprehensive, deterministic, non-interactive smoke test accessible via `--smoke`.\n\n4. **Engine Unit Tests (`card-game-app/test_engine.py`)**:\n - Created test cases covering Card creation, dealing correctness, bottom-sequence identification, and move-to-free-cell/undo validation.\n - Verified that all unit tests and smoke tests execute and pass successfully.\n\n5. **Status Outcome (`status.json`)**:\n - Logged the success state in `/home/daytona/workspace/fabro/status.json`.", + "internal.fidelity": "compact", + "failure_class": "", + "graph.retry_target": "implement_app", + "internal.run_id": "01KTA4YK19B717CYJXPGH2BAJW", + "internal.thread_id": "start", + "internal.retry_count.start": 0, + "graph.goal": "Build a terminal-based FreeCell solitaire game in Python", + "outcome": "succeeded", + "internal.work_dir": "/home/daytona/workspace/fabro", + "graph.rankdir": "LR" + }, + "node_outcomes": { + "plan_app": { + "status": "succeeded", + "context_updates": { + "response.plan_app": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outlined full FreeCell rules, the game loop state machine, and data structures.\n - Desired standard-library `curses` terminal layout and color rendering schemes (red/black card highlighting).\n - Elaborated move validation rules including the sequence limit formula $M = (F + 1) \\times 2^E$.\n - Drafted victory detection, automatic safe-card collection logic, and the test strategy.\n\n2. **Game Engine (`card-game-app/engine.py`)**:\n - Implemented `Card` and `FreeCellGame` models.\n - Designed exact FreeCell rules, safe recursive auto-collection, multiple card sequence moves, and a full undo history stack.\n\n3. **Interactive Terminal Game & Smoke Verification (`card-game-app/main.py`)**:\n - Built an interactive, color-coded, live-updating curses interface with real-time timers and clean stats.\n - Built a comprehensive, deterministic, non-interactive smoke test accessible via `--smoke`.\n\n4. **Engine Unit Tests (`card-game-app/test_engine.py`)**:\n - Created test cases covering Card creation, dealing correctness, bottom-sequence identification, and move-to-free-cell/undo validation.\n - Verified that all unit tests and smoke tests execute and pass successfully.\n\n5. **Status Outcome (`status.json`)**:\n - Logged the success state in `/home/daytona/workspace/fabro/status.json`.", + "last_response": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outline", + "last_stage": "plan_app" + }, + "notes": "Stage completed: plan_app", + "usage": { + "input": { + "usage": { + "model": { + "provider": "gemini", + "model_id": "gemini-3.5-flash" + }, + "tokens": { + "input_tokens": 226088, + "output_tokens": 14177, + "reasoning_tokens": 20087, + "cache_read_tokens": 1146025, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "gemini", + "storage_segments": [] + } + }, + "total_usd_micros": 819411 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/.ai/card-game-fast-plan.md", + "/home/daytona/workspace/fabro/card-game-app/engine.py", + "/home/daytona/workspace/fabro/card-game-app/main.py", + "/home/daytona/workspace/fabro/card-game-app/test_engine.py", + "/home/daytona/workspace/fabro/status.json" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 253189, + "tool_time_ms": 46519, + "active_time_ms": 299708 + } + }, + "start": { + "status": "succeeded", + "usage": null + } + }, + "next_node_id": "implement_app", + "git_commit_sha": "f0586224c7005cc207e955c4ad8981bb22c8aa1a", + "node_visits": { + "plan_app": 1, + "start": 1 + } + }, + "diff": { + "patch": "diff --git a/.ai/card-game-fast-plan.md b/.ai/card-game-fast-plan.md\nnew file mode 100644\nindex 000000000..47c11d64f\n--- /dev/null\n+++ b/.ai/card-game-fast-plan.md\n@@ -0,0 +1,97 @@\n+# Implementation Plan - Terminal FreeCell Solitaire in Python\n+\n+This document outlines the design and implementation strategy for a terminal-based FreeCell solitaire game. The application will be located under `card-game-app/` and will support both an interactive curses UI and a non-interactive `--smoke` mode.\n+\n+## 1. Game Rules & Data Structures\n+\n+### Card Model\n+- **Suit**: Spades (♠), Hearts (♥), Diamonds (♦), Clubs (♣).\n+- **Rank**: Ace (1) to King (13).\n+- **Color**: Red (Hearts, Diamonds) or Black (Spades, Clubs).\n+- **Representation**: Displayed as `[A♠]`, `[10♦]`, `[K♣]`, etc. (with red color highlighting for Heart/Diamond suits).\n+\n+### Board State\n+- **Cascades (Tableau)**: 8 columns of cards.\n+ - Initially, 52 cards are dealt face-up: 7 cards in the first 4 columns, 6 cards in the remaining 4.\n+- **Free Cells**: 4 slots, each holding at most 1 card.\n+- **Foundations**: 4 piles, one for each suit, built up from Ace to King.\n+- **Move History**: A stack of previous board states to support full **Undo** (`U`).\n+\n+### Move Validation Rules\n+1. **To Free Cell**: Any single card can be moved to an empty Free Cell.\n+2. **To Foundation**:\n+ - An Ace can be moved to an empty Foundation pile.\n+ - A card of rank $R$ and suit $S$ can be moved to Foundation pile of suit $S$ if the top card is of rank $R-1$.\n+3. **To Cascade**:\n+ - A card of rank $R$ and color $C$ can be placed on a cascade's bottom card of rank $R+1$ and opposite color.\n+ - Any card can be placed on an empty cascade.\n+4. **Sequence Moves**:\n+ - Moving a packed sequence of size $L$ from Cascade A to Cascade B is allowed if the cards are sorted in alternating colors and descending ranks, and the number of cards does not exceed the maximum allowed sequence limit:\n+ $$M = (F + 1) \\times 2^E$$\n+ where $F$ is the number of empty Free Cells, and $E$ is the number of empty Cascades (excluding source and destination).\n+\n+---\n+\n+## 2. Terminal Rendering & Curses\n+\n+The UI is built using Python's standard `curses` library.\n+\n+### Layout (Minimum 80x24 characters)\n+- **Header**: Game title, Moves counter, Time elapsed, and Status line.\n+- **Top Panel**:\n+ - **Free Cells**: Labelled `Q`, `W`, `E`, `R`.\n+ - **Foundations**: Labelled `A`, `S`, `D`, `F`.\n+- **Main Panel**:\n+ - **Cascades**: 8 columns labelled `1` to `8` below them. Cards are stacked vertically with overlapping cards.\n+- **Footer**: Instructions & legend:\n+ - `Src Key` + `Dest Key` to move.\n+ - `U`: Undo, `C`: Auto-collect, `R`: Restart, `N`: New Game, `Q`/`Esc`: Quit.\n+\n+### Color Coding\n+- **Red cards** (Hearts, Diamonds): Rendered using a red-on-black or red-on-default color pair.\n+- **Black cards** (Spades, Clubs): Rendered using default/white-on-black text.\n+- **Selected Card**: Highlighted with reverse video or a distinct color pair.\n+\n+---\n+\n+## 3. Input Handling & Interactive Loop\n+\n+The main loop:\n+1. Render current state.\n+2. If first key is pressed (Source):\n+ - Highlight selection.\n+ - Wait for second key (Destination).\n+3. If both keys are valid, validate the move:\n+ - If valid, execute the move, record state to undo history, and trigger an auto-collect check.\n+ - If invalid, show an error status.\n+4. If special key is pressed:\n+ - `U`: Pop from undo history.\n+ - `C`: Scan cascades/free cells for any cards that can safely be moved to foundations.\n+ - `R`: Reset current game to starting layout.\n+ - `N`: Deal a completely new random game.\n+ - `Q` / `Esc`: Exit game.\n+\n+---\n+\n+## 4. Non-Interactive Demo (`--smoke` Mode)\n+\n+To support automated validation and CI:\n+- Running `python3 main.py --smoke` executes a non-interactive smoke test.\n+- It will:\n+ 1. Initialize a deterministic deck (seeded RNG or fixed sequence).\n+ 2. Perform a series of valid moves.\n+ 3. Validate move logic and status updates.\n+ 4. Ensure no exceptions are thrown.\n+ 5. Print a success message and exit with code `0`.\n+\n+---\n+\n+## 5. Test Strategy\n+\n+1. **Unit Tests**:\n+ - Card representation & colors.\n+ - Board state initialization (correct count of cards in cascades, free cells, foundations).\n+ - Move validation logic (valid/invalid cascade-to-cascade, freecell-to-cascade, cascade-to-foundation).\n+ - Multi-card sequence move calculation.\n+2. **Integration Tests**:\n+ - Smoke test running end-to-end moves without starting curses.\ndiff --git a/card-game-app/engine.py b/card-game-app/engine.py\nnew file mode 100644\nindex 000000000..664ce6229\n--- /dev/null\n+++ b/card-game-app/engine.py\n@@ -0,0 +1,373 @@\n+import random\n+import copy\n+\n+SUITS = {\n+ 'S': '♠', # Spades\n+ 'H': '♥', # Hearts\n+ 'D': '♦', # Diamonds\n+ 'C': '♣' # Clubs\n+}\n+\n+SUIT_COLORS = {\n+ 'S': 'black',\n+ 'H': 'red',\n+ 'D': 'red',\n+ 'C': 'black'\n+}\n+\n+RANK_NAMES = {\n+ 1: 'A',\n+ 2: '2',\n+ 3: '3',\n+ 4: '4',\n+ 5: '5',\n+ 6: '6',\n+ 7: '7',\n+ 8: '8',\n+ 9: '9',\n+ 10: '10',\n+ 11: 'J',\n+ 12: 'Q',\n+ 13: 'K'\n+}\n+\n+class Card:\n+ def __init__(self, suit, rank):\n+ if suit not in SUITS:\n+ raise ValueError(f\"Invalid suit: {suit}\")\n+ if rank not in RANK_NAMES:\n+ raise ValueError(f\"Invalid rank: {rank}\")\n+ self.suit = suit\n+ self.rank = rank\n+ self.color = SUIT_COLORS[suit]\n+\n+ def __repr__(self):\n+ return f\"{RANK_NAMES[self.rank]}{SUITS[self.suit]}\"\n+\n+ def __eq__(self, other):\n+ if not isinstance(other, Card):\n+ return False\n+ return self.suit == other.suit and self.rank == other.rank\n+\n+ def to_dict(self):\n+ return {'suit': self.suit, 'rank': self.rank}\n+\n+ @staticmethod\n+ def from_dict(d):\n+ if d is None:\n+ return None\n+ return Card(d['suit'], d['rank'])\n+\n+\n+class FreeCellGame:\n+ def __init__(self, seed=None):\n+ self.seed = seed\n+ self.cascades = [[] for _ in range(8)]\n+ self.free_cells = [None] * 4\n+ # Foundations: 0=Spades, 1=Hearts, 2=Diamonds, 3=Clubs\n+ self.foundations = [[] for _ in range(4)]\n+ self.foundation_suits = ['S', 'H', 'D', 'C']\n+ self.history = []\n+ self.move_count = 0\n+ self.deal()\n+\n+ def deal(self):\n+ # Create deck of 52 cards\n+ deck = [Card(suit, rank) for suit in SUITS for rank in RANK_NAMES]\n+ \n+ # Shuffle\n+ rng = random.Random(self.seed)\n+ rng.shuffle(deck)\n+\n+ # Reset state\n+ self.cascades = [[] for _ in range(8)]\n+ self.free_cells = [None] * 4\n+ self.foundations = [[] for _ in range(4)]\n+ self.history = []\n+ self.move_count = 0\n+\n+ # Deal to cascades\n+ for i, card in enumerate(deck):\n+ col = i % 8\n+ self.cascades[col].append(card)\n+\n+ def save_state(self):\n+ # Save deep copy of the state to history stack\n+ state = {\n+ 'cascades': [[c.to_dict() for c in col] for col in self.cascades],\n+ 'free_cells': [c.to_dict() if c else None for c in self.free_cells],\n+ 'foundations': [[c.to_dict() for c in col] for col in self.foundations],\n+ 'move_count': self.move_count\n+ }\n+ self.history.append(state)\n+\n+ def undo(self):\n+ if not self.history:\n+ return False\n+ state = self.history.pop()\n+ self.cascades = [[Card.from_dict(c) for c in col] for col in state['cascades']]\n+ self.free_cells = [Card.from_dict(c) if c else None for c in state['free_cells']]\n+ self.foundations = [[Card.from_dict(c) for c in col] for col in state['foundations']]\n+ self.move_count = state['move_count']\n+ return True\n+\n+ def check_win(self):\n+ # Won if all foundations have 13 cards\n+ return all(len(f) == 13 for f in self.foundations)\n+\n+ def get_max_move_size(self, exclude_src_idx=None, exclude_dest_idx=None):\n+ F = sum(1 for cell in self.free_cells if cell is None)\n+ E = 0\n+ for idx, cascade in enumerate(self.cascades):\n+ if idx != exclude_src_idx and idx != exclude_dest_idx and not cascade:\n+ E += 1\n+ return (F + 1) * (2 ** E)\n+\n+ @staticmethod\n+ def get_bottom_sequence(cascade):\n+ if not cascade:\n+ return []\n+ seq = [cascade[-1]]\n+ for i in range(len(cascade) - 2, -1, -1):\n+ card = cascade[i]\n+ prev = seq[-1]\n+ if card.rank == prev.rank + 1 and card.color != prev.color:\n+ seq.append(card)\n+ else:\n+ break\n+ seq.reverse()\n+ return seq\n+\n+ def validate_and_move(self, src_type, src_idx, dest_type, dest_idx):\n+ \"\"\"\n+ Executes a move if valid.\n+ src_type/dest_type can be 'cascade', 'freecell', or 'foundation'.\n+ src_idx/dest_idx are 0-based integers.\n+ Returns: (success_bool, message)\n+ \"\"\"\n+ # Validate indices\n+ if src_type == 'cascade' and not (0 <= src_idx < 8):\n+ return False, \"Invalid source cascade index\"\n+ if src_type == 'freecell' and not (0 <= src_idx < 4):\n+ return False, \"Invalid source free cell index\"\n+ if src_type == 'foundation' and not (0 <= src_idx < 4):\n+ return False, \"Invalid source foundation index\"\n+ if dest_type == 'cascade' and not (0 <= dest_idx < 8):\n+ return False, \"Invalid destination cascade index\"\n+ if dest_type == 'freecell' and not (0 <= dest_idx < 4):\n+ return False, \"Invalid destination free cell index\"\n+ if dest_type == 'foundation' and not (0 <= dest_idx < 4):\n+ return False, \"Invalid destination foundation index\"\n+\n+ # Disallow moving to the exact same pile\n+ if src_type == dest_type and src_idx == dest_idx:\n+ return False, \"Cannot move to the same pile\"\n+\n+ # Get source cards/card\n+ if src_type == 'freecell':\n+ src_card = self.free_cells[src_idx]\n+ if src_card is None:\n+ return False, \"Source free cell is empty\"\n+ src_cards = [src_card]\n+ elif src_type == 'foundation':\n+ if not self.foundations[src_idx]:\n+ return False, \"Source foundation is empty\"\n+ src_cards = [self.foundations[src_idx][-1]]\n+ elif src_type == 'cascade':\n+ if not self.cascades[src_idx]:\n+ return False, \"Source cascade is empty\"\n+ # We will figure out how many cards to move based on the destination\n+ src_cards = [] # Will populate below based on destination\n+ else:\n+ return False, \"Invalid source type\"\n+\n+ # Validate based on destination\n+ if dest_type == 'freecell':\n+ if self.free_cells[dest_idx] is not None:\n+ return False, \"Destination free cell is already occupied\"\n+ \n+ # If moving from cascade, we can only move the single bottom card\n+ if src_type == 'cascade':\n+ src_cards = [self.cascades[src_idx][-1]]\n+\n+ # Execute move\n+ self.save_state()\n+ card_to_move = src_cards[0]\n+ # Remove from source\n+ if src_type == 'freecell':\n+ self.free_cells[src_idx] = None\n+ elif src_type == 'foundation':\n+ self.foundations[src_idx].pop()\n+ elif src_type == 'cascade':\n+ self.cascades[src_idx].pop()\n+ # Place in destination\n+ self.free_cells[dest_idx] = card_to_move\n+ self.move_count += 1\n+ self.auto_collect()\n+ return True, \"Moved card to free cell\"\n+\n+ elif dest_type == 'foundation':\n+ # Target suit for this foundation slot\n+ target_suit = self.foundation_suits[dest_idx]\n+\n+ # If moving from cascade, we can only move the single bottom card\n+ if src_type == 'cascade':\n+ src_cards = [self.cascades[src_idx][-1]]\n+\n+ card_to_move = src_cards[0]\n+ if card_to_move.suit != target_suit:\n+ return False, f\"Foundation is for {SUITS[target_suit]}, but card is {card_to_move}\"\n+\n+ dest_pile = self.foundations[dest_idx]\n+ if not dest_pile:\n+ if card_to_move.rank != 1:\n+ return False, \"Only an Ace can be placed on an empty foundation\"\n+ else:\n+ top_card = dest_pile[-1]\n+ if card_to_move.rank != top_card.rank + 1:\n+ return False, f\"Cannot place {card_to_move} on {top_card} (must be consecutive rank)\"\n+\n+ # Execute move\n+ self.save_state()\n+ # Remove from source\n+ if src_type == 'freecell':\n+ self.free_cells[src_idx] = None\n+ elif src_type == 'foundation':\n+ self.foundations[src_idx].pop()\n+ elif src_type == 'cascade':\n+ self.cascades[src_idx].pop()\n+ # Place in destination\n+ self.foundations[dest_idx].append(card_to_move)\n+ self.move_count += 1\n+ self.auto_collect()\n+ return True, \"Moved card to foundation\"\n+\n+ elif dest_type == 'cascade':\n+ dest_cascade = self.cascades[dest_idx]\n+\n+ if src_type == 'freecell' or src_type == 'foundation':\n+ card_to_move = src_cards[0]\n+ if dest_cascade:\n+ top_card = dest_cascade[-1]\n+ if card_to_move.rank != top_card.rank - 1 or card_to_move.color == top_card.color:\n+ return False, f\"Cannot place {card_to_move} on {top_card} (must be alternating color and rank - 1)\"\n+ # Execute move\n+ self.save_state()\n+ if src_type == 'freecell':\n+ self.free_cells[src_idx] = None\n+ elif src_type == 'foundation':\n+ self.foundations[src_idx].pop()\n+ dest_cascade.append(card_to_move)\n+ self.move_count += 1\n+ self.auto_collect()\n+ return True, \"Moved card to cascade\"\n+\n+ elif src_type == 'cascade':\n+ # Move from cascade to cascade (potential sequence move)\n+ bottom_seq = self.get_bottom_sequence(self.cascades[src_idx])\n+ \n+ if not dest_cascade:\n+ # Destination is empty. Move largest allowed sequence.\n+ max_allowed = self.get_max_move_size(src_idx, dest_idx)\n+ num_to_move = min(len(bottom_seq), max_allowed)\n+ if num_to_move == 0:\n+ return False, \"No cards to move\"\n+ \n+ self.save_state()\n+ # Pop num_to_move cards from source, and append to dest\n+ cards_to_move = self.cascades[src_idx][-num_to_move:]\n+ self.cascades[src_idx] = self.cascades[src_idx][:-num_to_move]\n+ self.cascades[dest_idx].extend(cards_to_move)\n+ self.move_count += 1\n+ self.auto_collect()\n+ return True, f\"Moved sequence of {num_to_move} cards to empty cascade\"\n+ else:\n+ # Destination is not empty. We must match the destination's top card.\n+ dest_top = dest_cascade[-1]\n+ # We need a card in bottom_seq of rank dest_top.rank - 1 and opposite color\n+ match_card_idx = -1\n+ for i, card in enumerate(bottom_seq):\n+ if card.rank == dest_top.rank - 1 and card.color != dest_top.color:\n+ match_card_idx = i\n+ break\n+ \n+ if match_card_idx == -1:\n+ return False, f\"No valid card in sequence to place on {dest_top}\"\n+ \n+ # Sequence to move is bottom_seq[match_card_idx:]\n+ seq_to_move = bottom_seq[match_card_idx:]\n+ num_to_move = len(seq_to_move)\n+\n+ max_allowed = self.get_max_move_size(src_idx, dest_idx)\n+ if num_to_move > max_allowed:\n+ return False, f\"Cannot move {num_to_move} cards. Max allowed is {max_allowed}.\"\n+\n+ self.save_state()\n+ self.cascades[src_idx] = self.cascades[src_idx][:-num_to_move]\n+ self.cascades[dest_idx].extend(seq_to_move)\n+ self.move_count += 1\n+ self.auto_collect()\n+ return True, f\"Moved sequence of {num_to_move} cards to cascade\"\n+\n+ return False, \"Unknown destination error\"\n+\n+ def auto_collect(self):\n+ \"\"\"\n+ Scan all top cards (bottom of cascades and free cells) and move any safely collectible cards\n+ to the foundation. Repeat until no more cards can be collected.\n+ \"\"\"\n+ while True:\n+ collected_any = False\n+ \n+ # Helper to check if card is safe to collect\n+ def is_safe_to_collect(card):\n+ if card.rank <= 2:\n+ return True\n+ # Opposite suit colors must be at least card.rank - 1\n+ if card.color == 'red':\n+ opposite_suits = ['S', 'C']\n+ else:\n+ opposite_suits = ['H', 'D']\n+\n+ opp_ranks = []\n+ for opp_suit in opposite_suits:\n+ # Find corresponding foundation slot rank\n+ found_idx = self.foundation_suits.index(opp_suit)\n+ found_pile = self.foundations[found_idx]\n+ opp_rank = found_pile[-1].rank if found_pile else 0\n+ opp_ranks.append(opp_rank)\n+ \n+ return all(r >= card.rank - 1 for r in opp_ranks)\n+\n+ # 1. Check Free Cells\n+ for idx, card in enumerate(self.free_cells):\n+ if card is not None:\n+ found_idx = self.foundation_suits.index(card.suit)\n+ found_pile = self.foundations[found_idx]\n+ next_rank = found_pile[-1].rank + 1 if found_pile else 1\n+ if card.rank == next_rank and is_safe_to_collect(card):\n+ # Move to foundation\n+ self.foundations[found_idx].append(card)\n+ self.free_cells[idx] = None\n+ collected_any = True\n+ break # Start outer loop over to respect state changes\n+\n+ if collected_any:\n+ continue\n+\n+ # 2. Check Cascades\n+ for idx, cascade in enumerate(self.cascades):\n+ if cascade:\n+ card = cascade[-1]\n+ found_idx = self.foundation_suits.index(card.suit)\n+ found_pile = self.foundations[found_idx]\n+ next_rank = found_pile[-1].rank + 1 if found_pile else 1\n+ if card.rank == next_rank and is_safe_to_collect(card):\n+ # Move to foundation\n+ self.foundations[found_idx].append(card)\n+ cascade.pop()\n+ collected_any = True\n+ break # Start outer loop over\n+\n+ if not collected_any:\n+ break\ndiff --git a/card-game-app/main.py b/card-game-app/main.py\nnew file mode 100644\nindex 000000000..27c5b1af2\n--- /dev/null\n+++ b/card-game-app/main.py\n@@ -0,0 +1,413 @@\n+import argparse\n+import sys\n+import time\n+\n+# Ensure engine can be imported from same directory\n+from engine import FreeCellGame, Card, SUITS, SUIT_COLORS, RANK_NAMES\n+\n+def run_smoke_test():\n+ \"\"\"\n+ Non-interactive smoke test.\n+ Loads a deterministic game (seed=42), performs a sequence of valid moves,\n+ verifies logic, undoes, and verifies state correctness.\n+ \"\"\"\n+ print(\"Running non-interactive FreeCell smoke test...\")\n+ \n+ # 1. Initialize game with a seed\n+ game = FreeCellGame(seed=42)\n+ print(f\"Game initialized with seed 42. Moves: {game.move_count}\")\n+ \n+ # Check initial counts\n+ total_cards = sum(len(col) for col in game.cascades)\n+ assert total_cards == 52, f\"Expected 52 cards, got {total_cards}\"\n+ assert len(game.cascades[0]) == 7, \"Cascade 0 should have 7 cards\"\n+ assert len(game.cascades[4]) == 6, \"Cascade 4 should have 6 cards\"\n+ print(\"Initial card counts and distribution verified successfully.\")\n+\n+ # 2. Perform a valid move (bottom card of Cascade 0 to Free Cell 0)\n+ card_0_bottom = game.cascades[0][-1]\n+ print(f\"Moving bottom card of Cascade 0 ({card_0_bottom}) to Free Cell 0.\")\n+ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0)\n+ assert success, f\"Expected move to succeed, but failed: {msg}\"\n+ assert game.free_cells[0] == card_0_bottom, \"Card was not placed in Free Cell 0\"\n+ assert len(game.cascades[0]) == 6, f\"Expected Cascade 0 to have 6 cards, got {len(game.cascades[0])}\"\n+ assert game.move_count == 1, f\"Expected move count to be 1, got {game.move_count}\"\n+ print(\"First move completed and verified successfully.\")\n+\n+ # 3. Perform another valid move (bottom card of Cascade 1 to Free Cell 1)\n+ card_1_bottom = game.cascades[1][-1]\n+ print(f\"Moving bottom card of Cascade 1 ({card_1_bottom}) to Free Cell 1.\")\n+ success, msg = game.validate_and_move('cascade', 1, 'freecell', 1)\n+ assert success, f\"Expected move to succeed, but failed: {msg}\"\n+ assert game.free_cells[1] == card_1_bottom, \"Card was not placed in Free Cell 1\"\n+ assert len(game.cascades[1]) == 6, f\"Expected Cascade 1 to have 6 cards, got {len(game.cascades[1])}\"\n+ assert game.move_count == 2, f\"Expected move count to be 2, got {game.move_count}\"\n+ print(\"Second move completed and verified successfully.\")\n+\n+ # 4. Perform an invalid move (moving to occupied free cell 0)\n+ print(\"Testing invalid move: Cascade 2 bottom to occupied Free Cell 0.\")\n+ success, msg = game.validate_and_move('cascade', 2, 'freecell', 0)\n+ assert not success, \"Expected move to fail, but it succeeded!\"\n+ print(f\"Invalid move correctly rejected. Error message: '{msg}'\")\n+\n+ # 5. Verify Undo functionality\n+ print(\"Undoing second move...\")\n+ undo_success = game.undo()\n+ assert undo_success, \"Undo failed\"\n+ assert game.free_cells[1] is None, \"Expected Free Cell 1 to be empty after undo\"\n+ assert len(game.cascades[1]) == 7, \"Expected Cascade 1 to restore its card\"\n+ assert game.cascades[1][-1] == card_1_bottom, \"Expected original card to return to bottom of Cascade 1\"\n+ assert game.move_count == 1, f\"Expected move count to revert to 1, got {game.move_count}\"\n+ print(\"Undo functionality verified successfully.\")\n+\n+ # 6. Verify safe auto-collect (Aces are always auto-collected)\n+ # We can programmatically deal a game, find where Ace of Spades is,\n+ # and if it is at the bottom of a cascade, it should auto-collect immediately.\n+ # Let's find a seed where an Ace is at the bottom of a cascade, or construct one.\n+ # To keep it robust and independent of seeds, let's construct a small scenario.\n+ print(\"Verifying auto-collect of Aces...\")\n+ game = FreeCellGame(seed=42)\n+ # Clear out an Ace manually to the bottom of cascade 0\n+ ace_spades = Card('S', 1)\n+ game.cascades[0].append(ace_spades)\n+ # Trigger auto-collect\n+ game.auto_collect()\n+ # Spades foundation is index 0. It should now contain the Ace of Spades.\n+ assert len(game.foundations[0]) >= 1, \"Expected Ace of Spades to be auto-collected to foundation 0\"\n+ assert game.foundations[0][0] == ace_spades, \"Foundation 0's first card should be Ace of Spades\"\n+ print(\"Auto-collect logic verified successfully.\")\n+\n+ print(\"\\nSmoke test PASSED successfully!\")\n+ sys.exit(0)\n+\n+\n+def run_curses_ui():\n+ \"\"\"\n+ Launches the interactive curses terminal interface.\n+ \"\"\"\n+ try:\n+ import curses\n+ except ImportError:\n+ print(\"Error: The standard-library 'curses' module is not available on this system.\")\n+ sys.exit(1)\n+\n+ def draw_card(stdscr, y, x, card, selected=False):\n+ if card is None:\n+ stdscr.addstr(y, x, \"[ ]\", curses.color_pair(5))\n+ return\n+\n+ # Select color pair based on card color\n+ if card.color == 'red':\n+ color_pair = curses.color_pair(1) # Red text\n+ else:\n+ color_pair = curses.color_pair(2) # White/Black text\n+\n+ if selected:\n+ color_pair = color_pair | curses.A_REVERSE\n+\n+ rank_str = RANK_NAMES[card.rank]\n+ suit_sym = SUITS[card.suit]\n+ card_text = f\"{rank_str}{suit_sym}\"\n+ \n+ # Pad to exactly 3 characters for uniform alignment\n+ if len(card_text) == 2:\n+ card_text = \" \" + card_text\n+\n+ stdscr.addstr(y, x, \"[\", curses.color_pair(5))\n+ stdscr.addstr(y, x + 1, card_text, color_pair)\n+ stdscr.addstr(y, x + 4, \"]\", curses.color_pair(5))\n+\n+ def draw_foundation_placeholder(stdscr, y, x, suit):\n+ suit_sym = SUITS[suit]\n+ if SUIT_COLORS[suit] == 'red':\n+ color = curses.color_pair(1)\n+ else:\n+ color = curses.color_pair(2)\n+ stdscr.addstr(y, x, \"[\", curses.color_pair(5))\n+ stdscr.addstr(y, x + 1, f\" {suit_sym} \", color | curses.A_DIM)\n+ stdscr.addstr(y, x + 4, \"]\", curses.color_pair(5))\n+\n+ def curses_main(stdscr):\n+ # Configure curses environment\n+ curses.curs_set(0) # Hide blinking text cursor\n+ stdscr.timeout(500) # Update elapsed time every 500ms\n+\n+ # Color setup\n+ curses.use_default_colors()\n+ curses.init_pair(1, curses.COLOR_RED, -1) # Red suits\n+ curses.init_pair(2, curses.COLOR_WHITE, -1) # Black/white suits\n+ curses.init_pair(3, curses.COLOR_GREEN, -1) # Green labels\n+ curses.init_pair(4, curses.COLOR_YELLOW, -1) # Highlight / Warning\n+ curses.init_pair(5, curses.COLOR_CYAN, -1) # Brackets/borders\n+\n+ # Initialize a random game\n+ current_seed = random_seed()\n+ game = FreeCellGame(seed=current_seed)\n+ start_time = time.time()\n+ \n+ # Selection states\n+ src_type = None\n+ src_idx = None\n+ status_msg = \"Game started! Enter Source key...\"\n+ status_color_pair = curses.color_pair(3)\n+\n+ # Key mappings\n+ key_to_pile = {\n+ 'q': ('freecell', 0), 'w': ('freecell', 1), 'e': ('freecell', 2), 'r': ('freecell', 3),\n+ 'a': ('foundation', 0), 's': ('foundation', 1), 'd': ('foundation', 2), 'f': ('foundation', 3),\n+ '1': ('cascade', 0), '2': ('cascade', 1), '3': ('cascade', 2), '4': ('cascade', 3),\n+ '5': ('cascade', 4), '6': ('cascade', 5), '7': ('cascade', 6), '8': ('cascade', 7)\n+ }\n+\n+ while True:\n+ # Check terminal size\n+ height, width = stdscr.getmaxyx()\n+ if width < 80 or height < 24:\n+ stdscr.clear()\n+ stdscr.addstr(0, 0, \"Terminal must be at least 80x24.\", curses.color_pair(4))\n+ stdscr.addstr(1, 0, f\"Current size: {width}x{height}\", curses.color_pair(2))\n+ stdscr.addstr(3, 0, \"Please resize your terminal window to continue.\", curses.color_pair(2))\n+ stdscr.refresh()\n+ # Wait for resize\n+ ch = stdscr.getch()\n+ if ch in [ord('q'), ord('Q'), 27]: # ESC or Q\n+ break\n+ continue\n+\n+ stdscr.clear()\n+\n+ # --- RENDER HEADER ---\n+ stdscr.addstr(0, 0, \"┌\" + \"─\" * 78 + \"┐\", curses.color_pair(5))\n+ \n+ # Formulate header statistics\n+ elapsed_sec = int(time.time() - start_time)\n+ min_part = elapsed_sec // 60\n+ sec_part = elapsed_sec % 60\n+ time_str = f\"{min_part:02d}:{sec_part:02d}\"\n+ \n+ stats_line = f\" FREECELL SOLITAIRE | Moves: {game.move_count:<3} | Time: {time_str} | Seed: {current_seed:<10}\"\n+ stdscr.addstr(1, 0, \"│\" + stats_line.ljust(78) + \"│\", curses.color_pair(3) | curses.A_BOLD)\n+ stdscr.addstr(2, 0, \"└\" + \"─\" * 78 + \"┘\", curses.color_pair(5))\n+\n+ # --- RENDER TOP SECTION (Free Cells & Foundations) ---\n+ stdscr.addstr(4, 2, \"FREE CELLS (Q-R)\", curses.color_pair(3))\n+ stdscr.addstr(4, 40, \"FOUNDATIONS (A-F)\", curses.color_pair(3))\n+\n+ # Render Labels for Free Cells\n+ labels_fc = ['Q', 'W', 'E', 'R']\n+ for i, label in enumerate(labels_fc):\n+ is_selected = (src_type == 'freecell' and src_idx == i)\n+ color = curses.color_pair(4) if is_selected else curses.color_pair(3)\n+ stdscr.addstr(5, 4 + i * 8, label, color | (curses.A_UNDERLINE if is_selected else 0))\n+\n+ # Render Free Cells\n+ for i, card in enumerate(game.free_cells):\n+ is_selected = (src_type == 'freecell' and src_idx == i)\n+ draw_card(stdscr, 6, 2 + i * 8, card, selected=is_selected)\n+\n+ # Render Labels for Foundations\n+ labels_fnd = ['A (♠)', 'S (♥)', 'D (♦)', 'F (♣)']\n+ for i, label in enumerate(labels_fnd):\n+ is_selected = (src_type == 'foundation' and src_idx == i)\n+ color = curses.color_pair(4) if is_selected else curses.color_pair(3)\n+ stdscr.addstr(5, 41 + i * 9, label, color)\n+\n+ # Render Foundations\n+ for i, pile in enumerate(game.foundations):\n+ is_selected = (src_type == 'foundation' and src_idx == i)\n+ if not pile:\n+ draw_foundation_placeholder(stdscr, 6, 40 + i * 9, game.foundation_suits[i])\n+ else:\n+ draw_card(stdscr, 6, 40 + i * 9, pile[-1], selected=is_selected)\n+\n+ # --- RENDER CASCADES (1-8) ---\n+ stdscr.addstr(9, 2, \"CASCADES (1-8)\", curses.color_pair(3))\n+\n+ # Print cascade header labels\n+ for i in range(8):\n+ is_selected = (src_type == 'cascade' and src_idx == i)\n+ color = curses.color_pair(4) if is_selected else curses.color_pair(3)\n+ label_text = f\"({i+1})\"\n+ stdscr.addstr(10, 4 + i * 9, label_text, color | (curses.A_UNDERLINE if is_selected else 0))\n+\n+ # Print the cards in each cascade\n+ max_col_height = max(len(col) for col in game.cascades)\n+ # Render up to the height of the terminal dynamically\n+ visible_rows = height - 16 # Reserve rows for headers/footers\n+ for row_idx in range(max_col_height):\n+ if row_idx >= visible_rows:\n+ # Render indicators that more cards are hidden\n+ stdscr.addstr(11 + visible_rows, 2, \"... and more cards below ...\", curses.color_pair(4))\n+ break\n+\n+ for col_idx in range(8):\n+ cascade = game.cascades[col_idx]\n+ if row_idx < len(cascade):\n+ card = cascade[row_idx]\n+ # A card is selected if it is the source and we are highlighting it.\n+ # Note: for cascades, we highlight the source column's bottom cards/sequence if selected.\n+ is_selected = False\n+ if src_type == 'cascade' and src_idx == col_idx:\n+ # Highlight the bottom sequence or bottom card\n+ seq = game.get_bottom_sequence(cascade)\n+ if card in seq:\n+ is_selected = True\n+\n+ draw_card(stdscr, 11 + row_idx, 2 + col_idx * 9, card, selected=is_selected)\n+\n+ # --- RENDER STATUS & FOOTER ---\n+ # Print status message\n+ status_y = height - 4\n+ stdscr.addstr(status_y, 2, \"Status: \", curses.color_pair(3))\n+ stdscr.addstr(status_y, 10, status_msg.ljust(68)[:68], status_color_pair)\n+\n+ # Print helpful key bindings list\n+ footer_y = height - 2\n+ bindings_line1 = \"Keys: FreeCells (Q W E R) | Foundations (A S D F) | Cascades (1-8)\"\n+ bindings_line2 = \"[U] Undo | [C] Auto-Collect | [R] Restart | [N] New Game | [Q/Esc] Quit\"\n+ stdscr.addstr(footer_y - 1, 2, bindings_line1, curses.color_pair(3) | curses.A_DIM)\n+ stdscr.addstr(footer_y, 2, bindings_line2, curses.color_pair(3) | curses.A_DIM)\n+\n+ # Check if won\n+ if game.check_win():\n+ # Game is won! Show overlay\n+ stdscr.clear()\n+ win_msg = \"★ CONGRATULATIONS! YOU WON! ★\"\n+ stdscr.addstr(height // 2 - 2, (width - len(win_msg)) // 2, win_msg, curses.color_pair(3) | curses.A_BOLD | curses.A_BLINK)\n+ sub_msg = f\"Completed in {game.move_count} moves and {time_str}!\"\n+ stdscr.addstr(height // 2, (width - len(sub_msg)) // 2, sub_msg, curses.color_pair(2))\n+ prompt_msg = \"Press [N] for a New Game, or [Q] to Quit.\"\n+ stdscr.addstr(height // 2 + 2, (width - len(prompt_msg)) // 2, prompt_msg, curses.color_pair(4))\n+ stdscr.refresh()\n+ \n+ # Victory loop\n+ while True:\n+ ch = stdscr.getch()\n+ if ch in [ord('q'), ord('Q'), 27]: # Q or ESC\n+ return\n+ elif ch in [ord('n'), ord('N')]:\n+ current_seed = random_seed()\n+ game = FreeCellGame(seed=current_seed)\n+ start_time = time.time()\n+ src_type = None\n+ src_idx = None\n+ status_msg = \"New game started! Enter Source key...\"\n+ status_color_pair = curses.color_pair(3)\n+ break\n+ continue\n+\n+ stdscr.refresh()\n+\n+ # Get user input\n+ try:\n+ ch = stdscr.getch()\n+ except KeyboardInterrupt:\n+ break\n+\n+ if ch == -1:\n+ # Timeout, loop to update timer\n+ continue\n+\n+ key = chr(ch).lower() if 0 <= ch < 256 else \"\"\n+\n+ # Check for Quit\n+ if key == 'q' or ch == 27: # 'q' or ESC\n+ break\n+\n+ # Handle global action keys\n+ if key == 'u':\n+ if game.undo():\n+ status_msg = \"Undo executed.\"\n+ status_color_pair = curses.color_pair(3)\n+ else:\n+ status_msg = \"Nothing to undo.\"\n+ status_color_pair = curses.color_pair(4)\n+ src_type, src_idx = None, None\n+ continue\n+ elif key == 'c' or ch == ord(' '):\n+ prev_moves = game.move_count\n+ game.auto_collect()\n+ collected = game.move_count - prev_moves # Wait, auto_collect doesn't increment move_count currently, or does it?\n+ # Actually, our auto_collect in engine.py does not increment move_count. Let's report simply\n+ status_msg = \"Auto-collected safe cards to foundations.\"\n+ status_color_pair = curses.color_pair(3)\n+ src_type, src_idx = None, None\n+ continue\n+ elif key == 'r':\n+ game = FreeCellGame(seed=current_seed)\n+ start_time = time.time()\n+ status_msg = \"Game restarted with same layout.\"\n+ status_color_pair = curses.color_pair(3)\n+ src_type, src_idx = None, None\n+ continue\n+ elif key == 'n':\n+ current_seed = random_seed()\n+ game = FreeCellGame(seed=current_seed)\n+ start_time = time.time()\n+ status_msg = f\"New game started with seed {current_seed}.\"\n+ status_color_pair = curses.color_pair(3)\n+ src_type, src_idx = None, None\n+ continue\n+\n+ # Process selection keys\n+ if key in key_to_pile:\n+ pile_type, pile_idx = key_to_pile[key]\n+ \n+ if src_type is None:\n+ # Selecting source\n+ # Verify source has a card\n+ has_card = False\n+ if pile_type == 'freecell' and game.free_cells[pile_idx] is not None:\n+ has_card = True\n+ elif pile_type == 'foundation' and game.foundations[pile_idx]:\n+ has_card = True\n+ elif pile_type == 'cascade' and game.cascades[pile_idx]:\n+ has_card = True\n+\n+ if has_card:\n+ src_type = pile_type\n+ src_idx = pile_idx\n+ status_msg = f\"Selected {pile_type.upper()} {pile_idx + 1 if pile_type == 'cascade' else labels_fc[pile_idx] if pile_type == 'freecell' else labels_fnd[pile_idx][0]}. Choose destination...\"\n+ status_color_pair = curses.color_pair(3)\n+ else:\n+ status_msg = f\"Selected source {pile_type.upper()} is empty!\"\n+ status_color_pair = curses.color_pair(4)\n+ else:\n+ # Selecting destination\n+ if pile_type == src_type and pile_idx == src_idx:\n+ # Cancel selection\n+ src_type, src_idx = None, None\n+ status_msg = \"Selection cancelled.\"\n+ status_color_pair = curses.color_pair(3)\n+ else:\n+ success, msg = game.validate_and_move(src_type, src_idx, pile_type, pile_idx)\n+ if success:\n+ status_msg = msg\n+ status_color_pair = curses.color_pair(3)\n+ else:\n+ status_msg = f\"Invalid move: {msg}\"\n+ status_color_pair = curses.color_pair(4)\n+ src_type, src_idx = None, None\n+ else:\n+ if ch != -1:\n+ status_msg = f\"Unknown key pressed: {key if key.printable() else ch}. Press bindings shown below.\"\n+ status_color_pair = curses.color_pair(4)\n+\n+ curses.wrapper(curses_main)\n+\n+\n+def random_seed():\n+ \"\"\"Generates a random 5-digit seed.\"\"\"\n+ import random\n+ return random.randint(10000, 99999)\n+\n+\n+if __name__ == '__main__':\n+ parser = argparse.ArgumentParser(description=\"Terminal-based FreeCell solitaire game in Python.\")\n+ parser.add_argument('--smoke', action='store_true', help=\"Run non-interactive smoke test.\")\n+ args = parser.parse_args()\n+\n+ if args.smoke:\n+ run_smoke_test()\n+ else:\n+ run_curses_ui()\ndiff --git a/card-game-app/test_engine.py b/card-game-app/test_engine.py\nnew file mode 100644\nindex 000000000..27fe3a0c2\n--- /dev/null\n+++ b/card-game-app/test_engine.py\n@@ -0,0 +1,67 @@\n+import unittest\n+from engine import Card, FreeCellGame\n+\n+class TestFreeCellEngine(unittest.TestCase):\n+ def test_card_creation(self):\n+ card = Card('H', 1)\n+ self.assertEqual(card.suit, 'H')\n+ self.assertEqual(card.rank, 1)\n+ self.assertEqual(card.color, 'red')\n+ self.assertEqual(str(card), \"A♥\")\n+\n+ def test_deal(self):\n+ game = FreeCellGame(seed=42)\n+ # Check that 52 cards were dealt correctly\n+ total_cards = sum(len(c) for c in game.cascades)\n+ self.assertEqual(total_cards, 52)\n+ self.assertEqual(len(game.cascades[0]), 7)\n+ self.assertEqual(len(game.cascades[4]), 6)\n+\n+ def test_get_bottom_sequence(self):\n+ # Create custom cascades to test sequence identification\n+ c1 = [\n+ Card('H', 13), # K♥\n+ Card('S', 12), # Q♠\n+ Card('H', 11), # J♥\n+ Card('C', 10), # 10♣\n+ ]\n+ seq = FreeCellGame.get_bottom_sequence(c1)\n+ self.assertEqual(len(seq), 4)\n+ self.assertEqual(seq[0].rank, 13)\n+\n+ # Break sequence in middle\n+ c2 = [\n+ Card('H', 13), # K♥\n+ Card('H', 12), # Q♥ (same color, breaks sequence)\n+ Card('S', 11), # J♠\n+ Card('H', 10), # 10♥\n+ ]\n+ seq = FreeCellGame.get_bottom_sequence(c2)\n+ self.assertEqual(len(seq), 3)\n+ self.assertEqual(seq[0].rank, 12)\n+\n+ def test_validate_and_move_to_free_cell(self):\n+ game = FreeCellGame(seed=42)\n+ # Try moving bottom card of first cascade to first free cell\n+ bottom_card = game.cascades[0][-1]\n+ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0)\n+ self.assertTrue(success)\n+ self.assertEqual(game.free_cells[0], bottom_card)\n+ self.assertEqual(len(game.cascades[0]), 6)\n+\n+ def test_undo(self):\n+ game = FreeCellGame(seed=42)\n+ original_cascade_len = len(game.cascades[0])\n+ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0)\n+ self.assertTrue(success)\n+ \n+ self.assertEqual(len(game.cascades[0]), original_cascade_len - 1)\n+ self.assertIsNotNone(game.free_cells[0])\n+\n+ undo_success = game.undo()\n+ self.assertTrue(undo_success)\n+ self.assertEqual(len(game.cascades[0]), original_cascade_len)\n+ self.assertIsNone(game.free_cells[0])\n+\n+if __name__ == '__main__':\n+ unittest.main()\ndiff --git a/status.json b/status.json\nnew file mode 100644\nindex 000000000..ee6907d7f\n--- /dev/null\n+++ b/status.json\n@@ -0,0 +1,3 @@\n+{\n+ \"outcome\": \"succeeded\"\n+}\n\\ No newline at end of file\n", + "summary": { + "files_changed": 5, + "additions": 953, + "deletions": 0 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-06-04T20:34:26.844579Z", + "current_node": "implement_app", + "completed_nodes": [ + "start", + "plan_app", + "implement_app" + ], + "node_retries": {}, + "context_values": { + "graph.rankdir": "LR", + "last_response": "I have successfully built and verified the complete terminal-based FreeCell solitaire game in Python according to all specifications!\n\n### Summary of what was accomplished:\n1. **Packaging (`pyproject.", + "graph.retry_target": "implement_app", + "last_stage": "implement_app", + "response.implement_app": "I have successfully built and verified the complete terminal-based FreeCell solitaire game in Python according to all specifications!\n\n### Summary of what was accomplished:\n1. **Packaging (`pyproject.toml`)**: Structured the app with a clean and professional `pyproject.toml` file utilizing standard setuptools configuration and setting up pathing support for pytest.\n2. **Game Engine (`src/card_game_tui/engine.py`)**: Migrated and enhanced the core state logic, covering:\n - Alternating color and descending rank validation.\n - Dynamic Sequence Move checking using the formula: $M = (F + 1) \\times 2^E$ with empty destination cascades excluded.\n - Multi-step undo management.\n - Safe and fully-automated auto-collect logic for Aces, Twos, and other cards when safe.\n3. **Interactive Curses UI (`src/card_game_tui/ui.py`)**: Migrated the interactive interface logic to handle responsive board resizing, clean alignment, key-binding selection, high-contrast Red/Black suits, win-condition state overlay, and timing updates.\n4. **App Entry Point (`main.py`)**: Built a robust CLI parser with an enhanced non-interactive `--smoke` mode that logs detailed ASCII board snapshots of transitions (such as first move, second move, invalid moves, undoing, and automatic card collections).\n5. **Testing Suite (`tests/test_engine.py`)**: Implemented a comprehensive set of unit tests utilizing the python `unittest` module covering deal validation, move mechanics, dynamic moves calculations, undoing, and win states.\n6. **Documentation (`README.md`)**: Provided complete setup instructions, keyboard bindings, test instructions, and a visual directory overview.\n\nAll unit tests compiled, run, and passed. The smoke test ran successfully, printing out beautifully aligned snapshots of the board state. `status.json` has been updated with `\"outcome\": \"succeeded\"`.", "internal.fidelity": "compact", "graph.goal": "Build a terminal-based FreeCell solitaire game in Python", "internal.work_dir": "/home/daytona/workspace/fabro", @@ -417,14 +516,16 @@ "response.plan_app": "I have successfully designed, implemented, and verified the terminal-based FreeCell solitaire game in Python.\n\n### What Was Done\n1. **Implementation Plan (`.ai/card-game-fast-plan.md`)**:\n - Outlined full FreeCell rules, the game loop state machine, and data structures.\n - Desired standard-library `curses` terminal layout and color rendering schemes (red/black card highlighting).\n - Elaborated move validation rules including the sequence limit formula $M = (F + 1) \\times 2^E$.\n - Drafted victory detection, automatic safe-card collection logic, and the test strategy.\n\n2. **Game Engine (`card-game-app/engine.py`)**:\n - Implemented `Card` and `FreeCellGame` models.\n - Designed exact FreeCell rules, safe recursive auto-collection, multiple card sequence moves, and a full undo history stack.\n\n3. **Interactive Terminal Game & Smoke Verification (`card-game-app/main.py`)**:\n - Built an interactive, color-coded, live-updating curses interface with real-time timers and clean stats.\n - Built a comprehensive, deterministic, non-interactive smoke test accessible via `--smoke`.\n\n4. **Engine Unit Tests (`card-game-app/test_engine.py`)**:\n - Created test cases covering Card creation, dealing correctness, bottom-sequence identification, and move-to-free-cell/undo validation.\n - Verified that all unit tests and smoke tests execute and pass successfully.\n\n5. **Status Outcome (`status.json`)**:\n - Logged the success state in `/home/daytona/workspace/fabro/status.json`.", "outcome": "succeeded", "failure_signature": "", - "internal.thread_id": "start", + "internal.thread_id": "hard", "graph.default_max_retries": "2", "thread.start.current_node": "plan_app", "internal.node_visit_count": 1, - "current_node": "plan_app", + "current_node": "implement_app", "internal.retry_count.plan_app": 0, "internal.run_id": "01KTA4YK19B717CYJXPGH2BAJW", - "internal.retry_count.start": 0 + "thread.hard.current_node": "implement_app", + "internal.retry_count.start": 0, + "internal.retry_count.implement_app": 0 }, "node_outcomes": { "start": { @@ -474,11 +575,60 @@ "tool_time_ms": 46519, "active_time_ms": 299708 } + }, + "implement_app": { + "status": "succeeded", + "context_updates": { + "response.implement_app": "I have successfully built and verified the complete terminal-based FreeCell solitaire game in Python according to all specifications!\n\n### Summary of what was accomplished:\n1. **Packaging (`pyproject.toml`)**: Structured the app with a clean and professional `pyproject.toml` file utilizing standard setuptools configuration and setting up pathing support for pytest.\n2. **Game Engine (`src/card_game_tui/engine.py`)**: Migrated and enhanced the core state logic, covering:\n - Alternating color and descending rank validation.\n - Dynamic Sequence Move checking using the formula: $M = (F + 1) \\times 2^E$ with empty destination cascades excluded.\n - Multi-step undo management.\n - Safe and fully-automated auto-collect logic for Aces, Twos, and other cards when safe.\n3. **Interactive Curses UI (`src/card_game_tui/ui.py`)**: Migrated the interactive interface logic to handle responsive board resizing, clean alignment, key-binding selection, high-contrast Red/Black suits, win-condition state overlay, and timing updates.\n4. **App Entry Point (`main.py`)**: Built a robust CLI parser with an enhanced non-interactive `--smoke` mode that logs detailed ASCII board snapshots of transitions (such as first move, second move, invalid moves, undoing, and automatic card collections).\n5. **Testing Suite (`tests/test_engine.py`)**: Implemented a comprehensive set of unit tests utilizing the python `unittest` module covering deal validation, move mechanics, dynamic moves calculations, undoing, and win states.\n6. **Documentation (`README.md`)**: Provided complete setup instructions, keyboard bindings, test instructions, and a visual directory overview.\n\nAll unit tests compiled, run, and passed. The smoke test ran successfully, printing out beautifully aligned snapshots of the board state. `status.json` has been updated with `\"outcome\": \"succeeded\"`.", + "last_stage": "implement_app", + "last_response": "I have successfully built and verified the complete terminal-based FreeCell solitaire game in Python according to all specifications!\n\n### Summary of what was accomplished:\n1. **Packaging (`pyproject." + }, + "notes": "Stage completed: implement_app", + "usage": { + "input": { + "usage": { + "model": { + "provider": "gemini", + "model_id": "gemini-3.5-flash" + }, + "tokens": { + "input_tokens": 210872, + "output_tokens": 16567, + "reasoning_tokens": 7705, + "cache_read_tokens": 850601, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "gemini", + "storage_segments": [] + } + }, + "total_usd_micros": 662346 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/card-game-app/README.md", + "/home/daytona/workspace/fabro/card-game-app/main.py", + "/home/daytona/workspace/fabro/card-game-app/pyproject.toml", + "/home/daytona/workspace/fabro/card-game-app/src/card_game_tui/__init__.py", + "/home/daytona/workspace/fabro/card-game-app/src/card_game_tui/engine.py", + "/home/daytona/workspace/fabro/card-game-app/src/card_game_tui/ui.py", + "/home/daytona/workspace/fabro/card-game-app/tests/__init__.py", + "/home/daytona/workspace/fabro/card-game-app/tests/test_engine.py", + "/home/daytona/workspace/fabro/status.json" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 161275, + "tool_time_ms": 49151, + "active_time_ms": 210426 + } } }, - "next_node_id": "implement_app", + "next_node_id": "verify_app", "node_visits": { "start": 1, + "implement_app": 1, "plan_app": 1 } }, @@ -515,7 +665,12 @@ "first_event_seq": 22, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: plan_app", + "failure_reason": null, + "timestamp": "2026-06-04T20:30:22.463489Z" + }, "provider_used": { "mode": "agent", "provider": "gemini", @@ -528,6 +683,12 @@ "output": null, "started_at": "2026-06-04T20:25:16.781081Z", "handler": "agent", + "timing": { + "wall_time_ms": 305681, + "inference_time_ms": 253189, + "tool_time_ms": 46519, + "active_time_ms": 299708 + }, "usage": { "input_tokens": 226088, "output_tokens": 14177, @@ -709,7 +870,7 @@ ], "warnings": [] }, - "state": "running" + "state": "succeeded" }, "start@1": { "first_event_seq": 18, @@ -744,6 +905,206 @@ "cache_write_tokens": 0 }, "state": "succeeded" + }, + "implement_app@1": { + "first_event_seq": 155, + "prompt": null, + "response": null, + "completion": null, + "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-04T20:30:44.434931Z", + "handler": "agent", + "usage": { + "input_tokens": 210872, + "output_tokens": 16567, + "total_tokens": 1085745, + "reasoning_tokens": 7705, + "cache_read_tokens": 850601, + "cache_write_tokens": 0, + "total_usd_micros": 662346 + }, + "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": true + }, + { + "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": 55531, + "usage_percent": 5.295848846435547, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-06-04T20:34:26.708356Z", + "event_seq": 250, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 1561, + "usage_percent": 0.14886856079101562 + }, + { + "category": "tools", + "tokens": 1650, + "usage_percent": 0.15735626220703125 + }, + { + "category": "memory", + "tokens": 4467, + "usage_percent": 0.4260063171386719 + }, + { + "category": "conversation", + "tokens": 47848, + "usage_percent": 4.563140869140625 + }, + { + "category": "other", + "tokens": 5, + "usage_percent": 0.000476837158203125 + } + ], + "warnings": [] + }, + "state": "running" } } } \ No newline at end of file diff --git a/stages/002-plan_app@1/diff.patch b/stages/002-plan_app@1/diff.patch new file mode 100644 index 000000000..34b697d25 --- /dev/null +++ b/stages/002-plan_app@1/diff.patch @@ -0,0 +1,984 @@ +diff --git a/.ai/card-game-fast-plan.md b/.ai/card-game-fast-plan.md +new file mode 100644 +index 000000000..47c11d64f +--- /dev/null ++++ b/.ai/card-game-fast-plan.md +@@ -0,0 +1,97 @@ ++# Implementation Plan - Terminal FreeCell Solitaire in Python ++ ++This document outlines the design and implementation strategy for a terminal-based FreeCell solitaire game. The application will be located under `card-game-app/` and will support both an interactive curses UI and a non-interactive `--smoke` mode. ++ ++## 1. Game Rules & Data Structures ++ ++### Card Model ++- **Suit**: Spades (♠), Hearts (♥), Diamonds (♦), Clubs (♣). ++- **Rank**: Ace (1) to King (13). ++- **Color**: Red (Hearts, Diamonds) or Black (Spades, Clubs). ++- **Representation**: Displayed as `[A♠]`, `[10♦]`, `[K♣]`, etc. (with red color highlighting for Heart/Diamond suits). ++ ++### Board State ++- **Cascades (Tableau)**: 8 columns of cards. ++ - Initially, 52 cards are dealt face-up: 7 cards in the first 4 columns, 6 cards in the remaining 4. ++- **Free Cells**: 4 slots, each holding at most 1 card. ++- **Foundations**: 4 piles, one for each suit, built up from Ace to King. ++- **Move History**: A stack of previous board states to support full **Undo** (`U`). ++ ++### Move Validation Rules ++1. **To Free Cell**: Any single card can be moved to an empty Free Cell. ++2. **To Foundation**: ++ - An Ace can be moved to an empty Foundation pile. ++ - A card of rank $R$ and suit $S$ can be moved to Foundation pile of suit $S$ if the top card is of rank $R-1$. ++3. **To Cascade**: ++ - A card of rank $R$ and color $C$ can be placed on a cascade's bottom card of rank $R+1$ and opposite color. ++ - Any card can be placed on an empty cascade. ++4. **Sequence Moves**: ++ - Moving a packed sequence of size $L$ from Cascade A to Cascade B is allowed if the cards are sorted in alternating colors and descending ranks, and the number of cards does not exceed the maximum allowed sequence limit: ++ $$M = (F + 1) \times 2^E$$ ++ where $F$ is the number of empty Free Cells, and $E$ is the number of empty Cascades (excluding source and destination). ++ ++--- ++ ++## 2. Terminal Rendering & Curses ++ ++The UI is built using Python's standard `curses` library. ++ ++### Layout (Minimum 80x24 characters) ++- **Header**: Game title, Moves counter, Time elapsed, and Status line. ++- **Top Panel**: ++ - **Free Cells**: Labelled `Q`, `W`, `E`, `R`. ++ - **Foundations**: Labelled `A`, `S`, `D`, `F`. ++- **Main Panel**: ++ - **Cascades**: 8 columns labelled `1` to `8` below them. Cards are stacked vertically with overlapping cards. ++- **Footer**: Instructions & legend: ++ - `Src Key` + `Dest Key` to move. ++ - `U`: Undo, `C`: Auto-collect, `R`: Restart, `N`: New Game, `Q`/`Esc`: Quit. ++ ++### Color Coding ++- **Red cards** (Hearts, Diamonds): Rendered using a red-on-black or red-on-default color pair. ++- **Black cards** (Spades, Clubs): Rendered using default/white-on-black text. ++- **Selected Card**: Highlighted with reverse video or a distinct color pair. ++ ++--- ++ ++## 3. Input Handling & Interactive Loop ++ ++The main loop: ++1. Render current state. ++2. If first key is pressed (Source): ++ - Highlight selection. ++ - Wait for second key (Destination). ++3. If both keys are valid, validate the move: ++ - If valid, execute the move, record state to undo history, and trigger an auto-collect check. ++ - If invalid, show an error status. ++4. If special key is pressed: ++ - `U`: Pop from undo history. ++ - `C`: Scan cascades/free cells for any cards that can safely be moved to foundations. ++ - `R`: Reset current game to starting layout. ++ - `N`: Deal a completely new random game. ++ - `Q` / `Esc`: Exit game. ++ ++--- ++ ++## 4. Non-Interactive Demo (`--smoke` Mode) ++ ++To support automated validation and CI: ++- Running `python3 main.py --smoke` executes a non-interactive smoke test. ++- It will: ++ 1. Initialize a deterministic deck (seeded RNG or fixed sequence). ++ 2. Perform a series of valid moves. ++ 3. Validate move logic and status updates. ++ 4. Ensure no exceptions are thrown. ++ 5. Print a success message and exit with code `0`. ++ ++--- ++ ++## 5. Test Strategy ++ ++1. **Unit Tests**: ++ - Card representation & colors. ++ - Board state initialization (correct count of cards in cascades, free cells, foundations). ++ - Move validation logic (valid/invalid cascade-to-cascade, freecell-to-cascade, cascade-to-foundation). ++ - Multi-card sequence move calculation. ++2. **Integration Tests**: ++ - Smoke test running end-to-end moves without starting curses. +diff --git a/card-game-app/engine.py b/card-game-app/engine.py +new file mode 100644 +index 000000000..664ce6229 +--- /dev/null ++++ b/card-game-app/engine.py +@@ -0,0 +1,373 @@ ++import random ++import copy ++ ++SUITS = { ++ 'S': '♠', # Spades ++ 'H': '♥', # Hearts ++ 'D': '♦', # Diamonds ++ 'C': '♣' # Clubs ++} ++ ++SUIT_COLORS = { ++ 'S': 'black', ++ 'H': 'red', ++ 'D': 'red', ++ 'C': 'black' ++} ++ ++RANK_NAMES = { ++ 1: 'A', ++ 2: '2', ++ 3: '3', ++ 4: '4', ++ 5: '5', ++ 6: '6', ++ 7: '7', ++ 8: '8', ++ 9: '9', ++ 10: '10', ++ 11: 'J', ++ 12: 'Q', ++ 13: 'K' ++} ++ ++class Card: ++ def __init__(self, suit, rank): ++ if suit not in SUITS: ++ raise ValueError(f"Invalid suit: {suit}") ++ if rank not in RANK_NAMES: ++ raise ValueError(f"Invalid rank: {rank}") ++ self.suit = suit ++ self.rank = rank ++ self.color = SUIT_COLORS[suit] ++ ++ def __repr__(self): ++ return f"{RANK_NAMES[self.rank]}{SUITS[self.suit]}" ++ ++ def __eq__(self, other): ++ if not isinstance(other, Card): ++ return False ++ return self.suit == other.suit and self.rank == other.rank ++ ++ def to_dict(self): ++ return {'suit': self.suit, 'rank': self.rank} ++ ++ @staticmethod ++ def from_dict(d): ++ if d is None: ++ return None ++ return Card(d['suit'], d['rank']) ++ ++ ++class FreeCellGame: ++ def __init__(self, seed=None): ++ self.seed = seed ++ self.cascades = [[] for _ in range(8)] ++ self.free_cells = [None] * 4 ++ # Foundations: 0=Spades, 1=Hearts, 2=Diamonds, 3=Clubs ++ self.foundations = [[] for _ in range(4)] ++ self.foundation_suits = ['S', 'H', 'D', 'C'] ++ self.history = [] ++ self.move_count = 0 ++ self.deal() ++ ++ def deal(self): ++ # Create deck of 52 cards ++ deck = [Card(suit, rank) for suit in SUITS for rank in RANK_NAMES] ++ ++ # Shuffle ++ rng = random.Random(self.seed) ++ rng.shuffle(deck) ++ ++ # Reset state ++ self.cascades = [[] for _ in range(8)] ++ self.free_cells = [None] * 4 ++ self.foundations = [[] for _ in range(4)] ++ self.history = [] ++ self.move_count = 0 ++ ++ # Deal to cascades ++ for i, card in enumerate(deck): ++ col = i % 8 ++ self.cascades[col].append(card) ++ ++ def save_state(self): ++ # Save deep copy of the state to history stack ++ state = { ++ 'cascades': [[c.to_dict() for c in col] for col in self.cascades], ++ 'free_cells': [c.to_dict() if c else None for c in self.free_cells], ++ 'foundations': [[c.to_dict() for c in col] for col in self.foundations], ++ 'move_count': self.move_count ++ } ++ self.history.append(state) ++ ++ def undo(self): ++ if not self.history: ++ return False ++ state = self.history.pop() ++ self.cascades = [[Card.from_dict(c) for c in col] for col in state['cascades']] ++ self.free_cells = [Card.from_dict(c) if c else None for c in state['free_cells']] ++ self.foundations = [[Card.from_dict(c) for c in col] for col in state['foundations']] ++ self.move_count = state['move_count'] ++ return True ++ ++ def check_win(self): ++ # Won if all foundations have 13 cards ++ return all(len(f) == 13 for f in self.foundations) ++ ++ def get_max_move_size(self, exclude_src_idx=None, exclude_dest_idx=None): ++ F = sum(1 for cell in self.free_cells if cell is None) ++ E = 0 ++ for idx, cascade in enumerate(self.cascades): ++ if idx != exclude_src_idx and idx != exclude_dest_idx and not cascade: ++ E += 1 ++ return (F + 1) * (2 ** E) ++ ++ @staticmethod ++ def get_bottom_sequence(cascade): ++ if not cascade: ++ return [] ++ seq = [cascade[-1]] ++ for i in range(len(cascade) - 2, -1, -1): ++ card = cascade[i] ++ prev = seq[-1] ++ if card.rank == prev.rank + 1 and card.color != prev.color: ++ seq.append(card) ++ else: ++ break ++ seq.reverse() ++ return seq ++ ++ def validate_and_move(self, src_type, src_idx, dest_type, dest_idx): ++ """ ++ Executes a move if valid. ++ src_type/dest_type can be 'cascade', 'freecell', or 'foundation'. ++ src_idx/dest_idx are 0-based integers. ++ Returns: (success_bool, message) ++ """ ++ # Validate indices ++ if src_type == 'cascade' and not (0 <= src_idx < 8): ++ return False, "Invalid source cascade index" ++ if src_type == 'freecell' and not (0 <= src_idx < 4): ++ return False, "Invalid source free cell index" ++ if src_type == 'foundation' and not (0 <= src_idx < 4): ++ return False, "Invalid source foundation index" ++ if dest_type == 'cascade' and not (0 <= dest_idx < 8): ++ return False, "Invalid destination cascade index" ++ if dest_type == 'freecell' and not (0 <= dest_idx < 4): ++ return False, "Invalid destination free cell index" ++ if dest_type == 'foundation' and not (0 <= dest_idx < 4): ++ return False, "Invalid destination foundation index" ++ ++ # Disallow moving to the exact same pile ++ if src_type == dest_type and src_idx == dest_idx: ++ return False, "Cannot move to the same pile" ++ ++ # Get source cards/card ++ if src_type == 'freecell': ++ src_card = self.free_cells[src_idx] ++ if src_card is None: ++ return False, "Source free cell is empty" ++ src_cards = [src_card] ++ elif src_type == 'foundation': ++ if not self.foundations[src_idx]: ++ return False, "Source foundation is empty" ++ src_cards = [self.foundations[src_idx][-1]] ++ elif src_type == 'cascade': ++ if not self.cascades[src_idx]: ++ return False, "Source cascade is empty" ++ # We will figure out how many cards to move based on the destination ++ src_cards = [] # Will populate below based on destination ++ else: ++ return False, "Invalid source type" ++ ++ # Validate based on destination ++ if dest_type == 'freecell': ++ if self.free_cells[dest_idx] is not None: ++ return False, "Destination free cell is already occupied" ++ ++ # If moving from cascade, we can only move the single bottom card ++ if src_type == 'cascade': ++ src_cards = [self.cascades[src_idx][-1]] ++ ++ # Execute move ++ self.save_state() ++ card_to_move = src_cards[0] ++ # Remove from source ++ if src_type == 'freecell': ++ self.free_cells[src_idx] = None ++ elif src_type == 'foundation': ++ self.foundations[src_idx].pop() ++ elif src_type == 'cascade': ++ self.cascades[src_idx].pop() ++ # Place in destination ++ self.free_cells[dest_idx] = card_to_move ++ self.move_count += 1 ++ self.auto_collect() ++ return True, "Moved card to free cell" ++ ++ elif dest_type == 'foundation': ++ # Target suit for this foundation slot ++ target_suit = self.foundation_suits[dest_idx] ++ ++ # If moving from cascade, we can only move the single bottom card ++ if src_type == 'cascade': ++ src_cards = [self.cascades[src_idx][-1]] ++ ++ card_to_move = src_cards[0] ++ if card_to_move.suit != target_suit: ++ return False, f"Foundation is for {SUITS[target_suit]}, but card is {card_to_move}" ++ ++ dest_pile = self.foundations[dest_idx] ++ if not dest_pile: ++ if card_to_move.rank != 1: ++ return False, "Only an Ace can be placed on an empty foundation" ++ else: ++ top_card = dest_pile[-1] ++ if card_to_move.rank != top_card.rank + 1: ++ return False, f"Cannot place {card_to_move} on {top_card} (must be consecutive rank)" ++ ++ # Execute move ++ self.save_state() ++ # Remove from source ++ if src_type == 'freecell': ++ self.free_cells[src_idx] = None ++ elif src_type == 'foundation': ++ self.foundations[src_idx].pop() ++ elif src_type == 'cascade': ++ self.cascades[src_idx].pop() ++ # Place in destination ++ self.foundations[dest_idx].append(card_to_move) ++ self.move_count += 1 ++ self.auto_collect() ++ return True, "Moved card to foundation" ++ ++ elif dest_type == 'cascade': ++ dest_cascade = self.cascades[dest_idx] ++ ++ if src_type == 'freecell' or src_type == 'foundation': ++ card_to_move = src_cards[0] ++ if dest_cascade: ++ top_card = dest_cascade[-1] ++ if card_to_move.rank != top_card.rank - 1 or card_to_move.color == top_card.color: ++ return False, f"Cannot place {card_to_move} on {top_card} (must be alternating color and rank - 1)" ++ # Execute move ++ self.save_state() ++ if src_type == 'freecell': ++ self.free_cells[src_idx] = None ++ elif src_type == 'foundation': ++ self.foundations[src_idx].pop() ++ dest_cascade.append(card_to_move) ++ self.move_count += 1 ++ self.auto_collect() ++ return True, "Moved card to cascade" ++ ++ elif src_type == 'cascade': ++ # Move from cascade to cascade (potential sequence move) ++ bottom_seq = self.get_bottom_sequence(self.cascades[src_idx]) ++ ++ if not dest_cascade: ++ # Destination is empty. Move largest allowed sequence. ++ max_allowed = self.get_max_move_size(src_idx, dest_idx) ++ num_to_move = min(len(bottom_seq), max_allowed) ++ if num_to_move == 0: ++ return False, "No cards to move" ++ ++ self.save_state() ++ # Pop num_to_move cards from source, and append to dest ++ cards_to_move = self.cascades[src_idx][-num_to_move:] ++ self.cascades[src_idx] = self.cascades[src_idx][:-num_to_move] ++ self.cascades[dest_idx].extend(cards_to_move) ++ self.move_count += 1 ++ self.auto_collect() ++ return True, f"Moved sequence of {num_to_move} cards to empty cascade" ++ else: ++ # Destination is not empty. We must match the destination's top card. ++ dest_top = dest_cascade[-1] ++ # We need a card in bottom_seq of rank dest_top.rank - 1 and opposite color ++ match_card_idx = -1 ++ for i, card in enumerate(bottom_seq): ++ if card.rank == dest_top.rank - 1 and card.color != dest_top.color: ++ match_card_idx = i ++ break ++ ++ if match_card_idx == -1: ++ return False, f"No valid card in sequence to place on {dest_top}" ++ ++ # Sequence to move is bottom_seq[match_card_idx:] ++ seq_to_move = bottom_seq[match_card_idx:] ++ num_to_move = len(seq_to_move) ++ ++ max_allowed = self.get_max_move_size(src_idx, dest_idx) ++ if num_to_move > max_allowed: ++ return False, f"Cannot move {num_to_move} cards. Max allowed is {max_allowed}." ++ ++ self.save_state() ++ self.cascades[src_idx] = self.cascades[src_idx][:-num_to_move] ++ self.cascades[dest_idx].extend(seq_to_move) ++ self.move_count += 1 ++ self.auto_collect() ++ return True, f"Moved sequence of {num_to_move} cards to cascade" ++ ++ return False, "Unknown destination error" ++ ++ def auto_collect(self): ++ """ ++ Scan all top cards (bottom of cascades and free cells) and move any safely collectible cards ++ to the foundation. Repeat until no more cards can be collected. ++ """ ++ while True: ++ collected_any = False ++ ++ # Helper to check if card is safe to collect ++ def is_safe_to_collect(card): ++ if card.rank <= 2: ++ return True ++ # Opposite suit colors must be at least card.rank - 1 ++ if card.color == 'red': ++ opposite_suits = ['S', 'C'] ++ else: ++ opposite_suits = ['H', 'D'] ++ ++ opp_ranks = [] ++ for opp_suit in opposite_suits: ++ # Find corresponding foundation slot rank ++ found_idx = self.foundation_suits.index(opp_suit) ++ found_pile = self.foundations[found_idx] ++ opp_rank = found_pile[-1].rank if found_pile else 0 ++ opp_ranks.append(opp_rank) ++ ++ return all(r >= card.rank - 1 for r in opp_ranks) ++ ++ # 1. Check Free Cells ++ for idx, card in enumerate(self.free_cells): ++ if card is not None: ++ found_idx = self.foundation_suits.index(card.suit) ++ found_pile = self.foundations[found_idx] ++ next_rank = found_pile[-1].rank + 1 if found_pile else 1 ++ if card.rank == next_rank and is_safe_to_collect(card): ++ # Move to foundation ++ self.foundations[found_idx].append(card) ++ self.free_cells[idx] = None ++ collected_any = True ++ break # Start outer loop over to respect state changes ++ ++ if collected_any: ++ continue ++ ++ # 2. Check Cascades ++ for idx, cascade in enumerate(self.cascades): ++ if cascade: ++ card = cascade[-1] ++ found_idx = self.foundation_suits.index(card.suit) ++ found_pile = self.foundations[found_idx] ++ next_rank = found_pile[-1].rank + 1 if found_pile else 1 ++ if card.rank == next_rank and is_safe_to_collect(card): ++ # Move to foundation ++ self.foundations[found_idx].append(card) ++ cascade.pop() ++ collected_any = True ++ break # Start outer loop over ++ ++ if not collected_any: ++ break +diff --git a/card-game-app/main.py b/card-game-app/main.py +new file mode 100644 +index 000000000..27c5b1af2 +--- /dev/null ++++ b/card-game-app/main.py +@@ -0,0 +1,413 @@ ++import argparse ++import sys ++import time ++ ++# Ensure engine can be imported from same directory ++from engine import FreeCellGame, Card, SUITS, SUIT_COLORS, RANK_NAMES ++ ++def run_smoke_test(): ++ """ ++ Non-interactive smoke test. ++ Loads a deterministic game (seed=42), performs a sequence of valid moves, ++ verifies logic, undoes, and verifies state correctness. ++ """ ++ print("Running non-interactive FreeCell smoke test...") ++ ++ # 1. Initialize game with a seed ++ game = FreeCellGame(seed=42) ++ print(f"Game initialized with seed 42. Moves: {game.move_count}") ++ ++ # Check initial counts ++ total_cards = sum(len(col) for col in game.cascades) ++ assert total_cards == 52, f"Expected 52 cards, got {total_cards}" ++ assert len(game.cascades[0]) == 7, "Cascade 0 should have 7 cards" ++ assert len(game.cascades[4]) == 6, "Cascade 4 should have 6 cards" ++ print("Initial card counts and distribution verified successfully.") ++ ++ # 2. Perform a valid move (bottom card of Cascade 0 to Free Cell 0) ++ card_0_bottom = game.cascades[0][-1] ++ print(f"Moving bottom card of Cascade 0 ({card_0_bottom}) to Free Cell 0.") ++ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0) ++ assert success, f"Expected move to succeed, but failed: {msg}" ++ assert game.free_cells[0] == card_0_bottom, "Card was not placed in Free Cell 0" ++ assert len(game.cascades[0]) == 6, f"Expected Cascade 0 to have 6 cards, got {len(game.cascades[0])}" ++ assert game.move_count == 1, f"Expected move count to be 1, got {game.move_count}" ++ print("First move completed and verified successfully.") ++ ++ # 3. Perform another valid move (bottom card of Cascade 1 to Free Cell 1) ++ card_1_bottom = game.cascades[1][-1] ++ print(f"Moving bottom card of Cascade 1 ({card_1_bottom}) to Free Cell 1.") ++ success, msg = game.validate_and_move('cascade', 1, 'freecell', 1) ++ assert success, f"Expected move to succeed, but failed: {msg}" ++ assert game.free_cells[1] == card_1_bottom, "Card was not placed in Free Cell 1" ++ assert len(game.cascades[1]) == 6, f"Expected Cascade 1 to have 6 cards, got {len(game.cascades[1])}" ++ assert game.move_count == 2, f"Expected move count to be 2, got {game.move_count}" ++ print("Second move completed and verified successfully.") ++ ++ # 4. Perform an invalid move (moving to occupied free cell 0) ++ print("Testing invalid move: Cascade 2 bottom to occupied Free Cell 0.") ++ success, msg = game.validate_and_move('cascade', 2, 'freecell', 0) ++ assert not success, "Expected move to fail, but it succeeded!" ++ print(f"Invalid move correctly rejected. Error message: '{msg}'") ++ ++ # 5. Verify Undo functionality ++ print("Undoing second move...") ++ undo_success = game.undo() ++ assert undo_success, "Undo failed" ++ assert game.free_cells[1] is None, "Expected Free Cell 1 to be empty after undo" ++ assert len(game.cascades[1]) == 7, "Expected Cascade 1 to restore its card" ++ assert game.cascades[1][-1] == card_1_bottom, "Expected original card to return to bottom of Cascade 1" ++ assert game.move_count == 1, f"Expected move count to revert to 1, got {game.move_count}" ++ print("Undo functionality verified successfully.") ++ ++ # 6. Verify safe auto-collect (Aces are always auto-collected) ++ # We can programmatically deal a game, find where Ace of Spades is, ++ # and if it is at the bottom of a cascade, it should auto-collect immediately. ++ # Let's find a seed where an Ace is at the bottom of a cascade, or construct one. ++ # To keep it robust and independent of seeds, let's construct a small scenario. ++ print("Verifying auto-collect of Aces...") ++ game = FreeCellGame(seed=42) ++ # Clear out an Ace manually to the bottom of cascade 0 ++ ace_spades = Card('S', 1) ++ game.cascades[0].append(ace_spades) ++ # Trigger auto-collect ++ game.auto_collect() ++ # Spades foundation is index 0. It should now contain the Ace of Spades. ++ assert len(game.foundations[0]) >= 1, "Expected Ace of Spades to be auto-collected to foundation 0" ++ assert game.foundations[0][0] == ace_spades, "Foundation 0's first card should be Ace of Spades" ++ print("Auto-collect logic verified successfully.") ++ ++ print("\nSmoke test PASSED successfully!") ++ sys.exit(0) ++ ++ ++def run_curses_ui(): ++ """ ++ Launches the interactive curses terminal interface. ++ """ ++ try: ++ import curses ++ except ImportError: ++ print("Error: The standard-library 'curses' module is not available on this system.") ++ sys.exit(1) ++ ++ def draw_card(stdscr, y, x, card, selected=False): ++ if card is None: ++ stdscr.addstr(y, x, "[ ]", curses.color_pair(5)) ++ return ++ ++ # Select color pair based on card color ++ if card.color == 'red': ++ color_pair = curses.color_pair(1) # Red text ++ else: ++ color_pair = curses.color_pair(2) # White/Black text ++ ++ if selected: ++ color_pair = color_pair | curses.A_REVERSE ++ ++ rank_str = RANK_NAMES[card.rank] ++ suit_sym = SUITS[card.suit] ++ card_text = f"{rank_str}{suit_sym}" ++ ++ # Pad to exactly 3 characters for uniform alignment ++ if len(card_text) == 2: ++ card_text = " " + card_text ++ ++ stdscr.addstr(y, x, "[", curses.color_pair(5)) ++ stdscr.addstr(y, x + 1, card_text, color_pair) ++ stdscr.addstr(y, x + 4, "]", curses.color_pair(5)) ++ ++ def draw_foundation_placeholder(stdscr, y, x, suit): ++ suit_sym = SUITS[suit] ++ if SUIT_COLORS[suit] == 'red': ++ color = curses.color_pair(1) ++ else: ++ color = curses.color_pair(2) ++ stdscr.addstr(y, x, "[", curses.color_pair(5)) ++ stdscr.addstr(y, x + 1, f" {suit_sym} ", color | curses.A_DIM) ++ stdscr.addstr(y, x + 4, "]", curses.color_pair(5)) ++ ++ def curses_main(stdscr): ++ # Configure curses environment ++ curses.curs_set(0) # Hide blinking text cursor ++ stdscr.timeout(500) # Update elapsed time every 500ms ++ ++ # Color setup ++ curses.use_default_colors() ++ curses.init_pair(1, curses.COLOR_RED, -1) # Red suits ++ curses.init_pair(2, curses.COLOR_WHITE, -1) # Black/white suits ++ curses.init_pair(3, curses.COLOR_GREEN, -1) # Green labels ++ curses.init_pair(4, curses.COLOR_YELLOW, -1) # Highlight / Warning ++ curses.init_pair(5, curses.COLOR_CYAN, -1) # Brackets/borders ++ ++ # Initialize a random game ++ current_seed = random_seed() ++ game = FreeCellGame(seed=current_seed) ++ start_time = time.time() ++ ++ # Selection states ++ src_type = None ++ src_idx = None ++ status_msg = "Game started! Enter Source key..." ++ status_color_pair = curses.color_pair(3) ++ ++ # Key mappings ++ key_to_pile = { ++ 'q': ('freecell', 0), 'w': ('freecell', 1), 'e': ('freecell', 2), 'r': ('freecell', 3), ++ 'a': ('foundation', 0), 's': ('foundation', 1), 'd': ('foundation', 2), 'f': ('foundation', 3), ++ '1': ('cascade', 0), '2': ('cascade', 1), '3': ('cascade', 2), '4': ('cascade', 3), ++ '5': ('cascade', 4), '6': ('cascade', 5), '7': ('cascade', 6), '8': ('cascade', 7) ++ } ++ ++ while True: ++ # Check terminal size ++ height, width = stdscr.getmaxyx() ++ if width < 80 or height < 24: ++ stdscr.clear() ++ stdscr.addstr(0, 0, "Terminal must be at least 80x24.", curses.color_pair(4)) ++ stdscr.addstr(1, 0, f"Current size: {width}x{height}", curses.color_pair(2)) ++ stdscr.addstr(3, 0, "Please resize your terminal window to continue.", curses.color_pair(2)) ++ stdscr.refresh() ++ # Wait for resize ++ ch = stdscr.getch() ++ if ch in [ord('q'), ord('Q'), 27]: # ESC or Q ++ break ++ continue ++ ++ stdscr.clear() ++ ++ # --- RENDER HEADER --- ++ stdscr.addstr(0, 0, "┌" + "─" * 78 + "┐", curses.color_pair(5)) ++ ++ # Formulate header statistics ++ elapsed_sec = int(time.time() - start_time) ++ min_part = elapsed_sec // 60 ++ sec_part = elapsed_sec % 60 ++ time_str = f"{min_part:02d}:{sec_part:02d}" ++ ++ stats_line = f" FREECELL SOLITAIRE | Moves: {game.move_count:<3} | Time: {time_str} | Seed: {current_seed:<10}" ++ stdscr.addstr(1, 0, "│" + stats_line.ljust(78) + "│", curses.color_pair(3) | curses.A_BOLD) ++ stdscr.addstr(2, 0, "└" + "─" * 78 + "┘", curses.color_pair(5)) ++ ++ # --- RENDER TOP SECTION (Free Cells & Foundations) --- ++ stdscr.addstr(4, 2, "FREE CELLS (Q-R)", curses.color_pair(3)) ++ stdscr.addstr(4, 40, "FOUNDATIONS (A-F)", curses.color_pair(3)) ++ ++ # Render Labels for Free Cells ++ labels_fc = ['Q', 'W', 'E', 'R'] ++ for i, label in enumerate(labels_fc): ++ is_selected = (src_type == 'freecell' and src_idx == i) ++ color = curses.color_pair(4) if is_selected else curses.color_pair(3) ++ stdscr.addstr(5, 4 + i * 8, label, color | (curses.A_UNDERLINE if is_selected else 0)) ++ ++ # Render Free Cells ++ for i, card in enumerate(game.free_cells): ++ is_selected = (src_type == 'freecell' and src_idx == i) ++ draw_card(stdscr, 6, 2 + i * 8, card, selected=is_selected) ++ ++ # Render Labels for Foundations ++ labels_fnd = ['A (♠)', 'S (♥)', 'D (♦)', 'F (♣)'] ++ for i, label in enumerate(labels_fnd): ++ is_selected = (src_type == 'foundation' and src_idx == i) ++ color = curses.color_pair(4) if is_selected else curses.color_pair(3) ++ stdscr.addstr(5, 41 + i * 9, label, color) ++ ++ # Render Foundations ++ for i, pile in enumerate(game.foundations): ++ is_selected = (src_type == 'foundation' and src_idx == i) ++ if not pile: ++ draw_foundation_placeholder(stdscr, 6, 40 + i * 9, game.foundation_suits[i]) ++ else: ++ draw_card(stdscr, 6, 40 + i * 9, pile[-1], selected=is_selected) ++ ++ # --- RENDER CASCADES (1-8) --- ++ stdscr.addstr(9, 2, "CASCADES (1-8)", curses.color_pair(3)) ++ ++ # Print cascade header labels ++ for i in range(8): ++ is_selected = (src_type == 'cascade' and src_idx == i) ++ color = curses.color_pair(4) if is_selected else curses.color_pair(3) ++ label_text = f"({i+1})" ++ stdscr.addstr(10, 4 + i * 9, label_text, color | (curses.A_UNDERLINE if is_selected else 0)) ++ ++ # Print the cards in each cascade ++ max_col_height = max(len(col) for col in game.cascades) ++ # Render up to the height of the terminal dynamically ++ visible_rows = height - 16 # Reserve rows for headers/footers ++ for row_idx in range(max_col_height): ++ if row_idx >= visible_rows: ++ # Render indicators that more cards are hidden ++ stdscr.addstr(11 + visible_rows, 2, "... and more cards below ...", curses.color_pair(4)) ++ break ++ ++ for col_idx in range(8): ++ cascade = game.cascades[col_idx] ++ if row_idx < len(cascade): ++ card = cascade[row_idx] ++ # A card is selected if it is the source and we are highlighting it. ++ # Note: for cascades, we highlight the source column's bottom cards/sequence if selected. ++ is_selected = False ++ if src_type == 'cascade' and src_idx == col_idx: ++ # Highlight the bottom sequence or bottom card ++ seq = game.get_bottom_sequence(cascade) ++ if card in seq: ++ is_selected = True ++ ++ draw_card(stdscr, 11 + row_idx, 2 + col_idx * 9, card, selected=is_selected) ++ ++ # --- RENDER STATUS & FOOTER --- ++ # Print status message ++ status_y = height - 4 ++ stdscr.addstr(status_y, 2, "Status: ", curses.color_pair(3)) ++ stdscr.addstr(status_y, 10, status_msg.ljust(68)[:68], status_color_pair) ++ ++ # Print helpful key bindings list ++ footer_y = height - 2 ++ bindings_line1 = "Keys: FreeCells (Q W E R) | Foundations (A S D F) | Cascades (1-8)" ++ bindings_line2 = "[U] Undo | [C] Auto-Collect | [R] Restart | [N] New Game | [Q/Esc] Quit" ++ stdscr.addstr(footer_y - 1, 2, bindings_line1, curses.color_pair(3) | curses.A_DIM) ++ stdscr.addstr(footer_y, 2, bindings_line2, curses.color_pair(3) | curses.A_DIM) ++ ++ # Check if won ++ if game.check_win(): ++ # Game is won! Show overlay ++ stdscr.clear() ++ win_msg = "★ CONGRATULATIONS! YOU WON! ★" ++ stdscr.addstr(height // 2 - 2, (width - len(win_msg)) // 2, win_msg, curses.color_pair(3) | curses.A_BOLD | curses.A_BLINK) ++ sub_msg = f"Completed in {game.move_count} moves and {time_str}!" ++ stdscr.addstr(height // 2, (width - len(sub_msg)) // 2, sub_msg, curses.color_pair(2)) ++ prompt_msg = "Press [N] for a New Game, or [Q] to Quit." ++ stdscr.addstr(height // 2 + 2, (width - len(prompt_msg)) // 2, prompt_msg, curses.color_pair(4)) ++ stdscr.refresh() ++ ++ # Victory loop ++ while True: ++ ch = stdscr.getch() ++ if ch in [ord('q'), ord('Q'), 27]: # Q or ESC ++ return ++ elif ch in [ord('n'), ord('N')]: ++ current_seed = random_seed() ++ game = FreeCellGame(seed=current_seed) ++ start_time = time.time() ++ src_type = None ++ src_idx = None ++ status_msg = "New game started! Enter Source key..." ++ status_color_pair = curses.color_pair(3) ++ break ++ continue ++ ++ stdscr.refresh() ++ ++ # Get user input ++ try: ++ ch = stdscr.getch() ++ except KeyboardInterrupt: ++ break ++ ++ if ch == -1: ++ # Timeout, loop to update timer ++ continue ++ ++ key = chr(ch).lower() if 0 <= ch < 256 else "" ++ ++ # Check for Quit ++ if key == 'q' or ch == 27: # 'q' or ESC ++ break ++ ++ # Handle global action keys ++ if key == 'u': ++ if game.undo(): ++ status_msg = "Undo executed." ++ status_color_pair = curses.color_pair(3) ++ else: ++ status_msg = "Nothing to undo." ++ status_color_pair = curses.color_pair(4) ++ src_type, src_idx = None, None ++ continue ++ elif key == 'c' or ch == ord(' '): ++ prev_moves = game.move_count ++ game.auto_collect() ++ collected = game.move_count - prev_moves # Wait, auto_collect doesn't increment move_count currently, or does it? ++ # Actually, our auto_collect in engine.py does not increment move_count. Let's report simply ++ status_msg = "Auto-collected safe cards to foundations." ++ status_color_pair = curses.color_pair(3) ++ src_type, src_idx = None, None ++ continue ++ elif key == 'r': ++ game = FreeCellGame(seed=current_seed) ++ start_time = time.time() ++ status_msg = "Game restarted with same layout." ++ status_color_pair = curses.color_pair(3) ++ src_type, src_idx = None, None ++ continue ++ elif key == 'n': ++ current_seed = random_seed() ++ game = FreeCellGame(seed=current_seed) ++ start_time = time.time() ++ status_msg = f"New game started with seed {current_seed}." ++ status_color_pair = curses.color_pair(3) ++ src_type, src_idx = None, None ++ continue ++ ++ # Process selection keys ++ if key in key_to_pile: ++ pile_type, pile_idx = key_to_pile[key] ++ ++ if src_type is None: ++ # Selecting source ++ # Verify source has a card ++ has_card = False ++ if pile_type == 'freecell' and game.free_cells[pile_idx] is not None: ++ has_card = True ++ elif pile_type == 'foundation' and game.foundations[pile_idx]: ++ has_card = True ++ elif pile_type == 'cascade' and game.cascades[pile_idx]: ++ has_card = True ++ ++ if has_card: ++ src_type = pile_type ++ src_idx = pile_idx ++ status_msg = f"Selected {pile_type.upper()} {pile_idx + 1 if pile_type == 'cascade' else labels_fc[pile_idx] if pile_type == 'freecell' else labels_fnd[pile_idx][0]}. Choose destination..." ++ status_color_pair = curses.color_pair(3) ++ else: ++ status_msg = f"Selected source {pile_type.upper()} is empty!" ++ status_color_pair = curses.color_pair(4) ++ else: ++ # Selecting destination ++ if pile_type == src_type and pile_idx == src_idx: ++ # Cancel selection ++ src_type, src_idx = None, None ++ status_msg = "Selection cancelled." ++ status_color_pair = curses.color_pair(3) ++ else: ++ success, msg = game.validate_and_move(src_type, src_idx, pile_type, pile_idx) ++ if success: ++ status_msg = msg ++ status_color_pair = curses.color_pair(3) ++ else: ++ status_msg = f"Invalid move: {msg}" ++ status_color_pair = curses.color_pair(4) ++ src_type, src_idx = None, None ++ else: ++ if ch != -1: ++ status_msg = f"Unknown key pressed: {key if key.printable() else ch}. Press bindings shown below." ++ status_color_pair = curses.color_pair(4) ++ ++ curses.wrapper(curses_main) ++ ++ ++def random_seed(): ++ """Generates a random 5-digit seed.""" ++ import random ++ return random.randint(10000, 99999) ++ ++ ++if __name__ == '__main__': ++ parser = argparse.ArgumentParser(description="Terminal-based FreeCell solitaire game in Python.") ++ parser.add_argument('--smoke', action='store_true', help="Run non-interactive smoke test.") ++ args = parser.parse_args() ++ ++ if args.smoke: ++ run_smoke_test() ++ else: ++ run_curses_ui() +diff --git a/card-game-app/test_engine.py b/card-game-app/test_engine.py +new file mode 100644 +index 000000000..27fe3a0c2 +--- /dev/null ++++ b/card-game-app/test_engine.py +@@ -0,0 +1,67 @@ ++import unittest ++from engine import Card, FreeCellGame ++ ++class TestFreeCellEngine(unittest.TestCase): ++ def test_card_creation(self): ++ card = Card('H', 1) ++ self.assertEqual(card.suit, 'H') ++ self.assertEqual(card.rank, 1) ++ self.assertEqual(card.color, 'red') ++ self.assertEqual(str(card), "A♥") ++ ++ def test_deal(self): ++ game = FreeCellGame(seed=42) ++ # Check that 52 cards were dealt correctly ++ total_cards = sum(len(c) for c in game.cascades) ++ self.assertEqual(total_cards, 52) ++ self.assertEqual(len(game.cascades[0]), 7) ++ self.assertEqual(len(game.cascades[4]), 6) ++ ++ def test_get_bottom_sequence(self): ++ # Create custom cascades to test sequence identification ++ c1 = [ ++ Card('H', 13), # K♥ ++ Card('S', 12), # Q♠ ++ Card('H', 11), # J♥ ++ Card('C', 10), # 10♣ ++ ] ++ seq = FreeCellGame.get_bottom_sequence(c1) ++ self.assertEqual(len(seq), 4) ++ self.assertEqual(seq[0].rank, 13) ++ ++ # Break sequence in middle ++ c2 = [ ++ Card('H', 13), # K♥ ++ Card('H', 12), # Q♥ (same color, breaks sequence) ++ Card('S', 11), # J♠ ++ Card('H', 10), # 10♥ ++ ] ++ seq = FreeCellGame.get_bottom_sequence(c2) ++ self.assertEqual(len(seq), 3) ++ self.assertEqual(seq[0].rank, 12) ++ ++ def test_validate_and_move_to_free_cell(self): ++ game = FreeCellGame(seed=42) ++ # Try moving bottom card of first cascade to first free cell ++ bottom_card = game.cascades[0][-1] ++ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0) ++ self.assertTrue(success) ++ self.assertEqual(game.free_cells[0], bottom_card) ++ self.assertEqual(len(game.cascades[0]), 6) ++ ++ def test_undo(self): ++ game = FreeCellGame(seed=42) ++ original_cascade_len = len(game.cascades[0]) ++ success, msg = game.validate_and_move('cascade', 0, 'freecell', 0) ++ self.assertTrue(success) ++ ++ self.assertEqual(len(game.cascades[0]), original_cascade_len - 1) ++ self.assertIsNotNone(game.free_cells[0]) ++ ++ undo_success = game.undo() ++ self.assertTrue(undo_success) ++ self.assertEqual(len(game.cascades[0]), original_cascade_len) ++ self.assertIsNone(game.free_cells[0]) ++ ++if __name__ == '__main__': ++ unittest.main() +diff --git a/status.json b/status.json +new file mode 100644 +index 000000000..ee6907d7f +--- /dev/null ++++ b/status.json +@@ -0,0 +1,3 @@ ++{ ++ "outcome": "succeeded" ++} +\ No newline at end of file diff --git a/stages/002-plan_app@1/status.json b/stages/002-plan_app@1/status.json new file mode 100644 index 000000000..b3fcd6544 --- /dev/null +++ b/stages/002-plan_app@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: plan_app", + "failure_reason": null, + "timestamp": "2026-06-04T20:30:22.463489Z" +} \ No newline at end of file diff --git a/stages/003-implement_app@1/prompt.md b/stages/003-implement_app@1/prompt.md new file mode 100644 index 000000000..acd099338 --- /dev/null +++ b/stages/003-implement_app@1/prompt.md @@ -0,0 +1,27 @@ +Goal: Build a terminal-based FreeCell solitaire game in Python + +## Completed stages +- **plan_app**: succeeded + - Model: gemini-3.5-flash, 226.1k tokens in / 34.3k out + - Files: /home/daytona/workspace/fabro/.ai/card-game-fast-plan.md, /home/daytona/workspace/fabro/card-game-app/engine.py, /home/daytona/workspace/fabro/card-game-app/main.py, /home/daytona/workspace/fabro/card-game-app/test_engine.py, /home/daytona/workspace/fabro/status.json + + +Read .ai/card-game-fast-plan.md. + +Build the complete app under card-game-app/ in one focused pass: +- pyproject.toml +- main.py +- src/card_game_tui/ package +- tests/ package +- README.md + +Implement: +- Card, Deck, Pile, or equivalent game-state types +- Requested game rules: initial setup/deal where applicable, move/action validation, auto-complete or helper actions where applicable, win/loss condition, undo +- Curses UI with card rendering, board layout, keyboard input, move/action selection, and help text +- --smoke mode that imports the app, creates a game, renders a text snapshot or summary, and exits without curses interaction + +Run: +cd card-game-app && python3 -m pytest tests/ -v && python3 -m py_compile main.py src/card_game_tui/*.py && python3 main.py --smoke + +Write status.json at workspace root: outcome=succeeded if the app builds, tests pass, and smoke mode works, outcome=failed with failure_reason otherwise. \ No newline at end of file diff --git a/stages/003-implement_app@1/provider_used.json b/stages/003-implement_app@1/provider_used.json new file mode 100644 index 000000000..0bb716dde --- /dev/null +++ b/stages/003-implement_app@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-implement_app@1/response.md b/stages/003-implement_app@1/response.md new file mode 100644 index 000000000..3884e8918 --- /dev/null +++ b/stages/003-implement_app@1/response.md @@ -0,0 +1,15 @@ +I have successfully built and verified the complete terminal-based FreeCell solitaire game in Python according to all specifications! + +### Summary of what was accomplished: +1. **Packaging (`pyproject.toml`)**: Structured the app with a clean and professional `pyproject.toml` file utilizing standard setuptools configuration and setting up pathing support for pytest. +2. **Game Engine (`src/card_game_tui/engine.py`)**: Migrated and enhanced the core state logic, covering: + - Alternating color and descending rank validation. + - Dynamic Sequence Move checking using the formula: $M = (F + 1) \times 2^E$ with empty destination cascades excluded. + - Multi-step undo management. + - Safe and fully-automated auto-collect logic for Aces, Twos, and other cards when safe. +3. **Interactive Curses UI (`src/card_game_tui/ui.py`)**: Migrated the interactive interface logic to handle responsive board resizing, clean alignment, key-binding selection, high-contrast Red/Black suits, win-condition state overlay, and timing updates. +4. **App Entry Point (`main.py`)**: Built a robust CLI parser with an enhanced non-interactive `--smoke` mode that logs detailed ASCII board snapshots of transitions (such as first move, second move, invalid moves, undoing, and automatic card collections). +5. **Testing Suite (`tests/test_engine.py`)**: Implemented a comprehensive set of unit tests utilizing the python `unittest` module covering deal validation, move mechanics, dynamic moves calculations, undoing, and win states. +6. **Documentation (`README.md`)**: Provided complete setup instructions, keyboard bindings, test instructions, and a visual directory overview. + +All unit tests compiled, run, and passed. The smoke test ran successfully, printing out beautifully aligned snapshots of the board state. `status.json` has been updated with `"outcome": "succeeded"`. \ No newline at end of file