1294 lines
42 KiB
Python
1294 lines
42 KiB
Python
#!/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
|
|
v : Enter / exit tableau visual selection
|
|
y : Pick up selected card / stack
|
|
p : Drop held card / stack
|
|
? : Show a suggested move
|
|
Space or Enter : Pick up / drop a card or stack
|
|
f : Quick move selected card to a free cell
|
|
d : Quick move selected card to foundation
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HintMove:
|
|
"""Describes one suggested legal move for the current board."""
|
|
|
|
source_type: str
|
|
source_index: int
|
|
dest_type: str
|
|
dest_index: int
|
|
cards: Tuple[Card, ...]
|
|
score: int
|
|
|
|
|
|
class FreeCellGame:
|
|
"""Holds the state and rules for a small FreeCell prototype."""
|
|
|
|
def __init__(self) -> None:
|
|
self.columns: List[List[Card]] = [[] for _ in range(8)]
|
|
self.freecells: List[Optional[Card]] = [None] * 4
|
|
self.foundations: List[List[Card]] = [[] for _ in range(4)]
|
|
self.held: Optional[HeldCards] = None
|
|
self.hint_move: Optional[HintMove] = None
|
|
self.visual_mode = False
|
|
self.visual_row = 0
|
|
|
|
# Cursor zones:
|
|
# "top" -> freecells + foundations
|
|
# "bottom" -> tableau columns
|
|
self.cursor_zone = "bottom"
|
|
self.cursor_index = 0
|
|
|
|
self.status = "Arrow keys move, v selects a stack, ? shows a hint."
|
|
self._deal_new_game()
|
|
|
|
def clear_hint(self) -> None:
|
|
"""Drop the currently remembered hint highlight."""
|
|
self.hint_move = None
|
|
|
|
def _deal_new_game(self) -> None:
|
|
"""Build and shuffle a standard 52-card deck, then deal to 8 columns."""
|
|
deck: List[Card] = [Card(rank, suit) for suit in SUITS for rank in RANKS]
|
|
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 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, ""
|
|
|
|
def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]:
|
|
"""Return the currently selected stack slice for a tableau column."""
|
|
column = self.columns[col_index]
|
|
if not column:
|
|
return [], "That column is empty."
|
|
|
|
if self.visual_mode and self.cursor_zone == "bottom":
|
|
return self.current_visual_stack()
|
|
|
|
cards = [column[-1]]
|
|
|
|
return cards, ""
|
|
|
|
def can_place_stack_on_column(self, cards: List[Card], col_index: int) -> bool:
|
|
"""Return True if a valid held stack can be dropped on a tableau column."""
|
|
if not self.is_valid_tableau_sequence(cards):
|
|
return 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 count_hidden_low_cards(self) -> int:
|
|
"""Count aces and twos that are not currently exposed."""
|
|
count = 0
|
|
for column in self.columns:
|
|
for card in column[:-1]:
|
|
if card.value <= 2:
|
|
count += 1
|
|
return count
|
|
|
|
def card_can_help_foundation(self, card: Card) -> bool:
|
|
"""Return True for low cards that usually unlock early progress."""
|
|
return card.value <= 3
|
|
|
|
def describe_hint_move(self, move: HintMove) -> str:
|
|
"""Return a compact human-readable hint description."""
|
|
if move.source_type == "col":
|
|
source_name = f"column {move.source_index + 1}"
|
|
else:
|
|
source_name = f"free cell {move.source_index + 1}"
|
|
|
|
if move.dest_type == "col":
|
|
dest_name = f"column {move.dest_index + 1}"
|
|
elif move.dest_type == "free":
|
|
dest_name = f"free cell {move.dest_index + 1}"
|
|
else:
|
|
dest_name = f"{SUITS[move.dest_index].name} foundation"
|
|
|
|
if len(move.cards) == 1:
|
|
return (
|
|
f"move {move.cards[0].short_name()} from {source_name} to {dest_name}"
|
|
)
|
|
|
|
return (
|
|
f"move {len(move.cards)} cards {move.cards[0].short_name()}..{move.cards[-1].short_name()} "
|
|
f"from {source_name} to {dest_name}"
|
|
)
|
|
|
|
def score_hint_move(
|
|
self,
|
|
source_type: str,
|
|
source_index: int,
|
|
dest_type: str,
|
|
dest_index: int,
|
|
cards: List[Card],
|
|
) -> int:
|
|
"""Assign a cheap deterministic score to a legal move."""
|
|
score = 0
|
|
moving_top = cards[0]
|
|
|
|
source_column = self.columns[source_index] if source_type == "col" else None
|
|
source_exposes = None
|
|
hidden_low_before = self.count_hidden_low_cards()
|
|
|
|
if source_type == "col":
|
|
assert source_column is not None
|
|
remaining = source_column[: -len(cards)]
|
|
source_exposes = remaining[-1] if remaining else None
|
|
if not remaining:
|
|
score += 250
|
|
if source_exposes and self.card_can_help_foundation(source_exposes):
|
|
score += 320
|
|
|
|
if dest_type == "foundation":
|
|
score += 1000 + moving_top.value * 5
|
|
elif dest_type == "col":
|
|
destination = self.columns[dest_index]
|
|
if destination:
|
|
score += 220 + len(cards) * 25
|
|
if len(cards) > 1:
|
|
score += 120
|
|
else:
|
|
score += 60 if len(cards) > 1 else -120
|
|
elif dest_type == "free":
|
|
score -= 80
|
|
if moving_top.value <= 3:
|
|
score += 140
|
|
|
|
if source_exposes and self.can_move_to_foundation(source_exposes):
|
|
score += 180
|
|
|
|
if moving_top.value <= 3:
|
|
score += 80
|
|
|
|
hidden_low_after = hidden_low_before
|
|
if source_type == "col" and source_exposes and source_exposes.value <= 2:
|
|
hidden_low_after -= 1
|
|
score += (hidden_low_before - hidden_low_after) * 120
|
|
|
|
if source_type == "free" and dest_type == "col":
|
|
score += 200
|
|
|
|
return score
|
|
|
|
def enumerate_hint_moves(self) -> List[HintMove]:
|
|
"""Enumerate cheap one-ply legal moves for hinting."""
|
|
moves: List[HintMove] = []
|
|
|
|
for col_index, column in enumerate(self.columns):
|
|
if not column:
|
|
continue
|
|
|
|
top_card = column[-1]
|
|
if self.can_move_to_foundation(top_card):
|
|
cards = (top_card,)
|
|
moves.append(
|
|
HintMove(
|
|
source_type="col",
|
|
source_index=col_index,
|
|
dest_type="foundation",
|
|
dest_index=SUITS.index(top_card.suit),
|
|
cards=cards,
|
|
score=self.score_hint_move(
|
|
"col",
|
|
col_index,
|
|
"foundation",
|
|
SUITS.index(top_card.suit),
|
|
[top_card],
|
|
),
|
|
)
|
|
)
|
|
|
|
free_idx = self.first_empty_freecell()
|
|
if free_idx is not None:
|
|
cards = (top_card,)
|
|
moves.append(
|
|
HintMove(
|
|
source_type="col",
|
|
source_index=col_index,
|
|
dest_type="free",
|
|
dest_index=free_idx,
|
|
cards=cards,
|
|
score=self.score_hint_move(
|
|
"col", col_index, "free", free_idx, [top_card]
|
|
),
|
|
)
|
|
)
|
|
|
|
tail_start = self.valid_tail_start_index(col_index)
|
|
if tail_start is None:
|
|
continue
|
|
|
|
for start_index in range(len(column) - 1, tail_start - 1, -1):
|
|
cards_list = column[start_index:]
|
|
if len(cards_list) > self.max_movable_cards(col_index, len(cards_list)):
|
|
continue
|
|
for dest_index in range(8):
|
|
if dest_index == col_index:
|
|
continue
|
|
if self.can_place_stack_on_column(cards_list, dest_index):
|
|
moves.append(
|
|
HintMove(
|
|
source_type="col",
|
|
source_index=col_index,
|
|
dest_type="col",
|
|
dest_index=dest_index,
|
|
cards=tuple(cards_list),
|
|
score=self.score_hint_move(
|
|
"col", col_index, "col", dest_index, cards_list
|
|
),
|
|
)
|
|
)
|
|
|
|
for free_index, card in enumerate(self.freecells):
|
|
if card is None:
|
|
continue
|
|
|
|
if self.can_move_to_foundation(card):
|
|
moves.append(
|
|
HintMove(
|
|
source_type="free",
|
|
source_index=free_index,
|
|
dest_type="foundation",
|
|
dest_index=SUITS.index(card.suit),
|
|
cards=(card,),
|
|
score=self.score_hint_move(
|
|
"free",
|
|
free_index,
|
|
"foundation",
|
|
SUITS.index(card.suit),
|
|
[card],
|
|
),
|
|
)
|
|
)
|
|
|
|
for dest_index in range(8):
|
|
if self.can_place_stack_on_column([card], dest_index):
|
|
moves.append(
|
|
HintMove(
|
|
source_type="free",
|
|
source_index=free_index,
|
|
dest_type="col",
|
|
dest_index=dest_index,
|
|
cards=(card,),
|
|
score=self.score_hint_move(
|
|
"free", free_index, "col", dest_index, [card]
|
|
),
|
|
)
|
|
)
|
|
|
|
moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move)))
|
|
return moves
|
|
|
|
def show_hint(self) -> None:
|
|
"""Pick the best cheap legal move and remember it for highlighting."""
|
|
if self.held is not None:
|
|
self.status = "Drop or cancel the held cards before asking for a hint."
|
|
return
|
|
|
|
moves = self.enumerate_hint_moves()
|
|
if not moves:
|
|
self.hint_move = None
|
|
self.status = "Hint: no obvious legal move found."
|
|
return
|
|
|
|
self.hint_move = moves[0]
|
|
self.status = f"Hint: {self.describe_hint_move(self.hint_move)}."
|
|
|
|
def hinted_freecell(self, index: int) -> bool:
|
|
"""Return True when a freecell is part of the current hint."""
|
|
if self.hint_move is None:
|
|
return False
|
|
return (
|
|
self.hint_move.source_type == "free"
|
|
and self.hint_move.source_index == index
|
|
) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index)
|
|
|
|
def hinted_foundation(self, index: int) -> bool:
|
|
"""Return True when a foundation is the current hint destination."""
|
|
return (
|
|
self.hint_move is not None
|
|
and self.hint_move.dest_type == "foundation"
|
|
and self.hint_move.dest_index == index
|
|
)
|
|
|
|
def hinted_column_source_start(self, index: int) -> Optional[int]:
|
|
"""Return the start row for a hinted tableau source stack."""
|
|
if self.hint_move is None:
|
|
return None
|
|
if self.hint_move.source_type != "col" or self.hint_move.source_index != index:
|
|
return None
|
|
return len(self.columns[index]) - len(self.hint_move.cards)
|
|
|
|
def hinted_column_destination(self, index: int) -> bool:
|
|
"""Return True when a tableau column is the hinted destination."""
|
|
return (
|
|
self.hint_move is not None
|
|
and self.hint_move.dest_type == "col"
|
|
and self.hint_move.dest_index == index
|
|
)
|
|
|
|
def enter_visual_mode(self) -> None:
|
|
"""Enter tableau visual selection mode on the current column."""
|
|
if self.held is not None:
|
|
self.status = "Drop the held cards before selecting a new stack."
|
|
return
|
|
if self.cursor_zone != "bottom":
|
|
self.status = "Visual mode only works on tableau columns."
|
|
return
|
|
|
|
column = self.columns[self.cursor_index]
|
|
if not column:
|
|
self.status = "That column is empty."
|
|
return
|
|
|
|
self.visual_mode = 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()
|
|
|
|
if self.visual_row == old_row:
|
|
self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}."
|
|
return
|
|
|
|
if reason:
|
|
self.status = f"Visual: {len(cards)} cards selected. {reason}"
|
|
else:
|
|
self.status = (
|
|
f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected."
|
|
)
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Movement / interactions
|
|
# -----------------------------------------------------------------------
|
|
|
|
def pick_up(self) -> None:
|
|
"""
|
|
Pick up the bottom visible card from either:
|
|
- a tableau column
|
|
- a freecell slot
|
|
"""
|
|
if self.held is not None:
|
|
self.status = "You are already holding cards."
|
|
return
|
|
|
|
if self.cursor_zone == "bottom":
|
|
cards, reason = self.selected_stack_from_column(self.cursor_index)
|
|
if not cards:
|
|
self.status = reason
|
|
return
|
|
|
|
if reason:
|
|
self.status = reason
|
|
return
|
|
|
|
moving_count = len(cards)
|
|
self.clear_hint()
|
|
del self.columns[self.cursor_index][-moving_count:]
|
|
self.held = HeldCards(
|
|
cards=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}."
|
|
self.exit_visual_mode()
|
|
return
|
|
|
|
# top zone: indexes 0..3 freecells, 4..7 foundations
|
|
if self.cursor_index < 4:
|
|
card = self.freecells[self.cursor_index]
|
|
if card is None:
|
|
self.status = "That free cell is empty."
|
|
return
|
|
self.clear_hint()
|
|
self.freecells[self.cursor_index] = None
|
|
self.held = HeldCards(
|
|
cards=[card], source_type="free", source_index=self.cursor_index
|
|
)
|
|
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
|
|
self.clear_hint()
|
|
|
|
def drop(self) -> None:
|
|
"""
|
|
Drop the held card onto:
|
|
- a column
|
|
- a freecell
|
|
- a foundation
|
|
depending on the current cursor position and legal rules.
|
|
"""
|
|
if self.held is None:
|
|
self.status = "You are not holding a card."
|
|
return
|
|
|
|
cards = self.held.cards
|
|
moving_count = len(cards)
|
|
moving_top = cards[0]
|
|
|
|
if self.cursor_zone == "bottom":
|
|
if self.can_place_stack_on_column(cards, self.cursor_index):
|
|
self.clear_hint()
|
|
self.columns[self.cursor_index].extend(cards)
|
|
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.clear_hint()
|
|
self.freecells[self.cursor_index] = moving_top
|
|
self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}."
|
|
self.held = None
|
|
else:
|
|
self.status = "That free cell is occupied."
|
|
return
|
|
|
|
# drop to foundation
|
|
if moving_count != 1:
|
|
self.status = "Foundations accept only one card at a time."
|
|
return
|
|
|
|
if self.can_move_to_foundation(moving_top):
|
|
self.clear_hint()
|
|
foundation = self.foundation_for_suit(moving_top.suit.name)
|
|
foundation.append(moving_top)
|
|
self.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.visual_mode:
|
|
self.status = "Quick freecell move uses only the bottom card."
|
|
return
|
|
self.clear_hint()
|
|
self.freecells[free_idx] = col.pop()
|
|
self.status = f"Moved card to free cell {free_idx + 1}."
|
|
return
|
|
|
|
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.visual_mode:
|
|
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)
|
|
self.clear_hint()
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
def init_colors() -> None:
|
|
"""Initialize curses color pairs."""
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
|
|
# Pair 1: regular text
|
|
curses.init_pair(1, curses.COLOR_WHITE, -1)
|
|
|
|
# Pair 2: red cards / symbols
|
|
curses.init_pair(2, curses.COLOR_RED, -1)
|
|
|
|
# Pair 3: black-group cards rendered as cyan for visibility on dark terms
|
|
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
|
|
|
# Pair 4: highlighted cursor
|
|
curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
|
|
|
# Pair 5: held card
|
|
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
|
|
|
# Pair 6: hint highlight
|
|
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
|
|
|
|
def card_color_pair(card: 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, layout: BoardLayout) -> None:
|
|
"""Draw freecells and foundations."""
|
|
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,
|
|
)
|
|
|
|
# Freecells at indexes 0..3
|
|
for i in range(4):
|
|
x = layout.freecell_xs[i]
|
|
selected = game.cursor_zone == "top" and game.cursor_index == i
|
|
hinted = game.hinted_freecell(i)
|
|
attr = (
|
|
curses.color_pair(4)
|
|
if selected
|
|
else curses.color_pair(6)
|
|
if hinted
|
|
else curses.color_pair(1)
|
|
)
|
|
|
|
card = game.freecells[i]
|
|
draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr)
|
|
|
|
if card:
|
|
stdscr.addnstr(
|
|
TOP_Y + 1,
|
|
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 = layout.foundation_xs[i]
|
|
selected = game.cursor_zone == "top" and game.cursor_index == i + 4
|
|
hinted = game.hinted_foundation(i)
|
|
attr = (
|
|
curses.color_pair(4)
|
|
if selected
|
|
else curses.color_pair(6)
|
|
if hinted
|
|
else curses.color_pair(1)
|
|
)
|
|
|
|
foundation = game.foundations[i]
|
|
top_card = foundation[-1] if foundation else None
|
|
label = draw_card_label(top_card) if top_card else SUITS[i].glyph
|
|
draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr)
|
|
|
|
if top_card:
|
|
stdscr.addnstr(
|
|
TOP_Y + 1,
|
|
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 + 1, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color)
|
|
)
|
|
|
|
|
|
def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None:
|
|
"""Draw the tableau columns."""
|
|
stdscr.addstr(
|
|
COL_Y - 1,
|
|
layout.tableau_label_x,
|
|
"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 = layout.tableau_xs[col_idx]
|
|
selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx
|
|
hinted_dest = game.hinted_column_destination(col_idx)
|
|
header_attr = (
|
|
curses.color_pair(4)
|
|
if selected
|
|
else curses.color_pair(6)
|
|
if hinted_dest
|
|
else curses.color_pair(1)
|
|
)
|
|
|
|
draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr)
|
|
|
|
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 = ""
|
|
valid_tail_start = game.valid_tail_start_index(col_idx)
|
|
if valid_tail_start is None:
|
|
valid_tail_start = len(column)
|
|
hint_source_start = game.hinted_column_source_start(col_idx)
|
|
|
|
if selected and game.visual_mode:
|
|
selected_cards, selection_reason = game.selected_stack_from_column(col_idx)
|
|
|
|
selected_start = (
|
|
len(column) - len(selected_cards) if selected_cards else len(column)
|
|
)
|
|
|
|
for row_idx, card in enumerate(column):
|
|
y = COL_Y + 1 + row_idx
|
|
|
|
in_valid_tail = (
|
|
selected and game.visual_mode and row_idx >= valid_tail_start
|
|
)
|
|
in_selected_stack = (
|
|
selected and game.visual_mode and row_idx >= selected_start
|
|
)
|
|
in_hint_source = (
|
|
hint_source_start is not None and row_idx >= hint_source_start
|
|
)
|
|
if in_selected_stack:
|
|
attr = curses.color_pair(4)
|
|
if selection_reason:
|
|
attr |= curses.A_DIM
|
|
else:
|
|
attr |= curses.A_BOLD
|
|
elif in_hint_source:
|
|
attr = curses.color_pair(6) | curses.A_BOLD
|
|
elif in_valid_tail:
|
|
attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM
|
|
elif selected and row_idx == len(column) - 1:
|
|
attr = curses.color_pair(4)
|
|
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 v: visual y/p: pick/drop ?: hint Enter/Space: pick/drop f: freecell d: foundation Esc: cancel q: quit"
|
|
stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1))
|
|
|
|
|
|
def render(stdscr, game: FreeCellGame) -> None:
|
|
"""Redraw the full screen."""
|
|
stdscr.erase()
|
|
max_y, max_x = stdscr.getmaxyx()
|
|
layout = compute_board_layout(max_x)
|
|
|
|
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,
|
|
)
|
|
|
|
if game.visual_mode:
|
|
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
|
|
)
|
|
|
|
draw_top_row(stdscr, game, layout)
|
|
draw_columns(stdscr, game, layout)
|
|
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:
|
|
if game.visual_mode and game.cursor_zone == "bottom":
|
|
game.move_visual_selection(-1)
|
|
return
|
|
game.cursor_zone = "top"
|
|
game.cursor_index = min(game.cursor_index, 7)
|
|
return
|
|
|
|
if dy > 0:
|
|
if game.visual_mode and game.cursor_zone == "bottom":
|
|
game.move_visual_selection(1)
|
|
return
|
|
game.cursor_zone = "bottom"
|
|
game.cursor_index = min(game.cursor_index, 7)
|
|
return
|
|
|
|
# Horizontal motion
|
|
if game.visual_mode and dx != 0:
|
|
game.exit_visual_mode("Visual selection cancelled.")
|
|
return
|
|
|
|
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 in (ord("v"), ord("V")):
|
|
if game.visual_mode:
|
|
game.exit_visual_mode("Visual selection cancelled.")
|
|
else:
|
|
game.enter_visual_mode()
|
|
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
|
|
|
|
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
|
|
|
|
if ch == ord("?"):
|
|
game.show_hint()
|
|
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.visual_mode:
|
|
game.exit_visual_mode("Visual selection cancelled.")
|
|
elif 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()
|