diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f05978c --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; version 2 of the License. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index 4b66fae..25fdd72 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ # mcfreecell -`mcfreecell` on `leg-py1.5.2` is a terminal-native FreeCell build aimed at old Python and old curses environments. +`mcfreecell` is a terminal-native FreeCell game with a systems-theme twist. -This branch keeps the implementation within a Python-1.5.2-friendly subset and favors compatibility over modern terminal features. +It has two flavors: + +- a modern curses build with Linux-themed suits and richer terminal rendering +- a legacy branch aimed at very old Python and curses environments, including Python 1.5.2-era machines + +The project started as a novelty FreeCell prototype and grew into a playable, keyboard-driven curses card game with legal multi-card moves, visual stack selection, and lightweight move hints. ## Features - FreeCell rules with 8 tableau columns, 4 free cells, and 4 foundations -- keyboard-driven curses interface +- legal multi-card tableau moves with proper FreeCell move-capacity limits +- keyboard-driven play in a curses interface - visual stack selection mode -- single-step undo for completed moves -- lightweight `?` hint system -- ASCII-only suit tags for old terminals and fonts -- compatibility fallbacks for weak or incomplete curses implementations +- lightweight `?` hint system for suggesting a good next move +- responsive layout for wider terminals +- legacy branch compatible with old machines and older Python/curses behavior ## Controls @@ -20,15 +25,54 @@ This branch keeps the implementation within a Python-1.5.2-friendly subset and f - `v` - enter or exit tableau selection mode - `y` - pick up selected card or stack - `p` - drop held card or stack -- `u` - undo the last completed move - `Enter` / `Space` - alternate pick up / drop +- `u` - undo the last completed move - `?` - show a suggested move - `f` - quick move selected card to a free cell - `d` - quick move selected card to foundation -- `Esc` - cancel held move +- `Esc` - cancel selection or restore held cards - `q` - quit -## Suits +## Branches + +### Main branch + +The main branch targets modern Python 3 and modern terminals. + +This version includes: + +- Linux distro themed suits +- modern curses layout and highlighting +- Nerd Font-oriented suit glyphs +- richer visual presentation + +Current main-branch suits: + +- Debian +- Gentoo +- Arch +- Slackware + +These are split into two alternating-color groups for FreeCell rules: + +- red group: Debian, Gentoo +- black group: Arch, Slackware + +The main branch uses special glyphs for suit rendering. If your terminal or font does not display them well, Nerd Fonts can be downloaded for free at `https://www.nerdfonts.com/font-downloads`. + +### `leg-py1.5.2` branch + +The `leg-py1.5.2` branch is a backport for very old systems. + +This version is written in a Python-1.5.2-friendly style and avoids relying on modern Python features or modern terminal niceties. + +This branch includes: + +- ASCII-only suit tags +- compatibility fallbacks for weak or incomplete curses implementations +- visible ASCII cursor and hint markers for monochrome terminals + +Legacy-branch suits: - Debian (`Deb`) - Red Hat (`RHt`) @@ -40,24 +84,49 @@ These are split into the two FreeCell color groups as: - red group: Debian, Red Hat - black-equivalent group: Solaris, HP-UX -On older terminals, the non-red group may render as blue, cyan, or plain monochrome depending on curses support. +On legacy terminals, the non-red group may render as blue, cyan, or plain monochrome depending on curses support. ## Running -Run with whatever Python interpreter is available on the target machine: +### Modern branch + +Run with Python 3: + +```sh +python3 linux-freecell.py +``` + +### Legacy branch + +On the legacy branch, run with whatever Python interpreter is available on the target machine: ```sh python linux-freecell.py ``` -## Terminal Notes +For very old systems, terminal and curses support may vary. The legacy branch tries to fall back gracefully when color, standout attributes, or newer curses APIs are missing. -Terminal and curses behavior can vary significantly on older systems. +## Suit Theme Notes -- `xterm` generally gives the most reliable screen redraw behavior for this branch. -- Older `konsole` builds, including KDE 2.x-era versions, may show redraw artifacts or ghosting until the screen is resized. -- The game includes several repaint fallbacks for weaker curses implementations, but some VT100-style emulator quirks are still terminal-specific. +`mcfreecell` is not trying to be a generic casino card game. The suit sets are themed around operating systems and Unix history. + +The modern branch leans into playful terminal-native Linux theming. + +The legacy branch uses a more period-appropriate late-1990s / early-2000s mix of Linux and proprietary Unix systems to better fit older hardware and software environments. + +## License + +This project is released under the GNU General Public License, version 2 only. + +See `LICENSE` for the full license text. ## Status -This branch is intended as a playable legacy-friendly FreeCell build for old Unix-like systems and older Python environments. +This is a small personal curses game project, but it is playable and actively being shaped into both: + +- a fun modern terminal FreeCell +- a practical legacy-machine FreeCell + +## Possible Future Work + +- packaged releases for modern and legacy branches diff --git a/linux-freecell.py b/linux-freecell.py index 1fe99b0..f0939e6 100644 --- a/linux-freecell.py +++ b/linux-freecell.py @@ -1,14 +1,8 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ -mcfreecell +linux_freecell.py -A curses-based FreeCell game aimed at legacy Python and terminal setups. - -This branch keeps the game within a Python 1.5.2-friendly subset: -- no dataclasses -- no type annotations -- no f-strings -- ASCII-only suit tags +A curses-based FreeCell prototype that uses Linux distro themed suits. Controls -------- @@ -23,819 +17,731 @@ 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 import time - -try: - import random -except ImportError: - random = None - -try: - import whrandom # type: ignore -except ImportError: - whrandom = None +from dataclasses import dataclass +from typing import List, Optional, Tuple -try: - enumerate -except NameError: - - def enumerate(sequence): - index = 0 - result = [] - for item in sequence: - result.append((index, item)) - index = index + 1 - return result - +# --------------------------------------------------------------------------- +# Card model +# --------------------------------------------------------------------------- RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] -RANK_VALUES = {} -_rank_index = 0 -for _rank_name in RANKS: - _rank_index = _rank_index + 1 - RANK_VALUES[_rank_name] = _rank_index -del _rank_index - - -CARD_W = 7 -TOP_Y = 1 -COL_Y = 6 - - -def find_suit_index(suit_name): - index = 0 - for suit in SUITS: - if suit.name == suit_name: - return index - index = index + 1 - return 0 - - -def count_sequence(sequence): - count = 0 - for _item in sequence: - count = count + 1 - return count - - -def safe_addnstr(stdscr, y, x, text, limit, attr): - if limit <= 0: - return - max_y, max_x = stdscr.getmaxyx() - if y < 0 or y >= max_y or x >= max_x: - return - if x < 0: - text = text[-x:] - limit = limit + x - x = 0 - if limit <= 0: - return - width = max_x - x - if width <= 0: - return - if limit > width: - limit = width - text = text[:limit] - try: - if hasattr(stdscr, "addnstr"): - stdscr.addnstr(y, x, text, limit, attr) - else: - stdscr.addstr(y, x, text, attr) - except curses.error: - pass - - -def safe_addstr(stdscr, y, x, text, attr): - safe_addnstr(stdscr, y, x, text, len(text), attr) - - -def pair_attr(index): - if hasattr(curses, "color_pair"): - try: - return curses.color_pair(index) - except: - return 0 - return 0 - - -def text_attr(name): - if hasattr(curses, name): - try: - return getattr(curses, name) - except: - return 0 - return 0 - - -def highlight_attr(): - attr = text_attr("A_REVERSE") - if attr: - return attr - attr = text_attr("A_STANDOUT") - if attr: - return attr - attr = text_attr("A_BOLD") - if attr: - return attr - attr = text_attr("A_UNDERLINE") - if attr: - return attr - return 0 - - -def pad_right(text, width): - if width <= 0: - return "" - if len(text) > width: - text = text[:width] - if len(text) < width: - text = text + (" " * (width - len(text))) - return text - - -def pad_center(text, width): - if width <= 0: - return "" - if len(text) > width: - text = text[:width] - pad = width - len(text) - if pad <= 0: - return text - left = int(pad / 2) - right = pad - left - return (" " * left) + text + (" " * right) - - -def format_elapsed_time(seconds): - if seconds < 0: - seconds = 0 - minutes = int(seconds / 60) - remaining = seconds % 60 - return "%02d:%02d" % (minutes, remaining) - - -def rounded_position(start_x, span, index, steps): - if steps <= 0: - return start_x - return start_x + int((float(span) * float(index) / float(steps)) + 0.5) - - -def legacy_random(): - if random is not None and hasattr(random, "random"): - return random.random() - if whrandom is not None and hasattr(whrandom, "random"): - return whrandom.random() - return 0.5 - - -def shuffle_in_place(sequence): - index = len(sequence) - 1 - while index > 0: - swap_index = int(legacy_random() * (index + 1)) - if swap_index < 0: - swap_index = 0 - if swap_index > index: - swap_index = index - temp = sequence[index] - sequence[index] = sequence[swap_index] - sequence[swap_index] = temp - index = index - 1 +RANK_VALUES = {rank: i + 1 for i, rank in enumerate(RANKS)} +@dataclass(frozen=True) class SuitInfo: - def __init__(self, name, tag, color_group): - self.name = name - self.tag = tag - self.color_group = color_group + """Describes one suit in our Linux-themed deck.""" + + name: str + glyph: str + color_group: str # "red" or "black" +@dataclass class Card: - def __init__(self, rank, suit): - self.rank = rank - self.suit = suit + """Represents one playing card.""" - def value(self): + rank: str + suit: SuitInfo + + @property + def value(self) -> int: return RANK_VALUES[self.rank] - def color_group(self): + @property + def color_group(self) -> str: return self.suit.color_group - def short_name(self): - return self.rank + self.suit.tag + def short_name(self) -> str: + return f"{self.rank}{self.suit.glyph}" +# --------------------------------------------------------------------------- +# Suit set +# +# Replace glyphs below with Nerd Font icons you prefer. +# These defaults are deliberately conservative so they are more likely to +# render in ordinary terminals. +# --------------------------------------------------------------------------- + SUITS = [ - SuitInfo("Debian", "Deb", "red"), - SuitInfo("Red Hat", "RHt", "red"), - SuitInfo("Solaris", "Sol", "black"), - SuitInfo("HP-UX", "HPx", "black"), + SuitInfo(name="Debian", glyph="", color_group="red"), + SuitInfo(name="Gentoo", glyph="", color_group="red"), + SuitInfo(name="Arch", glyph="󰣇", color_group="black"), + SuitInfo(name="Slackware", glyph="", color_group="black"), ] +# --------------------------------------------------------------------------- +# Board / game state +# --------------------------------------------------------------------------- + + +@dataclass class HeldCards: - def __init__(self, cards, source_type, source_index): - self.cards = cards - self.source_type = source_type - self.source_index = source_index + """Represents one or more cards currently picked up by the player.""" + + cards: List[Card] + source_type: str # "col" or "free" + source_index: int +@dataclass(frozen=True) class HintMove: - def __init__(self, source_type, source_index, dest_type, dest_index, cards, score): - self.source_type = source_type - self.source_index = source_index - self.dest_type = dest_type - self.dest_index = dest_index - self.cards = cards - self.score = score + """Describes one suggested legal move for the current board.""" + + source_type: str + source_index: int + dest_type: str + dest_index: int + cards: Tuple[Card, ...] + score: int +@dataclass(frozen=True) class MoveRecord: - def __init__(self, source_type, source_index, dest_type, dest_index, cards): - self.source_type = source_type - self.source_index = source_index - self.dest_type = dest_type - self.dest_index = dest_index - self.cards = cards + """Describes one completed move that can be undone.""" - -class BoardLayout: - def __init__( - self, - freecell_xs, - foundation_xs, - tableau_xs, - freecells_label_x, - foundations_label_x, - tableau_label_x, - ): - self.freecell_xs = freecell_xs - self.foundation_xs = foundation_xs - self.tableau_xs = tableau_xs - self.freecells_label_x = freecells_label_x - self.foundations_label_x = foundations_label_x - self.tableau_label_x = tableau_label_x + source_type: str + source_index: int + dest_type: str + dest_index: int + cards: Tuple[Card, ...] class FreeCellGame: - def __init__(self): - self.columns = [[], [], [], [], [], [], [], []] - self.freecells = [None, None, None, None] - self.foundations = [[], [], [], []] - self.held = None - self.hint_move = None - self.move_history = [] - self.visual_mode = 0 + """Holds the state and rules for a small FreeCell prototype.""" + + def __init__(self) -> None: + self.columns: List[List[Card]] = [[] for _ in range(8)] + self.freecells: List[Optional[Card]] = [None] * 4 + self.foundations: List[List[Card]] = [[] for _ in range(4)] + self.held: Optional[HeldCards] = None + self.hint_move: Optional[HintMove] = None + self.move_history: List[MoveRecord] = [] + self.visual_mode = False self.visual_row = 0 + + # Cursor zones: + # "top" -> freecells + foundations + # "bottom" -> tableau columns self.cursor_zone = "bottom" self.cursor_index = 0 - self.status = "Arrows move, v selects, ? hints." - self.start_time = time.time() - self.finished_time_seconds = None - self.needs_full_repaint = 0 + + self.status = "Arrow keys move, v selects a stack, ? shows a hint." + self.start_time = time.monotonic() + self.finished_time_seconds: Optional[int] = None self._deal_new_game() - def request_full_repaint(self): - self.needs_full_repaint = 1 - - def elapsed_time_seconds(self): + 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.time() - self.start_time) - if elapsed < 0: - elapsed = 0 + + elapsed = int(time.monotonic() - self.start_time) if self.is_won(): self.finished_time_seconds = elapsed return self.finished_time_seconds return elapsed - def clear_hint(self): - had_hint = self.hint_move is not None + def clear_hint(self) -> None: + """Drop the currently remembered hint highlight.""" self.hint_move = None - if had_hint: - self.request_full_repaint() - def record_move(self, source_type, source_index, dest_type, dest_index, cards): + 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_index, dest_type, dest_index, cards[:]) + 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): - cards = move.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): - return 0 - if column[-len(cards) :] != cards: - return 0 + if len(column) < len(cards) or column[-len(cards) :] != cards: + return False del column[-len(cards) :] - return 1 + return True + if move.dest_type == "free": if self.freecells[move.dest_index] != cards[0]: - return 0 + return False self.freecells[move.dest_index] = None - return 1 + return True + foundation = self.foundations[move.dest_index] if not foundation or foundation[-1] != cards[0]: - return 0 + return False foundation.pop() - return 1 + 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) - def _restore_cards_to_source(self, move): - cards = move.cards if move.source_type == "col": self.columns[move.source_index].extend(cards) return + self.freecells[move.source_index] = cards[0] - def undo_last_move(self): + 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): self.status = ( "Undo failed because the board no longer matches the last move." ) return - del self.move_history[-1] + + self.move_history.pop() + self._restore_cards_to_source(move) self.clear_hint() if self.visual_mode: - self.exit_visual_mode(None) - self.request_full_repaint() - if len(move.cards) == 1: - self.status = "Undid move of %s." % move.cards[0].short_name() + 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 = "Undid move of %d cards." % len(move.cards) + self.status = f"Undid move of {move_count} cards." - def _deal_new_game(self): - deck = [] - for suit in SUITS: - for rank in RANKS: - deck.append(Card(rank, suit)) - shuffle_in_place(deck) - index = 0 - for card in deck: - self.columns[index % 8].append(card) - index = index + 1 + def _deal_new_game(self) -> None: + """Build and shuffle a standard 52-card deck, then deal to 8 columns.""" + deck: List[Card] = [Card(rank, suit) for suit in SUITS for rank in RANKS] + random.shuffle(deck) - def is_valid_tableau_sequence(self, cards): + 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 0 - index = 0 - while index < len(cards) - 1: - upper = cards[index] - lower = cards[index + 1] - if upper.value() != lower.value() + 1: - return 0 - if upper.color_group() == lower.color_group(): - return 0 - index = index + 1 - return 1 + return False - def count_empty_freecells(self): - count = 0 - for cell in self.freecells: - if cell is None: - count = count + 1 - return count + for upper, lower in zip(cards, cards[1:]): + if upper.value != lower.value + 1: + return False + if upper.color_group == lower.color_group: + return False - def count_empty_columns(self, exclude_index): - count = 0 - index = 0 - for column in self.columns: - if index != exclude_index and not column: - count = count + 1 - index = index + 1 - return count + return True - def max_movable_cards(self, source_col, moving_count): - if moving_count == len(self.columns[source_col]): - empty_columns = self.count_empty_columns(source_col) - else: - empty_columns = self.count_empty_columns(-1) + def count_empty_freecells(self) -> int: + """Count currently open freecells.""" + return sum(1 for cell in self.freecells if cell is None) + + def count_empty_columns(self, exclude_index: Optional[int] = None) -> int: + """Count empty tableau columns, optionally excluding one column.""" + empty_count = 0 + for col_index, column in enumerate(self.columns): + if col_index == exclude_index: + continue + if not column: + empty_count += 1 + return empty_count + + def max_movable_cards(self, source_col: int, moving_count: int) -> int: + """Return the maximum legal stack size movable from a tableau column.""" + source_becomes_empty = moving_count == len(self.columns[source_col]) + empty_columns = self.count_empty_columns( + exclude_index=source_col if source_becomes_empty else None + ) return (self.count_empty_freecells() + 1) * (2**empty_columns) - def valid_tail_start_index(self, col_index): + def valid_tail_start_index(self, col_index: int) -> Optional[int]: + """Return the first row of the bottom valid descending alternating tail.""" column = self.columns[col_index] if not column: return None + start = len(column) - 1 while start > 0: upper = column[start - 1] lower = column[start] - if upper.value() != lower.value() + 1: + if upper.value != lower.value + 1: break - if upper.color_group() == lower.color_group(): + if upper.color_group == lower.color_group: break - start = start - 1 + start -= 1 + return start - def current_visual_stack(self): + def current_visual_stack(self) -> Tuple[List[Card], str]: + """Return the currently highlighted tableau stack while in visual mode.""" if self.cursor_zone != "bottom": return [], "Visual mode only works on tableau columns." + column = self.columns[self.cursor_index] if not column: return [], "That column is empty." + tail_start = self.valid_tail_start_index(self.cursor_index) if tail_start is None: return [], "That column is empty." - start_index = self.visual_row - if start_index < tail_start: - start_index = tail_start - if start_index > len(column) - 1: - start_index = len(column) - 1 + + start_index = max(tail_start, min(self.visual_row, len(column) - 1)) cards = column[start_index:] max_cards = self.max_movable_cards(self.cursor_index, len(cards)) if len(cards) > max_cards: - return cards, "Move limit is %d cards with current free space." % max_cards + return cards, f"Move limit is {max_cards} cards with current free space." + return cards, "" - def selected_stack_from_column(self, col_index): + def selected_stack_from_column(self, col_index: int) -> Tuple[List[Card], str]: + """Return the currently selected stack slice for a tableau column.""" column = self.columns[col_index] if not column: return [], "That column is empty." + if self.visual_mode and self.cursor_zone == "bottom": return self.current_visual_stack() - return [column[-1]], "" - def can_place_stack_on_column(self, cards, col_index): + cards = [column[-1]] + + return cards, "" + + def can_place_stack_on_column(self, cards: List[Card], col_index: int) -> bool: + """Return True if a valid held stack can be dropped on a tableau column.""" if not self.is_valid_tableau_sequence(cards): - return 0 + return False + column = self.columns[col_index] if not column: - return 1 + return True + moving_top = cards[0] destination_top = column[-1] - if destination_top.value() != moving_top.value() + 1: - return 0 - if destination_top.color_group() == moving_top.color_group(): - return 0 - return 1 + return ( + destination_top.value == moving_top.value + 1 + and destination_top.color_group != moving_top.color_group + ) - def can_move_to_foundation(self, card): + def can_place_on_column(self, card: Card, col_index: int) -> bool: + """ + FreeCell tableau rule: + - Empty column accepts any card. + - Otherwise, placed card must be one rank lower than destination top, + and opposite color group. + """ + column = self.columns[col_index] + if not column: + return True + + top = column[-1] + return top.value == card.value + 1 and top.color_group != card.color_group + + def can_move_to_foundation(self, card: Card) -> bool: + """ + Foundation rule: + - Each suit builds upward from Ace to King. + """ foundation = self.foundation_for_suit(card.suit.name) if not foundation: return card.rank == "A" + top = foundation[-1] - if top.suit.name != card.suit.name: - return 0 - return card.value() == top.value() + 1 + return top.suit.name == card.suit.name and card.value == top.value + 1 - def foundation_for_suit(self, suit_name): - return self.foundations[find_suit_index(suit_name)] + def foundation_for_suit(self, suit_name: str) -> List[Card]: + """Return the foundation pile corresponding to a given suit.""" + suit_index = next(i for i, s in enumerate(SUITS) if s.name == suit_name) + return self.foundations[suit_index] - def first_empty_freecell(self): - index = 0 - for cell in self.freecells: + def first_empty_freecell(self) -> Optional[int]: + """Return the first free freecell slot, or None if full.""" + for i, cell in enumerate(self.freecells): if cell is None: - return index - index = index + 1 + return i return None - def count_hidden_low_cards(self): + def count_hidden_low_cards(self) -> int: + """Count aces and twos that are not currently exposed.""" count = 0 for column in self.columns: - index = 0 - while index < len(column) - 1: - if column[index].value() <= 2: - count = count + 1 - index = index + 1 + for card in column[:-1]: + if card.value <= 2: + count += 1 return count - def card_can_help_foundation(self, card): - return card.value() <= 3 + def card_can_help_foundation(self, card: Card) -> bool: + """Return True for low cards that usually unlock early progress.""" + return card.value <= 3 - def describe_hint_move(self, move): + def describe_hint_move(self, move: HintMove) -> str: + """Return a compact human-readable hint description.""" if move.source_type == "col": - source_name = "column %d" % (move.source_index + 1) + source_name = f"column {move.source_index + 1}" else: - source_name = "free cell %d" % (move.source_index + 1) + source_name = f"free cell {move.source_index + 1}" + if move.dest_type == "col": - dest_name = "column %d" % (move.dest_index + 1) + dest_name = f"column {move.dest_index + 1}" elif move.dest_type == "free": - dest_name = "free cell %d" % (move.dest_index + 1) + dest_name = f"free cell {move.dest_index + 1}" else: - dest_name = "%s foundation" % SUITS[move.dest_index].name + dest_name = f"{SUITS[move.dest_index].name} foundation" + if len(move.cards) == 1: - return "move %s from %s to %s" % ( - move.cards[0].short_name(), - source_name, - dest_name, + return ( + f"move {move.cards[0].short_name()} from {source_name} to {dest_name}" ) - return "move %d cards %s..%s from %s to %s" % ( - len(move.cards), - move.cards[0].short_name(), - move.cards[-1].short_name(), - source_name, - dest_name, + + return ( + f"move {len(move.cards)} cards {move.cards[0].short_name()}..{move.cards[-1].short_name()} " + f"from {source_name} to {dest_name}" ) - def score_hint_move(self, source_type, source_index, dest_type, dest_index, cards): + def score_hint_move( + self, + source_type: str, + source_index: int, + dest_type: str, + dest_index: int, + cards: List[Card], + ) -> int: + """Assign a cheap deterministic score to a legal move.""" score = 0 moving_top = cards[0] + + source_column = self.columns[source_index] if source_type == "col" else None source_exposes = None hidden_low_before = self.count_hidden_low_cards() + if source_type == "col": - source_column = self.columns[source_index] - remaining = source_column[: len(source_column) - len(cards)] - if remaining: - source_exposes = remaining[-1] - else: - score = score + 250 - if source_exposes is not None and self.card_can_help_foundation( - source_exposes - ): - score = score + 320 + assert source_column is not None + remaining = source_column[: -len(cards)] + source_exposes = remaining[-1] if remaining else None + if not remaining: + score += 250 + if source_exposes and self.card_can_help_foundation(source_exposes): + score += 320 + if dest_type == "foundation": - score = score + 1000 + moving_top.value() * 5 + score += 1000 + moving_top.value * 5 elif dest_type == "col": destination = self.columns[dest_index] if destination: - score = score + 220 + len(cards) * 25 + score += 220 + len(cards) * 25 if len(cards) > 1: - score = score + 120 + score += 120 else: - if len(cards) > 1: - score = score + 60 - else: - score = score - 120 + score += 60 if len(cards) > 1 else -120 elif dest_type == "free": - score = score - 80 - if moving_top.value() <= 3: - score = score + 140 - if source_exposes is not None and self.can_move_to_foundation(source_exposes): - score = score + 180 - if moving_top.value() <= 3: - score = score + 80 + score -= 80 + if moving_top.value <= 3: + score += 140 + + if source_exposes and self.can_move_to_foundation(source_exposes): + score += 180 + + if moving_top.value <= 3: + score += 80 + hidden_low_after = hidden_low_before - if ( - source_type == "col" - and source_exposes is not None - and source_exposes.value() <= 2 - ): - hidden_low_after = hidden_low_after - 1 - score = score + (hidden_low_before - hidden_low_after) * 120 + if source_type == "col" and source_exposes and source_exposes.value <= 2: + hidden_low_after -= 1 + score += (hidden_low_before - hidden_low_after) * 120 + if source_type == "free" and dest_type == "col": - score = score + 200 + score += 200 + return score - def enumerate_hint_moves(self): - moves = [] - col_index = 0 - for column in self.columns: - if column: - top_card = column[-1] - suit_index = find_suit_index(top_card.suit.name) - if self.can_move_to_foundation(top_card): - moves.append( - HintMove( - "col", - col_index, - "foundation", - suit_index, - [top_card], - self.score_hint_move( - "col", col_index, "foundation", suit_index, [top_card] - ), - ) - ) - free_idx = self.first_empty_freecell() - if free_idx is not None: - moves.append( - HintMove( - "col", - col_index, - "free", - free_idx, - [top_card], - self.score_hint_move( - "col", col_index, "free", free_idx, [top_card] - ), - ) - ) - tail_start = self.valid_tail_start_index(col_index) - if tail_start is not None: - start_index = len(column) - 1 - while start_index >= tail_start: - cards_list = column[start_index:] - if len(cards_list) <= self.max_movable_cards( - col_index, len(cards_list) - ): - dest_index = 0 - while dest_index < 8: - if ( - dest_index != col_index - and self.can_place_stack_on_column( - cards_list, dest_index - ) - ): - moves.append( - HintMove( - "col", - col_index, - "col", - dest_index, - cards_list[:], - self.score_hint_move( - "col", - col_index, - "col", - dest_index, - cards_list, - ), - ) - ) - dest_index = dest_index + 1 - start_index = start_index - 1 - col_index = col_index + 1 + def enumerate_hint_moves(self) -> List[HintMove]: + """Enumerate cheap one-ply legal moves for hinting.""" + moves: List[HintMove] = [] - free_index = 0 - for card in self.freecells: - if card is not None: - suit_index = find_suit_index(card.suit.name) - if self.can_move_to_foundation(card): - moves.append( - HintMove( - "free", - free_index, + for col_index, column in enumerate(self.columns): + if not column: + continue + + top_card = column[-1] + if self.can_move_to_foundation(top_card): + cards = (top_card,) + moves.append( + HintMove( + source_type="col", + source_index=col_index, + dest_type="foundation", + dest_index=SUITS.index(top_card.suit), + cards=cards, + score=self.score_hint_move( + "col", + col_index, "foundation", - suit_index, - [card], - self.score_hint_move( - "free", free_index, "foundation", suit_index, [card] - ), - ) + SUITS.index(top_card.suit), + [top_card], + ), ) - dest_index = 0 - while dest_index < 8: - if self.can_place_stack_on_column([card], dest_index): + ) + + free_idx = self.first_empty_freecell() + if free_idx is not None: + cards = (top_card,) + moves.append( + HintMove( + source_type="col", + source_index=col_index, + dest_type="free", + dest_index=free_idx, + cards=cards, + score=self.score_hint_move( + "col", col_index, "free", free_idx, [top_card] + ), + ) + ) + + tail_start = self.valid_tail_start_index(col_index) + if tail_start is None: + continue + + for start_index in range(len(column) - 1, tail_start - 1, -1): + cards_list = column[start_index:] + if len(cards_list) > self.max_movable_cards(col_index, len(cards_list)): + continue + for dest_index in range(8): + if dest_index == col_index: + continue + if self.can_place_stack_on_column(cards_list, dest_index): moves.append( HintMove( - "free", - free_index, - "col", - dest_index, - [card], - self.score_hint_move( - "free", free_index, "col", dest_index, [card] + source_type="col", + source_index=col_index, + dest_type="col", + dest_index=dest_index, + cards=tuple(cards_list), + score=self.score_hint_move( + "col", col_index, "col", dest_index, cards_list ), ) ) - dest_index = dest_index + 1 - free_index = free_index + 1 - decorated = [] - for move in moves: - decorated.append((0 - move.score, self.describe_hint_move(move), move)) - decorated.sort() - result = [] - for item in decorated: - result.append(item[2]) - return result + for free_index, card in enumerate(self.freecells): + if card is None: + continue - def show_hint(self): + if self.can_move_to_foundation(card): + moves.append( + HintMove( + source_type="free", + source_index=free_index, + dest_type="foundation", + dest_index=SUITS.index(card.suit), + cards=(card,), + score=self.score_hint_move( + "free", + free_index, + "foundation", + SUITS.index(card.suit), + [card], + ), + ) + ) + + for dest_index in range(8): + if self.can_place_stack_on_column([card], dest_index): + moves.append( + HintMove( + source_type="free", + source_index=free_index, + dest_type="col", + dest_index=dest_index, + cards=(card,), + score=self.score_hint_move( + "free", free_index, "col", dest_index, [card] + ), + ) + ) + + moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move))) + return moves + + def show_hint(self) -> None: + """Pick the best cheap legal move and remember it for highlighting.""" if self.held is not None: self.status = "Drop or cancel the held cards before asking for a hint." return + moves = self.enumerate_hint_moves() if not moves: self.hint_move = None self.status = "Hint: no obvious legal move found." - self.request_full_repaint() return - self.hint_move = moves[0] - self.status = "Hint: %s." % self.describe_hint_move(self.hint_move) - self.request_full_repaint() - def hinted_freecell(self, index): + self.hint_move = moves[0] + self.status = f"Hint: {self.describe_hint_move(self.hint_move)}." + + def hinted_freecell(self, index: int) -> bool: + """Return True when a freecell is part of the current hint.""" if self.hint_move is None: - return 0 - if ( + return False + return ( self.hint_move.source_type == "free" and self.hint_move.source_index == index - ): - return 1 - if self.hint_move.dest_type == "free" and self.hint_move.dest_index == index: - return 1 - return 0 + ) or (self.hint_move.dest_type == "free" and self.hint_move.dest_index == index) - def hinted_foundation(self, index): - if self.hint_move is None: - return 0 - if ( - self.hint_move.dest_type == "foundation" + def hinted_foundation(self, index: int) -> bool: + """Return True when a foundation is the current hint destination.""" + return ( + self.hint_move is not None + and self.hint_move.dest_type == "foundation" and self.hint_move.dest_index == index - ): - return 1 - return 0 + ) - def hinted_column_source_start(self, index): + def hinted_column_source_start(self, index: int) -> Optional[int]: + """Return the start row for a hinted tableau source stack.""" if self.hint_move is None: return None if self.hint_move.source_type != "col" or self.hint_move.source_index != index: return None return len(self.columns[index]) - len(self.hint_move.cards) - def hinted_column_destination(self, index): - if self.hint_move is None: - return 0 - if self.hint_move.dest_type == "col" and self.hint_move.dest_index == index: - return 1 - return 0 + def hinted_column_destination(self, index: int) -> bool: + """Return True when a tableau column is the hinted destination.""" + return ( + self.hint_move is not None + and self.hint_move.dest_type == "col" + and self.hint_move.dest_index == index + ) - def enter_visual_mode(self): + def enter_visual_mode(self) -> None: + """Enter tableau visual selection mode on the current column.""" if self.held is not None: self.status = "Drop the held cards before selecting a new stack." return if self.cursor_zone != "bottom": self.status = "Visual mode only works on tableau columns." return + column = self.columns[self.cursor_index] if not column: self.status = "That column is empty." return - self.visual_mode = 1 + + self.visual_mode = True self.visual_row = len(column) - 1 cards, reason = self.current_visual_stack() if reason: - self.status = "Visual: %d cards selected. %s" % (len(cards), reason) - elif len(cards) == 1: - self.status = "Visual: 1 card selected." + self.status = f"Visual: {len(cards)} cards selected. {reason}" else: - self.status = "Visual: %d cards selected." % len(cards) + self.status = ( + f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." + ) - def exit_visual_mode(self, message): - self.visual_mode = 0 + def exit_visual_mode(self, message: Optional[str] = None) -> None: + """Leave tableau visual selection mode.""" + self.visual_mode = False self.visual_row = 0 if message is not None: self.status = message - def move_visual_selection(self, delta): + def move_visual_selection(self, delta: int) -> None: + """Adjust the visual selection start within the valid tail.""" if not self.visual_mode: self.status = "Press v to start selecting a stack." return + column = self.columns[self.cursor_index] tail_start = self.valid_tail_start_index(self.cursor_index) if not column or tail_start is None: self.exit_visual_mode("That column is empty.") return - old_row = self.visual_row - self.visual_row = self.visual_row + delta - if self.visual_row < tail_start: - self.visual_row = tail_start - if self.visual_row > len(column) - 1: - self.visual_row = len(column) - 1 - cards, reason = self.current_visual_stack() - if self.visual_row == old_row: - if len(cards) == 1: - self.status = "Visual selection stays at 1 card." - else: - self.status = "Visual selection stays at %d cards." % len(cards) - return - if reason: - self.status = "Visual: %d cards selected. %s" % (len(cards), reason) - elif len(cards) == 1: - self.status = "Visual: 1 card selected." - else: - self.status = "Visual: %d cards selected." % len(cards) - def pick_up(self): + old_row = self.visual_row + self.visual_row = max(tail_start, min(len(column) - 1, self.visual_row + delta)) + cards, reason = self.current_visual_stack() + + if self.visual_row == old_row: + self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}." + return + + if reason: + self.status = f"Visual: {len(cards)} cards selected. {reason}" + else: + self.status = ( + f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." + ) + + # ----------------------------------------------------------------------- + # Movement / interactions + # ----------------------------------------------------------------------- + + def pick_up(self) -> None: + """ + Pick up the bottom visible card from either: + - a tableau column + - a freecell slot + """ if self.held is not None: self.status = "You are already holding cards." return + if self.cursor_zone == "bottom": cards, reason = self.selected_stack_from_column(self.cursor_index) if not cards: self.status = reason return + if reason: self.status = reason return + moving_count = len(cards) self.clear_hint() del self.columns[self.cursor_index][-moving_count:] - self.held = HeldCards(cards[:], "col", self.cursor_index) + self.held = HeldCards( + cards=cards, source_type="col", source_index=self.cursor_index + ) if moving_count == 1: - self.status = "Picked up %s from column %d." % ( - cards[0].short_name(), - self.cursor_index + 1, - ) + self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}." else: - self.status = "Picked up %d cards from column %d." % ( - moving_count, - self.cursor_index + 1, - ) - self.request_full_repaint() - self.exit_visual_mode(None) + self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." + self.exit_visual_mode() return + + # top zone: indexes 0..3 freecells, 4..7 foundations if self.cursor_index < 4: card = self.freecells[self.cursor_index] if card is None: @@ -843,34 +749,46 @@ class FreeCellGame: return self.clear_hint() self.freecells[self.cursor_index] = None - self.held = HeldCards([card], "free", self.cursor_index) - self.status = "Picked up %s from free cell %d." % ( - card.short_name(), - self.cursor_index + 1, + self.held = HeldCards( + cards=[card], source_type="free", source_index=self.cursor_index + ) + self.status = ( + f"Picked up {card.short_name()} from free cell {self.cursor_index + 1}." ) - self.request_full_repaint() return + self.status = "Cannot pick up directly from foundations." - def restore_held(self): + def restore_held(self) -> None: + """Put the held card back where it came from if a drop fails.""" if self.held is None: return + cards = self.held.cards if self.held.source_type == "col": self.columns[self.held.source_index].extend(cards) elif self.held.source_type == "free": self.freecells[self.held.source_index] = cards[0] + self.held = None self.clear_hint() - self.request_full_repaint() - def drop(self): + def drop(self) -> None: + """ + Drop the held card onto: + - a column + - a freecell + - a foundation + depending on the current cursor position and legal rules. + """ if self.held is None: self.status = "You are not holding a card." return + cards = self.held.cards moving_count = len(cards) moving_top = cards[0] + if self.cursor_zone == "bottom": if self.can_place_stack_on_column(cards, self.cursor_index): self.clear_hint() @@ -883,21 +801,17 @@ class FreeCellGame: cards, ) if moving_count == 1: - self.status = "Placed %s on column %d." % ( - moving_top.short_name(), - self.cursor_index + 1, - ) + self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." else: - self.status = "Placed %d cards on column %d." % ( - moving_count, - self.cursor_index + 1, - ) + self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}." self.held = None - self.request_full_repaint() else: self.status = "Illegal move for tableau column." return + + # top zone if self.cursor_index < 4: + # drop to freecell if moving_count != 1: self.status = "Free cells accept only one card." return @@ -911,18 +825,17 @@ class FreeCellGame: self.cursor_index, cards, ) - self.status = "Placed %s in free cell %d." % ( - moving_top.short_name(), - self.cursor_index + 1, - ) + self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." self.held = None - self.request_full_repaint() else: self.status = "That free cell is occupied." return + + # drop to foundation if moving_count != 1: self.status = "Foundations accept only one card at a time." return + if self.can_move_to_foundation(moving_top): self.clear_hint() foundation = self.foundation_for_suit(moving_top.suit.name) @@ -931,23 +844,28 @@ class FreeCellGame: self.held.source_type, self.held.source_index, "foundation", - find_suit_index(moving_top.suit.name), + SUITS.index(moving_top.suit), cards, ) - self.status = "Moved %s to foundation." % moving_top.short_name() + self.status = f"Moved {moving_top.short_name()} to foundation." self.held = None - self.request_full_repaint() else: self.status = "Illegal move for foundation." - def quick_to_freecell(self): + def quick_to_freecell(self) -> None: + """ + Move the currently selected bottom/freecell card into the first open + freecell, if possible. + """ if self.held is not None: self.status = "Drop the held card first." return + free_idx = self.first_empty_freecell() if free_idx is None: self.status = "No free freecells available." return + if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if not col: @@ -960,20 +878,29 @@ class FreeCellGame: moved_card = col.pop() self.freecells[free_idx] = moved_card self.record_move("col", self.cursor_index, "free", free_idx, [moved_card]) - self.status = "Moved card to free cell %d." % (free_idx + 1) - self.request_full_repaint() + self.status = f"Moved card to free cell {free_idx + 1}." return + if self.cursor_zone == "top" and self.cursor_index < 4: self.status = "Already on a free cell." return + self.status = "Select a tableau card first." - def quick_to_foundation(self): + def quick_to_foundation(self) -> None: + """ + Move the selected card directly to foundation if legal. + Works from: + - column bottoms + - freecells + """ if self.held is not None: self.status = "Drop the held card first." return - card = None - source = None + + card: Optional[Card] = None + source: Optional[Tuple[str, int]] = None + if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if col: @@ -982,291 +909,271 @@ class FreeCellGame: return card = col[-1] source = ("col", self.cursor_index) + elif self.cursor_zone == "top" and self.cursor_index < 4: - card = self.freecells[self.cursor_index] - if card is not None: + fc = self.freecells[self.cursor_index] + if fc is not None: + card = fc source = ("free", self.cursor_index) + if card is None or source is None: self.status = "No movable card selected." return + if not self.can_move_to_foundation(card): self.status = "That card cannot go to foundation yet." return + + foundation = self.foundation_for_suit(card.suit.name) self.clear_hint() - self.foundation_for_suit(card.suit.name).append(card) + foundation.append(card) + if source[0] == "col": self.columns[source[1]].pop() else: self.freecells[source[1]] = None + self.record_move( - source[0], - source[1], - "foundation", - find_suit_index(card.suit.name), - [card], + source[0], source[1], "foundation", SUITS.index(card.suit), [card] ) - self.status = "Moved %s to foundation." % card.short_name() - self.request_full_repaint() - def is_won(self): - total = 0 - for foundation in self.foundations: - total = total + len(foundation) - return total == 52 + self.status = f"Moved {card.short_name()} to foundation." + + def is_won(self) -> bool: + """Return True when all cards are in foundations.""" + return sum(len(f) for f in self.foundations) == 52 -def spread_positions(count, start_x, end_x): - positions = [] +# --------------------------------------------------------------------------- +# UI rendering +# --------------------------------------------------------------------------- + +CARD_W = 7 +TOP_Y = 1 +COL_Y = 6 + + +@dataclass(frozen=True) +class BoardLayout: + """Computed horizontal positions for the current terminal width.""" + + freecell_xs: Tuple[int, ...] + foundation_xs: Tuple[int, ...] + tableau_xs: Tuple[int, ...] + freecells_label_x: int + foundations_label_x: int + tableau_label_x: int + + +def spread_positions(count: int, start_x: int, end_x: int) -> Tuple[int, ...]: + """Evenly spread fixed-width slots between two inclusive bounds.""" if count <= 1: - return [start_x] - span = end_x - start_x - if span < 0: - span = 0 - steps = count - 1 - index = 0 - while index < count: - positions.append(rounded_position(start_x, span, index, steps)) - index = index + 1 - return positions + return (start_x,) + + span = max(0, end_x - start_x) + step = span / (count - 1) + positions = [] + for index in range(count): + positions.append(int(round(start_x + step * index))) + return tuple(positions) -def compute_board_layout(max_x): +def compute_board_layout(max_x: int) -> BoardLayout: + """Compute a centered, width-aware board layout.""" left_margin = 2 - right_margin = max_x - CARD_W - 2 - if right_margin < left_margin: - right_margin = left_margin + right_margin = max(left_margin, max_x - CARD_W - 2) + if max_x < 72: - top_positions = spread_positions(8, left_margin, right_margin) + top_start = left_margin + top_end = max(top_start, max_x - CARD_W - 2) + top_positions = spread_positions(8, top_start, top_end) freecell_xs = top_positions[:4] - foundation_xs = top_positions[4:8] + foundation_xs = top_positions[4:] else: - center_gap = max_x / 6 - if center_gap < 10: - center_gap = 10 - if center_gap > 20: - center_gap = 20 - left_end = (max_x / 2) - (center_gap / 2) - CARD_W - right_start = (max_x / 2) + (center_gap / 2) - if left_end < left_margin: - left_end = left_margin - if right_start > right_margin: - right_start = right_margin + center_gap = max(10, min(20, max_x // 6)) + left_end = max(left_margin, (max_x // 2) - (center_gap // 2) - CARD_W) + right_start = min(right_margin, (max_x // 2) + (center_gap // 2)) freecell_xs = spread_positions(4, left_margin, left_end) foundation_xs = spread_positions(4, right_start, right_margin) + tableau_xs = spread_positions(8, left_margin, right_margin) + freecells_label_x = freecell_xs[0] + foundations_label_x = foundation_xs[0] + tableau_label_x = tableau_xs[0] + return BoardLayout( - freecell_xs, - foundation_xs, - tableau_xs, - freecell_xs[0], - foundation_xs[0], - tableau_xs[0], + freecell_xs=freecell_xs, + foundation_xs=foundation_xs, + tableau_xs=tableau_xs, + freecells_label_x=freecells_label_x, + foundations_label_x=foundations_label_x, + tableau_label_x=tableau_label_x, ) -def init_colors(): - if not hasattr(curses, "start_color"): - return - if not hasattr(curses, "init_pair"): - return - try: - curses.start_color() - except: - return - if hasattr(curses, "use_default_colors"): - try: - curses.use_default_colors() - except: - pass - if not hasattr(curses, "COLOR_WHITE"): - return - try: - curses.init_pair(1, curses.COLOR_WHITE, -1) - curses.init_pair(2, curses.COLOR_RED, -1) - curses.init_pair(3, curses.COLOR_CYAN, -1) - curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) - curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN) - curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) - except: - pass +def init_colors() -> None: + """Initialize curses color pairs.""" + curses.start_color() + curses.use_default_colors() + + # Pair 1: regular text + curses.init_pair(1, curses.COLOR_WHITE, -1) + + # Pair 2: red cards / symbols + curses.init_pair(2, curses.COLOR_RED, -1) + + # Pair 3: black-group cards rendered as cyan for visibility on dark terms + curses.init_pair(3, curses.COLOR_CYAN, -1) + + # Pair 4: highlighted cursor + curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) + + # Pair 5: held card + curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_GREEN) + + # Pair 6: hint highlight + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) -def card_color_pair(card): - if card.color_group() == "red": - return 2 - return 3 +def card_color_pair(card: Card) -> int: + """Choose a color pair for a card.""" + return 2 if card.color_group == "red" else 3 -def draw_box(stdscr, y, x, w, label, attr): - inner = pad_center(label[: w - 2], w - 2) - text = "[" + inner + "]" - safe_addnstr(stdscr, y, x, text, w, attr) +def draw_box(stdscr, y: int, x: int, w: int, label: str, attr: int) -> None: + """Draw a simple one-line boxed area.""" + text = f"[{label:^{w - 2}}]" + stdscr.addnstr(y, x, text.ljust(w), w, attr) -def draw_card_label(card): +def draw_card_label(card: Optional[Card]) -> str: + """Return a compact label used to display a card or empty slot.""" if card is None: return " " return card.short_name() -def draw_top_row(stdscr, game, layout): - safe_addstr( - stdscr, +def draw_top_row(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: + """Draw freecells and foundations.""" + stdscr.addstr( TOP_Y, layout.freecells_label_x, "Free Cells", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - safe_addstr( - stdscr, + stdscr.addstr( TOP_Y, layout.foundations_label_x, "Foundations", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - index = 0 - while index < 4: - x = layout.freecell_xs[index] - safe_addstr(stdscr, TOP_Y + 1, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, TOP_Y + 1, x, " " * CARD_W, CARD_W, pair_attr(1)) - selected = game.cursor_zone == "top" and game.cursor_index == index - hinted = game.hinted_freecell(index) - marker = " " - if selected: - attr = pair_attr(4) | highlight_attr() - marker = ">" - elif hinted: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" - else: - attr = pair_attr(1) - safe_addstr(stdscr, TOP_Y + 1, x - 1, marker, attr) - card = game.freecells[index] + # Freecells at indexes 0..3 + for i in range(4): + x = layout.freecell_xs[i] + selected = game.cursor_zone == "top" and game.cursor_index == i + hinted = game.hinted_freecell(i) + attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted + else curses.color_pair(1) + ) + + card = game.freecells[i] draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr) - if card is not None: - safe_addnstr( - stdscr, + + if card: + stdscr.addnstr( TOP_Y + 1, x + 1, card.short_name(), CARD_W - 2, - pair_attr(card_color_pair(card)), + curses.color_pair(card_color_pair(card)), ) - index = index + 1 - index = 0 - while index < 4: - x = layout.foundation_xs[index] - safe_addstr(stdscr, TOP_Y + 1, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, TOP_Y + 1, x, " " * CARD_W, CARD_W, pair_attr(1)) - selected = game.cursor_zone == "top" and game.cursor_index == index + 4 - hinted = game.hinted_foundation(index) - marker = " " - if selected: - attr = pair_attr(4) | highlight_attr() - marker = ">" - elif hinted: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" - else: - attr = pair_attr(1) - safe_addstr(stdscr, TOP_Y + 1, x - 1, marker, attr) - foundation = game.foundations[index] - if foundation: - top_card = foundation[-1] - label = draw_card_label(top_card) - else: - top_card = None - label = SUITS[index].tag + # Foundations at indexes 4..7 in cursor space + for i in range(4): + x = layout.foundation_xs[i] + selected = game.cursor_zone == "top" and game.cursor_index == i + 4 + hinted = game.hinted_foundation(i) + attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted + else curses.color_pair(1) + ) + + foundation = game.foundations[i] + top_card = foundation[-1] if foundation else None + label = draw_card_label(top_card) if top_card else SUITS[i].glyph draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr) - if top_card is not None: - safe_addnstr( - stdscr, + + if top_card: + stdscr.addnstr( TOP_Y + 1, x + 1, top_card.short_name(), CARD_W - 2, - pair_attr(card_color_pair(top_card)), + curses.color_pair(card_color_pair(top_card)), ) else: - if SUITS[index].color_group == "red": - dummy_color = 2 - else: - dummy_color = 3 - safe_addnstr( - stdscr, - TOP_Y + 1, - x + 1, - SUITS[index].tag, - CARD_W - 2, - pair_attr(dummy_color), + # Show suit glyph placeholder in foundation slot color + dummy_color = 2 if SUITS[i].color_group == "red" else 3 + stdscr.addnstr( + TOP_Y + 1, x + 2, SUITS[i].glyph, 1, curses.color_pair(dummy_color) ) - index = index + 1 -def draw_columns(stdscr, game, layout): - safe_addstr( - stdscr, +def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: + """Draw the tableau columns.""" + stdscr.addstr( COL_Y - 1, layout.tableau_label_x, "Tableau", - pair_attr(1) | text_attr("A_BOLD"), + curses.color_pair(1) | curses.A_BOLD, ) - max_height = 0 - for column in game.columns: - if len(column) > max_height: - max_height = len(column) - col_idx = 0 - while col_idx < 8: + max_height = max((len(col) for col in game.columns), default=0) + + for col_idx in range(8): x = layout.tableau_xs[col_idx] - safe_addstr(stdscr, COL_Y, x - 1, " ", pair_attr(1)) - safe_addnstr(stdscr, COL_Y, x, " " * CARD_W, CARD_W, pair_attr(1)) selected = game.cursor_zone == "bottom" and game.cursor_index == col_idx hinted_dest = game.hinted_column_destination(col_idx) - header_label = str(col_idx + 1) - if selected: - header_attr = pair_attr(4) | highlight_attr() - header_label = ">" + header_label - elif hinted_dest: - header_attr = pair_attr(6) | text_attr("A_BOLD") - header_label = "+" + header_label - else: - header_attr = pair_attr(1) - draw_box(stdscr, COL_Y, x, CARD_W, header_label, header_attr) + header_attr = ( + curses.color_pair(4) + if selected + else curses.color_pair(6) + if hinted_dest + else curses.color_pair(1) + ) - clear_y = COL_Y + 1 - clear_limit = COL_Y + 8 + max_height - while clear_y < clear_limit: - safe_addstr(stdscr, clear_y, x - 1, " ", pair_attr(1)) - safe_addnstr( - stdscr, clear_y, x + 1, " " * (CARD_W - 2), CARD_W - 2, pair_attr(1) - ) - clear_y = clear_y + 1 + draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) column = game.columns[col_idx] if not column: - safe_addstr(stdscr, COL_Y + 1, x + 2, ".", pair_attr(1)) - col_idx = col_idx + 1 + stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1)) continue - selected_cards = [] + selected_cards: List[Card] = [] selection_reason = "" valid_tail_start = game.valid_tail_start_index(col_idx) if valid_tail_start is None: valid_tail_start = len(column) hint_source_start = game.hinted_column_source_start(col_idx) + if selected and game.visual_mode: selected_cards, selection_reason = game.selected_stack_from_column(col_idx) - if selected_cards: - selected_start = len(column) - len(selected_cards) - else: - selected_start = len(column) - row_idx = 0 - for card in column: + selected_start = ( + len(column) - len(selected_cards) if selected_cards else len(column) + ) + + for row_idx, card in enumerate(column): y = COL_Y + 1 + row_idx + in_valid_tail = ( selected and game.visual_mode and row_idx >= valid_tail_start ) @@ -1276,350 +1183,278 @@ def draw_columns(stdscr, game, layout): in_hint_source = ( hint_source_start is not None and row_idx >= hint_source_start ) - marker = " " if in_selected_stack: - attr = pair_attr(4) | highlight_attr() - marker = ">" + attr = curses.color_pair(4) if selection_reason: - attr = attr | text_attr("A_DIM") + attr |= curses.A_DIM else: - attr = attr | text_attr("A_BOLD") + attr |= curses.A_BOLD elif in_hint_source: - attr = pair_attr(6) | text_attr("A_BOLD") - marker = "+" + attr = curses.color_pair(6) | curses.A_BOLD elif in_valid_tail: - attr = pair_attr(card_color_pair(card)) | text_attr("A_DIM") + attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM elif selected and row_idx == len(column) - 1: - attr = pair_attr(4) | highlight_attr() - marker = ">" + attr = curses.color_pair(4) else: - attr = pair_attr(card_color_pair(card)) - safe_addstr(stdscr, y, x - 1, marker, attr) - safe_addnstr( - stdscr, - y, - x + 1, - pad_right(card.short_name(), CARD_W - 2), - CARD_W - 2, - attr, + attr = curses.color_pair(card_color_pair(card)) + + stdscr.addnstr( + y, x + 1, card.short_name().ljust(CARD_W - 2), CARD_W - 2, attr ) - row_idx = row_idx + 1 - col_idx = col_idx + 1 - - extra_y = COL_Y + 2 + max_height - while extra_y < COL_Y + 8 + max_height: - try: - stdscr.move(extra_y, 0) - stdscr.clrtoeol() - except curses.error: - pass - extra_y = extra_y + 1 - - -def draw_held(stdscr, game, max_y, max_x): - y = max_y - 3 - try: - stdscr.move(y, 0) + # Clear a few lines under the tallest used area to avoid visual leftovers. + for extra_y in range(COL_Y + 2 + max_height, COL_Y + 8 + max_height): + stdscr.move(extra_y, 0) stdscr.clrtoeol() - except curses.error: - pass + + +def draw_held(stdscr, game: FreeCellGame, max_y: int) -> None: + """Draw the currently held card stack near the bottom of the screen.""" + y = max_y - 3 + stdscr.move(y, 0) + stdscr.clrtoeol() + if game.held is None: - safe_addstr(stdscr, y, 2, "Held: (none)", pair_attr(1)) + stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1)) return - safe_addstr(stdscr, y, 2, "Held: ", pair_attr(1)) + + stdscr.addstr(y, 2, "Held: ", curses.color_pair(1)) cards = game.held.cards if len(cards) == 1: - safe_addstr( - stdscr, y, 8, cards[0].short_name(), pair_attr(5) | text_attr("A_BOLD") - ) + stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD) return - summary = "%d cards (%s..%s)" % ( - len(cards), - cards[0].short_name(), - cards[-1].short_name(), + + summary = f"{len(cards)} cards ({cards[0].short_name()}..{cards[-1].short_name()})" + stdscr.addnstr( + y, 8, summary, max(0, curses.COLS - 10), curses.color_pair(5) | curses.A_BOLD ) - safe_addnstr(stdscr, y, 8, summary, max_x - 10, pair_attr(5) | text_attr("A_BOLD")) -def draw_status(stdscr, game, max_y, max_x): +def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None: + """Draw a one-line status message.""" y = max_y - 2 - try: - stdscr.move(y, 0) - stdscr.clrtoeol() - except curses.error: - pass - safe_addnstr(stdscr, y, 2, game.status, max_x - 4, pair_attr(1)) + stdscr.move(y, 0) + stdscr.clrtoeol() + stdscr.addnstr(y, 2, game.status, max(0, curses.COLS - 4), curses.color_pair(1)) -def draw_help(stdscr, max_y, max_x): +def draw_help(stdscr, max_y: int) -> None: + """Draw a compact help line.""" y = max_y - 1 - try: - stdscr.move(y, 0) - stdscr.clrtoeol() - except curses.error: - pass + stdscr.move(y, 0) + stdscr.clrtoeol() help_text = "Move arrows/hjkl v select y/p/ENT/SPC move u undo ? hint f freecell d foundation Esc cancel q quit" - safe_addnstr(stdscr, y, 2, help_text, max_x - 4, pair_attr(1)) + stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) -def render(stdscr, game): +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}" + + +def render(stdscr, game: FreeCellGame) -> None: + """Redraw the full screen.""" stdscr.erase() max_y, max_x = stdscr.getmaxyx() layout = compute_board_layout(max_x) - elapsed_text = "Time: %s" % format_elapsed_time(game.elapsed_time_seconds()) - title = "mcfreecell 1.5.2" + elapsed_text = f"Time: {format_elapsed_time(game.elapsed_time_seconds())}" + + title = "mcfreecell 0.1" if len(title) > max_x - 4: title = "mcfreecell" - title_x = int((max_x - len(title)) / 2) - if title_x < 2: - title_x = 2 - safe_addnstr( - stdscr, + title_x = max(2, (max_x - len(title)) // 2) + stdscr.addnstr( 0, title_x, title, - max_x - title_x - 1, - pair_attr(1) | text_attr("A_BOLD"), + max(0, max_x - title_x - 1), + curses.color_pair(1) | curses.A_BOLD, ) - timer_x = max_x - len(elapsed_text) - 2 - if timer_x < 2: - timer_x = 2 + + timer_x = max(2, max_x - len(elapsed_text) - 2) if timer_x > title_x + len(title): - safe_addnstr( - stdscr, + stdscr.addnstr( 0, timer_x, elapsed_text, - max_x - timer_x - 1, - pair_attr(1) | text_attr("A_BOLD"), + max(0, max_x - timer_x - 1), + curses.color_pair(1) | curses.A_BOLD, ) + if game.visual_mode: visual_text = "-- VISUAL --" - visual_x = title_x + len(title) + 3 - if visual_x + len(visual_text) < max_x - 1: - safe_addstr( - stdscr, 0, visual_x, visual_text, pair_attr(5) | text_attr("A_BOLD") + visual_x = min(max_x - len(visual_text) - 2, title_x + len(title) + 3) + if visual_x > title_x + len(title): + stdscr.addstr( + 0, visual_x, visual_text, curses.color_pair(5) | curses.A_BOLD ) + draw_top_row(stdscr, game, layout) draw_columns(stdscr, game, layout) - draw_held(stdscr, game, max_y, max_x) - draw_status(stdscr, game, max_y, max_x) - draw_help(stdscr, max_y, max_x) + draw_held(stdscr, game, max_y) + draw_status(stdscr, game, max_y) + draw_help(stdscr, max_y) + if game.is_won(): - win_text = "You won in %s." % format_elapsed_time(game.elapsed_time_seconds()) - safe_addnstr( - stdscr, 3, 2, win_text, max_x - 4, pair_attr(5) | text_attr("A_BOLD") + 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, ) + stdscr.refresh() -def move_cursor(game, dy, dx): +# --------------------------------------------------------------------------- +# Input handling +# --------------------------------------------------------------------------- + + +def move_cursor(game: FreeCellGame, dy: int, dx: int) -> None: + """ + Move between the top zone and bottom zone, and across valid slot ranges. + """ if dy < 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(-1) return game.cursor_zone = "top" - if game.cursor_index > 7: - game.cursor_index = 7 + game.cursor_index = min(game.cursor_index, 7) return + if dy > 0: if game.visual_mode and game.cursor_zone == "bottom": game.move_visual_selection(1) return game.cursor_zone = "bottom" - if game.cursor_index > 7: - game.cursor_index = 7 + game.cursor_index = min(game.cursor_index, 7) return + + # Horizontal motion if game.visual_mode and dx != 0: game.exit_visual_mode("Visual selection cancelled.") return - game.cursor_index = game.cursor_index + dx - if game.cursor_index < 0: - game.cursor_index = 0 - if game.cursor_index > 7: - game.cursor_index = 7 + + if game.cursor_zone == "top": + game.cursor_index = max(0, min(7, game.cursor_index + dx)) + else: + game.cursor_index = max(0, min(7, game.cursor_index + dx)) -def handle_key(game, ch): - if ch == ord("q") or ch == ord("Q"): - return 0 - if ch == curses.KEY_LEFT or ch == ord("h"): +def handle_key(game: FreeCellGame, ch: int) -> bool: + """ + Handle one input event. + Returns False if the game should exit. + """ + if ch in (ord("q"), ord("Q")): + return False + + # Movement + if ch in (curses.KEY_LEFT, ord("h")): move_cursor(game, 0, -1) - return 1 - if ch == curses.KEY_RIGHT or ch == ord("l"): + return True + + if ch in (curses.KEY_RIGHT, ord("l")): move_cursor(game, 0, 1) - return 1 - if ch == curses.KEY_UP or ch == ord("k"): + return True + + if ch in (curses.KEY_UP, ord("k")): move_cursor(game, -1, 0) - return 1 - if ch == curses.KEY_DOWN or ch == ord("j"): + return True + + if ch in (curses.KEY_DOWN, ord("j")): move_cursor(game, 1, 0) - return 1 - if ch == ord("v") or ch == ord("V"): + return True + + if ch in (ord("v"), ord("V")): if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") else: game.enter_visual_mode() - return 1 - if ch == curses.KEY_ENTER or ch == 10 or ch == 13 or ch == ord(" "): + return True + + # Pick up / drop + if ch in (curses.KEY_ENTER, 10, 13, ord(" ")): if game.held is None: game.pick_up() else: game.drop() - return 1 - if ch == ord("y") or ch == ord("Y"): + return True + + if ch in (ord("y"), ord("Y")): if game.held is not None: game.status = "You are already holding cards." else: game.pick_up() - return 1 - if ch == ord("p") or ch == ord("P"): + return True + + if ch in (ord("p"), ord("P")): if game.held is None: game.status = "You are not holding any cards." else: game.drop() - return 1 - if ch == ord("u") or ch == ord("U"): + return True + + if ch in (ord("u"), ord("U")): game.undo_last_move() - return 1 + return True + if ch == ord("?"): game.show_hint() - return 1 - if ch == ord("f") or ch == ord("F"): + return True + + # Quick helpers + if ch in (ord("f"), ord("F")): game.quick_to_freecell() - return 1 - if ch == ord("d") or ch == ord("D"): + return True + + if ch in (ord("d"), ord("D")): game.quick_to_foundation() - return 1 + return True + + # Escape: cancel held card by restoring it if ch == 27: if game.visual_mode: game.exit_visual_mode("Visual selection cancelled.") elif game.held is not None: game.restore_held() game.status = "Cancelled move." - return 1 - return 1 + return True + + return True -def curses_main(stdscr): - idle_ticks = 0 - poll_delay = 0 - if hasattr(curses, "curs_set"): - try: - curses.curs_set(0) - except: - pass - if hasattr(stdscr, "keypad"): - try: - stdscr.keypad(1) - except: - pass - if hasattr(stdscr, "timeout"): - try: - stdscr.timeout(100) - except: - pass - elif hasattr(stdscr, "nodelay"): - try: - stdscr.nodelay(1) - poll_delay = 1 - except: - poll_delay = 0 +# --------------------------------------------------------------------------- +# Main curses loop +# --------------------------------------------------------------------------- + + +def curses_main(stdscr) -> None: + """Main UI entry point.""" + curses.curs_set(0) + stdscr.keypad(True) + stdscr.timeout(100) + init_colors() + game = FreeCellGame() - while 1: - if game.needs_full_repaint: - game.needs_full_repaint = 0 - if hasattr(stdscr, "redrawwin"): - try: - stdscr.redrawwin() - except: - pass - elif hasattr(stdscr, "touchwin"): - try: - stdscr.touchwin() - except: - pass - elif hasattr(stdscr, "clearok"): - try: - stdscr.clearok(1) - except: - pass + + while True: render(stdscr, game) ch = stdscr.getch() if ch == -1: - idle_ticks = idle_ticks + 1 - if idle_ticks >= 5: - idle_ticks = 0 - if hasattr(stdscr, "redrawwin"): - try: - stdscr.redrawwin() - except: - pass - elif hasattr(stdscr, "touchwin"): - try: - stdscr.touchwin() - except: - pass - elif hasattr(stdscr, "clearok"): - try: - stdscr.clearok(1) - except: - pass - if poll_delay: - try: - time.sleep(0.1) - except: - pass continue - idle_ticks = 0 if not handle_key(game, ch): break -def run_curses(main_func): - if hasattr(curses, "wrapper"): - curses.wrapper(main_func) - return - stdscr = curses.initscr() - if hasattr(curses, "noecho"): - try: - curses.noecho() - except: - pass - if hasattr(curses, "cbreak"): - try: - curses.cbreak() - except: - pass - try: - main_func(stdscr) - finally: - if hasattr(curses, "nocbreak"): - try: - curses.nocbreak() - except: - pass - if hasattr(stdscr, "keypad"): - try: - stdscr.keypad(not 1) - except: - pass - if hasattr(curses, "echo"): - try: - curses.echo() - except: - pass - if hasattr(curses, "endwin"): - try: - curses.endwin() - except: - pass - - -def main(): - run_curses(curses_main) +def main() -> None: + """Program entry point.""" + curses.wrapper(curses_main) if __name__ == "__main__":