diff --git a/README.md b/README.md deleted file mode 100644 index 4b66fae..0000000 --- a/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# mcfreecell - -`mcfreecell` on `leg-py1.5.2` is a terminal-native FreeCell build aimed at old Python and old curses environments. - -This branch keeps the implementation within a Python-1.5.2-friendly subset and favors compatibility over modern terminal features. - -## Features - -- 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 - -## Controls - -- `Arrow keys` / `h j k l` - move cursor -- `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 -- `d` - quick move selected card to foundation -- `Esc` - cancel held move -- `q` - quit - -## Suits - -- Debian (`Deb`) -- Red Hat (`RHt`) -- Solaris (`Sol`) -- HP-UX (`HPx`) - -These are split into the two FreeCell color groups as: - -- red group: Debian, Red Hat -- black-equivalent group: Solaris, HP-UX - -On older terminals, the non-red group may render as blue, cyan, or plain monochrome depending on curses support. - -## Running - -Run with whatever Python interpreter is available on the target machine: - -```sh -python linux-freecell.py -``` - -## Terminal Notes - -Terminal and curses behavior can vary significantly on older systems. - -- `xterm` generally gives the most reliable screen redraw behavior for this branch. -- Older `konsole` builds, including KDE 2.x-era versions, may show redraw artifacts or ghosting until the screen is resized. -- The game includes several repaint fallbacks for weaker curses implementations, but some VT100-style emulator quirks are still terminal-specific. - -## Status - -This branch is intended as a playable legacy-friendly FreeCell build for old Unix-like systems and older Python environments. diff --git a/linux-freecell.py b/linux-freecell.py index 1fe99b0..85e6fd6 100644 --- a/linux-freecell.py +++ b/linux-freecell.py @@ -1,14 +1,8 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ -mcfreecell +linux_freecell.py -A curses-based FreeCell game aimed at legacy Python and terminal setups. - -This branch keeps the game within a Python 1.5.2-friendly subset: -- no dataclasses -- no type annotations -- no f-strings -- ASCII-only suit tags +A curses-based FreeCell prototype that uses Linux distro themed suits. Controls -------- @@ -16,826 +10,629 @@ 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 d : Quick move selected card to foundation Esc : Cancel held move q : Quit + +Notes +----- +- This is a prototype, not a complete polished game. +- It assumes your terminal can display the suit glyphs you choose. +- If a Nerd Font glyph does not render correctly, replace the glyph strings + in SUITS below with safer ASCII or Unicode symbols. """ import curses -import time - -try: - import random -except ImportError: - random = None - -try: - import whrandom # type: ignore -except ImportError: - whrandom = None +import random +from dataclasses import dataclass +from typing import List, Optional, Tuple -try: - enumerate -except NameError: - - def enumerate(sequence): - index = 0 - result = [] - for item in sequence: - result.append((index, item)) - index = index + 1 - return result - +# --------------------------------------------------------------------------- +# Card model +# --------------------------------------------------------------------------- RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] -RANK_VALUES = {} -_rank_index = 0 -for _rank_name in RANKS: - _rank_index = _rank_index + 1 - RANK_VALUES[_rank_name] = _rank_index -del _rank_index - - -CARD_W = 7 -TOP_Y = 1 -COL_Y = 6 - - -def find_suit_index(suit_name): - index = 0 - for suit in SUITS: - if suit.name == suit_name: - return index - index = index + 1 - return 0 - - -def count_sequence(sequence): - count = 0 - for _item in sequence: - count = count + 1 - return count - - -def safe_addnstr(stdscr, y, x, text, limit, attr): - if limit <= 0: - return - max_y, max_x = stdscr.getmaxyx() - if y < 0 or y >= max_y or x >= max_x: - return - if x < 0: - text = text[-x:] - limit = limit + x - x = 0 - if limit <= 0: - return - width = max_x - x - if width <= 0: - return - if limit > width: - limit = width - text = text[:limit] - try: - if hasattr(stdscr, "addnstr"): - stdscr.addnstr(y, x, text, limit, attr) - else: - stdscr.addstr(y, x, text, attr) - except curses.error: - pass - - -def safe_addstr(stdscr, y, x, text, attr): - safe_addnstr(stdscr, y, x, text, len(text), attr) - - -def pair_attr(index): - if hasattr(curses, "color_pair"): - try: - return curses.color_pair(index) - except: - return 0 - return 0 - - -def text_attr(name): - if hasattr(curses, name): - try: - return getattr(curses, name) - except: - return 0 - return 0 - - -def highlight_attr(): - attr = text_attr("A_REVERSE") - if attr: - return attr - attr = text_attr("A_STANDOUT") - if attr: - return attr - attr = text_attr("A_BOLD") - if attr: - return attr - attr = text_attr("A_UNDERLINE") - if attr: - return attr - return 0 - - -def pad_right(text, width): - if width <= 0: - return "" - if len(text) > width: - text = text[:width] - if len(text) < width: - text = text + (" " * (width - len(text))) - return text - - -def pad_center(text, width): - if width <= 0: - return "" - if len(text) > width: - text = text[:width] - pad = width - len(text) - if pad <= 0: - return text - left = int(pad / 2) - right = pad - left - return (" " * left) + text + (" " * right) - - -def format_elapsed_time(seconds): - if seconds < 0: - seconds = 0 - minutes = int(seconds / 60) - remaining = seconds % 60 - return "%02d:%02d" % (minutes, remaining) - - -def rounded_position(start_x, span, index, steps): - if steps <= 0: - return start_x - return start_x + int((float(span) * float(index) / float(steps)) + 0.5) - - -def legacy_random(): - if random is not None and hasattr(random, "random"): - return random.random() - if whrandom is not None and hasattr(whrandom, "random"): - return whrandom.random() - return 0.5 - - -def shuffle_in_place(sequence): - index = len(sequence) - 1 - while index > 0: - swap_index = int(legacy_random() * (index + 1)) - if swap_index < 0: - swap_index = 0 - if swap_index > index: - swap_index = index - temp = sequence[index] - sequence[index] = sequence[swap_index] - sequence[swap_index] = temp - index = index - 1 +RANK_VALUES = {rank: i + 1 for i, rank in enumerate(RANKS)} +@dataclass(frozen=True) class SuitInfo: - def __init__(self, name, tag, color_group): - self.name = name - self.tag = tag - self.color_group = color_group + """Describes one suit in our Linux-themed deck.""" + + name: str + glyph: str + color_group: str # "red" or "black" +@dataclass class Card: - def __init__(self, rank, suit): - self.rank = rank - self.suit = suit + """Represents one playing card.""" - def value(self): + rank: str + suit: SuitInfo + + @property + def value(self) -> int: return RANK_VALUES[self.rank] - def color_group(self): + @property + def color_group(self) -> str: return self.suit.color_group - def short_name(self): - return self.rank + self.suit.tag + def short_name(self) -> str: + return f"{self.rank}{self.suit.glyph}" +# --------------------------------------------------------------------------- +# Suit set +# +# Replace glyphs below with Nerd Font icons you prefer. +# These defaults are deliberately conservative so they are more likely to +# render in ordinary terminals. +# --------------------------------------------------------------------------- + SUITS = [ - SuitInfo("Debian", "Deb", "red"), - SuitInfo("Red Hat", "RHt", "red"), - SuitInfo("Solaris", "Sol", "black"), - SuitInfo("HP-UX", "HPx", "black"), + SuitInfo(name="Debian", glyph="", color_group="red"), + SuitInfo(name="Gentoo", glyph="", color_group="red"), + SuitInfo(name="Arch", glyph="󰣇", color_group="black"), + SuitInfo(name="Slackware", glyph="", color_group="black"), ] +# --------------------------------------------------------------------------- +# Board / game state +# --------------------------------------------------------------------------- + + +@dataclass class HeldCards: - def __init__(self, cards, source_type, source_index): - self.cards = cards - self.source_type = source_type - self.source_index = source_index + """Represents one or more cards currently picked up by the player.""" + + cards: List[Card] + source_type: str # "col" or "free" + source_index: int +@dataclass(frozen=True) class HintMove: - def __init__(self, source_type, source_index, dest_type, dest_index, cards, score): - self.source_type = source_type - self.source_index = source_index - self.dest_type = dest_type - self.dest_index = dest_index - self.cards = cards - self.score = score + """Describes one suggested legal move for the current board.""" - -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, - freecell_xs, - foundation_xs, - tableau_xs, - freecells_label_x, - foundations_label_x, - tableau_label_x, - ): - self.freecell_xs = freecell_xs - self.foundation_xs = foundation_xs - self.tableau_xs = tableau_xs - self.freecells_label_x = freecells_label_x - self.foundations_label_x = foundations_label_x - self.tableau_label_x = tableau_label_x + source_type: str + source_index: int + dest_type: str + dest_index: int + cards: Tuple[Card, ...] + score: int class FreeCellGame: - def __init__(self): - self.columns = [[], [], [], [], [], [], [], []] - self.freecells = [None, None, None, None] - self.foundations = [[], [], [], []] - self.held = None - self.hint_move = None - self.move_history = [] - self.visual_mode = 0 + """Holds the state and rules for a small FreeCell prototype.""" + + def __init__(self) -> None: + self.columns: List[List[Card]] = [[] for _ in range(8)] + 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 + + # Cursor zones: + # "top" -> freecells + foundations + # "bottom" -> tableau columns self.cursor_zone = "bottom" self.cursor_index = 0 - self.status = "Arrows move, v selects, ? hints." - self.start_time = time.time() - self.finished_time_seconds = None - self.needs_full_repaint = 0 + + self.status = "Arrow keys move, v selects a stack, ? shows a hint." self._deal_new_game() - def request_full_repaint(self): - self.needs_full_repaint = 1 - - def elapsed_time_seconds(self): - if self.finished_time_seconds is not None: - return self.finished_time_seconds - elapsed = int(time.time() - self.start_time) - if elapsed < 0: - elapsed = 0 - if self.is_won(): - self.finished_time_seconds = elapsed - return self.finished_time_seconds - return elapsed - - def clear_hint(self): - had_hint = self.hint_move is not None + def clear_hint(self) -> None: + """Drop the currently remembered hint highlight.""" self.hint_move = None - 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 _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] + random.shuffle(deck) - 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 + for i, card in enumerate(deck): + self.columns[i % 8].append(card) - 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] + # ----------------------------------------------------------------------- + # Rules helpers + # ----------------------------------------------------------------------- - 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: - for rank in RANKS: - deck.append(Card(rank, suit)) - shuffle_in_place(deck) - index = 0 - for card in deck: - self.columns[index % 8].append(card) - index = index + 1 - - def is_valid_tableau_sequence(self, cards): + def is_valid_tableau_sequence(self, cards: List[Card]) -> bool: + """Return True if cards form a descending alternating-color stack.""" if not cards: - return 0 - index = 0 - while index < len(cards) - 1: - upper = cards[index] - lower = cards[index + 1] - if upper.value() != lower.value() + 1: - return 0 - if upper.color_group() == lower.color_group(): - return 0 - index = index + 1 - return 1 + return False - def count_empty_freecells(self): - count = 0 - for cell in self.freecells: - if cell is None: - count = count + 1 - return count + for upper, lower in zip(cards, cards[1:]): + if upper.value != lower.value + 1: + return False + if upper.color_group == lower.color_group: + return False - def count_empty_columns(self, exclude_index): - count = 0 - index = 0 - for column in self.columns: - if index != exclude_index and not column: - count = count + 1 - index = index + 1 - return count + return True - def max_movable_cards(self, source_col, moving_count): - if moving_count == len(self.columns[source_col]): - empty_columns = self.count_empty_columns(source_col) - else: - empty_columns = self.count_empty_columns(-1) + def count_empty_freecells(self) -> int: + """Count currently open freecells.""" + return sum(1 for cell in self.freecells if cell is None) + + def count_empty_columns(self, exclude_index: Optional[int] = None) -> int: + """Count empty tableau columns, optionally excluding one column.""" + empty_count = 0 + for col_index, column in enumerate(self.columns): + if col_index == exclude_index: + continue + if not column: + empty_count += 1 + return empty_count + + def max_movable_cards(self, source_col: int, moving_count: int) -> int: + """Return the maximum legal stack size movable from a tableau column.""" + source_becomes_empty = moving_count == len(self.columns[source_col]) + empty_columns = self.count_empty_columns( + exclude_index=source_col if source_becomes_empty else None + ) return (self.count_empty_freecells() + 1) * (2**empty_columns) - def valid_tail_start_index(self, col_index): + def valid_tail_start_index(self, col_index: int) -> Optional[int]: + """Return the first row of the bottom valid descending alternating tail.""" column = self.columns[col_index] if not column: return None + start = len(column) - 1 while start > 0: upper = column[start - 1] lower = column[start] - if upper.value() != lower.value() + 1: + if upper.value != lower.value + 1: break - if upper.color_group() == lower.color_group(): + if upper.color_group == lower.color_group: break - start = start - 1 + start -= 1 + return start - def current_visual_stack(self): + def current_visual_stack(self) -> Tuple[List[Card], str]: + """Return the currently highlighted tableau stack while in visual mode.""" if self.cursor_zone != "bottom": return [], "Visual mode only works on tableau columns." + column = self.columns[self.cursor_index] if not column: return [], "That column is empty." + tail_start = self.valid_tail_start_index(self.cursor_index) if tail_start is None: return [], "That column is empty." - start_index = self.visual_row - if start_index < tail_start: - start_index = tail_start - if start_index > len(column) - 1: - start_index = len(column) - 1 + + start_index = max(tail_start, min(self.visual_row, len(column) - 1)) cards = column[start_index:] max_cards = self.max_movable_cards(self.cursor_index, len(cards)) if len(cards) > max_cards: - return cards, "Move limit is %d cards with current free space." % max_cards + return cards, f"Move limit is {max_cards} cards with current free space." + return cards, "" - def selected_stack_from_column(self, col_index): + def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]: + """Return the currently selected stack slice for a tableau column.""" column = self.columns[col_index] if not column: return [], "That column is empty." + if self.visual_mode and self.cursor_zone == "bottom": return self.current_visual_stack() - return [column[-1]], "" - def can_place_stack_on_column(self, cards, col_index): + cards = [column[-1]] + + return cards, "" + + def can_place_stack_on_column(self, cards: List[Card], col_index: int) -> bool: + """Return True if a valid held stack can be dropped on a tableau column.""" if not self.is_valid_tableau_sequence(cards): - return 0 + return False + column = self.columns[col_index] if not column: - return 1 + return True + moving_top = cards[0] destination_top = column[-1] - if destination_top.value() != moving_top.value() + 1: - return 0 - if destination_top.color_group() == moving_top.color_group(): - return 0 - return 1 + return ( + destination_top.value == moving_top.value + 1 + and destination_top.color_group != moving_top.color_group + ) - def can_move_to_foundation(self, card): + def can_place_on_column(self, card: Card, col_index: int) -> bool: + """ + FreeCell tableau rule: + - Empty column accepts any card. + - Otherwise, placed card must be one rank lower than destination top, + and opposite color group. + """ + column = self.columns[col_index] + if not column: + return True + + top = column[-1] + return top.value == card.value + 1 and top.color_group != card.color_group + + def can_move_to_foundation(self, card: Card) -> bool: + """ + Foundation rule: + - Each suit builds upward from Ace to King. + """ foundation = self.foundation_for_suit(card.suit.name) if not foundation: return card.rank == "A" + top = foundation[-1] - if top.suit.name != card.suit.name: - return 0 - return card.value() == top.value() + 1 + return top.suit.name == card.suit.name and card.value == top.value + 1 - def foundation_for_suit(self, suit_name): - return self.foundations[find_suit_index(suit_name)] + def foundation_for_suit(self, suit_name: str) -> List[Card]: + """Return the foundation pile corresponding to a given suit.""" + suit_index = next(i for i, s in enumerate(SUITS) if s.name == suit_name) + return self.foundations[suit_index] - def first_empty_freecell(self): - index = 0 - for cell in self.freecells: + def first_empty_freecell(self) -> Optional[int]: + """Return the first free freecell slot, or None if full.""" + for i, cell in enumerate(self.freecells): if cell is None: - return index - index = index + 1 + return i return None - def count_hidden_low_cards(self): + def count_hidden_low_cards(self) -> int: + """Count aces and twos that are not currently exposed.""" count = 0 for column in self.columns: - index = 0 - while index < len(column) - 1: - if column[index].value() <= 2: - count = count + 1 - index = index + 1 + for card in column[:-1]: + if card.value <= 2: + count += 1 return count - def card_can_help_foundation(self, card): - return card.value() <= 3 + 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): + def describe_hint_move(self, move: HintMove) -> str: + """Return a compact human-readable hint description.""" if move.source_type == "col": - source_name = "column %d" % (move.source_index + 1) + source_name = f"column {move.source_index + 1}" else: - source_name = "free cell %d" % (move.source_index + 1) + source_name = f"free cell {move.source_index + 1}" + if move.dest_type == "col": - dest_name = "column %d" % (move.dest_index + 1) + dest_name = f"column {move.dest_index + 1}" elif move.dest_type == "free": - dest_name = "free cell %d" % (move.dest_index + 1) + dest_name = f"free cell {move.dest_index + 1}" else: - dest_name = "%s foundation" % SUITS[move.dest_index].name + dest_name = f"{SUITS[move.dest_index].name} foundation" + if len(move.cards) == 1: - return "move %s from %s to %s" % ( - move.cards[0].short_name(), - source_name, - dest_name, + return ( + f"move {move.cards[0].short_name()} from {source_name} to {dest_name}" ) - return "move %d cards %s..%s from %s to %s" % ( - len(move.cards), - move.cards[0].short_name(), - move.cards[-1].short_name(), - source_name, - 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, source_index, dest_type, dest_index, cards): + 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": - source_column = self.columns[source_index] - remaining = source_column[: len(source_column) - len(cards)] - if remaining: - source_exposes = remaining[-1] - else: - score = score + 250 - if source_exposes is not None and self.card_can_help_foundation( - source_exposes - ): - score = score + 320 + 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 = score + 1000 + moving_top.value() * 5 + score += 1000 + moving_top.value * 5 elif dest_type == "col": destination = self.columns[dest_index] if destination: - score = score + 220 + len(cards) * 25 + score += 220 + len(cards) * 25 if len(cards) > 1: - score = score + 120 + score += 120 else: - if len(cards) > 1: - score = score + 60 - else: - score = score - 120 + score += 60 if len(cards) > 1 else -120 elif dest_type == "free": - score = score - 80 - if moving_top.value() <= 3: - score = score + 140 - if source_exposes is not None and self.can_move_to_foundation(source_exposes): - score = score + 180 - if moving_top.value() <= 3: - score = score + 80 + 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 is not None - and source_exposes.value() <= 2 - ): - hidden_low_after = hidden_low_after - 1 - score = score + (hidden_low_before - hidden_low_after) * 120 + 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 = score + 200 + score += 200 + return score - def enumerate_hint_moves(self): - moves = [] - col_index = 0 - for column in self.columns: - if column: - top_card = column[-1] - suit_index = find_suit_index(top_card.suit.name) - if self.can_move_to_foundation(top_card): - moves.append( - HintMove( - "col", - col_index, - "foundation", - suit_index, - [top_card], - self.score_hint_move( - "col", col_index, "foundation", suit_index, [top_card] - ), - ) - ) - free_idx = self.first_empty_freecell() - if free_idx is not None: - moves.append( - HintMove( - "col", - col_index, - "free", - free_idx, - [top_card], - 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 not None: - start_index = len(column) - 1 - while start_index >= tail_start: - cards_list = column[start_index:] - if len(cards_list) <= self.max_movable_cards( - col_index, len(cards_list) - ): - dest_index = 0 - while dest_index < 8: - if ( - dest_index != col_index - and self.can_place_stack_on_column( - cards_list, dest_index - ) - ): - moves.append( - HintMove( - "col", - col_index, - "col", - dest_index, - cards_list[:], - self.score_hint_move( - "col", - col_index, - "col", - dest_index, - cards_list, - ), - ) - ) - dest_index = dest_index + 1 - start_index = start_index - 1 - col_index = col_index + 1 + def enumerate_hint_moves(self) -> List[HintMove]: + """Enumerate cheap one-ply legal moves for hinting.""" + moves: List[HintMove] = [] - free_index = 0 - for card in self.freecells: - if card is not None: - suit_index = find_suit_index(card.suit.name) - if self.can_move_to_foundation(card): - moves.append( - HintMove( - "free", - free_index, + 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", - suit_index, - [card], - self.score_hint_move( - "free", free_index, "foundation", suit_index, [card] - ), - ) + SUITS.index(top_card.suit), + [top_card], + ), ) - dest_index = 0 - while dest_index < 8: - if self.can_place_stack_on_column([card], dest_index): + ) + + 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( - "free", - free_index, - "col", - dest_index, - [card], - self.score_hint_move( - "free", free_index, "col", dest_index, [card] + 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 ), ) ) - dest_index = dest_index + 1 - free_index = free_index + 1 - decorated = [] - for move in moves: - decorated.append((0 - move.score, self.describe_hint_move(move), move)) - decorated.sort() - result = [] - for item in decorated: - result.append(item[2]) - return result + for free_index, card in enumerate(self.freecells): + if card is None: + continue - def show_hint(self): + 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." - self.request_full_repaint() return - self.hint_move = moves[0] - self.status = "Hint: %s." % self.describe_hint_move(self.hint_move) - self.request_full_repaint() - def hinted_freecell(self, index): + 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 0 - if ( + return False + return ( self.hint_move.source_type == "free" and self.hint_move.source_index == index - ): - return 1 - if self.hint_move.dest_type == "free" and self.hint_move.dest_index == index: - return 1 - return 0 + ) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index) - def hinted_foundation(self, index): - if self.hint_move is None: - return 0 - if ( - self.hint_move.dest_type == "foundation" + 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 - ): - return 1 - return 0 + ) - def hinted_column_source_start(self, 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): - if self.hint_move is None: - return 0 - if self.hint_move.dest_type == "col" and self.hint_move.dest_index == index: - return 1 - return 0 + 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): + def enter_visual_mode(self) -> None: + """Enter tableau visual selection mode on the current column.""" if self.held is not None: self.status = "Drop the held cards before selecting a new stack." return if self.cursor_zone != "bottom": self.status = "Visual mode only works on tableau columns." return + column = self.columns[self.cursor_index] if not column: self.status = "That column is empty." return - self.visual_mode = 1 + + self.visual_mode = True self.visual_row = len(column) - 1 cards, reason = self.current_visual_stack() if reason: - self.status = "Visual: %d cards selected. %s" % (len(cards), reason) - elif len(cards) == 1: - self.status = "Visual: 1 card selected." + self.status = f"Visual: {len(cards)} cards selected. {reason}" else: - self.status = "Visual: %d cards selected." % len(cards) + self.status = ( + f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." + ) - def exit_visual_mode(self, message): - self.visual_mode = 0 + def exit_visual_mode(self, message: Optional[str] = None) -> None: + """Leave tableau visual selection mode.""" + self.visual_mode = False self.visual_row = 0 if message is not None: self.status = message - def move_visual_selection(self, delta): + def move_visual_selection(self, delta: int) -> None: + """Adjust the visual selection start within the valid tail.""" if not self.visual_mode: self.status = "Press v to start selecting a stack." return + column = self.columns[self.cursor_index] tail_start = self.valid_tail_start_index(self.cursor_index) if not column or tail_start is None: self.exit_visual_mode("That column is empty.") return - old_row = self.visual_row - self.visual_row = self.visual_row + delta - if self.visual_row < tail_start: - self.visual_row = tail_start - if self.visual_row > len(column) - 1: - self.visual_row = len(column) - 1 - cards, reason = self.current_visual_stack() - if self.visual_row == old_row: - if len(cards) == 1: - self.status = "Visual selection stays at 1 card." - else: - self.status = "Visual selection stays at %d cards." % len(cards) - return - if reason: - self.status = "Visual: %d cards selected. %s" % (len(cards), reason) - elif len(cards) == 1: - self.status = "Visual: 1 card selected." - else: - self.status = "Visual: %d cards selected." % len(cards) - def pick_up(self): + old_row = self.visual_row + self.visual_row = max(tail_start, min(len(column) - 1, self.visual_row + delta)) + cards, reason = self.current_visual_stack() + + if self.visual_row == old_row: + self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}." + return + + if reason: + self.status = f"Visual: {len(cards)} cards selected. {reason}" + else: + self.status = ( + f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." + ) + + # ----------------------------------------------------------------------- + # Movement / interactions + # ----------------------------------------------------------------------- + + def pick_up(self) -> None: + """ + Pick up the bottom visible card from either: + - a tableau column + - a freecell slot + """ if self.held is not None: self.status = "You are already holding cards." return + if self.cursor_zone == "bottom": cards, reason = self.selected_stack_from_column(self.cursor_index) if not cards: self.status = reason return + if reason: self.status = reason return + moving_count = len(cards) self.clear_hint() del self.columns[self.cursor_index][-moving_count:] - self.held = HeldCards(cards[:], "col", self.cursor_index) + self.held = HeldCards( + cards=cards, source_type="col", source_index=self.cursor_index + ) if moving_count == 1: - self.status = "Picked up %s from column %d." % ( - cards[0].short_name(), - self.cursor_index + 1, - ) + self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}." else: - self.status = "Picked up %d cards from column %d." % ( - moving_count, - self.cursor_index + 1, - ) - self.request_full_repaint() - self.exit_visual_mode(None) + self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." + self.exit_visual_mode() return + + # top zone: indexes 0..3 freecells, 4..7 foundations if self.cursor_index < 4: card = self.freecells[self.cursor_index] if card is None: @@ -843,111 +640,102 @@ class FreeCellGame: return self.clear_hint() self.freecells[self.cursor_index] = None - self.held = HeldCards([card], "free", self.cursor_index) - self.status = "Picked up %s from free cell %d." % ( - card.short_name(), - self.cursor_index + 1, + self.held = HeldCards( + cards=[card], source_type="free", source_index=self.cursor_index + ) + self.status = ( + f"Picked up {card.short_name()} from free cell {self.cursor_index + 1}." ) - self.request_full_repaint() return + self.status = "Cannot pick up directly from foundations." - def restore_held(self): + def restore_held(self) -> None: + """Put the held card back where it came from if a drop fails.""" if self.held is None: return + cards = self.held.cards if self.held.source_type == "col": self.columns[self.held.source_index].extend(cards) elif self.held.source_type == "free": self.freecells[self.held.source_index] = cards[0] + self.held = None self.clear_hint() - self.request_full_repaint() - def drop(self): + def drop(self) -> None: + """ + Drop the held card onto: + - a column + - a freecell + - a foundation + depending on the current cursor position and legal rules. + """ if self.held is None: self.status = "You are not holding a card." return + cards = self.held.cards moving_count = len(cards) moving_top = cards[0] + 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) - 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(), - self.cursor_index + 1, - ) + self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." else: - self.status = "Placed %d cards on column %d." % ( - moving_count, - self.cursor_index + 1, - ) + self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}." self.held = None - self.request_full_repaint() else: self.status = "Illegal move for tableau column." return + + # top zone if self.cursor_index < 4: + # drop to freecell if moving_count != 1: 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.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, - ) + self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." self.held = None - self.request_full_repaint() else: self.status = "That free cell is occupied." return + + # drop to foundation if moving_count != 1: self.status = "Foundations accept only one card at a time." 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.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.status = f"Moved {moving_top.short_name()} to foundation." self.held = None - self.request_full_repaint() else: self.status = "Illegal move for foundation." - def quick_to_freecell(self): + def quick_to_freecell(self) -> None: + """ + Move the currently selected bottom/freecell card into the first open + freecell, if possible. + """ if self.held is not None: self.status = "Drop the held card first." return + free_idx = self.first_empty_freecell() if free_idx is None: self.status = "No free freecells available." return + if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if not col: @@ -957,23 +745,30 @@ class FreeCellGame: self.status = "Quick freecell move uses only the bottom card." return self.clear_hint() - 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() + self.freecells[free_idx] = col.pop() + self.status = f"Moved card to free cell {free_idx + 1}." return + if self.cursor_zone == "top" and self.cursor_index < 4: self.status = "Already on a free cell." return + self.status = "Select a tableau card first." - def quick_to_foundation(self): + def quick_to_foundation(self) -> None: + """ + Move the selected card directly to foundation if legal. + Works from: + - column bottoms + - freecells + """ if self.held is not None: self.status = "Drop the held card first." return - card = None - source = None + + card: Optional[Card] = None + source: Optional[Tuple[str, int]] = None + if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if col: @@ -982,291 +777,267 @@ class FreeCellGame: return card = col[-1] source = ("col", self.cursor_index) + elif self.cursor_zone == "top" and self.cursor_index < 4: - card = self.freecells[self.cursor_index] - if card is not None: + fc = self.freecells[self.cursor_index] + if fc is not None: + card = fc source = ("free", self.cursor_index) + if card is None or source is None: self.status = "No movable card selected." return + if not self.can_move_to_foundation(card): self.status = "That card cannot go to foundation yet." return + + foundation = self.foundation_for_suit(card.suit.name) self.clear_hint() - self.foundation_for_suit(card.suit.name).append(card) + foundation.append(card) + if source[0] == "col": 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() - def is_won(self): - total = 0 - for foundation in self.foundations: - total = total + len(foundation) - return total == 52 + self.status = f"Moved {card.short_name()} to foundation." + + def is_won(self) -> bool: + """Return True when all cards are in foundations.""" + return sum(len(f) for f in self.foundations) == 52 -def spread_positions(count, start_x, end_x): - positions = [] +# --------------------------------------------------------------------------- +# UI rendering +# --------------------------------------------------------------------------- + +CARD_W = 7 +TOP_Y = 1 +COL_Y = 6 + + +@dataclass(frozen=True) +class BoardLayout: + """Computed horizontal positions for the current terminal width.""" + + freecell_xs: Tuple[int, ...] + foundation_xs: Tuple[int, ...] + tableau_xs: Tuple[int, ...] + freecells_label_x: int + foundations_label_x: int + tableau_label_x: int + + +def spread_positions(count: int, start_x: int, end_x: int) -> Tuple[int, ...]: + """Evenly spread fixed-width slots between two inclusive bounds.""" if count <= 1: - return [start_x] - span = end_x - start_x - if span < 0: - span = 0 - steps = count - 1 - index = 0 - while index < count: - positions.append(rounded_position(start_x, span, index, steps)) - index = index + 1 - return positions + return (start_x,) + + span = max(0, end_x - start_x) + step = span / (count - 1) + positions = [] + for index in range(count): + positions.append(int(round(start_x + step * index))) + return tuple(positions) -def compute_board_layout(max_x): +def compute_board_layout(max_x: int) -> BoardLayout: + """Compute a centered, width-aware board layout.""" left_margin = 2 - right_margin = max_x - CARD_W - 2 - if right_margin < left_margin: - right_margin = left_margin + right_margin = max(left_margin, max_x - CARD_W - 2) + if max_x < 72: - top_positions = spread_positions(8, left_margin, right_margin) + top_start = left_margin + top_end = max(top_start, max_x - CARD_W - 2) + top_positions = spread_positions(8, top_start, top_end) freecell_xs = top_positions[:4] - foundation_xs = top_positions[4:8] + foundation_xs = top_positions[4:] else: - center_gap = max_x / 6 - if center_gap < 10: - center_gap = 10 - if center_gap > 20: - center_gap = 20 - left_end = (max_x / 2) - (center_gap / 2) - CARD_W - right_start = (max_x / 2) + (center_gap / 2) - if left_end < left_margin: - left_end = left_margin - if right_start > right_margin: - right_start = right_margin + center_gap = max(10, min(20, max_x // 6)) + left_end = max(left_margin, (max_x // 2) - (center_gap // 2) - CARD_W) + right_start = min(right_margin, (max_x // 2) + (center_gap // 2)) freecell_xs = spread_positions(4, left_margin, left_end) foundation_xs = spread_positions(4, right_start, right_margin) + tableau_xs = spread_positions(8, left_margin, right_margin) + freecells_label_x = freecell_xs[0] + foundations_label_x = foundation_xs[0] + tableau_label_x = tableau_xs[0] + return BoardLayout( - freecell_xs, - foundation_xs, - tableau_xs, - freecell_xs[0], - foundation_xs[0], - tableau_xs[0], + freecell_xs=freecell_xs, + foundation_xs=foundation_xs, + tableau_xs=tableau_xs, + freecells_label_x=freecells_label_x, + foundations_label_x=foundations_label_x, + tableau_label_x=tableau_label_x, ) -def init_colors(): - if not hasattr(curses, "start_color"): - return - if not hasattr(curses, "init_pair"): - return - try: - curses.start_color() - except: - return - if hasattr(curses, "use_default_colors"): - try: - curses.use_default_colors() - except: - pass - if not hasattr(curses, "COLOR_WHITE"): - return - try: - curses.init_pair(1, curses.COLOR_WHITE, -1) - curses.init_pair(2, curses.COLOR_RED, -1) - curses.init_pair(3, curses.COLOR_CYAN, -1) - curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) - curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN) - curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) - except: - pass +def init_colors() -> None: + """Initialize curses color pairs.""" + curses.start_color() + curses.use_default_colors() + + # Pair 1: regular text + curses.init_pair(1, curses.COLOR_WHITE, -1) + + # Pair 2: red cards / symbols + curses.init_pair(2, curses.COLOR_RED, -1) + + # Pair 3: black-group cards rendered as cyan for visibility on dark terms + curses.init_pair(3, curses.COLOR_CYAN, -1) + + # Pair 4: highlighted cursor + curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) + + # 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): - if card.color_group() == "red": - return 2 - return 3 +def card_color_pair(card: Card) -> int: + """Choose a color pair for a card.""" + return 2 if card.color_group == "red" else 3 -def draw_box(stdscr, y, x, w, label, attr): - inner = pad_center(label[: w - 2], w - 2) - text = "[" + inner + "]" - safe_addnstr(stdscr, y, x, text, w, attr) +def draw_box(stdscr, y: int, x: int, w: int, label: str, attr: int) -> None: + """Draw a simple one-line boxed area.""" + text = f"[{label:^{w - 2}}]" + stdscr.addnstr(y, x, text.ljust(w), w, attr) -def draw_card_label(card): +def draw_card_label(card: Optional[Card]) -> str: + """Return a compact label used to display a card or empty slot.""" if card is None: return " " return card.short_name() -def draw_top_row(stdscr, game, layout): - safe_addstr( - stdscr, +def draw_top_row(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: + """Draw freecells and foundations.""" + stdscr.addstr( TOP_Y, layout.freecells_label_x, "Free Cells", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - safe_addstr( - stdscr, + stdscr.addstr( TOP_Y, layout.foundations_label_x, "Foundations", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - index = 0 - while index < 4: - x = layout.freecell_xs[index] - safe_addstr(stdscr, TOP_Y + 1, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, TOP_Y + 1, x, " " * CARD_W, CARD_W, pair_attr(1)) - selected = game.cursor_zone == "top" and game.cursor_index == index - hinted = game.hinted_freecell(index) - marker = " " - if selected: - attr = pair_attr(4) | highlight_attr() - marker = ">" - elif hinted: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" - else: - attr = pair_attr(1) - safe_addstr(stdscr, TOP_Y + 1, x - 1, marker, attr) - card = game.freecells[index] + # Freecells at indexes 0..3 + for i in range(4): + x = layout.freecell_xs[i] + selected = game.cursor_zone == "top" and game.cursor_index == i + 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 + 1, x, CARD_W, draw_card_label(card), attr) - if card is not None: - safe_addnstr( - stdscr, + + if card: + stdscr.addnstr( TOP_Y + 1, x + 1, card.short_name(), CARD_W - 2, - pair_attr(card_color_pair(card)), + curses.color_pair(card_color_pair(card)), ) - index = index + 1 - index = 0 - while index < 4: - x = layout.foundation_xs[index] - safe_addstr(stdscr, TOP_Y + 1, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, TOP_Y + 1, x, " " * CARD_W, CARD_W, pair_attr(1)) - selected = game.cursor_zone == "top" and game.cursor_index == index + 4 - hinted = game.hinted_foundation(index) - marker = " " - if selected: - attr = pair_attr(4) | highlight_attr() - marker = ">" - elif hinted: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" - else: - attr = pair_attr(1) - safe_addstr(stdscr, TOP_Y + 1, x - 1, marker, attr) - foundation = game.foundations[index] - if foundation: - top_card = foundation[-1] - label = draw_card_label(top_card) - else: - top_card = None - label = SUITS[index].tag + # Foundations at indexes 4..7 in cursor space + for i in range(4): + x = layout.foundation_xs[i] + selected = game.cursor_zone == "top" and game.cursor_index == i + 4 + 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 + label = draw_card_label(top_card) if top_card else SUITS[i].glyph draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr) - if top_card is not None: - safe_addnstr( - stdscr, + + if top_card: + stdscr.addnstr( TOP_Y + 1, x + 1, top_card.short_name(), CARD_W - 2, - pair_attr(card_color_pair(top_card)), + curses.color_pair(card_color_pair(top_card)), ) else: - if SUITS[index].color_group == "red": - dummy_color = 2 - else: - dummy_color = 3 - safe_addnstr( - stdscr, - TOP_Y + 1, - x + 1, - SUITS[index].tag, - CARD_W - 2, - pair_attr(dummy_color), + # Show suit glyph placeholder in foundation slot color + dummy_color = 2 if SUITS[i].color_group == "red" else 3 + stdscr.addnstr( + TOP_Y + 1, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color) ) - index = index + 1 -def draw_columns(stdscr, game, layout): - safe_addstr( - stdscr, +def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: + """Draw the tableau columns.""" + stdscr.addstr( COL_Y - 1, layout.tableau_label_x, "Tableau", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - max_height = 0 - for column in game.columns: - if len(column) > max_height: - max_height = len(column) - col_idx = 0 - while col_idx < 8: + max_height = max((len(col) for col in game.columns), default=0) + + for col_idx in range(8): x = layout.tableau_xs[col_idx] - safe_addstr(stdscr, COL_Y, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, COL_Y, x, " " * CARD_W, CARD_W, pair_attr(1)) selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx hinted_dest = game.hinted_column_destination(col_idx) - header_label = str(col_idx + 1) - if selected: - header_attr = pair_attr(4) | highlight_attr() - header_label = ">" + header_label - elif hinted_dest: - header_attr = pair_attr(6) | text_attr("A_BOLD") - header_label = "+" + header_label - else: - header_attr = pair_attr(1) - draw_box(stdscr, COL_Y, x, CARD_W, header_label, header_attr) + header_attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted_dest + else curses.color_pair(1) + ) - clear_y = COL_Y + 1 - clear_limit = COL_Y + 8 + max_height - while clear_y < clear_limit: - safe_addstr(stdscr, clear_y, x - 1, " ", pair_attr(1)) - safe_addnstr( - stdscr, clear_y, x + 1, " " * (CARD_W - 2), CARD_W - 2, pair_attr(1) - ) - clear_y = clear_y + 1 + draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) column = game.columns[col_idx] if not column: - safe_addstr(stdscr, COL_Y + 1, x + 2, ".", pair_attr(1)) - col_idx = col_idx + 1 + stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1)) continue - selected_cards = [] + selected_cards: List[Card] = [] selection_reason = "" 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) - if selected_cards: - selected_start = len(column) - len(selected_cards) - else: - selected_start = len(column) - row_idx = 0 - for card in column: + selected_start = ( + len(column) - len(selected_cards) if selected_cards else len(column) + ) + + for row_idx, card in enumerate(column): y = COL_Y + 1 + row_idx + in_valid_tail = ( selected and game.visual_mode and row_idx >= valid_tail_start ) @@ -1276,350 +1047,247 @@ def draw_columns(stdscr, game, layout): in_hint_source = ( hint_source_start is not None and row_idx >= hint_source_start ) - marker = " " if in_selected_stack: - attr = pair_attr(4) | highlight_attr() - marker = ">" + attr = curses.color_pair(4) if selection_reason: - attr = attr | text_attr("A_DIM") + attr |= curses.A_DIM else: - attr = attr | text_attr("A_BOLD") + attr |= curses.A_BOLD elif in_hint_source: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" + attr = curses.color_pair(6) | curses.A_BOLD elif in_valid_tail: - attr = pair_attr(card_color_pair(card)) | text_attr("A_DIM") + attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM elif selected and row_idx == len(column) - 1: - attr = pair_attr(4) | highlight_attr() - marker = ">" + attr = curses.color_pair(4) else: - attr = pair_attr(card_color_pair(card)) - safe_addstr(stdscr, y, x - 1, marker, attr) - safe_addnstr( - stdscr, - y, - x + 1, - pad_right(card.short_name(), CARD_W - 2), - CARD_W - 2, - attr, + attr = curses.color_pair(card_color_pair(card)) + + stdscr.addnstr( + y, x + 1, card.short_name().ljust(CARD_W - 2), CARD_W - 2, attr ) - row_idx = row_idx + 1 - col_idx = col_idx + 1 - - extra_y = COL_Y + 2 + max_height - while extra_y < COL_Y + 8 + max_height: - try: - stdscr.move(extra_y, 0) - stdscr.clrtoeol() - except curses.error: - pass - extra_y = extra_y + 1 - - -def draw_held(stdscr, game, max_y, max_x): - y = max_y - 3 - try: - stdscr.move(y, 0) + # Clear a few lines under the tallest used area to avoid visual leftovers. + for extra_y in range(COL_Y + 2 + max_height, COL_Y + 8 + max_height): + stdscr.move(extra_y, 0) stdscr.clrtoeol() - except curses.error: - pass + + +def draw_held(stdscr, game: FreeCellGame, max_y: int) -> None: + """Draw the currently held card stack near the bottom of the screen.""" + y = max_y - 3 + stdscr.move(y, 0) + stdscr.clrtoeol() + if game.held is None: - safe_addstr(stdscr, y, 2, "Held: (none)", pair_attr(1)) + stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1)) return - safe_addstr(stdscr, y, 2, "Held: ", pair_attr(1)) + + stdscr.addstr(y, 2, "Held: ", curses.color_pair(1)) cards = game.held.cards if len(cards) == 1: - safe_addstr( - stdscr, y, 8, cards[0].short_name(), pair_attr(5) | text_attr("A_BOLD") - ) + stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD) return - summary = "%d cards (%s..%s)" % ( - len(cards), - cards[0].short_name(), - cards[-1].short_name(), + + summary = f"{len(cards)} cards ({cards[0].short_name()}..{cards[-1].short_name()})" + stdscr.addnstr( + y, 8, summary, max(0, curses.COLS - 10), curses.color_pair(5) | curses.A_BOLD ) - safe_addnstr(stdscr, y, 8, summary, max_x - 10, pair_attr(5) | text_attr("A_BOLD")) -def draw_status(stdscr, game, max_y, max_x): +def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None: + """Draw a one-line status message.""" y = max_y - 2 - try: - stdscr.move(y, 0) - stdscr.clrtoeol() - except curses.error: - pass - safe_addnstr(stdscr, y, 2, game.status, max_x - 4, pair_attr(1)) + stdscr.move(y, 0) + stdscr.clrtoeol() + stdscr.addnstr(y, 2, game.status, max(0, curses.COLS - 4), curses.color_pair(1)) -def draw_help(stdscr, max_y, max_x): +def draw_help(stdscr, max_y: int) -> None: + """Draw a compact help line.""" y = max_y - 1 - try: - stdscr.move(y, 0) - stdscr.clrtoeol() - except curses.error: - pass - 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)) + 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" + stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) -def render(stdscr, game): +def render(stdscr, game: FreeCellGame) -> None: + """Redraw the full screen.""" stdscr.erase() max_y, max_x = stdscr.getmaxyx() layout = compute_board_layout(max_x) - elapsed_text = "Time: %s" % format_elapsed_time(game.elapsed_time_seconds()) - title = "mcfreecell 1.5.2" + + title = "mcfreecell 0.1" if len(title) > max_x - 4: title = "mcfreecell" - title_x = int((max_x - len(title)) / 2) - if title_x < 2: - title_x = 2 - safe_addnstr( - stdscr, + title_x = max(2, (max_x - len(title)) // 2) + stdscr.addnstr( 0, title_x, title, - max_x - title_x - 1, - pair_attr(1) | text_attr("A_BOLD"), + max(0, max_x - title_x - 1), + curses.color_pair(1) | curses.A_BOLD, ) - timer_x = max_x - len(elapsed_text) - 2 - if timer_x < 2: - timer_x = 2 - if timer_x > title_x + len(title): - safe_addnstr( - stdscr, - 0, - timer_x, - elapsed_text, - max_x - timer_x - 1, - pair_attr(1) | text_attr("A_BOLD"), - ) + if game.visual_mode: visual_text = "-- VISUAL --" - visual_x = title_x + len(title) + 3 - if visual_x + len(visual_text) < max_x - 1: - safe_addstr( - stdscr, 0, visual_x, visual_text, pair_attr(5) | text_attr("A_BOLD") + visual_x = min(max_x - len(visual_text) - 2, title_x + len(title) + 3) + if visual_x > title_x + len(title): + stdscr.addstr( + 0, visual_x, visual_text, curses.color_pair(5) | curses.A_BOLD ) + draw_top_row(stdscr, game, layout) draw_columns(stdscr, game, layout) - draw_held(stdscr, game, max_y, max_x) - draw_status(stdscr, game, max_y, max_x) - draw_help(stdscr, max_y, max_x) + draw_held(stdscr, game, max_y) + draw_status(stdscr, game, max_y) + draw_help(stdscr, max_y) + if game.is_won(): - win_text = "You won in %s." % format_elapsed_time(game.elapsed_time_seconds()) - safe_addnstr( - stdscr, 3, 2, win_text, max_x - 4, pair_attr(5) | text_attr("A_BOLD") - ) + stdscr.addstr(3, 2, "You won.", curses.color_pair(5) | curses.A_BOLD) + stdscr.refresh() -def move_cursor(game, dy, dx): +# --------------------------------------------------------------------------- +# Input handling +# --------------------------------------------------------------------------- + + +def move_cursor(game: FreeCellGame, dy: int, dx: int) -> None: + """ + Move between the top zone and bottom zone, and across valid slot ranges. + """ if dy < 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(-1) return game.cursor_zone = "top" - if game.cursor_index > 7: - game.cursor_index = 7 + game.cursor_index = min(game.cursor_index, 7) return + if dy > 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(1) return game.cursor_zone = "bottom" - if game.cursor_index > 7: - game.cursor_index = 7 + game.cursor_index = min(game.cursor_index, 7) return + + # Horizontal motion if game.visual_mode and dx != 0: game.exit_visual_mode("Visual selection cancelled.") return - game.cursor_index = game.cursor_index + dx - if game.cursor_index < 0: - game.cursor_index = 0 - if game.cursor_index > 7: - game.cursor_index = 7 + + if game.cursor_zone == "top": + game.cursor_index = max(0, min(7, game.cursor_index + dx)) + else: + game.cursor_index = max(0, min(7, game.cursor_index + dx)) -def handle_key(game, ch): - if ch == ord("q") or ch == ord("Q"): - return 0 - if ch == curses.KEY_LEFT or ch == ord("h"): +def handle_key(game: FreeCellGame, ch: int) -> bool: + """ + Handle one input event. + Returns False if the game should exit. + """ + if ch in (ord("q"), ord("Q")): + return False + + # Movement + if ch in (curses.KEY_LEFT, ord("h")): move_cursor(game, 0, -1) - return 1 - if ch == curses.KEY_RIGHT or ch == ord("l"): + return True + + if ch in (curses.KEY_RIGHT, ord("l")): move_cursor(game, 0, 1) - return 1 - if ch == curses.KEY_UP or ch == ord("k"): + return True + + if ch in (curses.KEY_UP, ord("k")): move_cursor(game, -1, 0) - return 1 - if ch == curses.KEY_DOWN or ch == ord("j"): + return True + + if ch in (curses.KEY_DOWN, ord("j")): move_cursor(game, 1, 0) - return 1 - if ch == ord("v") or ch == ord("V"): + return True + + if ch in (ord("v"), ord("V")): if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") else: game.enter_visual_mode() - return 1 - if ch == curses.KEY_ENTER or ch == 10 or ch == 13 or ch == ord(" "): + return True + + # Pick up / drop + if ch in (curses.KEY_ENTER, 10, 13, ord(" ")): if game.held is None: game.pick_up() else: game.drop() - return 1 - if ch == ord("y") or ch == ord("Y"): + return True + + if ch in (ord("y"), ord("Y")): if game.held is not None: game.status = "You are already holding cards." else: game.pick_up() - return 1 - if ch == ord("p") or ch == ord("P"): + return True + + if ch in (ord("p"), ord("P")): if game.held is None: game.status = "You are not holding any cards." else: game.drop() - return 1 - if ch == ord("u") or ch == ord("U"): - game.undo_last_move() - return 1 + return True + if ch == ord("?"): game.show_hint() - return 1 - if ch == ord("f") or ch == ord("F"): + return True + + # Quick helpers + if ch in (ord("f"), ord("F")): game.quick_to_freecell() - return 1 - if ch == ord("d") or ch == ord("D"): + return True + + if ch in (ord("d"), ord("D")): game.quick_to_foundation() - return 1 + return True + + # Escape: cancel held card by restoring it if ch == 27: if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") elif game.held is not None: game.restore_held() game.status = "Cancelled move." - return 1 - return 1 + return True + + return True -def curses_main(stdscr): - idle_ticks = 0 - poll_delay = 0 - if hasattr(curses, "curs_set"): - try: - curses.curs_set(0) - except: - pass - if hasattr(stdscr, "keypad"): - try: - stdscr.keypad(1) - except: - pass - if hasattr(stdscr, "timeout"): - try: - stdscr.timeout(100) - except: - pass - elif hasattr(stdscr, "nodelay"): - try: - stdscr.nodelay(1) - poll_delay = 1 - except: - poll_delay = 0 +# --------------------------------------------------------------------------- +# Main curses loop +# --------------------------------------------------------------------------- + + +def curses_main(stdscr) -> None: + """Main UI entry point.""" + curses.curs_set(0) + stdscr.keypad(True) + init_colors() + game = FreeCellGame() - while 1: - if game.needs_full_repaint: - game.needs_full_repaint = 0 - if hasattr(stdscr, "redrawwin"): - try: - stdscr.redrawwin() - except: - pass - elif hasattr(stdscr, "touchwin"): - try: - stdscr.touchwin() - except: - pass - elif hasattr(stdscr, "clearok"): - try: - stdscr.clearok(1) - except: - pass + + while True: render(stdscr, game) ch = stdscr.getch() - if ch == -1: - idle_ticks = idle_ticks + 1 - if idle_ticks >= 5: - idle_ticks = 0 - if hasattr(stdscr, "redrawwin"): - try: - stdscr.redrawwin() - except: - pass - elif hasattr(stdscr, "touchwin"): - try: - stdscr.touchwin() - except: - pass - elif hasattr(stdscr, "clearok"): - try: - stdscr.clearok(1) - except: - pass - if poll_delay: - try: - time.sleep(0.1) - except: - pass - continue - idle_ticks = 0 if not handle_key(game, ch): break -def run_curses(main_func): - if hasattr(curses, "wrapper"): - curses.wrapper(main_func) - return - stdscr = curses.initscr() - if hasattr(curses, "noecho"): - try: - curses.noecho() - except: - pass - if hasattr(curses, "cbreak"): - try: - curses.cbreak() - except: - pass - try: - main_func(stdscr) - finally: - if hasattr(curses, "nocbreak"): - try: - curses.nocbreak() - except: - pass - if hasattr(stdscr, "keypad"): - try: - stdscr.keypad(not 1) - except: - pass - if hasattr(curses, "echo"): - try: - curses.echo() - except: - pass - if hasattr(curses, "endwin"): - try: - curses.endwin() - except: - pass - - -def main(): - run_curses(curses_main) +def main() -> None: + """Program entry point.""" + curses.wrapper(curses_main) if __name__ == "__main__":