fabro(01KT9YT14FHDYA4VTV9DSFG0FY): impl_data (succeeded)

Fabro-Run: 01KT9YT14FHDYA4VTV9DSFG0FY
Fabro-Completed: 6
Fabro-Checkpoint: 8b71e8ffe1

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-06-04 18:50:09 +00:00
parent b10c3badae
commit f2b42d6439
2 changed files with 580 additions and 2 deletions

View file

@ -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:

View file

@ -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