mcfreecell/linux-freecell.py

1525 lines
47 KiB
Python
Raw Normal View History

#!/usr/bin/env python
2026-03-18 17:25:39 -04:00
"""
mcfreecell
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
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-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
"""
import curses
2026-03-22 22:42:37 -04:00
import time
2026-03-18 17:25:39 -04:00
try:
import random
except ImportError:
random = None
try:
import whrandom # type: ignore
except ImportError:
whrandom = None
try:
enumerate
except NameError:
def enumerate(sequence):
index = 0
result = []
for item in sequence:
result.append((index, item))
index = index + 1
return result
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
CARD_W = 7
TOP_Y = 1
COL_Y = 6
2026-03-18 17:25:39 -04:00
def find_suit_index(suit_name):
index = 0
for suit in SUITS:
if suit.name == suit_name:
return index
index = index + 1
return 0
2026-03-18 17:25:39 -04:00
def count_sequence(sequence):
count = 0
for _item in sequence:
count = count + 1
return count
2026-03-18 17:25:39 -04:00
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)
2026-03-22 22:42:37 -04:00
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
class SuitInfo:
def __init__(self, name, tag, color_group):
self.name = name
self.tag = tag
self.color_group = color_group
class Card:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def value(self):
2026-03-18 17:25:39 -04:00
return RANK_VALUES[self.rank]
def color_group(self):
2026-03-18 17:25:39 -04:00
return self.suit.color_group
def short_name(self):
return self.rank + self.suit.tag
2026-03-18 17:25:39 -04:00
SUITS = [
SuitInfo("Debian", "Deb", "red"),
SuitInfo("Red Hat", "RHt", "red"),
SuitInfo("Solaris", "Sol", "black"),
SuitInfo("HP-UX", "HPx", "black"),
2026-03-18 17:25:39 -04:00
]
class HeldCards:
def __init__(self, cards, source_type, source_index):
self.cards = cards
self.source_type = source_type
self.source_index = source_index
2026-03-18 17:25:39 -04:00
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
2026-03-18 17:25:39 -04:00
class FreeCellGame:
def __init__(self):
self.columns = [[], [], [], [], [], [], [], []]
self.freecells = [None, None, None, None]
self.foundations = [[], [], [], []]
self.held = None
self.hint_move = None
self.visual_mode = 0
2026-03-18 17:59:01 -04:00
self.visual_row = 0
2026-03-18 17:25:39 -04:00
self.cursor_zone = "bottom"
self.cursor_index = 0
self.status = "Arrows move, v selects, ? hints."
2026-03-22 22:42:37 -04:00
self.start_time = time.time()
self.finished_time_seconds = None
self.needs_full_repaint = 0
2026-03-18 17:25:39 -04:00
self._deal_new_game()
2026-03-22 22:42:37 -04:00
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):
2026-03-22 22:42:37 -04:00
had_hint = self.hint_move is not None
2026-03-18 19:03:02 -04:00
self.hint_move = None
2026-03-22 22:42:37 -04:00
if had_hint:
self.request_full_repaint()
2026-03-18 19:03:02 -04:00
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):
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
def count_empty_freecells(self):
count = 0
for cell in self.freecells:
if cell is None:
count = count + 1
return count
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
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)
2026-03-18 17:25:39 -04:00
return (self.count_empty_freecells() + 1) * (2**empty_columns)
def valid_tail_start_index(self, col_index):
2026-03-18 17:59:01 -04:00
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:
2026-03-18 17:59:01 -04:00
break
if upper.color_group() == lower.color_group():
2026-03-18 17:59:01 -04:00
break
start = start - 1
2026-03-18 17:59:01 -04:00
return start
def current_visual_stack(self):
2026-03-18 17:59:01 -04:00
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
2026-03-18 17:59:01 -04:00
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
2026-03-18 17:59:01 -04:00
return cards, ""
def selected_stack_from_column(self, col_index):
2026-03-18 17:25:39 -04:00
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()
return [column[-1]], ""
2026-03-18 17:25:39 -04:00
def can_place_stack_on_column(self, cards, col_index):
2026-03-18 17:25:39 -04:00
if not self.is_valid_tableau_sequence(cards):
return 0
2026-03-18 17:25:39 -04:00
column = self.columns[col_index]
if not column:
return 1
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
def can_move_to_foundation(self, card):
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
def foundation_for_suit(self, suit_name):
return self.foundations[find_suit_index(suit_name)]
2026-03-18 17:25:39 -04:00
def first_empty_freecell(self):
index = 0
for cell in self.freecells:
2026-03-18 17:25:39 -04:00
if cell is None:
return index
index = index + 1
2026-03-18 17:25:39 -04:00
return None
def count_hidden_low_cards(self):
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
return count
def card_can_help_foundation(self, card):
return card.value() <= 3
2026-03-18 19:03:02 -04:00
def describe_hint_move(self, move):
2026-03-18 19:03:02 -04:00
if move.source_type == "col":
source_name = "column %d" % (move.source_index + 1)
2026-03-18 19:03:02 -04:00
else:
source_name = "free cell %d" % (move.source_index + 1)
2026-03-18 19:03:02 -04:00
if move.dest_type == "col":
dest_name = "column %d" % (move.dest_index + 1)
2026-03-18 19:03:02 -04:00
elif move.dest_type == "free":
dest_name = "free cell %d" % (move.dest_index + 1)
2026-03-18 19:03:02 -04:00
else:
dest_name = "%s foundation" % SUITS[move.dest_index].name
2026-03-18 19:03:02 -04:00
if len(move.cards) == 1:
return "move %s from %s to %s" % (
move.cards[0].short_name(),
source_name,
dest_name,
2026-03-18 19:03:02 -04:00
)
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,
2026-03-18 19:03:02 -04:00
)
def score_hint_move(self, source_type, source_index, dest_type, dest_index, cards):
2026-03-18 19:03:02 -04:00
score = 0
moving_top = cards[0]
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
2026-03-18 19:03:02 -04:00
if dest_type == "foundation":
score = score + 1000 + moving_top.value() * 5
2026-03-18 19:03:02 -04:00
elif dest_type == "col":
destination = self.columns[dest_index]
if destination:
score = score + 220 + len(cards) * 25
2026-03-18 19:03:02 -04:00
if len(cards) > 1:
score = score + 120
2026-03-18 19:03:02 -04:00
else:
if len(cards) > 1:
score = score + 60
else:
score = score - 120
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
if source_type == "free" and dest_type == "col":
score = score + 200
2026-03-18 19:03:02 -04:00
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(
2026-03-18 19:03:02 -04:00
"col",
col_index,
"foundation",
suit_index,
2026-03-18 19:03:02 -04:00
[top_card],
self.score_hint_move(
"col", col_index, "foundation", suit_index, [top_card]
),
)
2026-03-18 19:03:02 -04:00
)
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]
),
2026-03-18 19:03:02 -04:00
)
)
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
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(
2026-03-18 19:03:02 -04:00
"free",
free_index,
"foundation",
suit_index,
2026-03-18 19:03:02 -04:00
[card],
self.score_hint_move(
"free", free_index, "foundation", suit_index, [card]
2026-03-18 19:03:02 -04:00
),
)
)
dest_index = 0
while dest_index < 8:
if self.can_place_stack_on_column([card], dest_index):
moves.append(
HintMove(
"free",
free_index,
"col",
dest_index,
[card],
self.score_hint_move(
"free", free_index, "col", dest_index, [card]
),
)
)
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
def show_hint(self):
2026-03-18 19:03:02 -04:00
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."
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 19:03:02 -04:00
return
self.hint_move = moves[0]
self.status = "Hint: %s." % self.describe_hint_move(self.hint_move)
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 19:03:02 -04:00
def hinted_freecell(self, index):
2026-03-18 19:03:02 -04:00
if self.hint_move is None:
return 0
if (
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
def hinted_foundation(self, index):
if self.hint_move is None:
return 0
if (
self.hint_move.dest_type == "foundation"
2026-03-18 19:03:02 -04:00
and self.hint_move.dest_index == index
):
return 1
return 0
2026-03-18 19:03:02 -04:00
def hinted_column_source_start(self, index):
2026-03-18 19:03:02 -04:00
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
2026-03-18 19:03:02 -04:00
def enter_visual_mode(self):
2026-03-18 17:59:01 -04:00
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
self.visual_mode = 1
2026-03-18 17:59:01 -04:00
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."
2026-03-18 17:59:01 -04:00
else:
self.status = "Visual: %d cards selected." % len(cards)
2026-03-18 17:59:01 -04:00
def exit_visual_mode(self, message):
self.visual_mode = 0
2026-03-18 17:59:01 -04:00
self.visual_row = 0
if message is not None:
self.status = message
def move_visual_selection(self, delta):
2026-03-18 17:59:01 -04:00
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
2026-03-18 17:59:01 -04:00
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)
2026-03-18 17:25:39 -04:00
return
if reason:
self.status = "Visual: %d cards selected. %s" % (len(cards), reason)
elif len(cards) == 1:
self.status = "Visual: 1 card selected."
2026-03-18 17:25:39 -04:00
else:
self.status = "Visual: %d cards selected." % len(cards)
2026-03-18 17:25:39 -04:00
def pick_up(self):
2026-03-18 17:25:39 -04:00
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[:], "col", self.cursor_index)
2026-03-18 17:25:39 -04:00
if moving_count == 1:
self.status = "Picked up %s from column %d." % (
cards[0].short_name(),
self.cursor_index + 1,
)
2026-03-18 17:25:39 -04:00
else:
self.status = "Picked up %d cards from column %d." % (
moving_count,
self.cursor_index + 1,
)
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
self.exit_visual_mode(None)
2026-03-18 17:25:39 -04:00
return
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([card], "free", self.cursor_index)
self.status = "Picked up %s from free cell %d." % (
card.short_name(),
self.cursor_index + 1,
2026-03-18 17:25:39 -04:00
)
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
return
self.status = "Cannot pick up directly from foundations."
def restore_held(self):
2026-03-18 17:25:39 -04:00
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-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
def drop(self):
2026-03-18 17:25:39 -04:00
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)
if moving_count == 1:
self.status = "Placed %s on column %d." % (
moving_top.short_name(),
self.cursor_index + 1,
)
2026-03-18 17:25:39 -04:00
else:
self.status = "Placed %d cards on column %d." % (
moving_count,
self.cursor_index + 1,
)
2026-03-18 17:25:39 -04:00
self.held = None
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
else:
self.status = "Illegal move for tableau column."
return
if self.cursor_index < 4:
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
self.status = "Placed %s in free cell %d." % (
moving_top.short_name(),
self.cursor_index + 1,
)
2026-03-18 17:25:39 -04:00
self.held = None
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
else:
self.status = "That free cell is occupied."
return
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)
self.status = "Moved %s to foundation." % moving_top.short_name()
2026-03-18 17:25:39 -04:00
self.held = None
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
else:
self.status = "Illegal move for foundation."
def quick_to_freecell(self):
2026-03-18 17:25:39 -04:00
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-18 17:25:39 -04:00
self.freecells[free_idx] = col.pop()
self.status = "Moved card to free cell %d." % (free_idx + 1)
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
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):
2026-03-18 17:25:39 -04:00
if self.held is not None:
self.status = "Drop the held card first."
return
card = None
source = None
2026-03-18 17:25:39 -04:00
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:
card = self.freecells[self.cursor_index]
if card is not None:
2026-03-18 17:25:39 -04:00
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
2026-03-18 19:03:02 -04:00
self.clear_hint()
self.foundation_for_suit(card.suit.name).append(card)
2026-03-18 17:25:39 -04:00
if source[0] == "col":
self.columns[source[1]].pop()
else:
self.freecells[source[1]] = None
self.status = "Moved %s to foundation." % card.short_name()
2026-03-22 22:42:37 -04:00
self.request_full_repaint()
2026-03-18 17:25:39 -04:00
def is_won(self):
total = 0
for foundation in self.foundations:
total = total + len(foundation)
return total == 52
2026-03-18 17:25:39 -04:00
def spread_positions(count, start_x, end_x):
2026-03-18 21:10:30 -04:00
positions = []
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
def compute_board_layout(max_x):
2026-03-18 21:10:30 -04:00
left_margin = 2
right_margin = max_x - CARD_W - 2
if right_margin < left_margin:
right_margin = left_margin
2026-03-18 21:10:30 -04:00
if max_x < 72:
top_positions = spread_positions(8, left_margin, right_margin)
2026-03-18 21:10:30 -04:00
freecell_xs = top_positions[:4]
foundation_xs = top_positions[4:8]
2026-03-18 21:10:30 -04:00
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
2026-03-18 21:10:30 -04:00
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)
return BoardLayout(
freecell_xs,
foundation_xs,
tableau_xs,
freecell_xs[0],
foundation_xs[0],
tableau_xs[0],
2026-03-18 21:10:30 -04:00
)
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
2026-03-18 19:03:02 -04:00
2026-03-18 17:25:39 -04:00
def card_color_pair(card):
if card.color_group() == "red":
return 2
return 3
2026-03-18 17:25:39 -04:00
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)
2026-03-18 17:25:39 -04:00
def draw_card_label(card):
2026-03-18 17:25:39 -04:00
if card is None:
return " "
return card.short_name()
def draw_top_row(stdscr, game, layout):
safe_addstr(
stdscr,
2026-03-18 21:10:30 -04:00
TOP_Y,
layout.freecells_label_x,
"Free Cells",
pair_attr(1) | text_attr("A_BOLD"),
2026-03-18 21:10:30 -04:00
)
safe_addstr(
stdscr,
2026-03-18 21:10:30 -04:00
TOP_Y,
layout.foundations_label_x,
"Foundations",
pair_attr(1) | text_attr("A_BOLD"),
2026-03-18 21:10:30 -04:00
)
2026-03-18 17:25:39 -04:00
index = 0
while index < 4:
x = layout.freecell_xs[index]
2026-03-22 22:42:37 -04:00
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]
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr)
if card is not None:
safe_addnstr(
stdscr,
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,
pair_attr(card_color_pair(card)),
2026-03-18 17:25:39 -04:00
)
index = index + 1
index = 0
while index < 4:
x = layout.foundation_xs[index]
2026-03-22 22:42:37 -04:00
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
2026-03-18 21:03:31 -04:00
draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr)
if top_card is not None:
safe_addnstr(
stdscr,
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,
pair_attr(card_color_pair(top_card)),
2026-03-18 17:25:39 -04:00
)
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),
2026-03-18 17:25:39 -04:00
)
index = index + 1
2026-03-18 17:25:39 -04:00
def draw_columns(stdscr, game, layout):
safe_addstr(
stdscr,
2026-03-18 21:10:30 -04:00
COL_Y - 1,
layout.tableau_label_x,
"Tableau",
pair_attr(1) | text_attr("A_BOLD"),
2026-03-18 21:10:30 -04:00
)
max_height = 0
for column in game.columns:
if len(column) > max_height:
max_height = len(column)
2026-03-18 17:25:39 -04:00
col_idx = 0
while col_idx < 8:
2026-03-18 21:10:30 -04:00
x = layout.tableau_xs[col_idx]
2026-03-22 22:42:37 -04:00
safe_addstr(stdscr, COL_Y, x - 1, " ", pair_attr(1))
safe_addnstr(stdscr, COL_Y, x, " " * CARD_W, CARD_W, pair_attr(1))
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_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)
2026-03-18 17:25:39 -04:00
2026-03-22 22:42:37 -04:00
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
2026-03-18 17:25:39 -04:00
column = game.columns[col_idx]
if not column:
safe_addstr(stdscr, COL_Y + 1, x + 2, ".", pair_attr(1))
col_idx = col_idx + 1
2026-03-18 17:25:39 -04:00
continue
selected_cards = []
2026-03-18 17:25:39 -04:00
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)
if selected_cards:
selected_start = len(column) - len(selected_cards)
else:
selected_start = len(column)
2026-03-18 17:25:39 -04:00
row_idx = 0
for card in column:
2026-03-18 17:25:39 -04:00
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
)
marker = " "
2026-03-18 17:25:39 -04:00
if in_selected_stack:
attr = pair_attr(4) | highlight_attr()
marker = ">"
2026-03-18 17:25:39 -04:00
if selection_reason:
attr = attr | text_attr("A_DIM")
2026-03-18 17:25:39 -04:00
else:
attr = attr | text_attr("A_BOLD")
2026-03-18 19:03:02 -04:00
elif in_hint_source:
attr = pair_attr(6) | text_attr("A_BOLD")
marker = "+"
2026-03-18 17:59:01 -04:00
elif in_valid_tail:
attr = pair_attr(card_color_pair(card)) | text_attr("A_DIM")
2026-03-18 17:59:01 -04:00
elif selected and row_idx == len(column) - 1:
attr = pair_attr(4) | highlight_attr()
marker = ">"
2026-03-18 17:25:39 -04:00
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,
2026-03-18 17:25:39 -04:00
)
row_idx = row_idx + 1
2026-03-18 17:25:39 -04:00
col_idx = col_idx + 1
2026-03-18 17:25:39 -04:00
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
2026-03-18 17:25:39 -04:00
def draw_held(stdscr, game, max_y, max_x):
y = max_y - 3
try:
stdscr.move(y, 0)
stdscr.clrtoeol()
except curses.error:
pass
2026-03-18 17:25:39 -04:00
if game.held is None:
safe_addstr(stdscr, y, 2, "Held: (none)", pair_attr(1))
2026-03-18 17:25:39 -04:00
return
safe_addstr(stdscr, y, 2, "Held: ", pair_attr(1))
2026-03-18 17:25:39 -04:00
cards = game.held.cards
if len(cards) == 1:
safe_addstr(
stdscr, y, 8, cards[0].short_name(), pair_attr(5) | text_attr("A_BOLD")
)
2026-03-18 17:25:39 -04:00
return
summary = "%d cards (%s..%s)" % (
len(cards),
cards[0].short_name(),
cards[-1].short_name(),
2026-03-18 17:25:39 -04:00
)
safe_addnstr(stdscr, y, 8, summary, max_x - 10, pair_attr(5) | text_attr("A_BOLD"))
2026-03-18 17:25:39 -04:00
def draw_status(stdscr, game, max_y, max_x):
2026-03-18 17:25:39 -04:00
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))
2026-03-18 17:25:39 -04:00
def draw_help(stdscr, max_y, max_x):
2026-03-18 17:25:39 -04:00
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 ? hint f freecell d foundation Esc cancel q quit"
safe_addnstr(stdscr, y, 2, help_text, max_x - 4, pair_attr(1))
2026-03-18 17:25:39 -04:00
def render(stdscr, game):
2026-03-18 17:25:39 -04:00
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 22:42:37 -04:00
elapsed_text = "Time: %s" % format_elapsed_time(game.elapsed_time_seconds())
title = "mcfreecell 1.5.2"
2026-03-18 21:03:31 -04:00
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,
2026-03-18 21:03:31 -04:00
0,
title_x,
title,
max_x - title_x - 1,
pair_attr(1) | text_attr("A_BOLD"),
2026-03-18 21:03:31 -04:00
)
2026-03-22 22:42:37 -04:00
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"),
)
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 = 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")
2026-03-18 21:10:30 -04:00
)
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)
2026-03-18 17:25:39 -04:00
if game.is_won():
2026-03-22 22:42:37 -04:00
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")
)
2026-03-18 17:25:39 -04:00
stdscr.refresh()
def move_cursor(game, dy, dx):
2026-03-18 17:25:39 -04:00
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"
if game.cursor_index > 7:
game.cursor_index = 7
2026-03-18 17:25:39 -04:00
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"
if game.cursor_index > 7:
game.cursor_index = 7
2026-03-18 17:25:39 -04:00
return
2026-03-18 17:59:01 -04:00
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
2026-03-18 17:59:01 -04:00
2026-03-18 17:25:39 -04:00
def handle_key(game, ch):
if ch == ord("q") or ch == ord("Q"):
return 0
if ch == curses.KEY_LEFT or ch == ord("h"):
2026-03-18 17:25:39 -04:00
move_cursor(game, 0, -1)
return 1
if ch == curses.KEY_RIGHT or ch == ord("l"):
2026-03-18 17:25:39 -04:00
move_cursor(game, 0, 1)
return 1
if ch == curses.KEY_UP or ch == ord("k"):
2026-03-18 17:25:39 -04:00
move_cursor(game, -1, 0)
return 1
if ch == curses.KEY_DOWN or ch == ord("j"):
2026-03-18 17:25:39 -04:00
move_cursor(game, 1, 0)
return 1
if ch == ord("v") or ch == ord("V"):
2026-03-18 17:59:01 -04:00
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(" "):
2026-03-18 17:25:39 -04:00
if game.held is None:
game.pick_up()
else:
game.drop()
return 1
if ch == ord("y") or ch == ord("Y"):
2026-03-18 17:59:01 -04:00
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"):
2026-03-18 17:59:01 -04:00
if game.held is None:
game.status = "You are not holding any cards."
else:
game.drop()
return 1
2026-03-18 19:03:02 -04:00
if ch == ord("?"):
game.show_hint()
return 1
if ch == ord("f") or ch == ord("F"):
2026-03-18 17:25:39 -04:00
game.quick_to_freecell()
return 1
if ch == ord("d") or ch == ord("D"):
2026-03-18 17:25:39 -04:00
game.quick_to_foundation()
return 1
2026-03-18 17:25:39 -04:00
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 1
return 1
def curses_main(stdscr):
2026-03-22 22:42:37 -04:00
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
2026-03-22 22:42:37 -04:00
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
2026-03-18 17:25:39 -04:00
init_colors()
game = FreeCellGame()
while 1:
2026-03-22 22:42:37 -04:00
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
2026-03-18 17:25:39 -04:00
render(stdscr, game)
ch = stdscr.getch()
2026-03-22 22:42:37 -04:00
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
2026-03-18 17:25:39 -04:00
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)
2026-03-18 17:25:39 -04:00
if __name__ == "__main__":
main()