commit 26dff88a5bd73fe8448345f52ae1ee8c1b565183 Author: markmental Date: Wed Mar 18 17:25:39 2026 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29b232c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*pycache* diff --git a/linux-freecell.py b/linux-freecell.py new file mode 100644 index 0000000..a84d75b --- /dev/null +++ b/linux-freecell.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +""" +linux_freecell.py + +A curses-based FreeCell prototype that uses Linux distro themed suits. + +Controls +-------- +Arrow keys / h j k l : Move cursor +Space or Enter : Pick up / drop a card or stack +[ / ] : Expand / shrink tableau stack selection +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 random +from dataclasses import dataclass +from typing import List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Card model +# --------------------------------------------------------------------------- + +RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] +RANK_VALUES = {rank: i + 1 for i, rank in enumerate(RANKS)} + + +@dataclass(frozen=True) +class SuitInfo: + """Describes one suit in our Linux-themed deck.""" + + name: str + glyph: str + color_group: str # "red" or "black" + + +@dataclass +class Card: + """Represents one playing card.""" + + rank: str + suit: SuitInfo + + @property + def value(self) -> int: + return RANK_VALUES[self.rank] + + @property + def color_group(self) -> str: + return self.suit.color_group + + 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(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: + """Represents one or more cards currently picked up by the player.""" + + cards: List[Card] + source_type: str # "col" or "free" + source_index: int + + +class FreeCellGame: + """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.selection_offsets: List[int] = [0] * 8 + + # Cursor zones: + # "top" -> freecells + foundations + # "bottom" -> tableau columns + self.cursor_zone = "bottom" + self.cursor_index = 0 + + self.status = "Arrow keys move, Enter picks up/drops, [ ] change stack depth." + self._deal_new_game() + + 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) + + for i, card in enumerate(deck): + self.columns[i % 8].append(card) + + # ----------------------------------------------------------------------- + # Rules helpers + # ----------------------------------------------------------------------- + + def is_valid_tableau_sequence(self, cards: List[Card]) -> bool: + """Return True if cards form a descending alternating-color stack.""" + if not cards: + return False + + 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 + + return True + + 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 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." + + offset = min(self.selection_offsets[col_index], len(column) - 1) + moving_count = offset + 1 + cards = column[-moving_count:] + + if not self.is_valid_tableau_sequence(cards): + return ( + cards, + "Selected cards are not a valid alternating descending sequence.", + ) + + max_cards = self.max_movable_cards(col_index, moving_count) + if moving_count > max_cards: + return cards, f"Move limit is {max_cards} cards with current free space." + + 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 False + + column = self.columns[col_index] + if not column: + return True + + moving_top = cards[0] + destination_top = column[-1] + return ( + destination_top.value == moving_top.value + 1 + and destination_top.color_group != moving_top.color_group + ) + + 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] + return top.suit.name == card.suit.name and card.value == top.value + 1 + + 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) -> Optional[int]: + """Return the first free freecell slot, or None if full.""" + for i, cell in enumerate(self.freecells): + if cell is None: + return i + return None + + def adjust_selection_offset(self, delta: int) -> None: + """Grow or shrink the selected tableau stack depth for the current column.""" + if self.cursor_zone != "bottom": + self.status = "Stack depth only applies to tableau columns." + return + + column = self.columns[self.cursor_index] + if not column: + self.selection_offsets[self.cursor_index] = 0 + self.status = "That column is empty." + return + + max_offset = len(column) - 1 + old_offset = self.selection_offsets[self.cursor_index] + new_offset = max(0, min(max_offset, old_offset + delta)) + self.selection_offsets[self.cursor_index] = new_offset + + if new_offset == old_offset: + selected_count = new_offset + 1 + self.status = f"Selection stays at {selected_count} card{'s' if selected_count != 1 else ''}." + return + + selected_count = new_offset + 1 + _, reason = self.selected_stack_from_column(self.cursor_index) + if reason: + self.status = f"Selecting {selected_count} cards. {reason}" + else: + self.status = ( + f"Selecting {selected_count} cards from column {self.cursor_index + 1}." + ) + + # ----------------------------------------------------------------------- + # 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) + del self.columns[self.cursor_index][-moving_count:] + self.held = HeldCards( + cards=cards, source_type="col", source_index=self.cursor_index + ) + if moving_count == 1: + self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}." + else: + self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." + 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: + self.status = "That free cell is empty." + return + self.freecells[self.cursor_index] = None + 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}." + ) + return + + self.status = "Cannot pick up directly from foundations." + + 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 + + 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.columns[self.cursor_index].extend(cards) + if moving_count == 1: + self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." + else: + self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}." + self.held = None + 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.freecells[self.cursor_index] = moving_top + self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." + self.held = None + 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): + foundation = self.foundation_for_suit(moving_top.suit.name) + foundation.append(moving_top) + self.status = f"Moved {moving_top.short_name()} to foundation." + self.held = None + else: + self.status = "Illegal move for foundation." + + 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: + self.status = "That column is empty." + return + if self.selection_offsets[self.cursor_index] > 0: + self.status = "Quick freecell move uses only the bottom card." + return + 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) -> 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: Optional[Card] = None + source: Optional[Tuple[str, int]] = None + + if self.cursor_zone == "bottom": + col = self.columns[self.cursor_index] + if col: + if self.selection_offsets[self.cursor_index] > 0: + self.status = "Quick foundation move uses only the bottom card." + return + card = col[-1] + source = ("col", self.cursor_index) + + elif self.cursor_zone == "top" and self.cursor_index < 4: + 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) + foundation.append(card) + + if source[0] == "col": + self.columns[source[1]].pop() + else: + self.freecells[source[1]] = None + + 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 + + +# --------------------------------------------------------------------------- +# UI rendering +# --------------------------------------------------------------------------- + +CARD_W = 7 +TOP_Y = 1 +COL_Y = 6 + + +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) + + +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: 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: 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: FreeCellGame) -> None: + """Draw freecells and foundations.""" + stdscr.addstr(TOP_Y - 1, 2, "Free Cells", curses.color_pair(1) | curses.A_BOLD) + stdscr.addstr(TOP_Y - 1, 40, "Foundations", curses.color_pair(1) | curses.A_BOLD) + + # Freecells at indexes 0..3 + 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) + + card = game.freecells[i] + draw_box(stdscr, TOP_Y, x, CARD_W, draw_card_label(card), attr) + + if card: + stdscr.addnstr( + TOP_Y, + x + 1, + card.short_name(), + CARD_W - 2, + curses.color_pair(card_color_pair(card)), + ) + + # Foundations at indexes 4..7 in cursor space + 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) + + 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, x, CARD_W, label, attr) + + if top_card: + stdscr.addnstr( + TOP_Y, + x + 1, + top_card.short_name(), + CARD_W - 2, + curses.color_pair(card_color_pair(top_card)), + ) + else: + # Show suit glyph placeholder in foundation slot color + dummy_color = 2 if SUITS[i].color_group == "red" else 3 + stdscr.addnstr( + TOP_Y, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color) + ) + + +def draw_columns(stdscr, game: FreeCellGame) -> None: + """Draw the tableau columns.""" + stdscr.addstr(COL_Y - 1, 2, "Tableau", curses.color_pair(1) | curses.A_BOLD) + + max_height = max((len(col) for col in game.columns), default=0) + + 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) + + draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) + + column = game.columns[col_idx] + if not column: + stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1)) + continue + + selected_cards: List[Card] = [] + selection_reason = "" + if selected: + selected_cards, selection_reason = game.selected_stack_from_column(col_idx) + + 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_selected_stack = selected and row_idx >= selected_start + if in_selected_stack: + attr = curses.color_pair(4) + if selection_reason: + attr |= curses.A_DIM + else: + attr |= curses.A_BOLD + else: + attr = curses.color_pair(card_color_pair(card)) + + stdscr.addnstr( + y, x + 1, card.short_name().ljust(CARD_W - 2), CARD_W - 2, attr + ) + + # 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() + + +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: + stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1)) + return + + stdscr.addstr(y, 2, "Held: ", curses.color_pair(1)) + cards = game.held.cards + if len(cards) == 1: + stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD) + return + + 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 + ) + + +def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None: + """Draw a one-line status message.""" + y = max_y - 2 + 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: int) -> None: + """Draw a compact help line.""" + y = max_y - 1 + stdscr.move(y, 0) + stdscr.clrtoeol() + help_text = "Move: arrows/hjkl Pick/Drop: Enter/Space [ / ]: stack size 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: FreeCellGame) -> None: + """Redraw the full screen.""" + stdscr.erase() + max_y, _ = stdscr.getmaxyx() + + title = "Linux FreeCell Prototype" + stdscr.addstr(0, 2, title, curses.color_pair(1) | curses.A_BOLD) + + draw_top_row(stdscr, game) + draw_columns(stdscr, game) + draw_held(stdscr, game, max_y) + draw_status(stdscr, game, max_y) + draw_help(stdscr, max_y) + + if game.is_won(): + stdscr.addstr(3, 2, "You won.", curses.color_pair(5) | curses.A_BOLD) + + stdscr.refresh() + + +# --------------------------------------------------------------------------- +# 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: + game.cursor_zone = "top" + game.cursor_index = min(game.cursor_index, 7) + return + + if dy > 0: + game.cursor_zone = "bottom" + game.cursor_index = min(game.cursor_index, 7) + return + + # Horizontal motion + 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: 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 True + + if ch in (curses.KEY_RIGHT, ord("l")): + move_cursor(game, 0, 1) + return True + + if ch in (curses.KEY_UP, ord("k")): + move_cursor(game, -1, 0) + return True + + if ch in (curses.KEY_DOWN, ord("j")): + move_cursor(game, 1, 0) + return True + + if ch == ord("["): + game.adjust_selection_offset(1) + return True + + if ch == ord("]"): + game.adjust_selection_offset(-1) + 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 True + + # Quick helpers + if ch in (ord("f"), ord("F")): + game.quick_to_freecell() + return True + + if ch in (ord("d"), ord("D")): + game.quick_to_foundation() + return True + + # Escape: cancel held card by restoring it + if ch == 27: + if game.held is not None: + game.restore_held() + game.status = "Cancelled move." + return True + + return True + + +# --------------------------------------------------------------------------- +# Main curses loop +# --------------------------------------------------------------------------- + + +def curses_main(stdscr) -> None: + """Main UI entry point.""" + curses.curs_set(0) + stdscr.keypad(True) + + init_colors() + + game = FreeCellGame() + + while True: + render(stdscr, game) + ch = stdscr.getch() + if not handle_key(game, ch): + break + + +def main() -> None: + """Program entry point.""" + curses.wrapper(curses_main) + + +if __name__ == "__main__": + main()