From 08292b2aea807004b33cdc65e4a07c9be99ec60c Mon Sep 17 00:00:00 2001 From: markmental Date: Wed, 18 Mar 2026 19:03:02 -0400 Subject: [PATCH] Add basic hint system --- linux-freecell.py | 320 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 315 insertions(+), 5 deletions(-) diff --git a/linux-freecell.py b/linux-freecell.py index cab646c..15783fa 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 +? : Show a suggested move Space or Enter : Pick up / drop a card or stack f : Quick move selected card to a free cell d : Quick move selected card to foundation @@ -96,6 +97,18 @@ class HeldCards: source_index: int +@dataclass(frozen=True) +class HintMove: + """Describes one suggested legal move for the current board.""" + + source_type: str + source_index: int + dest_type: str + dest_index: int + cards: Tuple[Card, ...] + score: int + + class FreeCellGame: """Holds the state and rules for a small FreeCell prototype.""" @@ -104,6 +117,7 @@ class FreeCellGame: self.freecells: List[Optional[Card]] = [None] * 4 self.foundations: List[List[Card]] = [[] for _ in range(4)] self.held: Optional[HeldCards] = None + self.hint_move: Optional[HintMove] = None self.visual_mode = False self.visual_row = 0 @@ -113,9 +127,13 @@ class FreeCellGame: self.cursor_zone = "bottom" self.cursor_index = 0 - self.status = "Arrow keys move, v selects a stack, Enter picks up/drops." + self.status = "Arrow keys move, v selects a stack, ? shows a hint." self._deal_new_game() + def clear_hint(self) -> None: + """Drop the currently remembered hint highlight.""" + self.hint_move = None + 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] @@ -269,6 +287,256 @@ class FreeCellGame: return i return None + def count_hidden_low_cards(self) -> int: + """Count aces and twos that are not currently exposed.""" + count = 0 + for column in self.columns: + for card in column[:-1]: + if card.value <= 2: + count += 1 + return count + + def card_can_help_foundation(self, card: Card) -> bool: + """Return True for low cards that usually unlock early progress.""" + return card.value <= 3 + + def describe_hint_move(self, move: HintMove) -> str: + """Return a compact human-readable hint description.""" + if move.source_type == "col": + source_name = f"column {move.source_index + 1}" + else: + source_name = f"free cell {move.source_index + 1}" + + if move.dest_type == "col": + dest_name = f"column {move.dest_index + 1}" + elif move.dest_type == "free": + dest_name = f"free cell {move.dest_index + 1}" + else: + dest_name = f"{SUITS[move.dest_index].name} foundation" + + if len(move.cards) == 1: + return ( + f"move {move.cards[0].short_name()} from {source_name} to {dest_name}" + ) + + return ( + f"move {len(move.cards)} cards {move.cards[0].short_name()}..{move.cards[-1].short_name()} " + f"from {source_name} to {dest_name}" + ) + + def score_hint_move( + self, + source_type: str, + source_index: int, + dest_type: str, + dest_index: int, + cards: List[Card], + ) -> int: + """Assign a cheap deterministic score to a legal move.""" + score = 0 + moving_top = cards[0] + + source_column = self.columns[source_index] if source_type == "col" else None + source_exposes = None + hidden_low_before = self.count_hidden_low_cards() + + if source_type == "col": + assert source_column is not None + remaining = source_column[: -len(cards)] + source_exposes = remaining[-1] if remaining else None + if not remaining: + score += 250 + if source_exposes and self.card_can_help_foundation(source_exposes): + score += 320 + + if dest_type == "foundation": + score += 1000 + moving_top.value * 5 + elif dest_type == "col": + destination = self.columns[dest_index] + if destination: + score += 220 + len(cards) * 25 + if len(cards) > 1: + score += 120 + else: + score += 60 if len(cards) > 1 else -120 + elif dest_type == "free": + score -= 80 + if moving_top.value <= 3: + score += 140 + + if source_exposes and self.can_move_to_foundation(source_exposes): + score += 180 + + if moving_top.value <= 3: + score += 80 + + hidden_low_after = hidden_low_before + if source_type == "col" and source_exposes and source_exposes.value <= 2: + hidden_low_after -= 1 + score += (hidden_low_before - hidden_low_after) * 120 + + if source_type == "free" and dest_type == "col": + score += 200 + + return score + + def enumerate_hint_moves(self) -> List[HintMove]: + """Enumerate cheap one-ply legal moves for hinting.""" + moves: List[HintMove] = [] + + for col_index, column in enumerate(self.columns): + if not column: + continue + + top_card = column[-1] + if self.can_move_to_foundation(top_card): + cards = (top_card,) + moves.append( + HintMove( + source_type="col", + source_index=col_index, + dest_type="foundation", + dest_index=SUITS.index(top_card.suit), + cards=cards, + score=self.score_hint_move( + "col", + col_index, + "foundation", + SUITS.index(top_card.suit), + [top_card], + ), + ) + ) + + free_idx = self.first_empty_freecell() + if free_idx is not None: + cards = (top_card,) + moves.append( + HintMove( + source_type="col", + source_index=col_index, + dest_type="free", + dest_index=free_idx, + cards=cards, + score=self.score_hint_move( + "col", col_index, "free", free_idx, [top_card] + ), + ) + ) + + tail_start = self.valid_tail_start_index(col_index) + if tail_start is None: + continue + + for start_index in range(len(column) - 1, tail_start - 1, -1): + cards_list = column[start_index:] + if len(cards_list) > self.max_movable_cards(col_index, len(cards_list)): + continue + for dest_index in range(8): + if dest_index == col_index: + continue + if self.can_place_stack_on_column(cards_list, dest_index): + moves.append( + HintMove( + source_type="col", + source_index=col_index, + dest_type="col", + dest_index=dest_index, + cards=tuple(cards_list), + score=self.score_hint_move( + "col", col_index, "col", dest_index, cards_list + ), + ) + ) + + for free_index, card in enumerate(self.freecells): + if card is None: + continue + + if self.can_move_to_foundation(card): + moves.append( + HintMove( + source_type="free", + source_index=free_index, + dest_type="foundation", + dest_index=SUITS.index(card.suit), + cards=(card,), + score=self.score_hint_move( + "free", + free_index, + "foundation", + SUITS.index(card.suit), + [card], + ), + ) + ) + + for dest_index in range(8): + if self.can_place_stack_on_column([card], dest_index): + moves.append( + HintMove( + source_type="free", + source_index=free_index, + dest_type="col", + dest_index=dest_index, + cards=(card,), + score=self.score_hint_move( + "free", free_index, "col", dest_index, [card] + ), + ) + ) + + moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move))) + return moves + + def show_hint(self) -> None: + """Pick the best cheap legal move and remember it for highlighting.""" + if self.held is not None: + self.status = "Drop or cancel the held cards before asking for a hint." + return + + moves = self.enumerate_hint_moves() + if not moves: + self.hint_move = None + self.status = "Hint: no obvious legal move found." + return + + self.hint_move = moves[0] + self.status = f"Hint: {self.describe_hint_move(self.hint_move)}." + + def hinted_freecell(self, index: int) -> bool: + """Return True when a freecell is part of the current hint.""" + if self.hint_move is None: + return False + return ( + self.hint_move.source_type == "free" + and self.hint_move.source_index == index + ) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index) + + def hinted_foundation(self, index: int) -> bool: + """Return True when a foundation is the current hint destination.""" + return ( + self.hint_move is not None + and self.hint_move.dest_type == "foundation" + and self.hint_move.dest_index == index + ) + + def hinted_column_source_start(self, index: int) -> Optional[int]: + """Return the start row for a hinted tableau source stack.""" + if self.hint_move is None: + return None + if self.hint_move.source_type != "col" or self.hint_move.source_index != index: + return None + return len(self.columns[index]) - len(self.hint_move.cards) + + def hinted_column_destination(self, index: int) -> bool: + """Return True when a tableau column is the hinted destination.""" + return ( + self.hint_move is not None + and self.hint_move.dest_type == "col" + and self.hint_move.dest_index == index + ) + def enter_visual_mode(self) -> None: """Enter tableau visual selection mode on the current column.""" if self.held is not None: @@ -352,6 +620,7 @@ class FreeCellGame: return moving_count = len(cards) + self.clear_hint() del self.columns[self.cursor_index][-moving_count:] self.held = HeldCards( cards=cards, source_type="col", source_index=self.cursor_index @@ -369,6 +638,7 @@ class FreeCellGame: if card is None: self.status = "That free cell is empty." return + self.clear_hint() self.freecells[self.cursor_index] = None self.held = HeldCards( cards=[card], source_type="free", source_index=self.cursor_index @@ -392,6 +662,7 @@ class FreeCellGame: self.freecells[self.held.source_index] = cards[0] self.held = None + self.clear_hint() def drop(self) -> None: """ @@ -411,6 +682,7 @@ class FreeCellGame: if self.cursor_zone == "bottom": if self.can_place_stack_on_column(cards, self.cursor_index): + self.clear_hint() self.columns[self.cursor_index].extend(cards) if moving_count == 1: self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." @@ -428,6 +700,7 @@ class FreeCellGame: self.status = "Free cells accept only one card." return if self.freecells[self.cursor_index] is None: + self.clear_hint() self.freecells[self.cursor_index] = moving_top self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." self.held = None @@ -441,6 +714,7 @@ class FreeCellGame: return if self.can_move_to_foundation(moving_top): + self.clear_hint() foundation = self.foundation_for_suit(moving_top.suit.name) foundation.append(moving_top) self.status = f"Moved {moving_top.short_name()} to foundation." @@ -470,6 +744,7 @@ class FreeCellGame: if self.visual_mode: self.status = "Quick freecell move uses only the bottom card." return + self.clear_hint() self.freecells[free_idx] = col.pop() self.status = f"Moved card to free cell {free_idx + 1}." return @@ -518,6 +793,7 @@ class FreeCellGame: return foundation = self.foundation_for_suit(card.suit.name) + self.clear_hint() foundation.append(card) if source[0] == "col": @@ -561,6 +837,9 @@ def init_colors() -> None: # Pair 5: held card curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN) + # Pair 6: hint highlight + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) + def card_color_pair(card: Card) -> int: """Choose a color pair for a card.""" @@ -589,7 +868,14 @@ def draw_top_row(stdscr, game: FreeCellGame) -> None: for i in range(4): x = 2 + i * (CARD_W + 1) selected = game.cursor_zone == "top" and game.cursor_index == i - attr = curses.color_pair(4) if selected else curses.color_pair(1) + hinted = game.hinted_freecell(i) + attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted + else curses.color_pair(1) + ) card = game.freecells[i] draw_box(stdscr, TOP_Y, x, CARD_W, draw_card_label(card), attr) @@ -607,7 +893,14 @@ def draw_top_row(stdscr, game: FreeCellGame) -> None: for i in range(4): x = 40 + i * (CARD_W + 1) selected = game.cursor_zone == "top" and game.cursor_index == i + 4 - attr = curses.color_pair(4) if selected else curses.color_pair(1) + hinted = game.hinted_foundation(i) + attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted + else curses.color_pair(1) + ) foundation = game.foundations[i] top_card = foundation[-1] if foundation else None @@ -639,7 +932,14 @@ def draw_columns(stdscr, game: FreeCellGame) -> None: for col_idx in range(8): x = 2 + col_idx * 9 selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx - header_attr = curses.color_pair(4) if selected else curses.color_pair(1) + hinted_dest = game.hinted_column_destination(col_idx) + header_attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted_dest + else curses.color_pair(1) + ) draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) @@ -653,6 +953,7 @@ def draw_columns(stdscr, game: FreeCellGame) -> None: valid_tail_start = game.valid_tail_start_index(col_idx) if valid_tail_start is None: valid_tail_start = len(column) + hint_source_start = game.hinted_column_source_start(col_idx) if selected and game.visual_mode: selected_cards, selection_reason = game.selected_stack_from_column(col_idx) @@ -670,12 +971,17 @@ def draw_columns(stdscr, game: FreeCellGame) -> None: in_selected_stack = ( selected and game.visual_mode and row_idx >= selected_start ) + in_hint_source = ( + hint_source_start is not None and row_idx >= hint_source_start + ) if in_selected_stack: attr = curses.color_pair(4) if selection_reason: attr |= curses.A_DIM else: attr |= curses.A_BOLD + elif in_hint_source: + attr = curses.color_pair(6) | curses.A_BOLD elif in_valid_tail: attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM elif selected and row_idx == len(column) - 1: @@ -728,7 +1034,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: visual y/p: pick/drop Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit" + help_text = "Move: arrows/hjkl v: visual y/p: pick/drop ?: hint Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit" stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) @@ -845,6 +1151,10 @@ def handle_key(game: FreeCellGame, ch: int) -> bool: game.drop() return True + if ch == ord("?"): + game.show_hint() + return True + # Quick helpers if ch in (ord("f"), ord("F")): game.quick_to_freecell()