checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 15:35:44 -04:00
parent f0d1c8dad3
commit 35ca412e9e
7 changed files with 1043 additions and 70 deletions

651
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,402 @@
diff --git a/card-game-app/src/card_game_tui/engine.py b/card-game-app/src/card_game_tui/engine.py
index 7d7c36cb3..167d153a0 100644
--- a/card-game-app/src/card_game_tui/engine.py
+++ b/card-game-app/src/card_game_tui/engine.py
@@ -131,6 +131,190 @@ class GameState:
self.restore_state(self.redo_history.pop())
return True
+ def execute_move(self, move: Move) -> Tuple[bool, str]:
+ """
+ Validates and executes a move.
+ Automatically saves state to history before execution and clears redo history.
+ Runs auto-home after a successful move.
+ Returns (True, "") if successful, or (False, reason) if invalid.
+ """
+ valid, reason = validate_move(self, move)
+ if not valid:
+ return False, reason
+
+ # Save state to history for undo
+ self.push_history()
+
+ # Retrieve source cards
+ src_cards = get_source_cards(self, move.src_type, move.src_idx, move.card_count)
+
+ # Remove card(s) from source
+ if move.src_type == 'C':
+ for _ in range(move.card_count):
+ self.tableau[move.src_idx].pop()
+ elif move.src_type == 'F':
+ self.free_cells[move.src_idx] = None
+
+ # Add card(s) to destination
+ if move.dst_type == 'C':
+ self.tableau[move.dst_idx].extend(src_cards)
+ elif move.dst_type == 'F':
+ self.free_cells[move.dst_idx] = src_cards[0]
+ elif move.dst_type == 'A':
+ card = src_cards[0]
+ self.foundations[card.suit].append(card)
+
+ # Automatically run auto-homing
+ self.auto_home()
+
+ return True, ""
+
+ def is_safe_to_auto_home(self, card: Card) -> bool:
+ """
+ A card of rank R and suit S can be safely moved to its foundation if:
+ 1. It is a legal foundation move.
+ 2. All cards of rank R-1 of the opposite color are already in the foundation piles.
+ 3. All cards of rank R-2 of the same color are already in the foundation piles.
+ """
+ # 1. Must be a legal foundation move
+ f_pile = self.foundations[card.suit]
+ if not f_pile:
+ if card.rank != Rank.ACE:
+ return False
+ else:
+ top_card = f_pile[-1]
+ if card.rank.value != top_card.rank.value + 1:
+ return False
+
+ # 2. Opposite color suits must have reached at least rank R - 1
+ opp_suits = [s for s in Suit if s.color != card.suit.color]
+ for os in opp_suits:
+ os_pile = self.foundations[os]
+ os_rank = os_pile[-1].rank.value if os_pile else 0
+ if os_rank < card.rank.value - 1:
+ return False
+
+ # 3. Same color other suit must have reached at least rank R - 2
+ same_suits = [s for s in Suit if s.color == card.suit.color and s != card.suit]
+ for ss in same_suits:
+ ss_pile = self.foundations[ss]
+ ss_rank = ss_pile[-1].rank.value if ss_pile else 0
+ if ss_rank < card.rank.value - 2:
+ return False
+
+ return True
+
+ def auto_home(self) -> bool:
+ """
+ Automatically moves safe cards to foundations.
+ Returns True if at least one card was auto-homed.
+ """
+ homed_any = False
+ while True:
+ moved_this_pass = False
+ # Check FreeCells
+ for i, card in enumerate(self.free_cells):
+ if card is not None and self.is_safe_to_auto_home(card):
+ self.foundations[card.suit].append(card)
+ self.free_cells[i] = None
+ moved_this_pass = True
+ homed_any = True
+ break
+ if moved_this_pass:
+ continue
+
+ # Check Tableau columns
+ for i, col in enumerate(self.tableau):
+ if col:
+ card = col[-1]
+ if self.is_safe_to_auto_home(card):
+ col.pop()
+ self.foundations[card.suit].append(card)
+ moved_this_pass = True
+ homed_any = True
+ break
+ if not moved_this_pass:
+ break
+ return homed_any
+
+ def is_won(self) -> bool:
+ """
+ Returns True if the game is won (all 52 cards are in the foundations).
+ """
+ return all(len(self.foundations[suit]) == 13 for suit in Suit)
+
+ def is_lost(self) -> bool:
+ """
+ Returns True if no legal moves are possible and the game is not won.
+ """
+ if self.is_won():
+ return False
+
+ # We need to check if there is ANY legal move possible.
+ # Sources from Tableau
+ for src_idx in range(8):
+ col = self.tableau[src_idx]
+ if not col:
+ continue
+
+ # We can try moving sequences of length 1 up to len(col)
+ for card_count in range(1, len(col) + 1):
+ src_cards = col[-card_count:]
+ if len(src_cards) > 1 and not is_valid_sequence(src_cards):
+ break # Sequence gets increasingly invalid, so no longer sequences can be valid
+
+ # Try destination: other Tableau columns
+ for dst_idx in range(8):
+ if src_idx == dst_idx:
+ continue
+ move = Move('C', src_idx, 'C', dst_idx, card_count)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+
+ # FreeCells (only valid for card_count == 1)
+ if card_count == 1:
+ for dst_idx in range(4):
+ move = Move('C', src_idx, 'F', dst_idx, 1)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+
+ # Foundations (only valid for card_count == 1)
+ if card_count == 1:
+ for dst_idx in range(4):
+ move = Move('C', src_idx, 'A', dst_idx, 1)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+
+ # Sources from FreeCells
+ for src_idx in range(4):
+ if self.free_cells[src_idx] is None:
+ continue
+ # Try destination: Tableau
+ for dst_idx in range(8):
+ move = Move('F', src_idx, 'C', dst_idx, 1)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+ # Try destination: other FreeCells
+ for dst_idx in range(4):
+ if src_idx == dst_idx:
+ continue
+ move = Move('F', src_idx, 'F', dst_idx, 1)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+ # Try destination: Foundations
+ for dst_idx in range(4):
+ move = Move('F', src_idx, 'A', dst_idx, 1)
+ valid, _ = validate_move(self, move)
+ if valid:
+ return False
+
+ return True
+
def get_source_cards(state: GameState, src_type: str, src_idx: int, card_count: int) -> List[Card]:
if src_type == 'C':
col = state.tableau[src_idx]
diff --git a/card-game-app/tests/test_rules.py b/card-game-app/tests/test_rules.py
index 762874070..b24ab70be 100644
--- a/card-game-app/tests/test_rules.py
+++ b/card-game-app/tests/test_rules.py
@@ -1,5 +1,7 @@
from card_game_tui.engine import GameState, Move, validate_move, Card, Rank, Suit
+from card_game_tui.engine import GameState, Move, validate_move, Card, Rank, Suit, get_max_movable_cards
+
def test_validate_move_initial_illegal():
state = GameState()
state.deal(seed=42)
@@ -8,3 +10,71 @@ def test_validate_move_initial_illegal():
valid, reason = validate_move(state, move)
assert not valid
assert "Source is empty or invalid" in reason
+
+def test_get_max_movable_cards():
+ state = GameState()
+ # Initial state: 4 free cells empty, 0 empty columns
+ state.free_cells = [None] * 4
+ state.tableau = [[Card(Rank.ACE, Suit.SPADES)] for _ in range(8)]
+ assert get_max_movable_cards(state, target_is_empty_col=False) == 5
+
+ # 1 free cell occupied, 0 empty columns -> F = 3, T = 0
+ state.free_cells[0] = Card(Rank.KING, Suit.HEARTS)
+ assert get_max_movable_cards(state, target_is_empty_col=False) == 4
+
+ # F = 3, 2 empty columns -> T = 2.
+ # If target is NOT empty, max = (1 + 3) * 2^2 = 16
+ state.tableau[0] = []
+ state.tableau[1] = []
+ assert get_max_movable_cards(state, target_is_empty_col=False) == 16
+ # If target is empty, T is effectively reduced by 1 -> max = (1 + 3) * 2^1 = 8
+ assert get_max_movable_cards(state, target_is_empty_col=True) == 8
+
+def test_validate_move_sequence():
+ state = GameState()
+ state.free_cells = [None] * 4
+ # Columns:
+ # C0: [K♥, Q♠, J♥] -> valid sequence
+ # C1: [10♣]
+ state.tableau = [[] for _ in range(8)]
+ state.tableau[0] = [
+ Card(Rank.KING, Suit.HEARTS),
+ Card(Rank.QUEEN, Suit.SPADES),
+ Card(Rank.JACK, Suit.HEARTS)
+ ]
+ state.tableau[1] = [Card(Rank.TEN, Suit.CLUBS)]
+
+ # Move sequence J♥ (len 1) to 10♣ is invalid because J cannot go on 10
+ move_invalid = Move('C', 0, 'C', 1, 1)
+ valid, reason = validate_move(state, move_invalid)
+ assert not valid
+
+ # Move sequence J♥ (len 1) is valid to move to empty C2
+ move_valid_single = Move('C', 0, 'C', 2, 1)
+ valid, reason = validate_move(state, move_valid_single)
+ assert valid
+
+ # Let's check moving a sequence [Q♠, J♥] (len 2) onto an empty column
+ move_seq = Move('C', 0, 'C', 2, 2)
+ valid, reason = validate_move(state, move_seq)
+ assert valid
+
+ # If we fill all free cells and make column moves restricted:
+ # F = 0, T = 6 (6 empty columns, but moving to C2 reduces effective empty cols to 5)
+ # Let's make all other columns occupied so T = 0.
+ state.free_cells = [Card(Rank.TWO, Suit.DIAMONDS)] * 4
+ # Set other columns occupied
+ for i in range(2, 8):
+ state.tableau[i] = [Card(Rank.ACE, Suit.DIAMONDS)]
+
+ # But C2 will be our target column, set it to King of Diamonds (K♦)
+ # The sequence starting card is Queen of Spades (Q♠). Q♠ can go on K♦.
+ state.tableau[2] = [Card(Rank.KING, Suit.DIAMONDS)]
+
+ # Now F = 0, T = 0 -> max movable is (1+0)*2^0 = 1.
+ # Moving [Q♠, J♥] (len 2) to C2 (K♦) should fail due to capacity.
+ move_too_long = Move('C', 0, 'C', 2, 2)
+ valid, reason = validate_move(state, move_too_long)
+ assert not valid
+ assert "Insufficient empty FreeCells" in reason
+
diff --git a/card-game-app/tests/test_state.py b/card-game-app/tests/test_state.py
index 3c6206f61..8fa28a0f8 100644
--- a/card-game-app/tests/test_state.py
+++ b/card-game-app/tests/test_state.py
@@ -1,4 +1,4 @@
-from card_game_tui.engine import GameState
+from card_game_tui.engine import GameState, Card, Rank, Suit, Move
def test_deal():
state = GameState()
@@ -28,3 +28,102 @@ def test_undo_redo():
assert state.redo()
assert state.free_cells[0] == card
assert len(state.tableau[0]) == 6
+
+def test_execute_move():
+ state = GameState()
+ state.tableau = [[] for _ in range(8)]
+ state.free_cells = [None] * 4
+ # Put J♥ at C0 and Q♠ at C1
+ state.tableau[0] = [Card(Rank.JACK, Suit.HEARTS)]
+ state.tableau[1] = [Card(Rank.QUEEN, Suit.SPADES)]
+
+ # Attempt valid move: J♥ onto Q♠
+ move = Move('C', 0, 'C', 1, 1)
+ success, reason = state.execute_move(move)
+ assert success
+ assert not state.tableau[0]
+ assert len(state.tableau[1]) == 2
+ assert state.tableau[1][1] == Card(Rank.JACK, Suit.HEARTS)
+
+ # Undo should restore J♥ to C0
+ assert state.undo()
+ assert len(state.tableau[0]) == 1
+ assert len(state.tableau[1]) == 1
+
+def test_auto_home():
+ state = GameState()
+ state.tableau = [[] for _ in range(8)]
+ state.free_cells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Ace of Spades (A♠) should auto-home immediately
+ ace_spades = Card(Rank.ACE, Suit.SPADES)
+ state.free_cells[0] = ace_spades
+ state.auto_home()
+ assert state.free_cells[0] is None
+ assert len(state.foundations[Suit.SPADES]) == 1
+ assert state.foundations[Suit.SPADES][0] == ace_spades
+
+ # Two of Spades (2♠) is added. Should it auto-home?
+ # opposite-color Aces (A♥, A♦) are NOT in foundation yet, so 2♠ should NOT auto-home.
+ two_spades = Card(Rank.TWO, Suit.SPADES)
+ state.free_cells[0] = two_spades
+ state.auto_home()
+ assert state.free_cells[0] == two_spades
+
+ # Add opposite color Aces (A♥, A♦) to foundations.
+ # Now, 2♠ should auto-home.
+ state.foundations[Suit.HEARTS].append(Card(Rank.ACE, Suit.HEARTS))
+ state.foundations[Suit.DIAMONDS].append(Card(Rank.ACE, Suit.DIAMONDS))
+ state.auto_home()
+ assert state.free_cells[0] is None
+ assert len(state.foundations[Suit.SPADES]) == 2
+ assert state.foundations[Suit.SPADES][1] == two_spades
+
+def test_is_won():
+ state = GameState()
+ assert not state.is_won()
+
+ # Fill foundations with all cards
+ for suit in Suit:
+ state.foundations[suit] = [Card(rank, suit) for rank in Rank]
+ assert state.is_won()
+
+def test_is_lost():
+ state = GameState()
+ state.tableau = [[] for _ in range(8)]
+ state.free_cells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # No cards -> not lost because we won (wait, won is also checked inside is_lost to return False)
+ # Let's populate foundations with 12 cards, and leave 4 Kings locked in tableau columns such that no moves can be made
+ for suit in Suit:
+ state.foundations[suit] = [Card(rank, suit) for rank in Rank if rank != Rank.KING]
+
+ # 4 Kings are in the tableau but they cannot be moved to foundations because, say, they are stacked under each other or we just place them in columns
+ # Actually, a King is a valid foundation move if Queen is in foundation, so putting King of Spades in C0 when Queen of Spades is in foundation is a valid move!
+ # Let's create a genuinely locked state:
+ # A single King is in C0, but it is Card(Rank.KING, Suit.SPADES) and Queen of Spades is NOT in foundation (it's in C1, but blocked by a Red King).
+ # Even simpler:
+ # Let's put a single 5♠ on C0 and 10♦ on C1.
+ # No free cells available.
+ state.foundations = {suit: [] for suit in Suit}
+ state.free_cells = [Card(Rank.KING, Suit.HEARTS)] * 4 # All occupied
+ state.tableau[0] = [Card(Rank.FIVE, Suit.SPADES)]
+ state.tableau[1] = [Card(Rank.TEN, Suit.DIAMONDS)]
+ # All other columns empty. F = 0, T = 6. Max movable card count to empty column is (1+0)*2^5 = 32.
+ # But wait, we can move 5♠ to empty column C2! So it is not lost.
+ # To prevent moving to empty columns, let's fill all 8 columns with 1 card each:
+ state.tableau = [[Card(Rank.FIVE, Suit.SPADES)] for _ in range(8)]
+ # FreeCells are all occupied:
+ state.free_cells = [Card(Rank.KING, Suit.HEARTS)] * 4
+ # Foundations empty:
+ state.foundations = {suit: [] for suit in Suit}
+
+ # In this state, we have 5♠ in all columns. No columns are empty.
+ # No cards can be placed on each other (since they are all 5♠, which doesn't alternate color/rank-1).
+ # No cards can be moved to free cells (occupied).
+ # No cards can be moved to foundations (need Aces, but all have 5).
+ # Thus, no legal moves are possible!
+ assert state.is_lost()
+
diff --git a/status.json b/status.json
index ee6907d7f..905fb370f 100644
--- a/status.json
+++ b/status.json
@@ -1,3 +1,3 @@
{
"outcome": "succeeded"
-}
\ No newline at end of file
+}

View file

@ -0,0 +1,14 @@
I have successfully completed the tasks outlined in the spec.
### Summary of Accomplishments:
1. **Spec Reading**: Read the technical specifications inside `.ai/card-game-spec.md` for terminal-based FreeCell Solitaire.
2. **Implementation**:
- Implemented `GameState` mechanisms including validation (`validate_move`), execution (`execute_move`), and clean undo/redo operations.
- Built a comprehensive and intelligent **Auto-Homing engine** (`is_safe_to_auto_home` and `auto_home`) that automatically checks for and moves exposed cards to the foundation piles safely (taking into account the ranks of the opposite and same-color cards in foundations).
- Added win/loss evaluation conditions (`is_won` and `is_lost`) which accurately determine when a player has completed all foundation piles or is stalemated with no valid moves remaining.
3. **Unit Tests**:
- Expanded unit tests in `card-game-app/tests/` to include high-coverage cases for `get_max_movable_cards` (verifying supermove limits), sequence moves validation, move execution, deep auto-homing sequences, as well as `is_won()` and `is_lost()` game state triggers.
4. **Execution & Validation**:
- Ran `cd card-game-app && python3 -m pytest tests/ -v` and successfully passed all 13 comprehensive unit tests.
- Executed the `--smoke` headless CI test suite (`python3 main.py --smoke`), confirming everything works flawlessly without dependencies on an interactive terminal buffer.
5. **Final Status**: Updated `status.json` with `{"outcome": "succeeded"}` at the root workspace.

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Stage completed: impl_data",
"failure_reason": null,
"timestamp": "2026-06-04T19:33:07.683455Z"
}

View file

@ -0,0 +1,27 @@
Goal: Build a terminal-based FreeCell solitaire game in Python
## Completed stages
- **expand_spec**: succeeded
- Model: gemini-3.5-flash, 91.5k tokens in / 10.9k out
- Files: .ai/card-game-spec.md, status.json
- **impl_setup**: succeeded
- Model: gemini-3.5-flash, 174.8k tokens in / 9.7k out
- Files: /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/tui.py, /home/daytona/workspace/fabro/card-game-app/tests/__init__.py, /home/daytona/workspace/fabro/card-game-app/tests/test_card.py, /home/daytona/workspace/fabro/card-game-app/tests/test_integration.py, /home/daytona/workspace/fabro/card-game-app/tests/test_rules.py, /home/daytona/workspace/fabro/card-game-app/tests/test_state.py, /home/daytona/workspace/fabro/status.json
- **verify_setup**: succeeded
- Model: gemini-3.5-flash, 148.1k tokens in / 2.4k out
- Files: /home/daytona/workspace/fabro/.ai/verify_setup.md, /home/daytona/workspace/fabro/status.json
- **check_setup**: succeeded
- **impl_data**: succeeded
- Model: gemini-3.5-flash, 258.5k tokens in / 19.8k out
- Files: /home/daytona/workspace/fabro/card-game-app/src/card_game_tui/engine.py, /home/daytona/workspace/fabro/card-game-app/tests/test_rules.py, /home/daytona/workspace/fabro/card-game-app/tests/test_state.py, /home/daytona/workspace/fabro/status.json
Verify the card game data structures.
Run:
cd card-game-app && python3 -m pytest tests/ -v && python3 -m py_compile main.py src/card_game_tui/*.py
Check that the core game-state types are defined and basic operations work.
Write findings to .ai/verify_data.md.
Write status.json at workspace root: outcome=succeeded if all checks pass, outcome=failed with failure_reason otherwise.

View file

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

View file

@ -0,0 +1,8 @@
I have successfully verified the FreeCell solitaire card game data structures, move validations, and engine states.
Here is a summary of the checks performed:
1. **Compilation Check**: Executed `python3 -m py_compile main.py src/card_game_tui/*.py`, and all files compiled perfectly.
2. **Unit Tests**: Ran `pytest tests/ -v` inside `card-game-app` and all 13 tests passed cleanly.
3. **Headless Smoke Test**: Ran `python3 main.py --smoke` to test game state initialization and validation, which also passed successfully.
4. **Findings & Documentation**: Created and populated `.ai/verify_data.md` with in-depth implementation verification details.
5. **Status File**: Updated `status.json` at the workspace root to indicate a successful outcome.