mcfreecell/linux-freecell.py

1461 lines
47 KiB
Python
Raw Normal View History

2026-03-18 17:25:39 -04:00
#!/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
2026-03-18 17:59:01 -04:00
v : Enter / exit tableau visual selection
y : Pick up selected card / stack
p : Drop held card / stack
2026-03-20 00:48:53 -04:00
u : Undo the last completed move
2026-03-18 19:03:02 -04:00
? : Show a suggested move
2026-03-18 17:25:39 -04:00
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 random
2026-03-22 17:05:01 -04:00
import time
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
@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
2026-03-20 00:48:53 -04:00
@dataclass(frozen=True)
class MoveRecord:
"""Describes one completed move that can be undone."""
source_type: str
source_index: int
dest_type: str
dest_index: int
cards: Tuple[Card, ...]
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
self.hint_move: Optional[HintMove] = None
2026-03-20 00:48:53 -04:00
self.move_history: List[MoveRecord] = []
2026-03-18 17:59:01 -04:00
self.visual_mode = False
self.visual_row = 0
2026-03-18 17:25:39 -04:00
# Cursor zones:
# "top" -> freecells + foundations
# "bottom" -> tableau columns
self.cursor_zone = "bottom"
self.cursor_index = 0
2026-03-18 19:03:02 -04:00
self.status = "Arrow keys move, v selects a stack, ? shows a hint."
2026-03-22 17:05:01 -04:00
self.start_time = time.monotonic()
self.finished_time_seconds: Optional[int] = None
2026-03-18 17:25:39 -04:00
self._deal_new_game()
2026-03-22 17:05:01 -04:00
def elapsed_time_seconds(self) -> int:
"""Return elapsed play time, freezing it after a win."""
if self.finished_time_seconds is not None:
return self.finished_time_seconds
elapsed = int(time.monotonic() - self.start_time)
if self.is_won():
self.finished_time_seconds = elapsed
return self.finished_time_seconds
return elapsed
2026-03-18 19:03:02 -04:00
def clear_hint(self) -> None:
"""Drop the currently remembered hint highlight."""
self.hint_move = None
2026-03-20 00:48:53 -04:00
def record_move(
self,
source_type: str,
source_index: int,
dest_type: str,
dest_index: int,
cards: List[Card],
) -> None:
"""Remember one completed move so it can be undone later."""
self.move_history.append(
MoveRecord(
source_type=source_type,
source_index=source_index,
dest_type=dest_type,
dest_index=dest_index,
cards=tuple(cards),
)
)
def _remove_cards_from_destination(self, move: MoveRecord) -> bool:
"""Remove the recorded cards from a move destination during undo."""
cards = list(move.cards)
if move.dest_type == "col":
column = self.columns[move.dest_index]
if len(column) < len(cards) or column[-len(cards) :] != cards:
return False
del column[-len(cards) :]
return True
if move.dest_type == "free":
if self.freecells[move.dest_index] != cards[0]:
return False
self.freecells[move.dest_index] = None
return True
foundation = self.foundations[move.dest_index]
if not foundation or foundation[-1] != cards[0]:
return False
foundation.pop()
return True
def _restore_cards_to_source(self, move: MoveRecord) -> None:
"""Put cards back at the recorded move source during undo."""
cards = list(move.cards)
if move.source_type == "col":
self.columns[move.source_index].extend(cards)
return
self.freecells[move.source_index] = cards[0]
def undo_last_move(self) -> None:
"""Undo the most recent completed move."""
if self.held is not None:
self.status = "Drop or cancel the held cards before undoing a move."
return
if not self.move_history:
self.status = "Nothing to undo."
return
move = self.move_history[-1]
if not self._remove_cards_from_destination(move):
2026-03-22 17:05:01 -04:00
self.status = (
"Undo failed because the board no longer matches the last move."
)
2026-03-20 00:48:53 -04:00
return
self.move_history.pop()
self._restore_cards_to_source(move)
self.clear_hint()
if self.visual_mode:
self.exit_visual_mode()
move_count = len(move.cards)
if move_count == 1:
self.status = f"Undid move of {move.cards[0].short_name()}."
else:
self.status = f"Undid move of {move_count} cards."
2026-03-18 17:25:39 -04:00
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)
2026-03-18 17:59:01 -04:00
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:
break
if upper.color_group == lower.color_group:
break
start -= 1
return start
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 = 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, f"Move limit is {max_cards} cards with current free space."
return cards, ""
2026-03-18 17:25:39 -04:00
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."
2026-03-18 17:59:01 -04:00
if self.visual_mode and self.cursor_zone == "bottom":
return self.current_visual_stack()
2026-03-18 17:25:39 -04:00
2026-03-18 17:59:01 -04:00
cards = [column[-1]]
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
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
)
2026-03-18 17:59:01 -04:00
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
2026-03-18 17:25:39 -04:00
if self.cursor_zone != "bottom":
2026-03-18 17:59:01 -04:00
self.status = "Visual mode only works on tableau columns."
2026-03-18 17:25:39 -04:00
return
column = self.columns[self.cursor_index]
if not column:
self.status = "That column is empty."
return
2026-03-18 17:59:01 -04:00
self.visual_mode = True
self.visual_row = len(column) - 1
cards, reason = self.current_visual_stack()
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."
)
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: 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 = max(tail_start, min(len(column) - 1, self.visual_row + delta))
cards, reason = self.current_visual_stack()
2026-03-18 17:25:39 -04:00
2026-03-18 17:59:01 -04:00
if self.visual_row == old_row:
self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}."
2026-03-18 17:25:39 -04:00
return
if reason:
2026-03-18 17:59:01 -04:00
self.status = f"Visual: {len(cards)} cards selected. {reason}"
2026-03-18 17:25:39 -04:00
else:
self.status = (
2026-03-18 17:59:01 -04:00
f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected."
2026-03-18 17:25:39 -04:00
)
# -----------------------------------------------------------------------
# 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)
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
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}."
2026-03-18 17:59:01 -04:00
self.exit_visual_mode()
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
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):
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.columns[self.cursor_index].extend(cards)
2026-03-20 00:48:53 -04:00
self.record_move(
self.held.source_type,
self.held.source_index,
"col",
self.cursor_index,
cards,
)
2026-03-18 17:25:39 -04:00
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:
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
self.freecells[self.cursor_index] = moving_top
2026-03-20 00:48:53 -04:00
self.record_move(
self.held.source_type,
self.held.source_index,
"free",
self.cursor_index,
cards,
)
2026-03-18 17:25:39 -04:00
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):
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
foundation = self.foundation_for_suit(moving_top.suit.name)
foundation.append(moving_top)
2026-03-20 00:48:53 -04:00
self.record_move(
self.held.source_type,
self.held.source_index,
"foundation",
SUITS.index(moving_top.suit),
cards,
)
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:59:01 -04:00
if self.visual_mode:
2026-03-18 17:25:39 -04:00
self.status = "Quick freecell move uses only the bottom card."
return
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-20 00:48:53 -04:00
moved_card = col.pop()
self.freecells[free_idx] = moved_card
self.record_move("col", self.cursor_index, "free", free_idx, [moved_card])
2026-03-18 17:25:39 -04:00
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:
2026-03-18 17:59:01 -04:00
if self.visual_mode:
2026-03-18 17:25:39 -04:00
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)
2026-03-18 19:03:02 -04:00
self.clear_hint()
2026-03-18 17:25:39 -04:00
foundation.append(card)
if source[0] == "col":
self.columns[source[1]].pop()
else:
self.freecells[source[1]] = None
2026-03-22 17:05:01 -04:00
self.record_move(
source[0], source[1], "foundation", SUITS.index(card.suit), [card]
)
2026-03-20 00:48:53 -04:00
2026-03-18 17:25:39 -04:00
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
2026-03-18 21:10:30 -04:00
@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 = 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: int) -> BoardLayout:
"""Compute a centered, width-aware board layout."""
left_margin = 2
right_margin = max(left_margin, max_x - CARD_W - 2)
if max_x < 72:
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:]
else:
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=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,
)
2026-03-18 17:25:39 -04:00
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)
2026-03-18 19:03:02 -04:00
# Pair 6: hint highlight
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
2026-03-18 17:25:39 -04:00
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()
2026-03-18 21:10:30 -04:00
def draw_top_row(stdscr, game: FreeCellGame, layout: BoardLayout) -> None:
2026-03-18 17:25:39 -04:00
"""Draw freecells and foundations."""
2026-03-18 21:10:30 -04:00
stdscr.addstr(
TOP_Y,
layout.freecells_label_x,
"Free Cells",
curses.color_pair(1) | curses.A_BOLD,
)
stdscr.addstr(
TOP_Y,
layout.foundations_label_x,
"Foundations",
curses.color_pair(1) | curses.A_BOLD,
)
2026-03-18 17:25:39 -04:00
# Freecells at indexes 0..3
for i in range(4):
2026-03-18 21:10:30 -04:00
x = layout.freecell_xs[i]
2026-03-18 17:25:39 -04:00
selected = game.cursor_zone == "top" and game.cursor_index == i
2026-03-18 19:03:02 -04:00
hinted = game.hinted_freecell(i)
attr = (
curses.color_pair(4)
if selected
else curses.color_pair(6)
if hinted
else curses.color_pair(1)
)
2026-03-18 17:25:39 -04:00
card = game.freecells[i]
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr)
2026-03-18 17:25:39 -04:00
if card:
stdscr.addnstr(
2026-03-18 21:03:31 -04:00
TOP_Y + 1,
2026-03-18 17:25:39 -04:00
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):
2026-03-18 21:10:30 -04:00
x = layout.foundation_xs[i]
2026-03-18 17:25:39 -04:00
selected = game.cursor_zone == "top" and game.cursor_index == i + 4
2026-03-18 19:03:02 -04:00
hinted = game.hinted_foundation(i)
attr = (
curses.color_pair(4)
if selected
else curses.color_pair(6)
if hinted
else curses.color_pair(1)
)
2026-03-18 17:25:39 -04:00
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
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr)
2026-03-18 17:25:39 -04:00
if top_card:
stdscr.addnstr(
2026-03-18 21:03:31 -04:00
TOP_Y + 1,
2026-03-18 17:25:39 -04:00
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(
2026-03-18 21:03:31 -04:00
TOP_Y + 1, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color)
2026-03-18 17:25:39 -04:00
)
2026-03-18 21:10:30 -04:00
def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None:
2026-03-18 17:25:39 -04:00
"""Draw the tableau columns."""
2026-03-18 21:10:30 -04:00
stdscr.addstr(
COL_Y - 1,
layout.tableau_label_x,
"Tableau",
curses.color_pair(1) | curses.A_BOLD,
)
2026-03-18 17:25:39 -04:00
max_height = max((len(col) for col in game.columns), default=0)
for col_idx in range(8):
2026-03-18 21:10:30 -04:00
x = layout.tableau_xs[col_idx]
2026-03-18 17:25:39 -04:00
selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx
2026-03-18 19:03:02 -04:00
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)
)
2026-03-18 17:25:39 -04:00
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 = ""
2026-03-18 17:59:01 -04:00
valid_tail_start = game.valid_tail_start_index(col_idx)
if valid_tail_start is None:
valid_tail_start = len(column)
2026-03-18 19:03:02 -04:00
hint_source_start = game.hinted_column_source_start(col_idx)
2026-03-18 17:59:01 -04:00
if selected and game.visual_mode:
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:59:01 -04:00
in_valid_tail = (
selected and game.visual_mode and row_idx >= valid_tail_start
)
in_selected_stack = (
selected and game.visual_mode and row_idx >= selected_start
)
2026-03-18 19:03:02 -04:00
in_hint_source = (
hint_source_start is not None and row_idx >= hint_source_start
)
2026-03-18 17:25:39 -04:00
if in_selected_stack:
attr = curses.color_pair(4)
if selection_reason:
attr |= curses.A_DIM
else:
attr |= curses.A_BOLD
2026-03-18 19:03:02 -04:00
elif in_hint_source:
attr = curses.color_pair(6) | curses.A_BOLD
2026-03-18 17:59:01 -04:00
elif in_valid_tail:
attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM
elif selected and row_idx == len(column) - 1:
attr = curses.color_pair(4)
2026-03-18 17:25:39 -04:00
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()
2026-03-20 00:48:53 -04:00
help_text = "Move arrows/hjkl v select y/p/ENT/SPC move u undo ? hint f freecell d foundation Esc cancel q quit"
2026-03-18 17:25:39 -04:00
stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1))
2026-03-22 17:05:01 -04:00
def format_elapsed_time(seconds: int) -> str:
"""Format elapsed seconds as MM:SS."""
minutes, seconds = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{seconds:02d}"
2026-03-18 17:25:39 -04:00
def render(stdscr, game: FreeCellGame) -> None:
"""Redraw the full screen."""
stdscr.erase()
2026-03-18 21:03:31 -04:00
max_y, max_x = stdscr.getmaxyx()
2026-03-18 21:10:30 -04:00
layout = compute_board_layout(max_x)
2026-03-22 17:05:01 -04:00
elapsed_text = f"Time: {format_elapsed_time(game.elapsed_time_seconds())}"
2026-03-18 17:25:39 -04:00
2026-03-18 21:03:31 -04:00
title = "mcfreecell 0.1"
if len(title) > max_x - 4:
title = "mcfreecell"
title_x = max(2, (max_x - len(title)) // 2)
stdscr.addnstr(
0,
title_x,
title,
max(0, max_x - title_x - 1),
curses.color_pair(1) | curses.A_BOLD,
)
2026-03-18 17:25:39 -04:00
2026-03-22 17:05:01 -04:00
timer_x = max(2, max_x - len(elapsed_text) - 2)
if timer_x > title_x + len(title):
stdscr.addnstr(
0,
timer_x,
elapsed_text,
max(0, max_x - timer_x - 1),
curses.color_pair(1) | curses.A_BOLD,
)
2026-03-18 17:59:01 -04:00
if game.visual_mode:
2026-03-18 21:10:30 -04:00
visual_text = "-- VISUAL --"
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
)
2026-03-18 17:59:01 -04:00
2026-03-18 21:10:30 -04:00
draw_top_row(stdscr, game, layout)
draw_columns(stdscr, game, layout)
2026-03-18 17:25:39 -04:00
draw_held(stdscr, game, max_y)
draw_status(stdscr, game, max_y)
draw_help(stdscr, max_y)
if game.is_won():
2026-03-22 17:05:01 -04:00
win_text = f"You won in {format_elapsed_time(game.elapsed_time_seconds())}."
stdscr.addnstr(
3,
2,
win_text,
max(0, max_x - 4),
curses.color_pair(5) | curses.A_BOLD,
)
2026-03-18 17:25:39 -04:00
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:
2026-03-18 17:59:01 -04:00
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(-1)
return
2026-03-18 17:25:39 -04:00
game.cursor_zone = "top"
game.cursor_index = min(game.cursor_index, 7)
return
if dy > 0:
2026-03-18 17:59:01 -04:00
if game.visual_mode and game.cursor_zone == "bottom":
game.move_visual_selection(1)
return
2026-03-18 17:25:39 -04:00
game.cursor_zone = "bottom"
game.cursor_index = min(game.cursor_index, 7)
return
# Horizontal motion
2026-03-18 17:59:01 -04:00
if game.visual_mode and dx != 0:
game.exit_visual_mode("Visual selection cancelled.")
return
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:59:01 -04:00
if ch in (ord("v"), ord("V")):
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
else:
game.enter_visual_mode()
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:59:01 -04:00
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 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 True
2026-03-20 00:48:53 -04:00
if ch in (ord("u"), ord("U")):
game.undo_last_move()
return True
2026-03-18 19:03:02 -04:00
if ch == ord("?"):
game.show_hint()
return True
2026-03-18 17:25:39 -04:00
# 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:
2026-03-18 17:59:01 -04:00
if game.visual_mode:
game.exit_visual_mode("Visual selection cancelled.")
elif game.held is not None:
2026-03-18 17:25:39 -04:00
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)
2026-03-22 17:05:01 -04:00
stdscr.timeout(100)
2026-03-18 17:25:39 -04:00
init_colors()
game = FreeCellGame()
while True:
render(stdscr, game)
ch = stdscr.getch()
2026-03-22 17:05:01 -04:00
if ch == -1:
continue
2026-03-18 17:25:39 -04:00
if not handle_key(game, ch):
break
def main() -> None:
"""Program entry point."""
curses.wrapper(curses_main)
if __name__ == "__main__":
main()