diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58200d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8804b9a --- /dev/null +++ b/README.rst @@ -0,0 +1,216 @@ +===== +Tonoi +===== + + + +Overview +-------- + +- `Introduction`_ +- `Motivation`_ +- `Installation`_ + - `Using Source`_ + - `From Pypi`_ + - `Compiled Binaries`_ +- `How to Play`_ + - `Overview of the game`_ + - `Registering a player name`_ + - `Using the cheat`_ + - `Setting a time limit`_ + - `Checking a source file`_ + - `More`_ +- `Tonoi Configuration`_ + - `Implementation Details`_ + - `Syntax Highlighting`_ +- `Issues`_ +- `Todos`_ +- `Future Updates`_ +- `Bug Reports`_ +- `Support`_ +- `Contribution`_ +- `License`_ + + +Introduction +------------ +Tonoi is a playable implementation of the classsical mathematical game called `Towers of Hanoi`_. It is a +console based implementation that uses `ANSI Escape Sequences`_ for implementing its own Textual User Interface(TUI) library. only stdlib is used in this game. + +Motivation +---------- +Not totally related but this `Computerphile`_ video. Since most github searches yeilded visualizers or solution for the puzzle, i thought, maybe i should implement a console based version thats actually playable. then i had +the bright idea of implementing every thing from scratch using only stdlib. And BOY!, did i ever had more regrets. having no prior knowledge of how to work with ansi sequences, i had to binge search every resource. the current codebase is no way near perfect or good for that matter. But i am satisfied for now, and truth be told have +lost motivation to work on this project anymore. + +Installation +------------ + +Using Source +~~~~~~~~~~~~ +You can install the game using the source. all you have to do is download this repo using your preferred method, +be it may a `git clone` or directly downloading the `Zip`_. after downloading the repo, you can use `setup.py` +to install the game: +:: + + # if on *nix + $python3 setup.py install + # if on windows + $py setup.py install + +From Pypi +~~~~~~~~~ +You may install the game from Pypi also: +:: + + $python3 -m pip install tonoi==0.2.0 + + +Compiled Binaries +~~~~~~~~~~~~~~~~~ +x86_64 Binaries are compiled for Windows, for users not having python installed, using `Nuitka`_. see the Releases page for more info. + +**Note**: Since the game uses ANSI Escape Sequences, any terminal that supports it will run the game. Meanwhile +the opposite is also true. + +How to Play +----------- +Tonoi is a commandline based game. use your preferred terminal that supports ANSI to run the game. +:: + + # for help + $tonoi -h + # for simply running the game with defaults + $tonoi + +Overview of the game +~~~~~~~~~~~~~~~~~~~~ +Playing it is pretty simple. tonoi uses commmand line interface(CLI) for interacting with the game. the +three towers are labeled numerically in order. various commands are provided, which correspond to different +actions. for example one can use the "move" command to move a disk from source tower/rod to destination +tower/rod.i.e: "move 1 2" will move a disk from tower 1 to tower 2. The rules of the orignal game still apply. +one cannot place a smaller disk onto a bigger one, doing so will deduct a life(not in orignal game). this +behaviour, however can be disabled as mentioned below. + +Registering a player name +~~~~~~~~~~~~~~~~~~~~~~~~~ +A "Player name" can be registered through the interface, which will later be used to identify the player. all +of "Best game runs" and "Perfect game runs" attributed to the player are stored in a config file and then later +used. By default the player name is generated by using a seed value. if you wish to use a custom name, then +use "register-player PLAYERNAME" command. + +Using the cheat +~~~~~~~~~~~~~~~ +By default tonoi has a additional component, the "life system". Whenever a player voilates the game rules, 1 +life is deducted from the player. there are a total of 3 lives. If all 3 of them are lost, then the player +looses.If you want to disable this system, then you can use the "icheat" command to do so. also the loosing message might be offensive for some people.For turning that off, use "butmymamainnocent" command. + +Setting a time limit +~~~~~~~~~~~~~~~~~~~~ +Players can use a "time limit" system for setting a pseudo time limit for completing the game. the reason why +its a "pseudo" time limit is because players can still complete the game after the time runs out. the time +limit can only be set from commandline(not tonoi's but system's). use "--time-limit/-tl " +to set the time limit. + +Checking a source file +~~~~~~~~~~~~~~~~~~~~~~ +One can use a source text file containing all the game moves for solving the puzzle for particular disks. +this can be done by putting the disk count at the top of the file, and then all the moves. use the tower number for referencing it.for example: +:: + + 3 + + 1->3 + 1->2 + 3->2 + 1->3 + 2->1 + 2->3 + 1->3 + +the above source will solve the puzzle for 3 disks. + +More +~~~~ +Much more is available in the game. use "list-commands" command to list all the commands. + +Tonoi Configuration +------------------- +Tonoi has a custom Markup language called "Konf" for configuration.it is used for storing both player game-data +and configuration for tonoi. Most of the configuration that is available at commandline can be specified in the configuration file. + +Implementation Details +~~~~~~~~~~~~~~~~~~~~~~ +Konf uses custom constructs called "Sections" and "Blocks" for organizing the datum. Sections live in higher +hirerchy than Blocks.There may an arbitrary amount of Sections in a single Konf file.A Section may have an +arbitrary amount of Blocks, but these Blocks may not be nested.There is a special Section called the "Meta" +section which can be used to store states that are related to the Konf source file or are independent of +Sections. It is the first Section that is parsed by the parser. The Special Meta Section variable "expression_delimiter" is used for modifying the assignment delimiter, which by default is "=". For example one may do +something like: +:: + + some_another_var=some_val + expression_delimiter=>> + < END @meta + + :: START -> a_section + + : START -> a_block + some_number>>5 + some_bool>>True + some_string>>i am a string + < END a_block + + <- END a_section + + :: START -> another_section + a_num>>4 + a_string>>i am another string + <- END another_section + +As you can see, Konf supports the three basic Datatypes,i.e strings,numbers,booleans. + +Syntax Highlighting +~~~~~~~~~~~~~~~~~~~ +There is a minimal syntax file at "syntax/konf.vim" provided for vim/nvim users to do simple syntax highlighting. + +Issues +~~~~~~ +There is only a single known issue at the time. after changing the terminal size, the cursor goes the right-end +of the terminal.give an empty input(i.e: enter) to move it after the prompt. Don't know why this happens.Will hopefully be fixed someday. + +Todos +----- +- Complete the debugger and logger +- Use sockets for playing multiplayer +- Do BugFixes +- More Features? + +Future Updates +-------------- +As i have mentioned above, i have lost motivation to work on this project for now.Consider this the first +and the only release of the project. will comeback if my mind is changed. + +Bug Reports +----------- +You can use the github issue tracker for reporting bugs.but know that fixes are not promised since the project +is semi-abbandoned for now. + +Support +------- +Maybe star the project, if you like it. + +Contribution +------------ +The codebase is kinda messy, but contributions are still welcomed. code formatting is done via "black". + +License +------- +This project is Licensed under GNU GPLV3 and can be distributed with later versions. + + +.. _`Towers of Hanoi`: https://en.wikipedia.org/wiki/Tower_of_Hanoi +.. _`ANSI Escape Sequences`: https://en.wikipedia.org/wiki/ANSI_escape_code +.. _`Computerphile`: https://www.youtube.com/watch?v=8lhxIOAfDss +.. _`Nuitka`: https://github.com/Nuitka/Nuitka +.. _`Zip`: https://github.com/Justaus3r/tonoi/archive/refs/heads/Master.zip diff --git a/assets/tonoi.png b/assets/tonoi.png new file mode 100644 index 0000000..1f77e2b Binary files /dev/null and b/assets/tonoi.png differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7cc46da --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +from tonoi.misc import Misc +from setuptools import setup + +setup_attrs = { + "name": "tonoi", + "version": Misc.util_version, + "description": Misc.util_description, + "long_description": Misc.util_description, + "url": "https://www.github.com/Justaus3r/tonoi", + "author": "Justaus3r", + "license": "GPLV3", + "classifiers": [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Education", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + ], + "project_urls": {"Homepage": "https://www.github.com/Justaus3r/tonoi"}, + "packages": ["tonoi"], + "python_requires": ">=3", + "entry_points": {"console_scripts": ["tonoi=tonoi.tonoi:main_entry"]}, +} + + +setup(**setup_attrs) diff --git a/syntax/konf.vim b/syntax/konf.vim new file mode 100644 index 0000000..27f0ff1 --- /dev/null +++ b/syntax/konf.vim @@ -0,0 +1,29 @@ +" Vim syntax file +" Language: Konf +" Maintainer: Justaus3r +" Lastest Revision: 12 Mar 2023 + +if exists("b:current_syntax") + finish +endif + +" TODO: match custom Section names + +syn keyword KonfBoolean True False +syn keyword konfSection START END +syn keyword SectionName play_history +syn match SectionName /player_\d\+/ +syn match konfSpecial /\v(\-\>|\@meta|\@META|\<\-|\:\:|\:|\<)/ +syn match konfComment /\v(#|").*/ +syn match konfNumber /\d\+/ + +:let b:current_syntax = "konf" + +hi def link KonfSection Keyword +hi def link SectionName Identifier +hi def link KonfSpecial SpecialChar +hi def link KonfComment Comment +hi def link konfNumber Number +hi def link KonfBoolean Boolean + + diff --git a/tonoi.py b/tonoi.py new file mode 100644 index 0000000..7e167ab --- /dev/null +++ b/tonoi.py @@ -0,0 +1,4 @@ +from tonoi.tonoi import main_entry + + +main_entry() diff --git a/tonoi/__init__.py b/tonoi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tonoi/canvas.py b/tonoi/canvas.py new file mode 100644 index 0000000..b35f58b --- /dev/null +++ b/tonoi/canvas.py @@ -0,0 +1,654 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" drawer for the game """ + +import os +import random +from time import sleep +from .handlers import StdoutHandler +from .termutils import clear_screen +from .exceptions import TerminalSizeError +from typing import Any, List, Tuple, Dict, Callable, ClassVar, Union, Optional, Iterator + + +class GeometricalShapes: + # ascii -> ascii character encoding + # ansi -> unicode character encoding + class Shapes: + # codepoints can also be used for unicode characters + # but literal character definition is better and more understandable. + rod_component: Dict[str, str] = {"ascii": "||", "ansi": "██"} + disk_component: Dict[str, str] = { + "ascii": ["[", "#", "]"], + "ansi": ["[", "██", "]"], + } + box_component: Dict[str, Dict[str, str]] = { + "horizontal_line": {"ascii": "-", "ansi": "═"}, + "vertical_line": {"ascii": "|", "ansi": "║"}, + "ansi_box_corner": { + "top_right": "╗", + "top_left": "╔", + "bottom_right": "╝", + "bottom_left": "╚", + }, + } + life_indicator: Dict[str, str] = {"ascii": "*", "ansi": "♡"} + bird: Dict[str, str] = {"ascii": "~v~", "ansi": "🕊"} + + @classmethod + def render_box_component( + cls, render_type: str, characters: int = 1, render_format: str = "ansi" + ) -> str: + return cls.Shapes.box_component[render_type][render_format] * characters + + @classmethod + def render_rod_component(cls, render_format: str = "ansi") -> str: + return "".join(cls.Shapes.rod_component[render_format]) + + @classmethod + def render_disk_component( + cls, + characters: int = 1, + render_format: str = "ansi", + no_extremes: bool = False, + ) -> str: + disk_char_index: int = 1 + base_disk: List[str] = cls.Shapes.disk_component[render_format].copy() + if no_extremes: + base_disk = base_disk[1:-1] + disk_char_index = 0 + base_disk.insert(1, base_disk[disk_char_index] * (characters - 1)) + return "".join(base_disk) + + @classmethod + def render_life_indicator(cls, render_format: str = "ansi") -> None: + return cls.Shapes.life_indicator[render_format] + + @classmethod + def render_bird(cls, render_format: str): + return cls.Shapes.bird[render_format] + + +class Canvas(StdoutHandler): + def __init__(self, name: str, drawing_format: str) -> None: + super().__init__(name=name) + self.drawing_format: str + Canvas.drawing_format: ClassVar[str] + self.drawing_format = Canvas.drawing_format = drawing_format + self.item_info: Dict[str, Any] = {} + self.disks_info: Dict[int, Dict[int, Tuple[int, str, int]]] = {} + self.disk_width: Optional[int] = None + self.init_disk_width: Optional[int] = None + + def partial_reinit(self) -> None: + # for calculating the disk width, we divide total columns into 3 parts. + # a unit space being 1/3 of total columns, then we again divide that unit space + # by 2 to get to center of that unit space and subtracting an arbitrary, i.e: 5 + # gets us to more or less a suitable disk_width to start printing the disk. + # we double the disk width in ascii format as same number of disk components + # are reduced to render it properly. + Canvas.drawing_format = self.drawing_format + self.disk_width = self.disk_horizontal() + if not self.init_disk_width: + # if self.init_disk_width is set , that means + # terminal window is probably resized, in which + # case don't update inital disk width, otherwise + # update it. + self.init_disk_width = self.disk_horizontal() + + # synonmyous to disk_width + @classmethod + def disk_horizontal(cls) -> int: + cols, _ = os.get_terminal_size() + return ( + ((cols // 6) - 5) if cls.drawing_format == "ansi" else ((cols // 6) - 5) * 2 + ) + + def draw_tower( + self, + line: int, + column: int, + rod_no: int, + appearent_disk_count: int, + rod_component_count: int, + *, + is_cloud: bool = False, + top_el_only: bool = False, + actual_disk_count: Optional[int] = None + ) -> None: + rod: Dict[int, Tuple[int, str]] + cols_copy: int = column + try: + rod = self.disks_info[rod_no] + except KeyError: + rod = self.disks_info[rod_no] = {} + if rod_no == 1: + assert actual_disk_count, "`actual_disk_count` missing!" + column_offset: int = 0 + for disk_no in range(actual_disk_count): + self.disks_info[rod_no][disk_no] = ( + self.disk_width, + self.random_color, + column_offset, + ) + self.disk_width = ( + self.disk_width - 1 + if self.drawing_format == "ansi" + else self.disk_width - 2 + ) + column_offset += 1 + rod = self.disks_info[rod_no] + if is_cloud: + init_cloud_width: int = self.disk_horizontal() + rod = { + 0: (init_cloud_width, "cyan", 0), + 1: (init_cloud_width - 1, "cyan", 1), + 2: (init_cloud_width - 2, "cyan", 2), + } + if top_el_only: + try: + top_element_idx: int = list(rod.keys())[-1] + except IndexError: + pass + else: + top_element: Tuple[int, str, int] = rod[top_element_idx] + rod: Dict[int, Tuple[int, str, int]] = {0: top_element} + appearent_disk_count = 1 + rod_component_count = 1 + + if not len(rod) == 0: + for idx in range(appearent_disk_count): + column = cols_copy + rod[idx][2] + self.print_stdout( + position=(line, column), + text=GeometricalShapes.render_disk_component( + **{ + "characters": rod[idx][0], + "no_extremes": is_cloud, + "render_format": self.drawing_format, + }, + ), + color=rod[idx][1], + ) + line -= 1 + column = cols_copy + column += ( + self.init_disk_width + if self.drawing_format == "ansi" + else (self.init_disk_width // 2) + ) + for _ in range(rod_component_count): + self.print_stdout( + position=(line, column), + text=GeometricalShapes.render_rod_component( + **{"render_format": self.drawing_format} + ), + ) + line -= 1 + + def draw_box_output( + self, + text: str, + box_position: str, + text_position: int = 0, + box_title: Optional[str] = None, + blink_box_title: bool = False, + text_color: str = "random", + box_title_color: str = "random", + return_only: bool = False, + ) -> Optional[Dict[str, Any]]: + assert isinstance( + box_position, str + ), "Position of type '{}' is not supported!".format(type(box_position).__name__) + + # max_line_len is the maximum length of printable text in a line + cols: int + lines: int + max_line_len: int + text_list: List[str] + box_vertical_length: int + box_horizontal_length: int + + # an extra space a box saves a box... + """ + if text: + text += " " + """ + + if box_position == "center": + text_list = text.split("\n") + max_line_len = sorted(map(len, text_list))[-1] + # since we print one sentence from text_list per line, + # vertical length of the box will be length of text_list + 1 + # and horizontal length will be: max_line_len + 2(for corner characters) + box_vertical_length = len(text_list) + 1 + box_horizontal_length = max_line_len + 2 + lines = (self.coordinates.lines // 2) - (box_vertical_length // 2) + cols = (self.coordinates.columns // 2) - (box_horizontal_length // 2) + elif box_position == "bottom-full": + text_list = [" " * text_position + text] + # -2(for corner characters) - 1(as max_line_len will be 1 less than horizontal length) + # -1(since one column will be reserved for printing full screen banner ) + # -3(as printing from col 3) + max_line_len = self.coordinates.columns - 7 + box_horizontal_length = max_line_len + 2 + box_vertical_length = 1 + cols = 3 + # -2(for printing the actual box) -1(for printing full screen box) + lines = self.coordinates.lines - 3 + elif box_position == "full-screen": + max_line_len = self.coordinates.columns - 3 + box_horizontal_length = max_line_len + 2 + box_vertical_length = self.coordinates.lines + cols = 0 + lines = 1 + text_list = [""] * box_vertical_length + elif box_position == "top-full": + max_line_len = self.coordinates.columns - 7 + box_horizontal_length = max_line_len + 2 + box_vertical_length = 3 + cols = 3 + # 1(as printing at ln 0 doesn't seem to work) + 1(leaving 1 line for full screen box) + lines = 2 + text_list = ["", " " * text_position + text, ""] + + else: + raise AssertionError("Position string Undefined!") + self.item_info.update( + { + "{}-box".format(box_position): { + "box_vertical_length": box_vertical_length, + "box_horizontal_length": box_horizontal_length, + "text_list": text_list, + "coordinates": (lines, cols), + "max_line_len": max_line_len, + "box-title": box_title, + } + } + ) + if return_only: + return self.item_info + if box_title: + self.print_stdout( + (lines - 1, cols + max_line_len // 2 - len(box_title) // 2), + box_title, + box_title_color, + blink_text=blink_box_title, + ) + # draw upper and bottom exteriors of the box + def draw_box_exteriors(exterior_type: str, lines: int, cols: int) -> None: + assert exterior_type in ["top", "bottom"], "Undefined exerior type!" + self.print_stdout( + (lines, cols), + ( + GeometricalShapes.render_box_component( + render_type="ansi_box_corner", + render_format="{}_left".format(exterior_type), + ) + + GeometricalShapes.render_box_component( + render_type="horizontal_line", + characters=box_horizontal_length + - 2, # '- 2' cuz of two extra characters, i.e '╗/|' + render_format=self.drawing_format, + ) + + GeometricalShapes.render_box_component( + render_type="ansi_box_corner", + render_format="{}_right".format(exterior_type), + ) + ) + if self.drawing_format == "ansi" + else ( + GeometricalShapes.render_box_component( + render_type="vertical_line", render_format="ascii" + ) + + GeometricalShapes.render_box_component( + render_type="horizontal_line", + characters=box_horizontal_length - 2, + render_format=self.drawing_format, + ) + + GeometricalShapes.render_box_component( + render_type="vertical_line", render_format="ascii" + ) + ), + color="random", + ) + + draw_box_exteriors("top", lines, cols) + lines += 1 + # middle part of the box + for line in text_list: + # first vertical line and the text + self.print_stdout( + (lines, cols), + GeometricalShapes.render_box_component( + render_type="vertical_line", render_format=self.drawing_format + ) + + line, + color=text_color, + ) + # cols = cols + max_line_len + 2 when printing full-screen as last col is specifically + # left for printing it. otherwise 1 as all other text will be printed inside the + # full-screen box. + self.print_stdout( + ( + lines, + cols + max_line_len + (2 if box_position == "full-screen" else 1), + ), + GeometricalShapes.render_box_component( + render_type="vertical_line", render_format=self.drawing_format + ), + color=text_color, + ) + lines += 1 + + draw_box_exteriors("bottom", lines, cols) + lines += 1 + + def get_appearent_disk_count(self, actual_disk_count: int) -> Tuple[int, int, bool]: + # if actual disk count is renderable on current terminal size + # then return the actual_disk_count. otherwise calculate the + # appearent disk count. + """ + Returns the appearent disk count + optional int + bool to check whether to render clouds + , over limit indicator, i.e [^] + + """ + calculation: Tuple[int, int, bool] + + other_items_occupancy: int = ( + 11 # 6 for upper boxes + 4 for lower boxes + 1 for Rod + ) + available_lines: int = self.coordinates.lines - other_items_occupancy + # disk_width also represent the vertical lenght of the tower + # as that no of disks will be rendered to stdout. + disk_width: int = ( + self.disk_width if self.drawing_format == "ansi" else self.disk_width // 2 + ) + if actual_disk_count <= disk_width and actual_disk_count < available_lines: + return actual_disk_count, 0, False + # clouds rendering line postion w.r.t bottom + clouds_pos_line_bottomup: int = (disk_width + available_lines + 2) // 2 + # clouds rendering line postion w.r.t top + clouds_pos_line_updown: int = self.coordinates.lines - clouds_pos_line_bottomup + if disk_width >= available_lines: + calculation = ( + available_lines - 2, + 0, + True, + ) # -2 to make it more organic + elif ( + clouds_pos_line_bottomup + 3 >= available_lines + ): # +3 to add vertical length of a cloud + calculation = disk_width - 2, 0, True + elif clouds_pos_line_bottomup + 3 < available_lines: + calculation = disk_width - 2, clouds_pos_line_updown, True + else: + raise ValueError("Could not determine the appearent disk count!") + return calculation + + def get_appearent_rod_count( + self, appearent_disk_count_generic: int, appearent_disk_count_curr: int + ) -> int: + return (appearent_disk_count_generic + 1) - appearent_disk_count_curr + + def draw_scenery(self, lines: int, cols: int, rod_no: int) -> None: + def draw_cloud(lines, cols): + self.draw_tower( + lines, + cols, + rod_no=None, + appearent_disk_count=3, + rod_component_count=0, + is_cloud=True, + ) + + def draw_bird(lines, cols): + self.print_stdout( + (lines, cols), GeometricalShapes.render_bird(self.drawing_format) + ) + + def calc_bird_rendition_coords(**kwargs) -> Iterator[Tuple[int, int]]: + cols: int = kwargs.get("cols") + lines: int = kwargs.get("lines") + rod_no: int = kwargs.get("rod_no") + vertical_pos_limit: int = kwargs.get("vertical_pos_limit") + horizontal_pos_limit: int = kwargs.get("horizontal_pos_limit") + horizontal_pos_limit = ( + horizontal_pos_limit * 2 + if self.drawing_format == "ansi" + else horizontal_pos_limit + ) + assert rod_no in [1, 2, 3], "Invalid Rod number!" + print_vertical: bool = False if rod_no == 1 else True + try: + if print_vertical: + for vertical_position in range(vertical_pos_limit): + lines = lines + ( + 1 + if not ( + rod_no == 3 + and vertical_position == vertical_pos_limit - 1 + ) + else 0 + ) + yield lines, cols + random.randint(2, horizontal_pos_limit) + else: + yield lines + 1, cols + random.randint(2, horizontal_pos_limit) + except ValueError: + raise TerminalSizeError + + draw_cloud(lines, cols) + coords_iterator: Iterator[Tuple[int, int]] = calc_bird_rendition_coords( + lines=lines, + cols=cols, + rod_no=rod_no, + horizontal_pos_limit=self.disk_horizontal(), + vertical_pos_limit=3, + ) + bird_rendition_rate: int = 3 + for _ in range(bird_rendition_rate): + try: + ln, col = next(coords_iterator) + draw_bird(ln, col) + except StopIteration: + break + + def draw_overlimit_indicator( + self, disks_overlimit: int, lines: int, cols: int + ) -> None: + self.print_stdout( + (lines, cols), "[\x1b[1;31;5m^\x1b[0m{}]".format(disks_overlimit) + ) + + def prepare_upper_box_elements_string(self, **kwargs) -> str: + item_list: List[str] = [] + for key, val in kwargs.items(): + if key == "Remaining_Lives": + continue + item_list.append(key.replace("_", " ") + ": " + val) + remaining_lives: int = kwargs.get("Remaining_Lives") + upper_box_max_len: int = ( + self.draw_box_output("", "top-full", return_only=True) + )["top-full-box"].get("max_line_len") + acumulative_items_space: int = ( + sum( + map( + len, + item_list, + ) + ) + + int(remaining_lives) * 2 + + 17 + ) # remaining_lives * 2 as each life indicator will be seperated by a space + 17 for predicate text. + + # padding per item will be calculated by subtracting the max_line_len from + # acumulative_items_space(space occupied by all the items) then dividing it + # by 4 to get unit padding + padding: int = (upper_box_max_len - acumulative_items_space) // 4 + + final_str: str = (" " * padding).join( + item_list + + [ + "Remaining Lives: " + + " ".join( + [GeometricalShapes.render_life_indicator(self.drawing_format)] + * int(remaining_lives) + ), + ] + ) + + # Do the text wraping if length of the string is greater than available columns + # just trunctuate the part of string , that can't be fitted in the box + print + # ... to show such. + final_str = ( + final_str[: -(len(final_str) - upper_box_max_len + 4)] + "..." + if len(final_str) > upper_box_max_len + else final_str + ) + + return final_str + + def dettach_buffer_cache(sleep_after: int = 0) -> Callable: + def outer(func) -> Callable: + def inner(self, *args, **kwargs) -> Any: + self.hide_buffer_cache = True + clear_screen() + return_values = func(self, *args, **kwargs) + self.hide_buffer_cache = False + sleep(sleep_after) + return return_values + + return inner + + return outer + + @dettach_buffer_cache(sleep_after=4) + def draw_win_screen( + self, + used_moves: int, + disk_count: int, + ) -> None: + # clear the buffer cache to avoid printing garbage + # self.buffer_cache.clear() + # the minimum amount of moves(minima) required to solve puzzle having + # n number of disks is: 2^n - 1 + minima: int = pow(2, disk_count) - 1 + playing_efficiency: float = minima / used_moves * 100 + box_text: str = """You have successfully solved the puzzle! +Minimum Moves Required: %d +No of Moves Used: %d +Playing Efficiency: %f%%""" % ( + minima, + used_moves, + playing_efficiency, + ) + self.draw_box_output( + box_text, + box_position="center", + box_title="CONGRATULATION!", + blink_box_title=True, + ) + + @dettach_buffer_cache(sleep_after=13) + def draw_death_screen(self, msg: str) -> None: + self.draw_box_output( + text=msg, + box_position="center", + text_color="red", + box_title="FAILURE", + box_title_color="red", + blink_box_title=True, + ) + + @dettach_buffer_cache(sleep_after=3) + def draw_warning_screen(self, msg: str) -> None: + box_text = """ +MESSAGE: + {} +""".format( + msg + ) + self.draw_box_output( + text=box_text, + box_position="center", + text_color="yellow", + box_title="WARNING", + box_title_color="yellow", + blink_box_title=True, + ) + + @dettach_buffer_cache(sleep_after=0) + def draw_info_screen(self, msg: str) -> None: + box_text = """ +MESSAGE: + {} +""".format( + msg + ) + self.draw_box_output( + text=box_text, + box_position="center", + text_color="blue", + box_title="INFO", + box_title_color="blue", + blink_box_title=True, + ) + + @dettach_buffer_cache(sleep_after=3) + def draw_error_screen(self, msg: str) -> None: + box_text = """ +MESSAGE: + {} +""".format( + msg + ) + self.draw_box_output( + text=box_text, + box_position="center", + text_color="red", + box_title="ERROR", + box_title_color="red", + blink_box_title=True, + ) + + @dettach_buffer_cache(sleep_after=0) + def draw_confirmation_box(self, msg: str) -> Tuple[int, int]: + """Afterwards return proper cursor position to transit to, for taking input""" + box_text = """ +MESSAGE: + {} +=> Continue with the action [Y/n]: +""".format( + msg + ) + msg_len: int = len(msg.split("\n")) + line = self.coordinates.lines // 2 + msg_len // 2 + 1 + col = self.coordinates.columns // 2 + 18 + self.draw_box_output( + text=box_text, + box_position="center", + text_color="yellow", + box_title="CONFIRMATION", + box_title_color="yellow", + blink_box_title=True, + ) + return (line, col) diff --git a/tonoi/exceptions.py b/tonoi/exceptions.py new file mode 100644 index 0000000..a8827f2 --- /dev/null +++ b/tonoi/exceptions.py @@ -0,0 +1,99 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" exceptions for tonoi """ + + +class ProhibitedMove(Exception): + """Raised on a Prohhibited Move""" + + +class EmptyRodError(Exception): + """Raised when attempted to move disk from empty rod""" + + +class TerminalSizeError(Exception): + """Raised when terminal size is below threashold""" + + +class ConfigParseError: + """Raised on parsing a bad config""" + + class BadSectionDefinition(Exception): + """Raised when a section is defined using a bad syntax""" + + def __init__(self, msg: str) -> None: + self.msg: str = msg + + def __str__(self) -> str: + return "\x1b[31m: {}\x1b[m".format(self.msg) + + class BadBlockDefinition(Exception): + """Raised when a block is defined using bad syntax""" + + def __init__(self, msg: str) -> None: + self.msg: str = msg + + def __str__(self) -> str: + return "\x1b[31m: {}\x1b[m".format(self.msg) + + class SectionUndefinedError(Exception): + """Raised when an inquired section is not defined""" + + def __init__(self, msg: str) -> None: + self.msg: str = msg + + def __str__(self) -> str: + return "\x1b[31m: {}\x1b[m".format(self.msg) + + class BadAssignmentError(Exception): + """Raised when an assignment operation is done poorly""" + + def __init__(self, msg: str) -> None: + self.msg: str = msg + + def __str__(self) -> str: + return "\x1b[31m: {}\x1b[m".format(self.msg) + + class BadKeyValuePair(Exception): + def __init__(self, msg: str) -> None: + self.msg: str = msg + + def __str__(self) -> None: + return "\x1b[31m: {}\x1b[m".format(self.msg) + + +class BadCommandError(Exception): + """Raised upon receiving a bad command""" + + +class SubArgumentError(Exception): + """Raised when given insufficient sub-arguments""" + + def __init__(self, responsible_callback: str) -> None: + self.callback: str = responsible_callback + + +class DisksOverLimitError(Exception): + def __init__(self, msg) -> None: + self.msg = msg + + def __str__(self) -> str: + return "\x1b[31m {}\x1b[m".format(self.msg) diff --git a/tonoi/handlers.py b/tonoi/handlers.py new file mode 100644 index 0000000..8011e6f --- /dev/null +++ b/tonoi/handlers.py @@ -0,0 +1,655 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" stdout and screen handler """ + +import re +import os +import sys +import copy +import random +import logging +from time import sleep +from .misc import ReturnCode +from .intrinsics import RodHandler +from .exceptions import ConfigParseError, ProhibitedMove, EmptyRodError +from typing import Tuple, List, Dict, Union, Optional, Callable, TypeAlias, NoReturn + + +class ConfigHandler: + """Handle user config and + data. + + Contains a simple parser for a specialized + loosely typed markup language which wasn't even + needed in the first place but wrote it anyway cuz felt like it, xdd + the parser is pretty shitty. + """ + + CONFIG: TypeAlias = Dict[ + str, Dict[str, Union[str, bool, int, Dict[str, Union[str, bool, int]]]] + ] + + def __init__(self, conf_type) -> None: + is_tonoi_src: bool = conf_type.startswith("::#") + assert ( + conf_type in ["player_data", "player_config"] or is_tonoi_src + ), "Invalid config type!" + self.conf_type: str + self.conf_file: str + self.home_dir: str = os.getenv("HOME") or os.getenv("USERPROFILE") + if is_tonoi_src: + self.conf_type = "tonoi_src" + self.conf_file = conf_type[3:] + else: + self.conf_type = conf_type + self.conf_file = os.path.join(self.home_dir, conf_type + ".konf") + # invoke enter even if ConfigHandler is not used as a context manager + # warn:an extra invocation when used as a context manager + self.__enter__() + + def __enter__(self) -> "self": + try: + self.io_obj = open(self.conf_file, mode="r+") + except FileNotFoundError: + + class io: + def __init__(self, conf_file, super_object) -> None: + io.conf_file = conf_file + io.super_object = super_object + + def open(self, *args, **kwargs) -> None: + pass + + def read(self, *args, **kwargs) -> None: + pass + + def write(self, *args, **kwargs) -> None: + pass + + def close(self, *args, **kwargs) -> None: + try: + self.conf.close() + except AttributeError: + pass + + def create_conf(self, *args, **kwargs): + open(io.conf_file, "w").close() + self.conf = open(self.conf_file, "r+") + return io.super_object.__class__( + os.path.basename(io.conf_file).strip(".konf") + ) + + self.io_obj = io(self.conf_file, self) + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.io_obj.close() + + def __sanitize_raw_config(self, raw_conf: List[List[str]]) -> List[List[str]]: + raw_conf_copy: List[List[str]] = copy.deepcopy(raw_conf) + for index, section in enumerate(raw_conf): + for section_element in section: + if section_element.startswith('"') or section_element.strip() == "": + raw_conf_copy[index].remove(section_element) + return raw_conf_copy + + def __infere_datatypes(self, data: str) -> Union[str, int, bool]: + infered_data: Union[str, int, bool] + str_to_bool: Dict[str, bool] = {"True": True, "False": False} + if data in ["True", "False"]: + infered_data = str_to_bool[data] + elif data.isdigit(): + infered_data = int(data) + else: + infered_data = data + + return infered_data + + def __get_err_ln_col(self, operand_str: str) -> Tuple[int, int]: + # return line no of first match of str + # kinda shitty but works so... + for idx, line in enumerate(self.raw_config_copy.splitlines()): + if operand_str in line: + return idx + 1, line.index(operand_str.strip()) + + def __parse_sections( + self, section_list: List[List[str]], expr_delimiter: str + ) -> "ConfigHandler.CONFIG": + parsed_data: ConfigHandler.CONFIG = {} + + def get_section_name(section_element: str) -> str: + # if section_element starts with `:` then its + # should be a section/block definition, in which case + # get the section name. doing it this way as some other + # element might define a variable with -> as value + if section_element.startswith(":"): + return section_element.split("->")[1].strip() + else: + raise IndexError + + for section in section_list: + section_name: str + section_data: Dict[str, Union[str, Dict[str, str]]] = {} + is_block: bool = False + block_name: str + block_data: Dict[str, str] = {} + if len(section) == 0: + # continue if the section is empty + continue + for index, section_element in enumerate(section): + if index == 0: + try: + section_name = get_section_name(section_element) + except IndexError: + section_name = "Meta" + try: + key, val = section_element.split(expr_delimiter) + except ValueError: + ln, col = self.__get_err_ln_col(section_element) + raise ConfigParseError.BadAssignmentError( + "ln:{}:col:{}::Expected an '=' delimited assignment but got '{}' in section 'Meta'".format( + ln, col, section_element + ) + ) + else: + section_data.update( + {key.strip(): self.__infere_datatypes(val.strip())} + ) + finally: + continue + if is_block: + if re.match(r"<\s+END\s+\w+", section_element): + section_data.update({block_name: block_data.copy()}) + is_block = False + block_data.clear() + continue + try: + key, val = section_element.split(expr_delimiter) + except ValueError: + ln, col = self.__get_err_ln_col(section_element) + raise ConfigParseError.BadAssignmentError( + "ln:{}:col{}::Expected an '{}' delimited assignment but got '{}' in block '{}'".format( + ln, col, expr_delimiter, section_element, block_name + ) + ) + else: + block_data.update( + {key.strip(): self.__infere_datatypes(val.strip())} + ) + else: + try: + key, val = section_element.split(expr_delimiter) + except ValueError: + if "->" not in section_element: + ln, col = self.__get_err_ln_col(section_element) + raise ConfigParseError.BadAssignmentError( + "ln:{}:col{}::Expected an '{}' delimited assignment but got '{}' in section '{}'".format( + ln, + col, + expr_delimiter, + section_element, + section_name, + ) + ) + is_block = True + block_name = get_section_name(section_element) + else: + section_data.update( + {key.strip(): self.__infere_datatypes(val.strip())} + ) + + parsed_data.update({section_name: section_data}) + + return parsed_data + + def __get_section( + self, raw_conf: str, section: str, is_meta: bool = False + ) -> List[str]: + section_start_re: re.Pattern = re.compile( + r"::\s+START\s+->\s+{}".format(section) + ) + section_end_re: re.Pattern = re.compile(r"<-\s+END\s+@?{}".format(section)) + if section_start_pattern_obj := section_start_re.search(raw_conf) or is_meta: + raw_conf_list: List[str] = raw_conf.splitlines() + section_start_index: int + if not is_meta: + inq_section_start: str = section_start_pattern_obj.group() + try: + section_start_index = raw_conf_list.index(inq_section_start) + except ValueError: + ln, col = self.__get_err_ln_col(inq_section_start) + raise ConfigParseError.BadSectionDefinition( + "ln:{}:col:{}::Section Head definition '{}' was illegally prefixed by character(s)".format( + ln, col, inq_section_start + ) + ) + else: + section_start_index = 0 + if section_end_pattern_obj := section_end_re.search(raw_conf): + inq_section_end: str = section_end_pattern_obj.group() + try: + section_endpoint_index: int = raw_conf_list.index(inq_section_end) + except ValueError: + ln, col = self.__get_err_ln_col(inq_section_end) + raise ConfigParseError.BadSectionDefinition( + "ln:{}:col:{}::Section Endpoint definition '{}' was illegally prefixed by character(s).".format( + ln, col, inq_section_end + ) + ) + return raw_conf_list[section_start_index:section_endpoint_index] + else: + + raise ConfigParseError.BadSectionDefinition( + "Ambigious/Bad section definition for section `{}`!".format(section) + ) + else: + raise ConfigParseError.BadSectionDefinition( + "Ambigious/Bad section definition for section `{}`!".format(section) + ) + + def read_config(self, sections: Optional[List[str]] = []) -> Dict: + raw_config: str + self.raw_config_copy: str + raw_config = self.raw_config_copy = self.io_obj.read() + new_raw_conf: List[List[str]] = [] + assert isinstance( + sections, List + ), "\x1b[31mExpected section list of type 'List' but got of type '{}'\x1b[m".format( + type(sections).__name__ + ) + if sections: + for section in sections: + if not re.search(r"::\s+START\s+->\s+{}".format(section), raw_config): + raise ConfigParseError.SectionUndefinedError( + "No section definitions matched for '{}'!".format(section) + ) + new_raw_conf.append( + self.__get_section(raw_conf=raw_config, section=section) + ) + else: + if section_heads_list := re.findall(r"::\s+START\s+->\s+\w+", raw_config): + for section_head in section_heads_list: + section: str = section_head.split("->")[1].strip() + new_raw_conf.append( + self.__get_section(raw_conf=raw_config, section=section) + ) + else: + raise ConfigParseError.SectionUndefinedError( + "No valid section definitions found!" + ) + + meta_section: List[str] = self.__get_section( + raw_conf=raw_config, section="meta", is_meta=True + ) + sanitized_meta: List[List[str]] = self.__sanitize_raw_config([meta_section]) + parsed_meta: ConfigHandler.CONFIG = self.__parse_sections( + sanitized_meta, expr_delimiter="=" + ) + expression_delimiter = ( + parsed_meta["Meta"].get("expression_delimiter") if parsed_meta else "=" + ) + expression_delimiter = expression_delimiter or "=" + sanitized_raw_conf: List[List[str]] = self.__sanitize_raw_config(new_raw_conf) + parsed_conf: ConfigHandler.CONFIG = self.__parse_sections( + sanitized_raw_conf, expr_delimiter=expression_delimiter + ) + + parsed_conf.update(parsed_meta) + + self.io_obj.seek(0) + + return parsed_conf + + def validate_config_existence(self) -> bool: + return os.path.exists(self.conf_file) + + def create_player_data_file(self) -> int: + self.io_obj.write( + """ +" This Template was automatically created by tonoi +" this file stores 'play history' of every registered player. +" Don't edit this file unless you know what you are doing +seed_value = 0 +<- END @meta + + +:: START -> play_history +" this section stores player play history. +" All the player's play history will be here in data_structure called a block, +" each having its own block-name/id +" examplory block definition: +" : START -> block_name +" attr_1 = 10 +" attr_2 = a string +" attr 3 = False +" < END block_name + + +<- END play_history""" + ) + # return initial seed value + # -1 as generating player name will + # normalize it to 0 + return -1 + + def update_player_data(self, player_Id: int, **kwargs) -> None: + assert ( + self.conf_type == "player_data" + ), "This method only works with config of type `player_data`" + player_name_re: re.Pattern = re.compile( + r"\s*:\s+START\s+->\s+{}".format(player_Id) + ) + + def add_newline(position: str, op_str: str) -> str: + if position in ["start", "end"] and not ( + op_str.endswith("\n") or op_str.startswith("\n") + ): + return "\n" + return "" + + with open(self.conf_file, "r") as conf_file: + config_data: str = conf_file.read() + matched_patt = player_name_re.search(config_data) + if matched_patt: + conf_delimiter: str = matched_patt.group() + conf_data_list: List[str] = config_data.split(conf_delimiter) + conf_before: str = conf_data_list[0] + conf_after: str = conf_data_list[1] + var_list: List[str] = ["best_game_runs", "perfect_game_runs"] + for var in var_list: + var_matching_re: str = r"\s*{}\s+=\s+\w+".format(var) + conf_after = re.sub( + count=1, + pattern=var_matching_re, + repl="\n" + var + " = " + str(kwargs.get(var)), + string=conf_after, + ) + config_data: str = ( + conf_before + + add_newline("start", conf_before) + + conf_delimiter + + add_newline("end", conf_after) + + conf_after + ) + else: + config_data = ( + config_data[:-19] + + """ + +: START -> {} +best_game_runs = {} +perfect_game_runs = {} +< END {}""".format( + player_Id, + kwargs.get("best_game_runs"), + kwargs.get("perfect_game_runs"), + player_Id, + ) + + "\n<- END play_history" + ) + with open(self.conf_file, "w") as write_conf: + write_conf.write(config_data) + + @staticmethod + def create_config() -> None: + conf_path: str = os.path.join( + (os.getenv("HOME") or os.getenv("USERPROFILE")), "player_config.konf" + ) + if os.path.exists(conf_path): + return None + io_obj = open(conf_path, "w") + io_obj.write( + """ +" Tonoi configuration file +" Switches passed to tonoi can also be defined here +<- END @meta + +:: START -> tonoi_config + +disk_capacity = 3 +render_ascii = False +interface_type = graphics +debug = False + +<- END tonoi_config""" + ) + io_obj.close() + + def read_toh_src(self) -> Tuple[str, List[str]]: + if not self.validate_config_existence(): + raise FileNotFoundError( + "Source file not found!.check the path and try again." + ) + src_data: str = self.io_obj.read() + src_data_list = src_data.splitlines() + empty_str_count: int = src_data_list.count("") + for _ in range(empty_str_count): + src_data_list.remove("") + try: + _ = int(src_data_list[0]) + except ValueError: + raise AssertionError( + "\x1b[31mExpected an integer as a torwer size but got {}\x1b[m".format( + src_data_list[0] + ) + ) + return src_data_list[0], src_data_list[1:] + + def update_seed_value(self, seed_value: int) -> None: + get_seed_re = re.compile(r"seed_value\s+=\s+\d+") + config: str = self.io_obj.read() + self.io_obj.seek(0) + new_config = get_seed_re.sub("seed_value = {}".format(seed_value), config) + self.io_obj.write(new_config) + + def write_config(self, *args, **kwargs) -> NoReturn: + raise NotImplementedError("write_config() is not implemented as of now!") + + +class EventHandler: + def __init__(self) -> None: + self.event_list: Dict[str, Callable] = {} + + def register_event(self, name: str, callback: Callable) -> None: + """ + event types: + - terminal_size_change ---- + |- atrribute_change + |- terminal_size_error + - atrribute_change + - error_out --- + - |- crash_log + - time_log + """ + # TODO: for [0.2.0 .. 1.0.0]: multiplayer mode in local network using sockets. + self.event_list.update({name: callback}) + + def handle_event( + self, name: str, callback_args: Dict[str, Union[bool, str, int]] = {} + ) -> None: + self.event_list[name](**callback_args) + + +class StdoutHandler: + handlers_list: Dict[str, "StdoutHandler"] = {} + + class BufferCache(Dict): + def __init__(self, **kwargs) -> None: + self.counter = 0 + super().__init__(**kwargs) + + def update(self, mapping={}, **kwargs) -> None: + map_tuple = tuple(mapping.items())[0] + key, val = map_tuple + # `{n}_%%%` act as special characters delimiters + # used to distinguish same text. these characters + # cant be used in regular text + key = "{}_%%%{}".format(self.counter, key) + super().update({key: val}) + self.counter += 1 + + def get_line_data(self, line: int) -> Dict[int, str]: + # print(self) + # sleep(0.06) + # input() + line_data: Dict[int, str] = {} + for key, val in self.items(): + if val[0] == line: + key = key.split("_%%%")[1] + line_data.update({val[1]: "\x1b[{}m".format(val[2]) + key}) + return line_data + + def __init__(self, name: str = None) -> None: + self.name: str = name or self.name + # list of SGR parameters: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters + self.color_list: Dict[str, int] = { + "default": 0, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "cyan": 36, + "white": 97, + } + self.buffer_cache: StdoutHandler.BufferCache = StdoutHandler.BufferCache() + self.hide_buffer_cache: bool = False + StdoutHandler.handlers_list.update({name: self}) + + @property + def coordinates(self) -> os.terminal_size: + return os.get_terminal_size() + + @property + def random_color(self) -> str: + return random.choice(tuple(self.color_list.keys())) + + def transit_position(self, line: int, col: int) -> None: + print("\x1b[{};{}H".format(line, col), end="") + + def print_stdout( + self, + position: Union[Tuple[int, int], str], + text: str, + color: Optional[str] = None, + blink_text: bool = False, + return_coords_only: bool = False, + after_position: Optional[Union[Tuple[int, int], str]] = None, + ) -> Optional[Tuple[int, int]]: + if color is None: + color = "default" + elif color == "random": + color = self.random_color + if isinstance(position, Tuple): + lines, cols = position + elif isinstance(position, str): + cols, lines = self.coordinates + if position == "center": + cols = cols // 2 - len(text) // 2 + lines //= 2 + elif position == "top-left": + lines = 0 + cols = 0 + elif position == "top-right": + lines = 0 + cols = cols - len(text) + elif position == "bottom-left": + cols = 0 + elif position == "bottom-right": + cols = cols - len(text) + elif position == "after-prompt": + lines -= 2 + cols = 7 + else: + raise NotImplementedError("Undefined position string!") + else: + raise AssertionError( + "Position '{}' is not supported!".format(type(position)) + ) + if return_coords_only: + return lines, cols + if self.hide_buffer_cache: + line_data = {cols: "\x1b[{}m".format(self.color_list.get(color)) + text} + else: + self.buffer_cache.update({text: (lines, cols, self.color_list.get(color))}) + line_data = self.buffer_cache.get_line_data(lines) + index_print_order = sorted(line_data) + # ANSI control codes + # reference: + # https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences + # https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 + for print_index in index_print_order: + print("\x1b[5m" if blink_text else "", end="") + print( + "\x1b[{};{}H".format( + lines, + print_index, + ), + line_data[print_index], + end="", + ) + sys.stdout.flush() + print("\x1b[0K", end="") # erase from cursor to end of line + print("\x1b[25m", end="") # reset blinking effect + if after_position: + lines, cols = self.print_stdout(after_position, "", return_coords_only=True) + print("\x1b[{};{}H".format(lines, cols), end="") + + # print("\x1b[0K", end="") # erase from cursor to end of lines + # print("\x1b[{}D".format(len(text)), end="") # move cursor len(text) cols left + # print("\x1b[1K", end="") # erase from cursor to beginning of line + # print("\x1b[{}C".format(len(text)), end="") # move cursor len(text) right + + +class LogHandler: + """ + use logging module for both logging to stdout + and file. + """ + + def __init__(self) -> None: + pass + + +class SourceValidationHandler: + def __init__(self, toh_source: str) -> None: + self.toh_source: str = toh_source + + def validate_toh_source(self) -> Union[Tuple[str, str], bool]: + with ConfigHandler("::#" + self.toh_source) as toh_source: + rod_capacity, moves_list = toh_source.read_toh_src() + game_handle = RodHandler(int(rod_capacity)) + for move in moves_list: + try: + src, dest = move.split("->") + except ValueError: + # raised when there are less moves then + # declared for rod_capacity, tldr: not enough + # moves were given for game to end + return False + try: + game_won: bool = game_handle.move_disk(int(src), int(dest)) + except ProhibitedMove: + return move, "Larger disk can't be put over a smaller disk!" + except EmptyRodError: + return move, "Can't move a disk from empty rod" + if game_won: + return True + else: + return False diff --git a/tonoi/intrinsics.py b/tonoi/intrinsics.py new file mode 100644 index 0000000..97d7b57 --- /dev/null +++ b/tonoi/intrinsics.py @@ -0,0 +1,133 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" All intrinsics of tower of hanoi """ + +import random +from typing import List, Union, Dict, Optional +from .exceptions import ProhibitedMove, EmptyRodError + + +class DiskSizeGenerator: + disk_size_cache: List[int] = [] + + def __call__(self, upper_disk_limit) -> int: + rand_no: int + while True: + rand_no = random.randint(1, upper_disk_limit) + if rand_no not in DiskSizeGenerator.disk_size_cache: + DiskSizeGenerator.disk_size_cache.append(rand_no) + break + DiskSizeGenerator.disk_size_cache.append(rand_no) + return rand_no + + +class Disk: + def __init__(self, size: int) -> None: + assert isinstance(size, int) + self.size: int = size + + def __repr__(self) -> str: + return "(Disk object, size={})".format(self.size) + + def __str__(self) -> str: + return str(self.size) + + def __lt__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size < other + + def __le__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size <= other + + def __gt__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size > other + + def __ge__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size >= other + + def __eq__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size == other + + def __ne__(self, other: Union[int, "Disk"]) -> bool: + assert isinstance(other, (int, Disk)) + return self.size != other + + +class Rod(List): + def __init__(self, **kwargs) -> None: + self.is_primary: bool = kwargs.get("primary_rod") + Rod.rod_capacity: int = kwargs.get("rod_capacity") or Rod.rod_capacity + self.__items: List[Disk] = sorted( + [ + Disk(size=DiskSizeGenerator()(Rod.rod_capacity + 1)) + for _ in range(Rod.rod_capacity) + ] + if self.is_primary + else [], + reverse=True, + ) + super().extend(self.__items) + + def append(self, item: Disk, src_rod: Optional["Rod"] = None) -> None: + try: + if item > self[-1]: + # if an illegal move was attempted, push + # the src element back to the rod, from + # where it was popped + src_rod.append(item) + raise ProhibitedMove + except IndexError: + # means that the dest rod is empty + pass + super().append(item) + + +class RodHandler: + def __init__(self, rod_capacity: int) -> None: + self.rod_capacity: int = rod_capacity + self.primary_rod: Rod = Rod(primary_rod=True, rod_capacity=rod_capacity) + self.secondary_rod: Rod = Rod() + self.tertiary_rod: Rod = Rod() + self.int_to_obj: Dict[int, Rod] = { + 1: self.primary_rod, + 2: self.secondary_rod, + 3: self.tertiary_rod, + } + + def move_disk(self, source_rod: int, dest_rod: int) -> Optional[bool]: + assert source_rod in (1, 2, 3) and dest_rod in ( + 1, + 2, + 3, + ), "source_rod/dest_rod must be >=1&&<=3" + source_rod: Rod = self.int_to_obj.get(source_rod) + dest_rod: Rod = self.int_to_obj.get(dest_rod) + try: + disk: Disk = source_rod.pop() + except IndexError: + raise EmptyRodError + dest_rod.append(disk, source_rod) + if len(self.tertiary_rod) == self.rod_capacity: + return True diff --git a/tonoi/misc.py b/tonoi/misc.py new file mode 100644 index 0000000..a4beacf --- /dev/null +++ b/tonoi/misc.py @@ -0,0 +1,165 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" Misc stuff """ + +import os +import pprint +import random +import subprocess +from time import sleep +from typing import Dict, Optional, Union, Callable, Type + +no_requests: bool = False + +try: + import requests + from requests.exceptions import ConnectionError +except ImportError: + import json + from urllib.request import urlopen + from urllib.error import HTTPError + + no_requests = True + + class ConnectionError(Exception): + """dummpy exception class when requests isn't available""" + + +class Misc: + util_name: str = "tonoi" + util_version: str = "0.2.0" + util_usage: str = "{} [options]".format(util_name) + util_description: str = "A playable implementation of towers of hanoi" + util_epilog: str = """Notes: +(1)Most configuration switches specified here can also be specified in '{}/player_config.konf'. +(2)Disk count can't be greater than maxima value provided by --get-maxima/-gm option.""".format( + os.getenv("HOME") + ) + util_howtoplay: str = """Welcome to Tonoi +This is a playable implementation of a mathematical +puzzle called Towers of hanoi. the game is pretty simple, +one has to move disks from first rod to third abiding by +following two rules: +1: Only one disk can be moved at a time. +2: A bigger disk may not be put over a smaller disk. +Wiki link: https://en.wikipedia.org/wiki/Tower_of_Hanoi). + +You can use `list-commands` command to list all the +valid commands. + +Press Enter to Continue""" + default_config: int = { + "disk_capacity": 3, + "render_ascii": False, + "interface_type": "graphics", + "debug": False, + } + + +class ReturnCode: + SUCCESS: int = 0 + FAILURE: int = 1 + + +def inject_debugger( + absorbed_obj: Union[object, Callable, int], dump_debug: bool = False +) -> Union[object, Callable]: + + if isinstance(absorbed_obj, type): + + class innercls(absorbed_obj): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def recon(self) -> Dict[str, str]: + recon_data: Dict[str, str] = {} + self.recon_data.update({"instance_vars: ": {self.__dict__}}) + self.recon_data.update( + { + "class_vars: ": dict( + [ + (key, val) + for key, val in self.__class__.__dict__.items + if not key.startswith("__") + ] + ) + } + ) + return recon_data + + def dump_debug_data(self) -> None: + pp = pprint.PrettyPrinter(indent=4) # vewy sus + recon_data: Dict[str, str] = self.recon() + pp.pprint(recon_data["instance_vars"]) + print("\n\n") + pp.pprint(recon_data["class_vars"]) + + return innercls + elif isinstance(absorbed_obj, (Callable, int)): + + def innerfunc(func): + # incase the decorator is used on a function, + # absorb the integer delay value and delay + # after executing the function. + def inside_inner(*args, **kwargs): + kwargs.update({"dump_debug": dump_debug}) + if dump_debug: + print( + "**DEBUG**: After-func-exec delay time: {}".format(absorbed_obj) + ) + func(*args, **kwargs) + sleep(int(absorbed_obj)) + + return inside_inner + + return innerfunc + else: + raise AssertionError("es no décorateur..") + + +def version_validator() -> Optional[Union[str, Type]]: + try: + new_ver: str + endpoint: str = "https://api.github.com/repos/justaus3r/tonoi/releases/latest" + if not no_requests: + new_ver = requests.get(endpoint).json().get("name") + else: + response: str = urlopen(endpoint).read().decode() + response_json: Dict = json.loads(response) + new_ver = response_json.get("name") + if "v{}".format(Misc.util_version) != new_ver and new_ver is not None: + return new_ver + elif new_ver is None: + return requests.exceptions.HTTPError + except ConnectionError: + return ConnectionError + except HTTPError: + + class UrllibHTTPError(HTTPError): + def __init__(self, msg=None) -> None: + self.msg = msg + super().__init__(code=404, msg=msg, hdrs=None, fp=None, url=endpoint) + + return UrllibHTTPError + + +def gen_player_name(seed: int) -> str: + return "player_{}".format(seed + 1) diff --git a/tonoi/termutils.py b/tonoi/termutils.py new file mode 100644 index 0000000..6d02eb2 --- /dev/null +++ b/tonoi/termutils.py @@ -0,0 +1,114 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" terminal utils """ + +import os +import platform +import threading +import subprocess +from time import sleep +from typing import Optional + + +def clear_screen() -> None: + print("\x1b[2J", end="") + + +def get_platform() -> str: + return platform.system() + + +def ansi_controlcode_supported() -> Optional[bool]: + if os.getenv("ANSI_SUPPORTED"): + return True + if platform.system() == "Windows": + reg_get_cmd: str = 'powershell "$(Get-ItemProperty HKCU:\Console VirtualTerminalLevel).VirtualTerminalLevel"' + reg_set_cmd: str = ( + "Set-ItemProperty HKCU:\Console VirtualTerminalLevel -Type DWORD 1" + ) + platform_version: List[str] = platform.version().split(".") + if platform_version[0] == "10" and int(platform_version[2]) > 14393: + if not subprocess.getoutput(reg_get_cmd) == "1": + print( + "Your Windows build supports Ansi Escape Sequences but it is not enabled.\nIt can be done by creating a DWORD named `VirtualTerminalLevel` with `1` stored.\nFollowing command can also be used in an administrative powershell instance to do so:\n{}".format( + reg_set_cmd + ) + ) + return None + else: + return False + return True + + +class ThreadedEventsAdaptor(threading.Thread): + # 2 -> pause the execution status of the thread ; 1 -> default, normal run ; 0 -> halt the execution of + # thread + + signal: int = 1 + + def __init__( + self, + event_handle: "EventHandler", + screen_data: "ScreenData", + canvas: "Canvas", + tonoi: "Tonoi", + ) -> None: + self.event_handle: "EventHandler" = event_handle + self.screen_data: "ScreenData" = screen_data + self.tonoi_obj: "Tonoi" = tonoi + self.canvas_obj: "Canvas" = canvas + super().__init__() + + def run(self) -> None: + self.overwatch(self.event_handle) + + def overwatch(self, event_handle: "EventHandler") -> None: + while self.signal: + # Ewwwk! busywaiting..... + # Reference: https://en.wikipedia.org/wiki/Busy_waiting + # removing the sleep will cause your pc to go into jihad mode. + sleep(0.3) + if self.signal == 2: + continue + lines: int + columns: int + columns, lines = os.get_terminal_size() + + if columns != self.screen_data.columns and lines == self.screen_data.lines: + self.screen_data.columns = columns + event_handle.handle_event("elict_term_width_warning") + + elif (lines, columns) != (self.screen_data.lines, self.screen_data.columns): + self.screen_data.lines, self.screen_data.columns = lines, columns + ( + self.screen_data.appearent_max_disk_count_nrod, + *_, + ) = self.canvas_obj.get_appearent_disk_count( + self.canvas_obj.disk_horizontal() + ) + event_handle.handle_event("redraw") + + elif self.tonoi_obj.time_limit and not self.tonoi_obj.no_tl_dialog: + time_limit: int = self.tonoi_obj.get_time_limit(value_only=True) + self.event_handle.handle_event( + "time_limit_indicator", + callback_args={"time_remaining": time_limit}, + ) diff --git a/tonoi/tonoi.py b/tonoi/tonoi.py new file mode 100644 index 0000000..a443e27 --- /dev/null +++ b/tonoi/tonoi.py @@ -0,0 +1,883 @@ +# _______ _ +# |__ __| (_) +# | | ___ _ __ ___ _ +# | |/ _ \| '_ \ / _ \| | +# | | (_) | | | | (_) | | +# |_|\___/|_| |_|\___/|_| +# ©Justaus3r 2022 +# This file is part of "Tonoi",a playable implementation of tower of hanoi. +# Distributed under GPLV3 +# 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, either version 3 of the License, or +# (at your option) any later version. +# 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, see . +""" Main entrypoint and argument handler """ + + +import sys +import random +import argparse +import datetime +import subprocess +from time import sleep +from .canvas import Canvas +from pprint import PrettyPrinter +from .intrinsics import RodHandler +from typing import List, NoReturn, Tuple, Optional, Union, Callable +from .handlers import ConfigHandler, EventHandler, SourceValidationHandler +from .misc import Misc, ReturnCode, version_validator, gen_player_name, inject_debugger +from .termutils import ( + clear_screen, + ThreadedEventsAdaptor, + get_platform, + ansi_controlcode_supported, +) +from .exceptions import ( + EmptyRodError, + BadCommandError, + ProhibitedMove, + SubArgumentError, + DisksOverLimitError, + ConfigParseError, +) + + +if get_platform() in ("Linux", "Darwin"): + import readline + + +@inject_debugger +class Tonoi: + + canvas = Canvas("canvas_handle", drawing_format=None) + + def __init__(self, **kwargs) -> None: + self.config: ConfigHandler.CONFIG = {} + self.game_finished: bool = False + self.game_won: bool = False + self.allow_illegal_move: bool = False + self.ye_mama_innocent: bool = False + self.no_tl_dialog: bool = False + self.command_history: List[str] = [] + self.time_limit: Optional[int] = kwargs.get("time_limit") + self.init_time: Optional[datetime.datetime] = None + if self.time_limit: + self.init_time = datetime.datetime.now() + self.do_debugging: bool = kwargs.get("debug") + with ConfigHandler("player_config") as config_handle: + if config_handle.validate_config_existence(): + entire_config = config_handle.read_config() + self.config = entire_config["tonoi_config"] + else: + self.config = Misc.default_config + self.interaction_mode: str = ( + kwargs.get("interaction_mode") or self.config["interface_type"] + ) + with ConfigHandler("player_data") as players_data_handle: + seed_value: int + if players_data_handle.validate_config_existence(): + players_data = players_data_handle.read_config() + seed_value = players_data["Meta"].get("seed_value") + else: + players_data_handle = players_data_handle.io_obj.create_conf() + seed_value = players_data_handle.create_player_data_file() + players_data = {} + new_player_name = gen_player_name(seed_value) + self.config.update({"player_name": new_player_name}) + default_player_data: Dict[str, Optional[str]] = { + "best_game_runs": "None", + "perfect_game_runs": "None", + } + if player_name := kwargs.get("player_name"): + try: + player_data = players_data["play_history"][player_name] + except KeyError: + player_data = default_player_data + finally: + default_player_data.update({"player_name": player_name}) + else: + player_data = default_player_data + players_data_handle.update_seed_value(seed_value + 1) + + self.config.update(kwargs) + self.config.update({"player_data": player_data}) + + class ScreenData: + player_name: str + moves: str + lines: int + columns: int + best_game_run: int + perfect_game_runs: int + remaining_lives: int + actual_disk_count: str + appearent_max_disk_count_nrod: int + disk_count_1rod: int + disk_count_2rod: int + disk_count_3rod: int + + def draw_interface(self, **kwargs) -> None: + if not self.is_graphics_mode(): + # probably redraw is being used in textual mode + print("`redraw` is unavailable in this mode") + return None + clear_screen() + Tonoi.canvas.buffer_cache.counter = 0 + Tonoi.canvas.buffer_cache.clear() + Tonoi.canvas.partial_reinit() + # disk_count_first_rod initially being equal to actual_disk_count + # will change throughout the game. + actual_disk_count_1rod = Tonoi.ScreenData.disk_count_1rod + actual_disk_count_2rod = Tonoi.ScreenData.disk_count_2rod + actual_disk_count_3rod = Tonoi.ScreenData.disk_count_3rod + + draw_top_only: bool = kwargs.get("top_el_only") + + def draw_unit_tower( + rod_no: int, actual_disk_count_nrod: int, top_el_only: bool = False + ) -> int: + assert rod_no in [1, 2, 3], "Rod no must be in range 1..3" + + ( + appearent_disk_count_nrod, + cloud_line_nrod, + render_overlimiter_nrod, + ) = Tonoi.canvas.get_appearent_disk_count(actual_disk_count_nrod) + + rod_count_curr: int = Tonoi.canvas.get_appearent_rod_count( + Tonoi.ScreenData.appearent_max_disk_count_nrod, + appearent_disk_count_nrod, + ) + cloud_col_nrod: int + disk_rendering_col: int + cloud_col_nrod = disk_rendering_col = ( + 3 + if rod_no == 1 + else ( + (Tonoi.canvas.coordinates.columns // 3 - 3) + if rod_no == 2 + else ((Tonoi.canvas.coordinates.columns // 3 - 3) * 2) + ) + ) + + if cloud_line_nrod: + Tonoi.canvas.draw_scenery( + cloud_line_nrod, cloud_col_nrod, rod_no=rod_no + ) + + if render_overlimiter_nrod: + # subtracting the lines occupied by the disks - the lines occupied items at the item + # will give us the line at which the 'Rod' is rendered. + overlimiter_render_line: int = ( + Tonoi.canvas.coordinates.lines - appearent_disk_count_nrod - 4 + ) + Tonoi.canvas.draw_overlimit_indicator( + actual_disk_count_nrod - appearent_disk_count_nrod, + overlimiter_render_line, + cloud_col_nrod, + ) + Tonoi.canvas.draw_tower( + Tonoi.canvas.coordinates.lines - 4, + disk_rendering_col, + rod_no, + appearent_disk_count_nrod, + rod_count_curr, + top_el_only=top_el_only, + actual_disk_count=Tonoi.ScreenData.actual_disk_count, + ) + + draw_unit_tower( + rod_no=1, + actual_disk_count_nrod=actual_disk_count_1rod, + top_el_only=draw_top_only, + ) + + draw_unit_tower( + rod_no=2, + actual_disk_count_nrod=actual_disk_count_2rod, + top_el_only=draw_top_only, + ) + draw_unit_tower( + rod_no=3, + actual_disk_count_nrod=actual_disk_count_3rod, + top_el_only=draw_top_only, + ) + upper_box_str = Tonoi.canvas.prepare_upper_box_elements_string( + Player_Name=Tonoi.ScreenData.player_name, + Best_Runs=Tonoi.ScreenData.best_game_runs, + Perfect_Runs=Tonoi.ScreenData.perfect_game_runs, + Current_Moves=Tonoi.ScreenData.moves, + Remaining_Lives=Tonoi.ScreenData.remaining_lives, + ) + + Tonoi.canvas.draw_box_output( + upper_box_str, + box_position="top-full", + text_color="random", + ) + + Tonoi.canvas.draw_box_output( + text=None, + box_position="full-screen", + text_color="green", + ) + Tonoi.canvas.draw_box_output( + ">>", + box_position="bottom-full", + text_color="green", + ) + Tonoi.canvas.transit_position(Tonoi.canvas.coordinates.lines - 2, 7) + + def allow_prohibited_move(self) -> None: + self.allow_illegal_move = True + + def disallow_prohibited_move(self) -> None: + self.allow_illegal_move = False + + def mama_no_innocent(self) -> None: + self.ye_mama_innocent = True + + def no_time_limit_dialog(self) -> None: + self.no_tl_dialog = True + + def list_commands(self) -> None: + cmd_txt = r""" +------------------------------------------------ +|Commands |Description | +|-----------------|----------------------------| +|redraw, rd | redraw the screen | +| |(won't work in textual mode)| +|-----------------|----------------------------| +|quit,exit |quit the game | +|-----------------|----------------------------| +|move, m |move a disk from src to dest| +| |both must be +ve integers | +|-----------------|----------------------------| +|register-player |register player with a name | +|, rg | | +|-----------------|----------------------------| +|help |show basic help | +|-----------------|----------------------------| +|replay |replay the game, optional | +| |disk_no(use 'p' to go with | +| |current disk_no) may be | +| |given for the new game | +|-----------------|----------------------------| +|show-minima, sm |display minimum moves req | +| |to solve the puzzle | +|-----------------|----------------------------| +|seek-top |show topmost disks of every | +| |rod | +|-----------------|----------------------------| +|icheat, inocheat |use them respectively to | +| |either enable | +| |`no life deduction` or not | +|-----------------|----------------------------| +|butmymamainnocent|no yo mama jokes for you | +|-----------------|----------------------------| +|history |show command history | +|-----------------|----------------------------| +|toggle-mode |toggle interface mode from | +| |graphics to textual and | +| |vice-versa.use 'g', 'tui' | +| |'graphics' for graphics mode| +| |and 'textual', 't' for text | +| |mode | +|-----------------|----------------------------| +|list-commands, lc|show this command table | +|-----------------|----------------------------| +|create-config, cc|create config file in HOME | +|-----------------|----------------------------| +|time-limit, tl |show time limit,if available| +|-----------------|----------------------------| +|no-tld |disable time limit dialog | +------------------------------------------------ +""" + self.show_pager_output(cmd_txt) + + def invoke_action(self, callback: str, action_args: List[str]) -> Optional[int]: + valid_actions = { + "redraw": self.draw_interface, + "rd": self.draw_interface, + "quit": self.quit, + "exit": self.quit, + "move": self.move, + "m": self.move, + "register-player": self.register_player, + "rg": self.register_player, + "help": self.help, + "replay": self.replay, + "seek-top": self.seek_top, + "show-minima": self.show_minima, + "sm": self.show_minima, + "icheat": self.allow_prohibited_move, + "inocheat": self.disallow_prohibited_move, + "butmymamainnocent": self.mama_no_innocent, + "history": self.show_history, + "toggle-mode": self.toggle_mode, + "tm": self.toggle_mode, + "list-commands": self.list_commands, + "lc": self.list_commands, + "create-config": ConfigHandler.create_config, + "cc": ConfigHandler.create_config, + "time-limit": self.get_time_limit, + "tl": self.get_time_limit, + "no-tld": self.no_time_limit_dialog, + "dbc": self._dump_buffer_cache, + } + try: + game_won: bool = valid_actions[callback](*action_args) + if game_won: + return True + except KeyError: + raise BadCommandError + except TypeError: + raise SubArgumentError(callback) + + def confirmation_prompt(self, msg: str, opt_col_offset: int = 0) -> str: + line, col = self.canvas.draw_confirmation_box(msg) + self.canvas.transit_position(line, col - opt_col_offset) + ans: str = input("\x1b[31m") + if ans in ["Y", "y"]: + return True + else: + return False + + def replay(self, disk_count: Union[int, str]) -> None: + if not self.game_finished: + confirmation: bool = self.confirmation_prompt( + "Current game has not finished yet!,\n replay will cause all game data to be lost ", + 4, + ) + if confirmation: + pass + else: + return None + if disk_count == "p": + pass + else: + try: + disk_count = int(disk_count) + Tonoi.ScreenData.actual_disk_count = disk_count + except ValueError: + self.canvas.draw_error_screen("`replay expects an integer argument!`") + self.play(init_only=True) + self.canvas.disks_info.clear() + self.game_finished = False + self.game_won = False + self.allow_illegal_move = False + self.internal_game_handle.__init__(Tonoi.ScreenData.actual_disk_count) + + def move(self, src: str, dest: str) -> Optional[None]: + try: + src = int(src) + dest = int(dest) + except ValueError: + self.canvas.draw_error_screen("`move` expects integer arguments!") + return None + game_won: Optional[bool] = self.internal_game_handle.move_disk(src, dest) + _, top_element = self.canvas.disks_info[src].popitem() + dest_top: int + try: + dest_top = list(self.canvas.disks_info[dest].keys())[-1] + 1 + except IndexError: + dest_top = 0 + finally: + self.canvas.disks_info[dest][dest_top] = top_element + src_disk_count: int = getattr(Tonoi.ScreenData, "disk_count_{}rod".format(src)) + dest_disk_count: int = getattr( + Tonoi.ScreenData, "disk_count_{}rod".format(dest) + ) + setattr(Tonoi.ScreenData, "disk_count_{}rod".format(src), src_disk_count - 1) + setattr(Tonoi.ScreenData, "disk_count_{}rod".format(dest), dest_disk_count + 1) + if game_won: + return True + + def seek_top(self) -> None: + self.draw_interface(top_el_only=True) + print("Press Enter to continue!", end="") + input() + + def repl_mode(self) -> None: + print(self.internal_game_handle.primary_rod) + print(self.internal_game_handle.secondary_rod) + print(self.internal_game_handle.tertiary_rod) + print(">>", end="") + + def toggle_mode(self, mode: str) -> None: + self.interaction_mode = mode + if self.is_graphics_mode(): + self.tsv_instance.signal = 1 + else: + self.tsv_instance.signal = 2 + clear_screen() + + def is_graphics_mode(self) -> bool: + return True if self.interaction_mode in ("graphics", "g", "tui") else False + + def invoke_display_method(self, tui_mode: Callable, repl_mode: Callable) -> None: + if self.is_graphics_mode(): + tui_mode() + else: + self.repl_mode() + + def _dump_buffer_cache(self) -> None: + print("\n\n") + clear_screen() + pp = PrettyPrinter(indent=4) + p_dump = pp.pformat(Tonoi.canvas.buffer_cache) + self.show_pager_output(p_dump) + + def show_minima(self) -> None: + minima: int = 2 ** int(Tonoi.ScreenData.actual_disk_count) - 1 + prepared_str: str = "MM: {}".format(minima) + len_prepared_str: int = len(prepared_str) + Tonoi.canvas.print_stdout( + ( + Tonoi.ScreenData.lines - 2, + Tonoi.ScreenData.columns - len_prepared_str - 5, + ), + prepared_str, + after_position="after-prompt", + ) + input("Press Enter to Continue") + + def show_history(self) -> None: + cmd_str: str = "\n".join(self.command_history) + self.show_pager_output(cmd_str) + + def show_pager_output(self, text: str) -> None: + # clear_screen doesn't work properly here + # some residue text from previous draw_interface + # call still remains, so we also print some new lines and + # call the pager + clear_screen() + print("\n\n") + try: + return_code: int = subprocess.run( + ["more"], input=text.encode(), shell=True + ).returncode + except FileNotFoundError: + # 127 -> command not found returncode? + return_code = 127 + if return_code != 0: + self.canvas.draw_warning_screen( + "No pager program found or\nsubprocess returned a non-zero code. \nfalling back to simple terminal output " + ) + clear_screen() + self.canvas.transit_position(0, 0) + text += "\nPress Enter to Continue!" + print(text) + input() + + def show_stacktrace(self) -> None: + raise NotImplementedError + + def register_player(self, player_name: str) -> None: + self.ScreenData.player_name = player_name + + def help(self) -> None: + self.canvas.draw_info_screen(Misc.util_howtoplay) + input() + + def quit(self) -> Optional[NoReturn]: + if not self.game_finished: + confirmation: bool = self.confirmation_prompt( + "Are you sure you want to\nend the game abruptly?\nNote that all game data will be lost!" + ) + if confirmation: + pass + else: + return None + print("\x1b[0m", end="") + clear_screen() + self.tsv_instance.signal = 0 + sys.exit(0) + + def get_time_limit(self, value_only: bool = False) -> Optional[datetime.datetime]: + if self.time_limit: + time_delta: datetime.datetime = self.time_limit - ( + (datetime.datetime.now() - self.init_time).seconds + ) + if value_only: + return time_delta + time_delta_pretty: str = datetime.timedelta(time_delta) + self.canvas.print_stdout( + (self.ScreenData.lines - 2, 7), + "Remaining Time: {}, \tPress Enter to Continue".format(time_delta), + ) + input() + + def time_limit_indicator(self, time_remaining: int) -> None: + if not self.time_limit: + return None + if time_remaining in (self.time_limit // 2, self.time_limit // 4): + self.canvas.draw_info_screen( + "Remaining time: {} ".format( + datetime.timedelta(seconds=time_remaining) + ) + ) + sleep(2) + self.invoke_display_method(self.draw_interface, self.repl_mode) + self.canvas.transit_position(self.ScreenData.lines - 2, 7) + elif time_remaining == 0: + self.canvas.draw_error_screen("Timelimit Exceeded!") + self.invoke_display_method(self.draw_interface, self.repl_mode) + self.canvas.transit_position(self.ScreenData.lines - 2, 7) + + def term_width_warning(self) -> None: + self.canvas.draw_warning_screen( + "Terminal width change detected!\nPlease revert to orignal terminal width for an optimal gamerun." + ) + + def yo_mama_jokes(self) -> str: + yo_mama_joke_list: List[str] = [ + "Yo mama's such a cold bitch, \nher tits give soft serve ice cream", + "Yo mama's so easy that when she heard Santa Claus \nsay Ho Ho Ho she thought she was getting \nit three times", + "Yo mama sucks so much, a black hole would be embarrased", + "Yo mama sucks so much dick, \nher lips went double platinum", + "Yo mama so stupid she put cat-food \ndown her pants to feed her pussy", + "Yo mama so fat that your dad has to have a 'heavy machinary' \nlicense to have sex", + "Yo mama so bad at sex, \nthe only kind of head she gives is severed", + "Yo mama reminds me of \na toilet, fat, white, and smells like shit", + ] + return random.choice(yo_mama_joke_list) + + def play(self, init_only=False) -> ReturnCode: + """Initialize `ScreenData`""" + Tonoi.ScreenData.player_name = self.config.get("player_name") + Tonoi.ScreenData.moves = "0" + try: + Tonoi.ScreenData.best_game_runs = str( + self.config["player_data"]["best_game_runs"] + ) + Tonoi.ScreenData.perfect_game_runs = str( + self.config["player_data"]["perfect_game_runs"] + ) + Tonoi.ScreenData.actual_disk_count = ( + Tonoi.ScreenData.actual_disk_count + if init_only + else self.config["disk_capacity"] + ) + Tonoi.canvas.drawing_format = ( + "ascii" if self.config["render_ascii"] else "ansi" + ) + except KeyError: + raise ConfigParseError.BadKeyValuePair( + "Incomplete Configuration Data!. Possibly due to incompatible key-value pair assignment" + ) + Tonoi.ScreenData.remaining_lives = 3 + # if init_only=True, probably means that a replay was invoke, + # in which case actual_disk_count has already been set by the procedure + # otherwise get it from config + + Tonoi.ScreenData.disk_count_1rod = Tonoi.ScreenData.actual_disk_count + Tonoi.ScreenData.disk_count_2rod = 0 + Tonoi.ScreenData.disk_count_3rod = 0 + Tonoi.canvas.partial_reinit() + ( + Tonoi.ScreenData.appearent_max_disk_count_nrod, + *_, + ) = Tonoi.canvas.get_appearent_disk_count(Tonoi.canvas.disk_horizontal()) + if init_only: + return None + Tonoi.ScreenData.lines = Tonoi.canvas.coordinates.lines + Tonoi.ScreenData.columns = Tonoi.canvas.coordinates.columns + self.internal_game_handle = RodHandler(Tonoi.ScreenData.actual_disk_count) + event_handler = EventHandler() + event_handler.register_event(name="redraw", callback=self.draw_interface) + event_handler.register_event( + name="elict_term_width_warning", callback=self.term_width_warning + ) + event_handler.register_event( + name="time_limit_indicator", callback=self.time_limit_indicator + ) + self.tsv_instance = ThreadedEventsAdaptor( + event_handler, Tonoi.ScreenData, Tonoi.canvas, self + ) + self.tsv_instance.start() + while Tonoi.ScreenData.remaining_lives > 0: + try: + self.invoke_display_method(self.draw_interface, self.repl_mode) + raw_input = input( + "\x1b[{}m".format(random.choice([0, 31, 32, 33, 34, 36, 97])) + ) + self.command_history.append(raw_input) + try: + action, *args = raw_input.split() + game_won: Optional[bool] = self.invoke_action(action, args) + if game_won: + self.canvas.draw_win_screen( + int(Tonoi.ScreenData.moves) + 1, + Tonoi.ScreenData.actual_disk_count, + ) + self.game_finished = True + with ConfigHandler("player_data") as ch: + minimum_moves_req: int = ( + 2 ** int(Tonoi.ScreenData.actual_disk_count) - 1 + ) + Tonoi.ScreenData.best_game_runs = ( + "0" + if Tonoi.ScreenData.best_game_runs == "None" + else Tonoi.ScreenData.best_game_runs + ) + Tonoi.ScreenData.perfect_game_runs = ( + "0" + if Tonoi.ScreenData.perfect_game_runs == "None" + else Tonoi.ScreenData.perfect_game_runs + ) + best_game_runs: int = int( + Tonoi.ScreenData.best_game_runs + ) + ( + 1 + if int(Tonoi.ScreenData.moves) + 1 + in range(minimum_moves_req + 1, minimum_moves_req + 7) + else 0 + ) + perfect_game_runs = int( + Tonoi.ScreenData.perfect_game_runs + ) + ( + 1 + if int(Tonoi.ScreenData.moves) + 1 == minimum_moves_req + else 0 + ) + Tonoi.ScreenData.best_game_runs = str(best_game_runs) + Tonoi.ScreenData.perfect_game_runs = str(perfect_game_runs) + ch.update_player_data( + Tonoi.ScreenData.player_name, + best_game_runs=best_game_runs, + perfect_game_runs=perfect_game_runs, + ) + except BadCommandError: + self.canvas.draw_error_screen("entrée de données très erronée!") + except SubArgumentError as e: + self.canvas.draw_error_screen( + "'{}' expects some arguments!".format(e.callback) + ) + Tonoi.ScreenData.moves = str(int(Tonoi.ScreenData.moves) - 1) + except ValueError: + # prolly empty str input + pass + except ProhibitedMove: + if not self.allow_illegal_move: + self.canvas.draw_error_screen( + "Smaller disk can't be put over a larger disk " + ) + Tonoi.ScreenData.remaining_lives -= 1 + # will normalize the moves increament + Tonoi.ScreenData.moves = str(int(Tonoi.ScreenData.moves) - 1) + except EmptyRodError: + self.canvas.draw_warning_screen( + "Can't move disk from an empty rod " + ) + Tonoi.ScreenData.moves = str(int(Tonoi.ScreenData.moves) - 1) + if raw_input.strip() and action in ("move", "m"): + Tonoi.ScreenData.moves = str(int(Tonoi.ScreenData.moves) + 1) + except AttributeError as e: + clear_screen() + print("I am AttributeError with val: {}".format(e)) + input() + except (KeyboardInterrupt, EOFError): + if not self.game_finished: + confirmation: bool = self.confirmation_prompt( + "A game is in progress!.data might be lost ", 5 + ) + if confirmation: + self.game_finished = True + self.quit() + else: + self.tsv_instance.signal = 0 + self.game_finished = True + Tonoi.canvas.draw_death_screen( + "YOU ARE A FAILURE!" + + ( + "\nAND\n{}".format(self.yo_mama_jokes()) + if not self.ye_mama_innocent + else "" + ) + ) + clear_screen() + self.quit() + + +def main(sys_args: List[str], dump_debug: bool = False) -> int: + argparser: argparse.ArgumentParser = argparse.ArgumentParser( + prog=Misc.util_name, + usage=Misc.util_usage, + description=Misc.util_description, + epilog=Misc.util_epilog, + ) + + argparser.add_argument( + "-pn", "--player-name", type=str, help="use custom player name" + ) + argparser.add_argument( + "-dc", + "--disk-capacity", + type=int, + help="initial disk count for the primary tower", + default=None, + ) + argparser.add_argument( + "-gm", + "--get-maxima", + help="get maxima value for disk capacity", + action="store_true", + ) + argparser.add_argument( + "--ascii", help="use ascii characters for rendering", action="store_true" + ) + argparser.add_argument( + "-im", + "--interaction-mode", + type=str, + help="game interaction mode", + default=None, + ) + argparser.add_argument( + "-tl", + "--time-limit", + type=int, + help="pseudo time limit to solve toh in seconds", + ) + argparser.add_argument("-f", "--file", type=str, help="solve toh from a file") + argparser.add_argument( + "-cv", + "--check-version", + help="check for new version and exit", + action="store_true", + ) + argparser.add_argument( + "-d", + "--debug", + help="invoke debugger(for developer)", + action="store_true", + ) + argparser.add_argument( + "-dt", + "--delay-time", + help="debug delay time after executing a subroutine(for developer)", + action="store_true", + ) + argparser.add_argument( + "-v", + "--version", + help="Show utility version", + action="version", + version="{} {}".format(Misc.util_name, Misc.util_version), + ) + args: argparse.Namespace = argparser.parse_args(sys_args) + + rod_disk_capacity: Optional[int] = args.disk_capacity + interface_type: Optional[str] = args.interaction_mode + render_ascii: bool = args.ascii + if args.check_version: + if version := version_validator(): + if not isinstance(version, (int, str)): + # contains exception class in such case + raise version("Internet Connection Error, retry after troubleshoot") + else: + print("New version available!: {}".format(version)) + else: + print("Aready up to date") + sys.exit(0) + if args.get_maxima: + # since Canvas isn't instantiated at this point Canvas.drawing_format + # will be None , resulting in disk_width for ascii mode, which is actually + # doubled for mentioned reasons. below we normalize that value. + print("Maxima value: {}".format(Canvas.disk_horizontal() // 2)) + sys.exit(0) + if args.file: + validate_src = SourceValidationHandler(args.file) + response: Union[str, bool] = validate_src.validate_toh_source() + if isinstance(response, Tuple): + print( + """\x1b[31m**Illegal Move Attempted** +move: {} +Reason: {}\x1b[m""".format( + response[0], response[1] + ) + ) + else: + if response: + print("\x1b[32mFile Validated Successfully!\x1b[m") + else: + print( + "\x1b[33mNo errors were encountered during the validation, but the puzzle was not solved, probably due to insufficient moves.\x1b[m" + ) + sys.exit(0) + + disk_capacity = ( + args.disk_capacity + if args.disk_capacity + else Misc.default_config["disk_capacity"] + ) + if disk_capacity > Canvas.disk_horizontal() // 2: + raise DisksOverLimitError( + "Disk number is over the permitted limit. provide a value `less than/equal to` value provided by --get-maxima." + ) + contructer_args = {} + for arg_name, arg_val in ( + ("player_name", args.player_name), + ("disk_capacity", args.disk_capacity), + ("render_ascii", args.ascii), + ("interaction_mode", args.interaction_mode), + ("time_limit", args.time_limit), + ): + if arg_val: + if arg_name == "interaction_mode" and arg_val not in ( + "graphics", + "textual", + "t", + "g", + "tui", + ): + continue + contructer_args.update({arg_name: arg_val}) + if dump_debug: + print("**DEBUG**: CommandLineArg:: {}: {}".format(arg_name, arg_val)) + if dump_debug: + sleep(5) + + contructer_args.update({"debug": dump_debug}) + tonoi_instance: Tonoi = Tonoi(**contructer_args) + tonoi_instance.play() + + +def __into_main__(main_func) -> NoReturn: + ansi_support: Optional[bool] = ansi_controlcode_supported() + if ansi_support is False: + print( + """Your Windows build does not support Ansi Escape Sequences for cmd.exe. +You can do the following things: +1:Install Windows Anniversary Update(build-number: 14393), if on windows 10 +2:Use Terminal emulator like ConEmu that supports Ansi Escape Sequences and set an environmental variable named `ANSI_SUPPORTED`(with any value), if on < Windows 10 +3:Windows sucks, change to *nix""" + ) + sys.exit(1) + elif ansi_support is None: + sys.exit(0) + do_debug: bool = False + delay_time: int = 5 + for idx, debug_arg in enumerate(["-d", "--debug", "--delay-time", "-dt"]): + if debug_arg in sys.argv and idx in range(0, 2): + do_debug = True + sys.argv.remove(debug_arg) + elif debug_arg in sys.argv and idx in range(2, 4): + delay_time = int(sys.argv[sys.argv.index(debug_arg) + 1]) + sys.argv.remove(str(delay_time)) + sys.argv.remove(debug_arg) + main = inject_debugger(absorbed_obj=delay_time, dump_debug=do_debug)(main_func) + main(sys.argv[1:]) + + +def main_entry() -> NoReturn: + __into_main__(main) +