From 3b3cfa9d8ab966aa5a96ab7aa2cf70d1d8bea966 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Fri, 20 Mar 2026 00:48:53 -0400 Subject: [PATCH] Adds undo feature --- README.md | 1 + linux-freecell.py | 126 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1187b83..25fdd72 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The project started as a novelty FreeCell prototype and grew into a playable, ke - `y` - pick up selected card or stack - `p` - drop held card or stack - `Enter` / `Space` - alternate pick up / drop +- `u` - undo the last completed move - `?` - show a suggested move - `f` - quick move selected card to a free cell - `d` - quick move selected card to foundation diff --git a/linux-freecell.py b/linux-freecell.py index 85e6fd6..444e7bc 100644 --- a/linux-freecell.py +++ b/linux-freecell.py @@ -10,6 +10,7 @@ Arrow keys / h j k l : Move cursor v : Enter / exit tableau visual selection y : Pick up selected card / stack p : Drop held card / stack +u : Undo the last completed move ? : Show a suggested move Space or Enter : Pick up / drop a card or stack f : Quick move selected card to a free cell @@ -109,6 +110,17 @@ class HintMove: score: int +@dataclass(frozen=True) +class MoveRecord: + """Describes one completed move that can be undone.""" + + source_type: str + source_index: int + dest_type: str + dest_index: int + cards: Tuple[Card, ...] + + class FreeCellGame: """Holds the state and rules for a small FreeCell prototype.""" @@ -118,6 +130,7 @@ class FreeCellGame: self.foundations: List[List[Card]] = [[] for _ in range(4)] self.held: Optional[HeldCards] = None self.hint_move: Optional[HintMove] = None + self.move_history: List[MoveRecord] = [] self.visual_mode = False self.visual_row = 0 @@ -134,6 +147,86 @@ class FreeCellGame: """Drop the currently remembered hint highlight.""" self.hint_move = None + def record_move( + self, + source_type: str, + source_index: int, + dest_type: str, + dest_index: int, + cards: List[Card], + ) -> None: + """Remember one completed move so it can be undone later.""" + self.move_history.append( + MoveRecord( + source_type=source_type, + source_index=source_index, + dest_type=dest_type, + dest_index=dest_index, + cards=tuple(cards), + ) + ) + + def _remove_cards_from_destination(self, move: MoveRecord) -> bool: + """Remove the recorded cards from a move destination during undo.""" + cards = list(move.cards) + + if move.dest_type == "col": + column = self.columns[move.dest_index] + if len(column) < len(cards) or column[-len(cards) :] != cards: + return False + del column[-len(cards) :] + return True + + if move.dest_type == "free": + if self.freecells[move.dest_index] != cards[0]: + return False + self.freecells[move.dest_index] = None + return True + + foundation = self.foundations[move.dest_index] + if not foundation or foundation[-1] != cards[0]: + return False + foundation.pop() + return True + + def _restore_cards_to_source(self, move: MoveRecord) -> None: + """Put cards back at the recorded move source during undo.""" + cards = list(move.cards) + + if move.source_type == "col": + self.columns[move.source_index].extend(cards) + return + + self.freecells[move.source_index] = cards[0] + + def undo_last_move(self) -> None: + """Undo the most recent completed move.""" + if self.held is not None: + self.status = "Drop or cancel the held cards before undoing a move." + return + + if not self.move_history: + self.status = "Nothing to undo." + return + + move = self.move_history[-1] + if not self._remove_cards_from_destination(move): + self.status = "Undo failed because the board no longer matches the last move." + return + + self.move_history.pop() + + self._restore_cards_to_source(move) + self.clear_hint() + if self.visual_mode: + self.exit_visual_mode() + + move_count = len(move.cards) + if move_count == 1: + self.status = f"Undid move of {move.cards[0].short_name()}." + else: + self.status = f"Undid move of {move_count} cards." + def _deal_new_game(self) -> None: """Build and shuffle a standard 52-card deck, then deal to 8 columns.""" deck: List[Card] = [Card(rank, suit) for suit in SUITS for rank in RANKS] @@ -684,6 +777,13 @@ class FreeCellGame: if self.can_place_stack_on_column(cards, self.cursor_index): self.clear_hint() self.columns[self.cursor_index].extend(cards) + self.record_move( + self.held.source_type, + self.held.source_index, + "col", + self.cursor_index, + cards, + ) if moving_count == 1: self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." else: @@ -702,6 +802,13 @@ class FreeCellGame: if self.freecells[self.cursor_index] is None: self.clear_hint() self.freecells[self.cursor_index] = moving_top + self.record_move( + self.held.source_type, + self.held.source_index, + "free", + self.cursor_index, + cards, + ) self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." self.held = None else: @@ -717,6 +824,13 @@ class FreeCellGame: self.clear_hint() foundation = self.foundation_for_suit(moving_top.suit.name) foundation.append(moving_top) + self.record_move( + self.held.source_type, + self.held.source_index, + "foundation", + SUITS.index(moving_top.suit), + cards, + ) self.status = f"Moved {moving_top.short_name()} to foundation." self.held = None else: @@ -745,7 +859,9 @@ class FreeCellGame: self.status = "Quick freecell move uses only the bottom card." return self.clear_hint() - self.freecells[free_idx] = col.pop() + moved_card = col.pop() + self.freecells[free_idx] = moved_card + self.record_move("col", self.cursor_index, "free", free_idx, [moved_card]) self.status = f"Moved card to free cell {free_idx + 1}." return @@ -801,6 +917,8 @@ class FreeCellGame: else: self.freecells[source[1]] = None + self.record_move(source[0], source[1], "foundation", SUITS.index(card.suit), [card]) + self.status = f"Moved {card.short_name()} to foundation." def is_won(self) -> bool: @@ -1107,7 +1225,7 @@ def draw_help(stdscr, max_y: int) -> None: y = max_y - 1 stdscr.move(y, 0) stdscr.clrtoeol() - help_text = "Move arrows/hjkl v select y/p/ENT/SPC move ? hint f freecell d foundation Esc cancel q quit" + help_text = "Move arrows/hjkl v select y/p/ENT/SPC move u undo ? hint f freecell d foundation Esc cancel q quit" stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) @@ -1239,6 +1357,10 @@ def handle_key(game: FreeCellGame, ch: int) -> bool: game.drop() return True + if ch in (ord("u"), ord("U")): + game.undo_last_move() + return True + if ch == ord("?"): game.show_hint() return True