checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 14:51:07 -04:00
parent 8b71e8ffe1
commit 28f1dd6c3f
6 changed files with 1253 additions and 61 deletions

628
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,630 @@
diff --git a/card-game-app/src/card_game_tui/domain.py b/card-game-app/src/card_game_tui/domain.py
index db4a2fa8f..fe733cef3 100644
--- a/card-game-app/src/card_game_tui/domain.py
+++ b/card-game-app/src/card_game_tui/domain.py
@@ -1,5 +1,5 @@
import random
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import Enum, auto
from typing import List, Union, Dict
@@ -84,6 +84,10 @@ class MoveRecord:
cards: List[Card] # Captured for single or sequence moves
auto_moves: List['MoveRecord'] = None # Nested moves triggered by auto-homing
+ def __post_init__(self):
+ if self.auto_moves is None:
+ self.auto_moves = []
+
class GameState:
def __init__(self, seed: int = None):
self.tableaus: List[List[Card]] = Deck(seed).deal()
@@ -107,7 +111,108 @@ class GameState:
"""
Calculates whether moving 'count' cards from from_pos to to_pos is legal.
"""
- # Placeholder for validation
+ if count < 1:
+ return False
+
+ if from_pos == to_pos:
+ return False
+
+ # Validate index boundaries
+ if from_pos.type == LocationType.TABLEAU:
+ if not (0 <= from_pos.index < 8):
+ return False
+ elif from_pos.type == LocationType.FREECELL:
+ if not (0 <= from_pos.index < 4):
+ return False
+ if count != 1:
+ return False
+ elif from_pos.type == LocationType.FOUNDATION:
+ return False
+ else:
+ return False
+
+ if to_pos.type == LocationType.TABLEAU:
+ if not (0 <= to_pos.index < 8):
+ return False
+ elif to_pos.type == LocationType.FREECELL:
+ if not (0 <= to_pos.index < 4):
+ return False
+ if count != 1:
+ return False
+ elif to_pos.type == LocationType.FOUNDATION:
+ if not (0 <= to_pos.index < 4):
+ return False
+ if count != 1:
+ return False
+ else:
+ return False
+
+ # Extract moving cards
+ if from_pos.type == LocationType.FREECELL:
+ card = self.freecells[from_pos.index]
+ if card is None:
+ return False
+ moving_cards = [card]
+ elif from_pos.type == LocationType.TABLEAU:
+ col = self.tableaus[from_pos.index]
+ if len(col) < count:
+ return False
+ moving_cards = col[-count:]
+ else:
+ return False
+
+ # Validate sequence if moving multiple cards
+ if count > 1:
+ for i in range(count - 1):
+ c1 = moving_cards[i]
+ c2 = moving_cards[i+1]
+ if c2.rank.value != c1.rank.value - 1:
+ return False
+ if c2.suit.color == c1.suit.color:
+ return False
+
+ # Validate destination constraints
+ if to_pos.type == LocationType.FREECELL:
+ if self.freecells[to_pos.index] is not None:
+ return False
+
+ elif to_pos.type == LocationType.FOUNDATION:
+ target_suit = list(Suit)[to_pos.index]
+ card = moving_cards[0]
+ if card.suit != target_suit:
+ return False
+
+ foundation_pile = self.foundations[target_suit]
+ if card.rank == Rank.ACE:
+ if len(foundation_pile) != 0:
+ return False
+ else:
+ if len(foundation_pile) != card.rank.value - 1:
+ return False
+
+ elif to_pos.type == LocationType.TABLEAU:
+ dest_col = self.tableaus[to_pos.index]
+ first_moving_card = moving_cards[0]
+ if len(dest_col) > 0:
+ dest_top_card = dest_col[-1]
+ if first_moving_card.rank.value != dest_top_card.rank.value - 1:
+ return False
+ if first_moving_card.suit.color == dest_top_card.suit.color:
+ return False
+
+ # Capacity constraint
+ empty_freecells = sum(1 for c in self.freecells if c is None)
+ empty_tableaus = sum(1 for col in self.tableaus if len(col) == 0)
+
+ if len(dest_col) == 0:
+ transit_empty_tableaus = max(0, empty_tableaus - 1)
+ else:
+ transit_empty_tableaus = empty_tableaus
+
+ max_cards = (1 + empty_freecells) * (2 ** transit_empty_tableaus)
+ if count > max_cards:
+ return False
+
return True
def execute_move(self, from_pos: Position, to_pos: Position, count: int = 1) -> bool:
@@ -116,12 +221,156 @@ class GameState:
"""
if not self.validate_move(from_pos, to_pos, count):
return False
+
+ # Extract moving cards
+ if from_pos.type == LocationType.FREECELL:
+ moving_cards = [self.freecells[from_pos.index]]
+ elif from_pos.type == LocationType.TABLEAU:
+ moving_cards = self.tableaus[from_pos.index][-count:]
+ else:
+ return False
+
+ move_record = MoveRecord(from_pos=from_pos, to_pos=to_pos, cards=moving_cards)
+
+ # Apply the move
+ self._apply_single_move(move_record)
+
+ # Run auto-homing
+ self._run_auto_homing(move_record)
+
+ # Record on stacks
+ self.undo_stack.append(move_record)
+ self.redo_stack.clear()
+ return True
+
+ def _apply_single_move(self, record: MoveRecord):
+ # Remove from from_pos
+ if record.from_pos.type == LocationType.FREECELL:
+ self.freecells[record.from_pos.index] = None
+ elif record.from_pos.type == LocationType.TABLEAU:
+ count = len(record.cards)
+ self.tableaus[record.from_pos.index] = self.tableaus[record.from_pos.index][:-count]
+
+ # Put to to_pos
+ if record.to_pos.type == LocationType.FREECELL:
+ self.freecells[record.to_pos.index] = record.cards[0]
+ elif record.to_pos.type == LocationType.FOUNDATION:
+ target_suit = list(Suit)[record.to_pos.index]
+ self.foundations[target_suit].append(record.cards[0])
+ elif record.to_pos.type == LocationType.TABLEAU:
+ self.tableaus[record.to_pos.index].extend(record.cards)
+
+ def _revert_single_move(self, record: MoveRecord):
+ # Remove from to_pos
+ if record.to_pos.type == LocationType.FREECELL:
+ self.freecells[record.to_pos.index] = None
+ elif record.to_pos.type == LocationType.FOUNDATION:
+ target_suit = list(Suit)[record.to_pos.index]
+ self.foundations[target_suit].pop()
+ elif record.to_pos.type == LocationType.TABLEAU:
+ count = len(record.cards)
+ self.tableaus[record.to_pos.index] = self.tableaus[record.to_pos.index][:-count]
+
+ # Put to from_pos
+ if record.from_pos.type == LocationType.FREECELL:
+ self.freecells[record.from_pos.index] = record.cards[0]
+ elif record.from_pos.type == LocationType.TABLEAU:
+ self.tableaus[record.from_pos.index].extend(record.cards)
+
+ def _should_auto_home(self, card: Card) -> bool:
+ """
+ Checks if a card can be safely auto-homed to the foundations.
+ """
+ target_foundation = self.foundations[card.suit]
+ if card.rank.value != len(target_foundation) + 1:
+ return False
+
+ # Opposite color suits must have all lower cards in the foundations
+ if card.suit.color == "RED":
+ opp_suits = [Suit.CLUBS, Suit.SPADES]
+ else:
+ opp_suits = [Suit.HEARTS, Suit.DIAMONDS]
+
+ for s_opp in opp_suits:
+ if len(self.foundations[s_opp]) < card.rank.value - 1:
+ return False
+
return True
+ def _run_auto_homing(self, move_record: MoveRecord):
+ """
+ Scans freecells and tableau tops, moving eligible cards to foundations.
+ Repeats until no more cards can be auto-homed.
+ """
+ any_homed = True
+ while any_homed:
+ any_homed = False
+
+ # Check free cells
+ for i, card in enumerate(self.freecells):
+ if card is not None and self._should_auto_home(card):
+ from_p = Position(LocationType.FREECELL, i)
+ suit_idx = list(Suit).index(card.suit)
+ to_p = Position(LocationType.FOUNDATION, suit_idx)
+
+ auto_rec = MoveRecord(from_pos=from_p, to_pos=to_p, cards=[card])
+ self._apply_single_move(auto_rec)
+ move_record.auto_moves.append(auto_rec)
+
+ any_homed = True
+ break
+
+ if any_homed:
+ continue
+
+ # Check tableaus
+ for i, col in enumerate(self.tableaus):
+ if col:
+ card = col[-1]
+ if self._should_auto_home(card):
+ from_p = Position(LocationType.TABLEAU, i)
+ suit_idx = list(Suit).index(card.suit)
+ to_p = Position(LocationType.FOUNDATION, suit_idx)
+
+ auto_rec = MoveRecord(from_pos=from_p, to_pos=to_p, cards=[card])
+ self._apply_single_move(auto_rec)
+ move_record.auto_moves.append(auto_rec)
+
+ any_homed = True
+ break
+
def undo(self) -> bool:
"""Reverts the last move, including nested auto-homing steps."""
if not self.undo_stack:
return False
+
+ move_record = self.undo_stack.pop()
+
+ # Revert auto moves in reverse order
+ for auto_move in reversed(move_record.auto_moves):
+ self._revert_single_move(auto_move)
+
+ # Revert the main move
+ self._revert_single_move(move_record)
+
+ self.redo_stack.append(move_record)
+ return True
+
+ def redo(self) -> bool:
+ """Reapplies the last undone move."""
+ if not self.redo_stack:
+ return False
+
+ move_record = self.redo_stack.pop()
+
+ # Apply the main move
+ self._apply_single_move(move_record)
+
+ # Re-apply auto moves
+ for auto_move in move_record.auto_moves:
+ self._apply_single_move(auto_move)
+
+ self.undo_stack.append(move_record)
return True
def check_win(self) -> bool:
diff --git a/card-game-app/tests/test_domain.py b/card-game-app/tests/test_domain.py
index 3ddb4344b..0fa4360ed 100644
--- a/card-game-app/tests/test_domain.py
+++ b/card-game-app/tests/test_domain.py
@@ -1,3 +1,4 @@
+import copy
from card_game_tui.domain import GameState, Suit, Rank, Card, Deck, LocationType, Position
def test_deck_deals_52_cards():
@@ -18,3 +19,331 @@ def test_get_card_at():
card = state.get_card_at(pos)
assert card is not None
assert isinstance(card, Card)
+
+def test_deck_deterministic_seeding():
+ deck1 = Deck(seed=42)
+ deck2 = Deck(seed=42)
+ assert deck1.cards == deck2.cards
+
+ # Verify uniqueness of 52 cards
+ assert len(set(deck1.cards)) == 52
+
+def test_moves_to_free_cells():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Put a card in Tableau 0
+ c_hearts = Card(Suit.HEARTS, Rank.TEN)
+ state.tableaus[0] = [c_hearts]
+
+ from_pos = Position(LocationType.TABLEAU, 0)
+ to_pos = Position(LocationType.FREECELL, 0)
+
+ # Valid single card move to empty freecell
+ assert state.validate_move(from_pos, to_pos, count=1) is True
+
+ # Try invalid sequence count to free cell
+ assert state.validate_move(from_pos, to_pos, count=2) is False
+
+ # Execute move
+ assert state.execute_move(from_pos, to_pos, count=1) is True
+ assert state.freecells[0] == c_hearts
+ assert len(state.tableaus[0]) == 0
+
+ # Try moving to occupied freecell
+ state.tableaus[0] = [Card(Suit.SPADES, Rank.FIVE)]
+ assert state.validate_move(from_pos, to_pos, count=1) is False
+
+ # Try moving to invalid freecell index
+ invalid_to_pos = Position(LocationType.FREECELL, 4)
+ assert state.validate_move(from_pos, invalid_to_pos, count=1) is False
+
+def test_moves_to_foundations():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ ace_hearts = Card(Suit.HEARTS, Rank.ACE)
+ two_hearts = Card(Suit.HEARTS, Rank.TWO)
+ three_hearts = Card(Suit.HEARTS, Rank.THREE)
+ ace_diamonds = Card(Suit.DIAMONDS, Rank.ACE)
+
+ state.tableaus[0] = [ace_hearts]
+ state.tableaus[1] = [two_hearts]
+ state.tableaus[2] = [three_hearts]
+ state.tableaus[3] = [ace_diamonds]
+
+ # Move Ace of Hearts to index 0 (HEARTS) foundation
+ # list(Suit) indices: 0: HEARTS, 1: DIAMONDS, 2: CLUBS, 3: SPADES
+ hearts_found = Position(LocationType.FOUNDATION, 0)
+ diamonds_found = Position(LocationType.FOUNDATION, 1)
+
+ # Valid Ace to empty foundation
+ assert state.validate_move(Position(LocationType.TABLEAU, 0), hearts_found, count=1) is True
+ # Invalid non-Ace to empty foundation
+ assert state.validate_move(Position(LocationType.TABLEAU, 1), hearts_found, count=1) is False
+ # Invalid Ace to wrong suit foundation
+ assert state.validate_move(Position(LocationType.TABLEAU, 3), hearts_found, count=1) is False
+
+ # Execute valid Ace move
+ assert state.execute_move(Position(LocationType.TABLEAU, 0), hearts_found, count=1) is True
+ assert state.foundations[Suit.HEARTS] == [ace_hearts]
+
+ # Now Two of Hearts is on Tableau 1. Can it move to Hearts foundation? Yes.
+ assert state.validate_move(Position(LocationType.TABLEAU, 1), hearts_found, count=1) is True
+ # Can Three of Hearts move? No, since it needs Two of Hearts first.
+ assert state.validate_move(Position(LocationType.TABLEAU, 2), hearts_found, count=1) is False
+
+def test_moves_to_tableau_single_card():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Red 8 (Hearts) on Black 9 (Spades)
+ c_red8 = Card(Suit.HEARTS, Rank.EIGHT)
+ c_black9 = Card(Suit.SPADES, Rank.NINE)
+
+ state.tableaus[0] = [c_red8]
+ state.tableaus[1] = [c_black9]
+
+ # Test moving Red 8 onto Black 9
+ assert state.validate_move(Position(LocationType.TABLEAU, 0), Position(LocationType.TABLEAU, 1), count=1) is True
+
+ # Test same color invalid move: Red 8 (Hearts) on Red 9 (Diamonds)
+ c_red9 = Card(Suit.DIAMONDS, Rank.NINE)
+ state.tableaus[1] = [c_red9]
+ assert state.validate_move(Position(LocationType.TABLEAU, 0), Position(LocationType.TABLEAU, 1), count=1) is False
+
+ # Test wrong rank invalid move: Red 8 on Black 10 (Spades)
+ c_black10 = Card(Suit.SPADES, Rank.TEN)
+ state.tableaus[1] = [c_black10]
+ assert state.validate_move(Position(LocationType.TABLEAU, 0), Position(LocationType.TABLEAU, 1), count=1) is False
+
+ # Test moving to empty tableau
+ state.tableaus[1] = []
+ assert state.validate_move(Position(LocationType.TABLEAU, 0), Position(LocationType.TABLEAU, 1), count=1) is True
+
+def test_sequence_moves_tableau_to_tableau():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Red 10, Black 9, Red 8, Black 7, Red 6
+ c10 = Card(Suit.HEARTS, Rank.TEN)
+ c9 = Card(Suit.SPADES, Rank.NINE)
+ c8 = Card(Suit.DIAMONDS, Rank.EIGHT)
+ c7 = Card(Suit.CLUBS, Rank.SEVEN)
+ c6 = Card(Suit.HEARTS, Rank.SIX)
+
+ state.tableaus[0] = [c10, c9, c8, c7, c6]
+ state.tableaus[1] = [Card(Suit.SPADES, Rank.JACK)]
+
+ # 4 empty freecells, 6 empty tableaus.
+ # Exclude destination: no, destination is Col 1 which has 1 card (not empty).
+ # Transit empty tableaus = 6
+ # Max cards = (1 + 4) * 2^6 = 5 * 64 = 320 cards.
+ # Moving 5 cards should be valid!
+ from_pos = Position(LocationType.TABLEAU, 0)
+ to_pos = Position(LocationType.TABLEAU, 1)
+ assert state.validate_move(from_pos, to_pos, count=5) is True
+
+ # Restrict capacity
+ state.freecells = [Card(Suit.CLUBS, Rank.TWO)] * 4 # 0 empty freecells
+ for i in range(2, 8):
+ state.tableaus[i] = [Card(Suit.DIAMONDS, Rank.KING)] # 0 empty tableaus
+ # Now empty freecells = 0, empty tableaus = 0
+ # Max cards = (1 + 0) * 2^0 = 1
+ # Moving 5 cards should be invalid
+ assert state.validate_move(from_pos, to_pos, count=5) is False
+
+def test_sequence_moves_to_empty_tableau():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Sequence of 3 cards on Col 0: Red 10, Black 9, Red 8
+ c10 = Card(Suit.HEARTS, Rank.TEN)
+ c9 = Card(Suit.SPADES, Rank.NINE)
+ c8 = Card(Suit.DIAMONDS, Rank.EIGHT)
+ state.tableaus[0] = [c10, c9, c8]
+
+ # Destination is Col 1 (empty)
+ # We want to move 2 cards: Black 9, Red 8
+ # Capacity constraint:
+ # Destination Col 1 is empty, so transit_empty_tableaus = empty_tableaus - 1.
+ # Empty tableaus before move = 7.
+ # Transit empty tableaus = 6.
+ # Empty free cells = 4.
+ # Max cards = (1 + 4) * 2^6 = 320.
+ from_pos = Position(LocationType.TABLEAU, 0)
+ to_pos = Position(LocationType.TABLEAU, 1)
+ assert state.validate_move(from_pos, to_pos, count=2) is True
+
+ # Restrict capacity so Max Cards is exactly 1
+ state.freecells = [Card(Suit.CLUBS, Rank.TWO)] * 4 # 0 empty free cells
+ for i in range(2, 8):
+ state.tableaus[i] = [Card(Suit.DIAMONDS, Rank.KING)] # 0 transit empty tableaus
+ # Empty freecells = 0, empty tableaus = 1 (Col 1).
+ # Since Col 1 is the destination, transit_empty_tableaus = 1 - 1 = 0.
+ # Max cards = (1 + 0) * 2^0 = 1.
+ # Moving 2 cards should be invalid, but 1 card should be valid.
+ assert state.validate_move(from_pos, to_pos, count=2) is False
+ assert state.validate_move(from_pos, to_pos, count=1) is True
+
+def test_auto_homing_logic():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Hearts is RED. Opposite suits are CLUBS and SPADES.
+ # Put Ace of Hearts in Tableau 0.
+ state.tableaus[0] = [Card(Suit.HEARTS, Rank.ACE)]
+ # Put King of Spades in Tableau 1 to use as a dummy move trigger.
+ state.tableaus[1] = [Card(Suit.SPADES, Rank.KING)]
+
+ assert len(state.foundations[Suit.HEARTS]) == 0
+
+ # Execute move of King of Spades to FreeCell 0. This should trigger auto-homing of Ace of Hearts.
+ from_pos = Position(LocationType.TABLEAU, 1)
+ to_pos = Position(LocationType.FREECELL, 0)
+ success = state.execute_move(from_pos, to_pos, count=1)
+ assert success is True
+
+ assert state.freecells[0] == Card(Suit.SPADES, Rank.KING)
+ assert len(state.tableaus[0]) == 0
+ assert len(state.foundations[Suit.HEARTS]) == 1
+ assert state.foundations[Suit.HEARTS][0] == Card(Suit.HEARTS, Rank.ACE)
+
+ # Two of Hearts is put in Tableau 0.
+ # Foundations: Hearts has Ace. CLUBS has [], SPADES has [].
+ # Opposite color (Black) lower cards are Ace of Clubs and Ace of Spades.
+ # Since they are NOT in the foundations, Two of Hearts should NOT auto-home.
+ state.tableaus[0] = [Card(Suit.HEARTS, Rank.TWO)]
+
+ # Move King of Spades from FreeCell 0 to Tableau 1 to trigger auto-homing checks
+ from_pos = Position(LocationType.FREECELL, 0)
+ to_pos = Position(LocationType.TABLEAU, 1)
+ success = state.execute_move(from_pos, to_pos, count=1)
+ assert success is True
+
+ # Two of Hearts should still be in Tableau 0 (not auto-homed)
+ assert len(state.tableaus[0]) == 1
+ assert state.tableaus[0][0] == Card(Suit.HEARTS, Rank.TWO)
+ assert len(state.foundations[Suit.HEARTS]) == 1
+
+ # Place Ace of Clubs and Ace of Spades in their foundations.
+ state.foundations[Suit.CLUBS] = [Card(Suit.CLUBS, Rank.ACE)]
+ state.foundations[Suit.SPADES] = [Card(Suit.SPADES, Rank.ACE)]
+
+ # Move King of Spades back to FreeCell 0 to trigger auto-homing checks
+ from_pos = Position(LocationType.TABLEAU, 1)
+ to_pos = Position(LocationType.FREECELL, 0)
+ success = state.execute_move(from_pos, to_pos, count=1)
+ assert success is True
+
+ # Two of Hearts should now have auto-homed!
+ assert len(state.tableaus[0]) == 0
+ assert len(state.foundations[Suit.HEARTS]) == 2
+ assert state.foundations[Suit.HEARTS][1] == Card(Suit.HEARTS, Rank.TWO)
+
+def test_undo_redo_system():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Col 0: [Red 10 (Hearts)]
+ # Col 1: [Black J (Spades)]
+ c10 = Card(Suit.HEARTS, Rank.TEN)
+ cJ = Card(Suit.SPADES, Rank.JACK)
+ state.tableaus[0] = [c10]
+ state.tableaus[1] = [cJ]
+
+ # Save original state for verification
+ orig_tableaus = copy.deepcopy(state.tableaus)
+ orig_freecells = copy.deepcopy(state.freecells)
+ orig_foundations = copy.deepcopy(state.foundations)
+
+ # 1. Execute a move
+ from_pos = Position(LocationType.TABLEAU, 0)
+ to_pos = Position(LocationType.TABLEAU, 1)
+ success = state.execute_move(from_pos, to_pos, count=1)
+ assert success is True
+
+ assert state.tableaus[0] == []
+ assert state.tableaus[1] == [cJ, c10]
+
+ # 2. Undo the move
+ undo_success = state.undo()
+ assert undo_success is True
+ assert state.tableaus == orig_tableaus
+ assert state.freecells == orig_freecells
+ assert state.foundations == orig_foundations
+
+ # 3. Redo the move
+ redo_success = state.redo()
+ assert redo_success is True
+ assert state.tableaus[0] == []
+ assert state.tableaus[1] == [cJ, c10]
+
+ # 4. Undo again to restore
+ assert state.undo() is True
+ assert state.tableaus == orig_tableaus
+
+def test_undo_with_auto_homing():
+ state = GameState()
+ state.tableaus = [[] for _ in range(8)]
+ state.freecells = [None] * 4
+ state.foundations = {suit: [] for suit in Suit}
+
+ # Tableau 0: Ace of Hearts
+ # Tableau 1: King of Spades
+ # Move King of Spades to FreeCell 0. This will trigger auto-homing of Ace of Hearts to foundation.
+ state.tableaus[0] = [Card(Suit.HEARTS, Rank.ACE)]
+ state.tableaus[1] = [Card(Suit.SPADES, Rank.KING)]
+
+ orig_tableaus = copy.deepcopy(state.tableaus)
+ orig_freecells = copy.deepcopy(state.freecells)
+ orig_foundations = copy.deepcopy(state.foundations)
+
+ from_pos = Position(LocationType.TABLEAU, 1)
+ to_pos = Position(LocationType.FREECELL, 0)
+ success = state.execute_move(from_pos, to_pos, count=1)
+ assert success is True
+
+ # Check state after move and auto-homing
+ assert state.freecells[0] == Card(Suit.SPADES, Rank.KING)
+ assert len(state.tableaus[0]) == 0
+ assert len(state.foundations[Suit.HEARTS]) == 1
+
+ # Undo the move! This should revert both King of Spades and Ace of Hearts!
+ undo_success = state.undo()
+ assert undo_success is True
+ assert state.tableaus == orig_tableaus
+ assert state.freecells == orig_freecells
+ assert state.foundations == orig_foundations
+
+ # Redo the move!
+ redo_success = state.redo()
+ assert redo_success is True
+ assert state.freecells[0] == Card(Suit.SPADES, Rank.KING)
+ assert len(state.tableaus[0]) == 0
+ assert len(state.foundations[Suit.HEARTS]) == 1
+
+def test_win_detection():
+ state = GameState()
+ # Fill foundations
+ for suit in Suit:
+ state.foundations[suit] = [Card(suit, rank) for rank in Rank]
+ assert state.check_win() is True
+
+ # Remove one card
+ state.foundations[Suit.HEARTS].pop()
+ assert state.check_win() is False

View file

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

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, 85.9k tokens in / 8.3k out
- Files: /home/daytona/workspace/fabro/.ai/card-game-spec.md, /home/daytona/workspace/fabro/status.json
- **impl_setup**: succeeded
- Model: gemini-3.5-flash, 144.1k tokens in / 6.8k 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/domain.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_domain.py, /home/daytona/workspace/fabro/status.json
- **verify_setup**: succeeded
- Model: gemini-3.5-flash, 185.9k tokens in / 2.2k 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, 179.9k tokens in / 32.0k out
- Files: /home/daytona/workspace/fabro/card-game-app/src/card_game_tui/domain.py, /home/daytona/workspace/fabro/card-game-app/tests/test_domain.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,18 @@
I have completed the verification process for the card game data structures in FreeCell Solitaire.
### Summary of Actions Taken:
1. **Compilation & Tests Executed:** Ran tests and compiled python files successfully.
- `python3 -m pytest tests/ -v` -> All 13 tests passed.
- `python3 -m py_compile main.py src/card_game_tui/*.py` -> Compiled cleanly with zero syntax/import warnings.
2. **Analysis of Core Types:** Inspected the data structures and validated:
- **Enums:** `Suit`, `Rank`, and `LocationType`.
- **Entities:** `Card` (frozen/immutable), `Position` (frozen), and `MoveRecord`.
- **State Manager:** `GameState` containing Tableaus, FreeCells, Foundations, and full validation/execution of standard FreeCell logic.
3. **Advanced Mechanics Verified:**
- Alternate-color and descending-rank move validations.
- Multi-card sequential moves with correct capacity limits based on empty free cells and empty tableaus.
- Smart, cascading auto-homing that avoids locking cards required for lower ranks of opposing colors.
- Linear undo/redo stack managing primary moves alongside automatic cascades.
4. **Output Generation:**
- Detailed findings recorded in `.ai/verify_data.md`.
- `status.json` updated with `"outcome": "succeeded"`.