Adds undo feature

This commit is contained in:
markmental 2026-03-26 20:42:49 -04:00
commit 2600a40280
2 changed files with 105 additions and 2 deletions

View file

@ -9,6 +9,7 @@ This branch keeps the implementation within a Python-1.5.2-friendly subset and f
- FreeCell rules with 8 tableau columns, 4 free cells, and 4 foundations
- keyboard-driven curses interface
- visual stack selection mode
- single-step undo for completed moves
- lightweight `?` hint system
- ASCII-only suit tags for old terminals and fonts
- compatibility fallbacks for weak or incomplete curses implementations
@ -19,6 +20,7 @@ This branch keeps the implementation within a Python-1.5.2-friendly subset and f
- `v` - enter or exit tableau selection mode
- `y` - pick up selected card or stack
- `p` - drop held card or stack
- `u` - undo the last completed move
- `Enter` / `Space` - alternate pick up / drop
- `?` - show a suggested move
- `f` - quick move selected card to a free cell

View file

@ -16,6 +16,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
@ -252,6 +253,15 @@ class HintMove:
self.score = score
class MoveRecord:
def __init__(self, source_type, source_index, dest_type, dest_index, cards):
self.source_type = source_type
self.source_index = source_index
self.dest_type = dest_type
self.dest_index = dest_index
self.cards = cards
class BoardLayout:
def __init__(
self,
@ -277,6 +287,7 @@ class FreeCellGame:
self.foundations = [[], [], [], []]
self.held = None
self.hint_move = None
self.move_history = []
self.visual_mode = 0
self.visual_row = 0
self.cursor_zone = "bottom"
@ -307,6 +318,63 @@ class FreeCellGame:
if had_hint:
self.request_full_repaint()
def record_move(self, source_type, source_index, dest_type, dest_index, cards):
self.move_history.append(
MoveRecord(source_type, source_index, dest_type, dest_index, cards[:])
)
def _remove_cards_from_destination(self, move):
cards = move.cards
if move.dest_type == "col":
column = self.columns[move.dest_index]
if len(column) < len(cards):
return 0
if column[-len(cards) :] != cards:
return 0
del column[-len(cards) :]
return 1
if move.dest_type == "free":
if self.freecells[move.dest_index] != cards[0]:
return 0
self.freecells[move.dest_index] = None
return 1
foundation = self.foundations[move.dest_index]
if not foundation or foundation[-1] != cards[0]:
return 0
foundation.pop()
return 1
def _restore_cards_to_source(self, move):
cards = 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):
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
del self.move_history[-1]
self._restore_cards_to_source(move)
self.clear_hint()
if self.visual_mode:
self.exit_visual_mode(None)
self.request_full_repaint()
if len(move.cards) == 1:
self.status = "Undid move of %s." % move.cards[0].short_name()
else:
self.status = "Undid move of %d cards." % len(move.cards)
def _deal_new_game(self):
deck = []
for suit in SUITS:
@ -807,6 +875,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 = "Placed %s on column %d." % (
moving_top.short_name(),
@ -829,6 +904,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 = "Placed %s in free cell %d." % (
moving_top.short_name(),
self.cursor_index + 1,
@ -845,6 +927,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",
find_suit_index(moving_top.suit.name),
cards,
)
self.status = "Moved %s to foundation." % moving_top.short_name()
self.held = None
self.request_full_repaint()
@ -868,7 +957,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 = "Moved card to free cell %d." % (free_idx + 1)
self.request_full_repaint()
return
@ -907,6 +998,13 @@ class FreeCellGame:
self.columns[source[1]].pop()
else:
self.freecells[source[1]] = None
self.record_move(
source[0],
source[1],
"foundation",
find_suit_index(card.suit.name),
[card],
)
self.status = "Moved %s to foundation." % card.short_name()
self.request_full_repaint()
@ -1261,7 +1359,7 @@ def draw_help(stdscr, max_y, max_x):
stdscr.clrtoeol()
except curses.error:
pass
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"
safe_addnstr(stdscr, y, 2, help_text, max_x - 4, pair_attr(1))
@ -1382,6 +1480,9 @@ def handle_key(game, ch):
else:
game.drop()
return 1
if ch == ord("u") or ch == ord("U"):
game.undo_last_move()
return 1
if ch == ord("?"):
game.show_hint()
return 1