diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f05978c..0000000 --- a/LICENSE +++ /dev/null @@ -1,338 +0,0 @@ - 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 25fdd72..4b66fae 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,18 @@ # mcfreecell -`mcfreecell` is a terminal-native FreeCell game with a systems-theme twist. +`mcfreecell` on `leg-py1.5.2` is a terminal-native FreeCell build aimed at old Python and old curses environments. -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. +This branch keeps the implementation within a Python-1.5.2-friendly subset and favors compatibility over modern terminal features. ## Features - FreeCell rules with 8 tableau columns, 4 free cells, and 4 foundations -- legal multi-card tableau moves with proper FreeCell move-capacity limits -- keyboard-driven play in a curses interface +- keyboard-driven curses interface - visual stack selection mode -- 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 +- 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 ## Controls @@ -25,54 +20,15 @@ The project started as a novelty FreeCell prototype and grew into a playable, ke - `v` - enter or exit tableau selection mode - `y` - pick up selected card or stack - `p` - drop held card or stack -- `Enter` / `Space` - alternate pick up / drop - `u` - undo the last completed move +- `Enter` / `Space` - alternate pick up / drop - `?` - show a suggested move - `f` - quick move selected card to a free cell - `d` - quick move selected card to foundation -- `Esc` - cancel selection or restore held cards +- `Esc` - cancel held move - `q` - quit -## 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: +## Suits - Debian (`Deb`) - Red Hat (`RHt`) @@ -84,49 +40,24 @@ These are split into the two FreeCell color groups as: - red group: Debian, Red Hat - black-equivalent group: Solaris, HP-UX -On legacy terminals, the non-red group may render as blue, cyan, or plain monochrome depending on curses support. +On older terminals, the non-red group may render as blue, cyan, or plain monochrome depending on curses support. ## Running -### 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: +Run with whatever Python interpreter is available on the target machine: ```sh python linux-freecell.py ``` -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 Notes -## Suit Theme Notes +Terminal and curses behavior can vary significantly on older systems. -`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. +- `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. ## Status -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 +This branch is intended as a playable legacy-friendly FreeCell build for old Unix-like systems and older Python environments. diff --git a/linux-freecell.py b/linux-freecell.py index f0939e6..1fe99b0 100644 --- a/linux-freecell.py +++ b/linux-freecell.py @@ -1,8 +1,14 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -linux_freecell.py +mcfreecell -A curses-based FreeCell prototype that uses Linux distro themed suits. +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 Controls -------- @@ -17,731 +23,819 @@ 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 -from dataclasses import dataclass -from typing import List, Optional, Tuple + +try: + import random +except ImportError: + random = None + +try: + import whrandom # type: ignore +except ImportError: + whrandom = None -# --------------------------------------------------------------------------- -# Card model -# --------------------------------------------------------------------------- +try: + enumerate +except NameError: + + def enumerate(sequence): + index = 0 + result = [] + for item in sequence: + result.append((index, item)) + index = index + 1 + return result + 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)} +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 -@dataclass(frozen=True) class SuitInfo: - """Describes one suit in our Linux-themed deck.""" - - name: str - glyph: str - color_group: str # "red" or "black" + def __init__(self, name, tag, color_group): + self.name = name + self.tag = tag + self.color_group = color_group -@dataclass class Card: - """Represents one playing card.""" + def __init__(self, rank, suit): + self.rank = rank + self.suit = suit - rank: str - suit: SuitInfo - - @property - def value(self) -> int: + def value(self): return RANK_VALUES[self.rank] - @property - def color_group(self) -> str: + def color_group(self): return self.suit.color_group - def short_name(self) -> str: - return f"{self.rank}{self.suit.glyph}" + def short_name(self): + return self.rank + self.suit.tag -# --------------------------------------------------------------------------- -# 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"), + SuitInfo("Debian", "Deb", "red"), + SuitInfo("Red Hat", "RHt", "red"), + SuitInfo("Solaris", "Sol", "black"), + SuitInfo("HP-UX", "HPx", "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 + def __init__(self, cards, source_type, source_index): + self.cards = cards + self.source_type = source_type + self.source_index = source_index -@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 + 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 -@dataclass(frozen=True) class MoveRecord: - """Describes one completed move that can be undone.""" + 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 - source_type: str - source_index: int - dest_type: str - dest_index: int - cards: Tuple[Card, ...] + +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 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.move_history: List[MoveRecord] = [] - self.visual_mode = False + 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 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.start_time = time.monotonic() - self.finished_time_seconds: Optional[int] = None + self.status = "Arrows move, v selects, ? hints." + self.start_time = time.time() + self.finished_time_seconds = None + self.needs_full_repaint = 0 self._deal_new_game() - def elapsed_time_seconds(self) -> int: - """Return elapsed play time, freezing it after a win.""" + def request_full_repaint(self): + self.needs_full_repaint = 1 + + def elapsed_time_seconds(self): if self.finished_time_seconds is not None: return self.finished_time_seconds - - elapsed = int(time.monotonic() - self.start_time) + elapsed = int(time.time() - self.start_time) + if elapsed < 0: + elapsed = 0 if self.is_won(): self.finished_time_seconds = elapsed return self.finished_time_seconds return elapsed - def clear_hint(self) -> None: - """Drop the currently remembered hint highlight.""" + def clear_hint(self): + had_hint = self.hint_move is not None self.hint_move = None + if had_hint: + self.request_full_repaint() - 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.""" + def record_move(self, source_type, source_index, dest_type, dest_index, cards): self.move_history.append( - MoveRecord( - source_type=source_type, - source_index=source_index, - dest_type=dest_type, - dest_index=dest_index, - cards=tuple(cards), - ) + MoveRecord(source_type, source_index, dest_type, dest_index, cards[:]) ) - def _remove_cards_from_destination(self, move: MoveRecord) -> bool: - """Remove the recorded cards from a move destination during undo.""" - cards = list(move.cards) - + def _remove_cards_from_destination(self, move): + cards = move.cards if move.dest_type == "col": column = self.columns[move.dest_index] - if len(column) < len(cards) or column[-len(cards) :] != cards: - return False + if len(column) < len(cards): + return 0 + if column[-len(cards) :] != cards: + return 0 del column[-len(cards) :] - return True - + return 1 if move.dest_type == "free": if self.freecells[move.dest_index] != cards[0]: - return False + return 0 self.freecells[move.dest_index] = None - return True - + return 1 foundation = self.foundations[move.dest_index] if not foundation or foundation[-1] != cards[0]: - return False + return 0 foundation.pop() - return True - - def _restore_cards_to_source(self, move: MoveRecord) -> None: - """Put cards back at the recorded move source during undo.""" - cards = list(move.cards) + return 1 + 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) -> None: - """Undo the most recent completed move.""" + def undo_last_move(self): 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 - - self.move_history.pop() - + del self.move_history[-1] self._restore_cards_to_source(move) self.clear_hint() if self.visual_mode: - self.exit_visual_mode() - - move_count = len(move.cards) - if move_count == 1: - self.status = f"Undid move of {move.cards[0].short_name()}." + 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() else: - self.status = f"Undid move of {move_count} cards." + self.status = "Undid move of %d cards." % len(move.cards) - 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 _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 - 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.""" + def is_valid_tableau_sequence(self, cards): if not cards: - return False + 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 - 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_freecells(self): + count = 0 + for cell in self.freecells: + if cell is None: + count = count + 1 + return count - return True + 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 - 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 - ) + 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) 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.""" + def valid_tail_start_index(self, col_index): 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 -= 1 - + start = start - 1 return start - def current_visual_stack(self) -> Tuple[List[Card], str]: - """Return the currently highlighted tableau stack while in visual mode.""" + def current_visual_stack(self): 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)) + 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 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, "Move limit is %d cards with current free space." % max_cards 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.""" + def selected_stack_from_column(self, col_index): 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]], "" - 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.""" + def can_place_stack_on_column(self, cards, col_index): if not self.is_valid_tableau_sequence(cards): - return False - + return 0 column = self.columns[col_index] if not column: - return True - + return 1 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 - ) + if destination_top.value() != moving_top.value() + 1: + return 0 + if destination_top.color_group() == moving_top.color_group(): + return 0 + return 1 - 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. - """ + def can_move_to_foundation(self, card): 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 + if top.suit.name != card.suit.name: + return 0 + return 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 foundation_for_suit(self, suit_name): + return self.foundations[find_suit_index(suit_name)] - def first_empty_freecell(self) -> Optional[int]: - """Return the first free freecell slot, or None if full.""" - for i, cell in enumerate(self.freecells): + def first_empty_freecell(self): + index = 0 + for cell in self.freecells: if cell is None: - return i + return index + index = index + 1 return None - def count_hidden_low_cards(self) -> int: - """Count aces and twos that are not currently exposed.""" + def count_hidden_low_cards(self): count = 0 for column in self.columns: - for card in column[:-1]: - if card.value <= 2: - count += 1 + index = 0 + while index < len(column) - 1: + if column[index].value() <= 2: + count = count + 1 + index = index + 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 card_can_help_foundation(self, card): + return card.value() <= 3 - def describe_hint_move(self, move: HintMove) -> str: - """Return a compact human-readable hint description.""" + def describe_hint_move(self, move): if move.source_type == "col": - source_name = f"column {move.source_index + 1}" + source_name = "column %d" % (move.source_index + 1) else: - source_name = f"free cell {move.source_index + 1}" - + source_name = "free cell %d" % (move.source_index + 1) if move.dest_type == "col": - dest_name = f"column {move.dest_index + 1}" + dest_name = "column %d" % (move.dest_index + 1) elif move.dest_type == "free": - dest_name = f"free cell {move.dest_index + 1}" + dest_name = "free cell %d" % (move.dest_index + 1) else: - dest_name = f"{SUITS[move.dest_index].name} foundation" - + dest_name = "%s foundation" % SUITS[move.dest_index].name if len(move.cards) == 1: - return ( - f"move {move.cards[0].short_name()} from {source_name} to {dest_name}" + return "move %s from %s to %s" % ( + move.cards[0].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}" + 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, ) - 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.""" + def score_hint_move(self, source_type, source_index, dest_type, dest_index, cards): 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 - + 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 if dest_type == "foundation": - score += 1000 + moving_top.value * 5 + score = score + 1000 + moving_top.value() * 5 elif dest_type == "col": destination = self.columns[dest_index] if destination: - score += 220 + len(cards) * 25 + score = score + 220 + len(cards) * 25 if len(cards) > 1: - score += 120 + score = score + 120 else: - score += 60 if len(cards) > 1 else -120 + if len(cards) > 1: + score = score + 60 + else: + score = score - 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 - + 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 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 == "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 == "free" and dest_type == "col": - score += 200 - + score = 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( + 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", - SUITS.index(top_card.suit), + suit_index, [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] + 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 - moves.sort(key=lambda move: (-move.score, self.describe_hint_move(move))) - return moves + 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, + "foundation", + suit_index, + [card], + self.score_hint_move( + "free", free_index, "foundation", suit_index, [card] + ), + ) + ) + dest_index = 0 + while dest_index < 8: + if self.can_place_stack_on_column([card], dest_index): + moves.append( + HintMove( + "free", + free_index, + "col", + dest_index, + [card], + self.score_hint_move( + "free", free_index, "col", dest_index, [card] + ), + ) + ) + dest_index = dest_index + 1 + free_index = free_index + 1 - def show_hint(self) -> None: - """Pick the best cheap legal move and remember it for highlighting.""" + decorated = [] + for move in moves: + decorated.append((0 - move.score, self.describe_hint_move(move), move)) + decorated.sort() + result = [] + for item in decorated: + result.append(item[2]) + return result + + def show_hint(self): 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 = f"Hint: {self.describe_hint_move(self.hint_move)}." + self.status = "Hint: %s." % self.describe_hint_move(self.hint_move) + self.request_full_repaint() - def hinted_freecell(self, index: int) -> bool: - """Return True when a freecell is part of the current hint.""" + def hinted_freecell(self, index): if self.hint_move is None: - return False - return ( + return 0 + if ( 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) + ): + return 1 + if self.hint_move.dest_type == "free" and self.hint_move.dest_index == index: + return 1 + return 0 - 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" + def hinted_foundation(self, index): + if self.hint_move is None: + return 0 + if ( + self.hint_move.dest_type == "foundation" and self.hint_move.dest_index == index - ) + ): + return 1 + return 0 - def hinted_column_source_start(self, index: int) -> Optional[int]: - """Return the start row for a hinted tableau source stack.""" + def hinted_column_source_start(self, index): 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 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 enter_visual_mode(self) -> None: - """Enter tableau visual selection mode on the current column.""" + def enter_visual_mode(self): 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_mode = 1 self.visual_row = len(column) - 1 cards, reason = self.current_visual_stack() if reason: - self.status = f"Visual: {len(cards)} cards selected. {reason}" + self.status = "Visual: %d cards selected. %s" % (len(cards), reason) + elif len(cards) == 1: + self.status = "Visual: 1 card selected." else: - self.status = ( - f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." - ) + self.status = "Visual: %d cards selected." % len(cards) - def exit_visual_mode(self, message: Optional[str] = None) -> None: - """Leave tableau visual selection mode.""" - self.visual_mode = False + def exit_visual_mode(self, message): + self.visual_mode = 0 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.""" + def move_visual_selection(self, delta): 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)) + 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: - self.status = f"Visual selection stays at {len(cards)} card{'s' if len(cards) != 1 else ''}." + 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 = f"Visual: {len(cards)} cards selected. {reason}" + self.status = "Visual: %d cards selected. %s" % (len(cards), reason) + elif len(cards) == 1: + self.status = "Visual: 1 card selected." else: - self.status = ( - f"Visual: {len(cards)} card{'s' if len(cards) != 1 else ''} selected." - ) + self.status = "Visual: %d cards selected." % len(cards) - # ----------------------------------------------------------------------- - # Movement / interactions - # ----------------------------------------------------------------------- - - def pick_up(self) -> None: - """ - Pick up the bottom visible card from either: - - a tableau column - - a freecell slot - """ + def pick_up(self): 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 - ) + self.held = HeldCards(cards[:], "col", self.cursor_index) if moving_count == 1: - self.status = f"Picked up {cards[0].short_name()} from column {self.cursor_index + 1}." + self.status = "Picked up %s from column %d." % ( + cards[0].short_name(), + self.cursor_index + 1, + ) else: - self.status = f"Picked up {moving_count} cards from column {self.cursor_index + 1}." - self.exit_visual_mode() + self.status = "Picked up %d cards from column %d." % ( + moving_count, + self.cursor_index + 1, + ) + self.request_full_repaint() + self.exit_visual_mode(None) 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: @@ -749,46 +843,34 @@ class FreeCellGame: 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}." + 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.request_full_repaint() 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.""" + def restore_held(self): 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) -> None: - """ - Drop the held card onto: - - a column - - a freecell - - a foundation - depending on the current cursor position and legal rules. - """ + def drop(self): 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() @@ -801,17 +883,21 @@ class FreeCellGame: cards, ) if moving_count == 1: - self.status = f"Placed {moving_top.short_name()} on column {self.cursor_index + 1}." + self.status = "Placed %s on column %d." % ( + moving_top.short_name(), + self.cursor_index + 1, + ) else: - self.status = f"Placed {moving_count} cards on column {self.cursor_index + 1}." + self.status = "Placed %d cards on column %d." % ( + moving_count, + 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 @@ -825,17 +911,18 @@ class FreeCellGame: self.cursor_index, cards, ) - self.status = f"Placed {moving_top.short_name()} in free cell {self.cursor_index + 1}." + self.status = "Placed %s in free cell %d." % ( + moving_top.short_name(), + 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) @@ -844,28 +931,23 @@ class FreeCellGame: self.held.source_type, self.held.source_index, "foundation", - SUITS.index(moving_top.suit), + find_suit_index(moving_top.suit.name), cards, ) - self.status = f"Moved {moving_top.short_name()} to foundation." + self.status = "Moved %s to foundation." % moving_top.short_name() self.held = None + self.request_full_repaint() 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. - """ + def quick_to_freecell(self): 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: @@ -878,29 +960,20 @@ 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 = f"Moved card to free cell {free_idx + 1}." + self.status = "Moved card to free cell %d." % (free_idx + 1) + self.request_full_repaint() 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 - """ + def quick_to_foundation(self): if self.held is not None: self.status = "Drop the held card first." return - - card: Optional[Card] = None - source: Optional[Tuple[str, int]] = None - + card = None + source = None if self.cursor_zone == "bottom": col = self.columns[self.cursor_index] if col: @@ -909,271 +982,291 @@ class FreeCellGame: 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 + card = self.freecells[self.cursor_index] + if card is not None: 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) - + self.foundation_for_suit(card.suit.name).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", SUITS.index(card.suit), [card] + source[0], + source[1], + "foundation", + find_suit_index(card.suit.name), + [card], ) + self.status = "Moved %s to foundation." % card.short_name() + self.request_full_repaint() - 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 is_won(self): + total = 0 + for foundation in self.foundations: + total = total + len(foundation) + return total == 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) +def spread_positions(count, start_x, end_x): positions = [] - for index in range(count): - positions.append(int(round(start_x + step * index))) - return tuple(positions) + if count <= 1: + return [start_x] + span = end_x - start_x + if span < 0: + span = 0 + steps = count - 1 + index = 0 + while index < count: + positions.append(rounded_position(start_x, span, index, steps)) + index = index + 1 + return positions -def compute_board_layout(max_x: int) -> BoardLayout: - """Compute a centered, width-aware board layout.""" +def compute_board_layout(max_x): left_margin = 2 - right_margin = max(left_margin, max_x - CARD_W - 2) - + right_margin = max_x - CARD_W - 2 + if right_margin < left_margin: + right_margin = left_margin 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) + top_positions = spread_positions(8, left_margin, right_margin) freecell_xs = top_positions[:4] - foundation_xs = top_positions[4:] + foundation_xs = top_positions[4:8] 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)) + 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 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, + freecell_xs, + foundation_xs, + tableau_xs, + freecell_xs[0], + foundation_xs[0], + tableau_xs[0], ) -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 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 card_color_pair(card: Card) -> int: - """Choose a color pair for a card.""" - return 2 if card.color_group == "red" else 3 +def card_color_pair(card): + if card.color_group() == "red": + return 2 + return 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_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_card_label(card: Optional[Card]) -> str: - """Return a compact label used to display a card or empty slot.""" +def draw_card_label(card): 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( +def draw_top_row(stdscr, game, layout): + safe_addstr( + stdscr, TOP_Y, layout.freecells_label_x, "Free Cells", - curses.color_pair(1) | curses.A_BOLD, + pair_attr(1) | text_attr("A_BOLD"), ) - stdscr.addstr( + safe_addstr( + stdscr, TOP_Y, layout.foundations_label_x, "Foundations", - curses.color_pair(1) | curses.A_BOLD, + pair_attr(1) | text_attr("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] + 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] draw_box(stdscr, TOP_Y + 1, x, CARD_W, draw_card_label(card), attr) - - if card: - stdscr.addnstr( + if card is not None: + safe_addnstr( + stdscr, TOP_Y + 1, x + 1, card.short_name(), CARD_W - 2, - curses.color_pair(card_color_pair(card)), + pair_attr(card_color_pair(card)), ) + index = index + 1 - # 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 + 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 draw_box(stdscr, TOP_Y + 1, x, CARD_W, label, attr) - - if top_card: - stdscr.addnstr( + if top_card is not None: + safe_addnstr( + stdscr, TOP_Y + 1, x + 1, top_card.short_name(), CARD_W - 2, - curses.color_pair(card_color_pair(top_card)), + pair_attr(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) + 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), ) + index = index + 1 -def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: - """Draw the tableau columns.""" - stdscr.addstr( +def draw_columns(stdscr, game, layout): + safe_addstr( + stdscr, COL_Y - 1, layout.tableau_label_x, "Tableau", - curses.color_pair(1) | curses.A_BOLD, + pair_attr(1) | text_attr("A_BOLD"), ) + max_height = 0 + for column in game.columns: + if len(column) > max_height: + max_height = len(column) - max_height = max((len(col) for col in game.columns), default=0) - - for col_idx in range(8): + col_idx = 0 + while col_idx < 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_attr = ( - curses.color_pair(4) - if selected - else curses.color_pair(6) - if hinted_dest - else curses.color_pair(1) - ) + 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) - draw_box(stdscr, COL_Y, x, CARD_W, str(col_idx + 1), header_attr) + 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 column = game.columns[col_idx] if not column: - stdscr.addstr(COL_Y + 1, x + 2, ".", curses.color_pair(1)) + safe_addstr(stdscr, COL_Y + 1, x + 2, ".", pair_attr(1)) + col_idx = col_idx + 1 continue - selected_cards: List[Card] = [] + selected_cards = [] 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) - selected_start = ( - len(column) - len(selected_cards) if selected_cards else len(column) - ) - - for row_idx, card in enumerate(column): + row_idx = 0 + for card in column: y = COL_Y + 1 + row_idx - in_valid_tail = ( selected and game.visual_mode and row_idx >= valid_tail_start ) @@ -1183,278 +1276,350 @@ def draw_columns(stdscr, game: FreeCellGame, layout: BoardLayout) -> None: in_hint_source = ( hint_source_start is not None and row_idx >= hint_source_start ) + marker = " " if in_selected_stack: - attr = curses.color_pair(4) + attr = pair_attr(4) | highlight_attr() + marker = ">" if selection_reason: - attr |= curses.A_DIM + attr = attr | text_attr("A_DIM") else: - attr |= curses.A_BOLD + attr = attr | text_attr("A_BOLD") elif in_hint_source: - attr = curses.color_pair(6) | curses.A_BOLD + attr = pair_attr(6) | text_attr("A_BOLD") + marker = "+" elif in_valid_tail: - attr = curses.color_pair(card_color_pair(card)) | curses.A_DIM + attr = pair_attr(card_color_pair(card)) | text_attr("A_DIM") elif selected and row_idx == len(column) - 1: - attr = curses.color_pair(4) + attr = pair_attr(4) | highlight_attr() + marker = ">" 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 + 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, ) + row_idx = row_idx + 1 - # 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() + 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: FreeCellGame, max_y: int) -> None: - """Draw the currently held card stack near the bottom of the screen.""" +def draw_held(stdscr, game, max_y, max_x): y = max_y - 3 - stdscr.move(y, 0) - stdscr.clrtoeol() - + try: + stdscr.move(y, 0) + stdscr.clrtoeol() + except curses.error: + pass if game.held is None: - stdscr.addstr(y, 2, "Held: (none)", curses.color_pair(1)) + safe_addstr(stdscr, y, 2, "Held: (none)", pair_attr(1)) return - - stdscr.addstr(y, 2, "Held: ", curses.color_pair(1)) + safe_addstr(stdscr, y, 2, "Held: ", pair_attr(1)) cards = game.held.cards if len(cards) == 1: - stdscr.addstr(y, 8, cards[0].short_name(), curses.color_pair(5) | curses.A_BOLD) + safe_addstr( + stdscr, y, 8, cards[0].short_name(), pair_attr(5) | text_attr("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 + summary = "%d cards (%s..%s)" % ( + len(cards), + cards[0].short_name(), + cards[-1].short_name(), ) + safe_addnstr(stdscr, y, 8, summary, max_x - 10, pair_attr(5) | text_attr("A_BOLD")) -def draw_status(stdscr, game: FreeCellGame, max_y: int) -> None: - """Draw a one-line status message.""" +def draw_status(stdscr, game, max_y, max_x): 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)) + try: + stdscr.move(y, 0) + stdscr.clrtoeol() + except curses.error: + pass + safe_addnstr(stdscr, y, 2, game.status, max_x - 4, pair_attr(1)) -def draw_help(stdscr, max_y: int) -> None: - """Draw a compact help line.""" +def draw_help(stdscr, max_y, max_x): y = max_y - 1 - stdscr.move(y, 0) - stdscr.clrtoeol() + try: + stdscr.move(y, 0) + stdscr.clrtoeol() + except curses.error: + pass help_text = "Move arrows/hjkl v select y/p/ENT/SPC move u undo ? hint f freecell d foundation Esc cancel q quit" - stdscr.addnstr(y, 2, help_text, max(0, curses.COLS - 4), curses.color_pair(1)) + safe_addnstr(stdscr, y, 2, help_text, max_x - 4, pair_attr(1)) -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.""" +def render(stdscr, game): stdscr.erase() max_y, max_x = stdscr.getmaxyx() layout = compute_board_layout(max_x) - elapsed_text = f"Time: {format_elapsed_time(game.elapsed_time_seconds())}" - - title = "mcfreecell 0.1" + elapsed_text = "Time: %s" % format_elapsed_time(game.elapsed_time_seconds()) + title = "mcfreecell 1.5.2" if len(title) > max_x - 4: title = "mcfreecell" - title_x = max(2, (max_x - len(title)) // 2) - stdscr.addnstr( + title_x = int((max_x - len(title)) / 2) + if title_x < 2: + title_x = 2 + safe_addnstr( + stdscr, 0, title_x, title, - max(0, max_x - title_x - 1), - curses.color_pair(1) | curses.A_BOLD, + max_x - title_x - 1, + pair_attr(1) | text_attr("A_BOLD"), ) - - timer_x = max(2, max_x - len(elapsed_text) - 2) + timer_x = max_x - len(elapsed_text) - 2 + if timer_x < 2: + timer_x = 2 if timer_x > title_x + len(title): - stdscr.addnstr( + safe_addnstr( + stdscr, 0, timer_x, elapsed_text, - max(0, max_x - timer_x - 1), - curses.color_pair(1) | curses.A_BOLD, + max_x - timer_x - 1, + pair_attr(1) | text_attr("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 + 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") ) - 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) - + draw_held(stdscr, game, max_y, max_x) + draw_status(stdscr, game, max_y, max_x) + draw_help(stdscr, max_y, max_x) if game.is_won(): - 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, + 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") ) - 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. - """ +def move_cursor(game, dy, dx): 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) + if game.cursor_index > 7: + 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) + if game.cursor_index > 7: + 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)) + 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 -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")): +def handle_key(game, ch): + if ch == ord("q") or ch == ord("Q"): + return 0 + if ch == curses.KEY_LEFT or ch == ord("h"): move_cursor(game, 0, -1) - return True - - if ch in (curses.KEY_RIGHT, ord("l")): + return 1 + if ch == curses.KEY_RIGHT or ch == ord("l"): move_cursor(game, 0, 1) - return True - - if ch in (curses.KEY_UP, ord("k")): + return 1 + if ch == curses.KEY_UP or ch == ord("k"): move_cursor(game, -1, 0) - return True - - if ch in (curses.KEY_DOWN, ord("j")): + return 1 + if ch == curses.KEY_DOWN or ch == ord("j"): move_cursor(game, 1, 0) - return True - - if ch in (ord("v"), ord("V")): + return 1 + if ch == ord("v") or ch == 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(" ")): + return 1 + if ch == curses.KEY_ENTER or ch == 10 or ch == 13 or ch == ord(" "): if game.held is None: game.pick_up() else: game.drop() - return True - - if ch in (ord("y"), ord("Y")): + return 1 + if ch == ord("y") or ch == 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")): + return 1 + if ch == ord("p") or ch == ord("P"): if game.held is None: game.status = "You are not holding any cards." else: game.drop() - return True - - if ch in (ord("u"), ord("U")): + return 1 + if ch == ord("u") or ch == ord("U"): game.undo_last_move() - return True - + return 1 if ch == ord("?"): game.show_hint() - return True - - # Quick helpers - if ch in (ord("f"), ord("F")): + return 1 + if ch == ord("f") or ch == ord("F"): game.quick_to_freecell() - return True - - if ch in (ord("d"), ord("D")): + return 1 + if ch == ord("d") or ch == ord("D"): game.quick_to_foundation() - return True - - # Escape: cancel held card by restoring it + return 1 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 + return 1 + return 1 -# --------------------------------------------------------------------------- -# Main curses loop -# --------------------------------------------------------------------------- - - -def curses_main(stdscr) -> None: - """Main UI entry point.""" - curses.curs_set(0) - stdscr.keypad(True) - stdscr.timeout(100) - +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 init_colors() - game = FreeCellGame() - - while True: + 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 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 main() -> None: - """Program entry point.""" - curses.wrapper(curses_main) +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) if __name__ == "__main__":