mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
fabro(01KTA1FC5W5W0BHTQSV865A1H6): impl_data (succeeded)
Fabro-Run: 01KTA1FC5W5W0BHTQSV865A1H6
Fabro-Completed: 6
Fabro-Checkpoint: f0d1c8dad3
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
6b9c7ff591
commit
0dc40560d8
4 changed files with 355 additions and 2 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"outcome": "succeeded"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue