...with properly packaged code, automated testing, and *no dependencies*. Only Python 3.12 is supported. Please ignore my non-PEP8-compliant code style. Only pyproject.toml and .py files are included here; still, there are about 1400 LOC. For non-code files (and/or a better code-reading experience), view the project [on GitHub][1]. ## The project structure ```none . ├── LICENSE ├── README.md ├── pyproject.toml ├── src │ ├── _hangman │ │ └── runner.py │ └── hangman │ ├── __init__.py │ ├── _lax_enum.py │ ├── _static_reader.py │ ├── assets │ │ ├── gallows.txt │ │ ├── head.txt │ │ ├── instructions.txt │ │ ├── left_arm.txt │ │ ├── left_leg.txt │ │ ├── right_arm.txt │ │ ├── right_leg.txt │ │ ├── title.txt │ │ ├── trunk.txt │ │ └── you_lost.txt │ ├── canvas.py │ ├── choice_list.py │ ├── conversation.py │ ├── game.py │ ├── py.typed │ ├── word.py │ ├── word_list.py │ └── words │ ├── easy.txt │ ├── hard.txt │ ├── medium.txt │ └── unix.txt ├── tests └── tox.ini ``` ## pyproject.toml ```lang-toml [project] name = "hangman" version = "0.2.1" description = "A CLI hangman game" readme = "README.md" requires-python = ">=3.12" license = { text = "Unlicense" } authors = [ { name = "InSyncWithFoo", email = "[email protected]" } ] classifiers = [ "License :: OSI Approved :: The Unlicense (Unlicense)", "Topic :: Games/Entertainment" ] [project.optional-dependencies] dev = [ "build~=1.0.3", "mypy~=1.6.0", "pytest~=7.4.2", "pytest-cov~=4.1.0", "setuptools~=68.2.2", "tox~=4.11.3" ] [project.urls] Homepage = "https://github.com/InSyncWithFoo/hangman" [project.scripts] hangman = "_hangman.runner:main" [build-system] requires = ["setuptools>=68.0.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] package-data = { "*" = ["*.txt"] } ``` ## src/_hangman/runner.py ```py from hangman import Game def main(): Game().start() if __name__ == '__main__': main() ``` ## src/hangman/__init__.py ```py from .canvas import Canvas, Layer from .choice_list import ChoiceList, Choices from .conversation import Conversation from .game import Game from .word_list import Level, WordList __all__ = [ 'Game', 'Canvas', 'Choices', 'ChoiceList', 'Conversation', 'Layer', 'Level', 'WordList' ] ``` ## src/hangman/_lax_enum.py ```py import re from collections.abc import Generator from re import Pattern from typing import ClassVar class LaxEnum(type): ''' Despite its name, a LaxEnum is no different from a normal class except for that it yields every item that is not a dunder when being iterated over. ''' _dunder: ClassVar[Pattern[str]] = re.compile(r'__.+__') def __iter__(cls) -> Generator[object, None, None]: for member_name, member in cls.__dict__.items(): if not cls._dunder.fullmatch(member_name): yield member ``` ## src/hangman/_static_reader.py ```py from functools import partial from os import PathLike from pathlib import Path package_directory = Path(__file__).resolve().parent assets_directory = package_directory / 'assets' word_list_directory = package_directory / 'words' def _read(base_directory: Path, filename: PathLike[str]) -> str: ''' Read a file and return its contents. :param base_directory: The base directory to look up the file :param filename: The name of the file :return: The contents of the file ''' with open(base_directory / filename) as file: return file.read() get_asset = partial(_read, assets_directory) get_word_list = partial(_read, word_list_directory) ``` ## src/hangman/canvas.py ```py from __future__ import annotations import dataclasses from collections.abc import Collection, Generator, Iterator, Sequence from dataclasses import dataclass from functools import partial from itertools import batched from typing import overload, Self from ._lax_enum import LaxEnum from ._static_reader import get_asset @dataclass(frozen = True) class LayerCell: ''' Represents a layer cell. ``value`` must be a single character. ''' row: int column: int value: str def __post_init__(self) -> None: if len(self.value) != 1: raise ValueError('"value" must be a single character') @property def is_transparent(self) -> bool: ''' Whether the value contains only whitespaces. ''' return self.value.strip() == '' _GridOfStrings = Sequence[Sequence[str]] class Layer: r''' A rectangle grid of :class:`LayerCell`\ s. ''' __slots__ = ('_cells', '_height', '_width') _cells: list[LayerCell] _height: int _width: int def __new__(cls, argument: _GridOfStrings) -> 'Self': # PY-62301 ''' Construct a :class:`Layer`. :param argument: A grid of strings. Cannot be a string itself. ''' if isinstance(argument, str): raise TypeError('"rows" must not be a string') instance = super().__new__(cls) grid = [list(row) for row in argument] first_row = grid[0] same_width = all(len(row) == len(first_row) for row in grid) if not same_width: raise ValueError('All rows must have the same width') instance._height = len(grid) instance._width = len(first_row) instance._cells = [] for row_index, row in enumerate(grid): for column_index, cell_value in enumerate(row): cell = LayerCell(row_index, column_index, cell_value) instance._cells.append(cell) return instance def __repr__(self) -> str: horizontal_frame = f'+{'-' * self._width}+' joined_rows = ( f'|{''.join(cell.value for cell in row)}|' for row in self.rows() ) return '\n'.join([ horizontal_frame, *joined_rows, horizontal_frame ]) @overload def __getitem__(self, item: int) -> LayerCell: ... @overload def __getitem__(self, item: tuple[int, int]) -> LayerCell: ... def __getitem__(self, item: int | tuple[int, int]) -> LayerCell: if isinstance(item, int): return self._cells[item] if isinstance(item, tuple) and len(item) == 2: row_index, column_index = item if row_index >= self._height or column_index >= self._width: raise IndexError('Row or column index is out of bounds') index = self._width * row_index + column_index return self[index] raise TypeError(f'Invalid index') def __iter__(self) -> Iterator[LayerCell]: yield from self._cells def __len__(self) -> int: return len(self._cells) def __eq__(self, other: object) -> bool: if not isinstance(other, Layer): return NotImplemented return self._cells == other._cells def __add__(self, other: Layer) -> Layer: if not isinstance(other, Layer): return NotImplemented copied = self.copy() copied += other return copied def __iadd__(self, other: Layer) -> Self: if not isinstance(other, Layer): return NotImplemented if self.height != other.height or self.width != other.width: raise ValueError( 'To be added, two layers must have the same height and width' ) copy_cell = dataclasses.replace for index, other_cell in enumerate(other): if not other_cell.is_transparent: self._cells[index] = copy_cell(other_cell) return self @property def height(self) -> int: ''' The height of the layer. ''' return self._height @property def width(self) -> int: ''' The width of the layer. ''' return self._width @classmethod def from_text(cls, text: str, width: int | None = None) -> Self: ''' Construct a :class:`Layer` from a piece of text. :param text: Any string, with one or more lines. :param width: \ The width of the layer. :return: A new :class:`Layer`. ''' if width is None: width = -1 lines = text.splitlines() longest_line_length = max(len(line) for line in lines) width = max([longest_line_length, width]) return cls([line.ljust(width) for line in text.splitlines()]) @classmethod def from_sequence(cls, cells: Sequence[str], width: int) -> Self: ''' Construct a :class:`Layer` from a sequence of strings. :param cells: A :class:`Sequence` of strings. :param width: \ The number of cells per chunk. \ The last chunk is padded with spaces. :return: A new :class:`Layer`. ''' rows = [] for row in batched(cells, width): if len(row) < width: padder = ' ' * (width - len(row)) rows.append([*row, *padder]) else: rows.append(list(row)) return cls(rows) def rows(self) -> Generator[tuple[LayerCell, ...], None, None]: r''' Yield a tuple of :class:`LayerCell`\ s for each row. ''' yield from batched(self._cells, self._width) def columns(self) -> Generator[tuple[LayerCell, ...], None, None]: r''' Yield a tuple of :class:`LayerCell`\ s for each column. ''' for column in zip(*self.rows()): yield column def cells(self) -> Generator[LayerCell, None, None]: ''' Synonym of :meth:`__iter__`. ''' yield from self def copy(self) -> Self: ''' Construct a new :class:`Layer` from this one. ''' string_cells = [cell.value for cell in self] return self.__class__.from_sequence(string_cells, self._width) class Canvas(Collection[Layer]): r''' A collection of :class:`Layers`. Its string representation is that of all its layers merged. ''' __slots__ = ('_height', '_width', '_layers') _height: int _width: int _layers: list[Layer] def __init__(self, height: int, width: int) -> None: ''' Construct a :class:`Canvas` of given height and width. :param height: The height of the canvas. :param width: The width of the canvas. ''' self._height = height self._width = width self._layers = [] def __str__(self) -> str: if not self._layers: return '\n'.join([' ' * self._width] * self._height) first, *others = self._layers flattened = sum(others, start = first) joined_rows = [ ''.join(cell.value for cell in row) for row in flattened.rows() ] return '\n'.join(joined_rows) def __contains__(self, layer: object) -> bool: return layer in self._layers def __iter__(self) -> Iterator[Layer]: return iter(self._layers) def __len__(self) -> int: return len(self._layers) @property def height(self) -> int: ''' The height of the canvas. ''' return self._height @property def width(self) -> int: ''' The width of the canvas. ''' return self._width @property def layers(self) -> Generator[Layer, None, None]: ''' Yield every layer the canvas contains. ''' for layer in self._layers: yield layer @classmethod def from_layer(cls, layer: Layer) -> Self: ''' Construct a :class:`Canvas` from a layer using its height and width. :param layer: A :class:`Layer`. :return: A new :class:`Canvas`. ''' canvas = cls(height = layer.height, width = layer.width) canvas.add_layers(layer) return canvas def _fits_layer(self, layer: Layer) -> bool: ''' Whether the layer has same height and width as the canvas. :param layer: A :class:`Layer`. :return: ``True`` if the layer fits, ``False`` otherwise. ''' return self._height == layer.height and self._width == layer.width def add_layers(self, *layers: Layer) -> None: r''' Add one or more :class:`Layer`\ s to the canvas. :param layers: One or more :class:`Layer`\ s. ''' if not all(self._fits_layer(layer) for layer in layers): raise ValueError( 'Layers must have same height and width as canvas' ) self._layers.extend(layers) _make_80_wide_layer = partial(Layer.from_text, width = 80) class Component(metaclass = LaxEnum): r''' Pre-built :class:`Layer`\ s to be used in the game. ''' GALLOWS = _make_80_wide_layer(get_asset('gallows.txt')) HEAD = _make_80_wide_layer(get_asset('head.txt')) TRUNK = _make_80_wide_layer(get_asset('trunk.txt')) LEFT_ARM = _make_80_wide_layer(get_asset('left_arm.txt')) RIGHT_ARM = _make_80_wide_layer(get_asset('right_arm.txt')) LEFT_LEG = _make_80_wide_layer(get_asset('left_leg.txt')) RIGHT_LEG = _make_80_wide_layer(get_asset('right_leg.txt')) YOU_LOST = _make_80_wide_layer(get_asset('you_lost.txt')) ``` ## src/hangman/choice_list.py ```py from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass from typing import Mapping, NamedTuple, Self from ._lax_enum import LaxEnum from .word_list import Level @dataclass(frozen = True, slots = True) class Choice: ''' Represents a valid choice of a :class:`ChoiceList`. ''' shortcut: str description: str aliases: frozenset[str] value: str | None = None def __str__(self) -> str: return f'[{self.shortcut}] {self.description}' _ChoiceDescriptor = tuple[str, set[str], str | None] class ChoiceDescriptor(NamedTuple): ''' Syntactic sugar for a bare tuple containing three elements: ``description``, ``aliases``, and ``value``. ''' description: str aliases: set[str] = set() value: str | None = None class _LengthList(list[int]): ''' A list of integers which keeps track of the sum. Meant for internal use only. ''' total: int def __new__(cls) -> 'Self': # PY-62301 instance = super().__new__(cls) instance.total = 0 return instance def append(self, value: int) -> None: ''' Append an integer value to the end of the list and add it the total. :param value: A length. ''' self.total += value super().append(value) class ChoiceList: __slots__ = ('_shortcut_map', '_alias_map', 'max_width', 'separator') _shortcut_map: dict[str, Choice] _alias_map: dict[str, Choice] separator: str max_width: int def __new__( cls, /, argument: Mapping[str, _ChoiceDescriptor] | None = None, *, separator: str = ' ', max_width: int = 80, **kwargs: _ChoiceDescriptor ) -> 'Self': # PY-62301 r''' Construct a list of valid choices whose string representation looks like the following:: [A] Foobar bazqux [BAR] Lorem ipsum [C] Consectetur adipiscing elit All shorcuts and aliases are case-insensitive and mapped to their corresponding :class:`Choice`. Shortcuts are uppercased in the string representation. A :class:`Choice` can be chosen by referencing either ``shortcut`` or any of the ``aliases``. ``argument`` and ``kwargs`` are shortcut-to-:class:`ChoiceDescriptor` maps. Each ``shortcut`` *should* be a single character, whereas the ``description``\ s need to be human-readable. ``value`` is the value the choice represents, defaults to ``None``. :param argument: A :class:``collections.abc.Mapping``. :param separator: \ A string to be used as the separator in the string representation. :param max_width: \ The maximum width of the string representation, in characters. :param kwargs: \ Other shortcut-to-descriptor arguments. ''' if isinstance(argument, Mapping): kwargs = {**argument, **kwargs} instance = super().__new__(cls) instance.separator = separator instance.max_width = max_width shortcut_map = instance._shortcut_map = {} alias_map = instance._alias_map = {} for shortcut, (description, aliases, value) in kwargs.items(): shortcut = shortcut.upper() uppercased_aliases = frozenset(alias.upper() for alias in aliases) choice = Choice( shortcut, description, uppercased_aliases, value ) shortcut_map[shortcut] = choice for alias in uppercased_aliases: alias_map[alias] = choice return instance def __contains__(self, item: object) -> bool: if not isinstance(item, str): return False item = item.upper() return item in self._shortcut_map or item in self._alias_map def __getitem__(self, item: str) -> Choice: item = item.upper() if item in self._shortcut_map: return self._shortcut_map[item] return self._alias_map[item] def __str__(self) -> str: output: list[list[str]] = [[]] lengths: list[_LengthList] = [_LengthList()] for choice in self: choice_stringified = str(choice) choice_length = len(choice_stringified) total_choice_length = lengths[-1].total + choice_length total_separator_length = len(self.separator) * len(lengths[-1]) new_last_row_length = total_choice_length + total_separator_length if new_last_row_length > self.max_width: output.append([]) lengths.append(_LengthList()) output[-1].append(choice_stringified) lengths[-1].append(choice_length) return '\n'.join(self.separator.join(row) for row in output) def __repr__(self) -> str: return ( f'{self.__class__.__name__}(' + ', '.join(repr(choice) for choice in self) + f')' ) def __iter__(self) -> Generator[Choice, None, None]: yield from self._shortcut_map.values() class Choices(metaclass = LaxEnum): ''' Pre-built instances of :class:`ChoicesList`. ''' CONFIRMATION = ChoiceList( Y = ChoiceDescriptor( description = 'Yes', aliases = {'Yes'}, value = 'YES' ), N = ChoiceDescriptor( description = 'No', aliases = {'No'}, value = 'NO' ) ) LEVEL = ChoiceList( E = ChoiceDescriptor( description = 'Easy (22.5k words)', aliases = {'EASY'}, value = Level.EASY ), M = ChoiceDescriptor( description = 'Medium (74.5k words)', aliases = {'MEDIUM'}, value = Level.MEDIUM ), H = ChoiceDescriptor( description = 'Hard (168k words)', aliases = {'HARD'}, value = Level.HARD ), U = ChoiceDescriptor( description = 'Unix (205k words)', aliases = {'UNIX'}, value = Level.UNIX ) ) ``` ## src/hangman/conversation.py ```py from __future__ import annotations from collections.abc import Callable, Generator from dataclasses import dataclass from typing import ClassVar, Literal from .choice_list import ChoiceList def _response_is_valid_choice( response: str, choices: ChoiceList | None ) -> bool: ''' Checks if the response is a valid choice. ''' assert choices is not None return response in choices def _no_op(_response: str, _choices: ChoiceList | None) -> Literal[True]: ''' A validator that always returns ``True``. ''' return True @dataclass(frozen = True, slots = True, eq = False) class Validator: ''' Callable wrapper for a validator function. The second argument is the warning message to be output when this validator fails. ''' predicate: ResponseValidator warning: str def __call__(self, response: str, choices: ChoiceList | None) -> bool: return self.predicate(response, choices) InputGetter = Callable[[str], str] OutputDisplayer = Callable[[str], None] ResponseValidator = Callable[[str, ChoiceList | None], bool] OneOrManyValidators = Validator | list[Validator] _FailingValidators = Generator[Validator, None, None] class Conversation: ''' Protocol for input-output operations. ''' _INVALID_RESPONSE: ClassVar[str] = \ 'Invalid response. Please try again.' _INVALID_CHOICE: ClassVar[str] = \ 'Invalid choice. Please try again.' __slots__ = ('_input', '_output') _input: InputGetter _output: OutputDisplayer def __init__(self, ask: InputGetter, answer: OutputDisplayer) -> None: ''' Construct a :class:`Conversation`. :param ask: A ``input``-like callable to be called for inputs. :param answer: A ``print``-like callable to be called to output. ''' self._input = ask self._output = answer def _get_response(self, prompt: str) -> str: ''' Get a raw response. :param prompt: The prompt to be used. :return: The response, uppercased. ''' return self._input(prompt).upper() def _ask( self, prompt: str, /, choices: ChoiceList | None = None, *, validators: list[Validator] ) -> str: ''' Get a response, then validate it against the validators. Repeat this process until the response passes all validations. ''' failing_validators: Callable[[], _FailingValidators] = lambda: ( validator for validator in validators if not validator(response, choices) ) find_first_failing_validator: Callable[[], Validator | None] = \ lambda: next(failing_validators(), None) response = self._get_response(prompt) while failing_validator := find_first_failing_validator(): self.answer(failing_validator.warning) response = self._get_response(prompt) return response def ask( self, question: str, /, choices: ChoiceList | None = None, *, until: OneOrManyValidators | None = None ) -> str: r''' Thin wrapper around :meth:`_ask`. If ``choices`` is given, it will be included in the prompt text. If ``until`` is ``None``, a default validator will be used to check if the response is a valid choice. If both ``choices`` and ``until`` are ``None``, no validators will be applied. :param question: The question to ask. :param choices: The choices to choose from. Optional. :param until: \ A :class:`Callable`, a :class:`Validator` or a list of :class:`Validator`\ s. Optional. :return: The response of the user. ''' prompt = f'{question}\n' prompt += f'{choices}\n' if choices is not None else '' if choices is None and until is None: validators = [Validator(_no_op, '')] elif until is None: validators = [ Validator(_response_is_valid_choice, self._INVALID_CHOICE) ] else: validators = [until] if callable(until) else until return self._ask( prompt, choices, validators = validators ) def answer(self, answer: str) -> None: ''' Outputs the caller's message. :param answer: The message to output. ''' self._output(answer) ``` ## src/hangman/game.py ```py from __future__ import annotations from typing import ClassVar from ._static_reader import get_asset from .canvas import Canvas, Component, Layer from .choice_list import ChoiceList, Choices from .conversation import ( Conversation, InputGetter, OneOrManyValidators, OutputDisplayer, Validator ) from .word import Word from .word_list import Level, WordList def _response_is_ascii_letter(character: str, _: ChoiceList | None) -> bool: return len(character) == 1 and 'A' <= character <= 'Z' class Game: ''' The Hangman game. ''' TITLE: ClassVar[str] = get_asset('title.txt') INSTRUCTIONS: ClassVar[str] = get_asset('instructions.txt') COEFFICENTS: ClassVar[dict[Level, int]] = { Level.EASY: 1, Level.MEDIUM: 2, Level.HARD: 3, Level.UNIX: 4 } _MAX_DISPLAY_WIDTH: ClassVar[int] = 80 __slots__ = ( '_conversation', '_used_words', '_points', '_reward', '_penalty', '_ended' ) _conversation: Conversation _used_words: set[str] _points: int _reward: int _penalty: int _ended: bool def __init__( self, input_getter: InputGetter = input, output_displayer: OutputDisplayer = print, reward: int = 2, penalty: int = -1 ) -> None: ''' Initialize a new game. See :class:`Conversation` for more information on ``input_getter`` and ``output_displayer``. :param input_getter: \ An ``input``-like function. Defaults to ``input``. :param output_displayer: \ A ``print``-like function. Defaults to ``print``. :param reward: \ The number of points to be added to the total on each correct guess. :param penalty: \ The number of points to be subtracted from the total on each incorrect guess. ''' self._used_words = set() self._points = 0 self._reward, self._penalty = reward, penalty self._ended = False self._conversation = Conversation( ask = input_getter, answer = output_displayer ) @property def points(self) -> int: ''' The total points earned in this game. ''' return self._points @points.setter def points(self, value: int) -> None: ''' Called on operations such as the following: game.points += 1 The number of points cannot be negative. ''' self._points = max(value, 0) def _start(self) -> None: ''' Output the title, the instructions, then start the first :class:`GameRound`. If that round is won and the user wants to continue, start another. Otherwise, if the game has not ended (user did not lose in the latest round), end the game. ''' self._output_game_title() self._output_game_instructions() self._start_round() while not self._ended and self._prompt_for_continue_confirmation(): self._start_round() if not self._ended: self.end() def _output_game_title(self) -> None: ''' Output the title, which is just some fancy ASCII art. ''' self.output(self.TITLE) def _output_game_instructions(self) -> None: ''' Output the instructions. ''' self.output(self.INSTRUCTIONS) def _prompt_for_continue_confirmation(self) -> bool: ''' Ask for a response until it is a yes/no answer. :return: Whether the user wants to continue. ''' answer = self.input('Continue?', Choices.CONFIRMATION) return answer in ('Y', 'YES') def _start_round(self) -> None: ''' Ask for a level, construct a :class:`WordList` and a coefficient from that level, then get a random word that has not been used. Finally, initialize a :class:`GameRound` by passing the word and the coefficent to it. ''' level = self._prompt_for_level() word_list = WordList.from_level(level) coefficient = self.COEFFICENTS[level] word = word_list.get_random_word() while word in self._used_words: word = word_list.get_random_word() self._used_words.add(word) game_round = self._initialize_round(word, coefficient) game_round.start() def _initialize_round(self, word: str, coefficient: int) -> GameRound: ''' Pass ``word`` and ``coefficient`` as arguments to :class:`GameRound`. ''' return GameRound(self, word, coefficient) def _prompt_for_level(self) -> Level: ''' Ask for a response until it is a valid level. :return: The corresponding :class:`Level`. ''' choices = Choices.LEVEL response = self._conversation.ask('Choose a level:', choices) value = choices[response].value assert value is not None return Level(value) def start(self) -> None: ''' Start the game. If a :class:`KeyboardInterrupt` is caught, call :meth:`end`. ''' try: self._start() except KeyboardInterrupt: self.end() def end(self) -> None: ''' Switch a boolean flag and call :meth:`output_current_points`. ''' self._ended = True self.output('Game over.'.center(self._MAX_DISPLAY_WIDTH, '-')) self.output_current_points() def input( self, prompt: str, choices: ChoiceList | None = None, validators: OneOrManyValidators | None = None ) -> str: ''' Shorthand for ``self.conversation.ask``. ''' return self._conversation.ask(prompt, choices, until = validators) def output(self, answer: str) -> None: ''' Shorthand for ``self.conversation.answer``. ''' return self._conversation.answer(answer) def output_current_points(self) -> None: ''' Output the total number of points earned. ''' self.output(f'Points: {self._points}') def reward_correct_guess(self, count: int, coefficient: int) -> None: ''' Add ``reward`` multiplied by ``coefficient`` and ``count`` to the number of points. ''' self.points += self._reward * count * coefficient def penalize_incorrect_guess(self, coefficient: int) -> None: ''' Substract ``penalty`` multiplied by ``coefficient`` from the number of points. ''' self.points += self._penalty * coefficient class GameRound: ''' A game round. The game ends when a game round ends with a loss. ''' _INVALID_GUESS: ClassVar[str] = \ 'Invalid guess. Please input a letter.' _ALREADY_GUESSED: ClassVar[str] = \ 'You have already guessed this letter. Please try again.' __slots__ = ( '_game', '_canvas', '_layer_stack', '_word', '_coefficient', '_guesses' ) _game: Game _canvas: Canvas _layer_stack: list[Layer] _word: Word _coefficient: int _guesses: set[str] def __init__(self, game: Game, word: str, coefficient: int) -> None: ''' Initialize a game round. There are initially 6 layers in the stack. Each incorrect guess pops one from the stack and adds it to the canvas. When the stack reaches 0, the entire game is over. See :class:`Word` for relevant checking logic. :param game: The game this round belongs to. :param word: The word to guess in this round. :param coefficient: \ The coefficient corresponding to the level of this round. ''' self._game = game self._canvas = Canvas.from_layer(Component.GALLOWS) self._layer_stack = [ Component.HEAD, Component.TRUNK, Component.LEFT_ARM, Component.RIGHT_ARM, Component.LEFT_LEG, Component.RIGHT_LEG ] self._word = Word(word) self._coefficient = coefficient self._guesses = set() @property def lives_left(self) -> int: ''' The number of layers left in the stack. ''' return len(self._layer_stack) def _output_canvas(self) -> None: ''' Output the canvas with all components lost via incorrect guesses. ''' self._game.output(str(self._canvas)) def _output_current_word_state(self) -> None: ''' Output the word with unknown characters masked. ''' self._game.output(f'Word: {self._word.current_state}') def _output_word(self) -> None: ''' Output the word. Only called when the game is over. ''' self._game.output(f'The word was "{self._word}".') def _start_turn(self) -> None: ''' Call :meth:`_output_canvas` and :meth:`_output_current_word_state`. Ask for a new guess, then check it against the word and handle the result accordingly. ''' self._output_canvas() self._output_current_word_state() guess = self._prompt_for_guess() count = self._word.count(guess) self._guesses.add(guess) if count == 0: self._handle_incorrect_guess() else: self._handle_correct_guess(guess, count) def _handle_incorrect_guess(self) -> None: ''' Output a notice, then call :meth:`Game.penalize_incorrect_guess` and :meth:`Game.output_current_points`. Also call :meth:`_minus_1_life`. If the number of lives left is 0, add :attr:`Component.YOU_LOST` to the canvas. ''' self._game.output('Incorrect guess.') self._game.penalize_incorrect_guess(self._coefficient) self._game.output_current_points() self._minus_1_life() if self.lives_left == 0: self._canvas.add_layers(Component.YOU_LOST) def _handle_correct_guess(self, guess: str, count: int) -> None: ''' Output a notice, then call :meth:`Game.reward_correct_guess` and :meth:`Game.output_current_points`. :param guess: The character guessed. :param count: The number of that character's appearances in the word. ''' if count == 1: self._game.output(f'There is {count} "{guess}"!') else: self._game.output(f'There are {count} "{guess}"s!') self._game.reward_correct_guess(count, self._coefficient) self._game.output_current_points() def _prompt_for_guess(self) -> str: ''' Ask for a new guess which must be an ASCII letter. :return: The guess. ''' validators = [ Validator(_response_is_ascii_letter, self._INVALID_GUESS), Validator(self._not_previously_guessed, self._ALREADY_GUESSED) ] return self._game.input('Your guess:', validators = validators) def _not_previously_guessed( self, response: str, _choices: ChoiceList | None ) -> bool: ''' Check if ``response`` is a previous guess. Meant to be called in :meth:`_prompt_for_guess`. :param response: The response to check. :return: Whether ``response`` is a previous guess. ''' return response not in self._guesses def _minus_1_life(self) -> None: ''' Pops a layer from the stack and add it to the canvas. ''' self._canvas.add_layers(self._layer_stack.pop(0)) def start(self) -> None: ''' While the word is not completely solved and there are still some lives left, start a turn. If there are no lives left (user lost the game), call :meth:`_output_canvas` and :meth:`_output_word`, then :meth:`Game.end`. Otherwise, :meth:`_output_current_word_state`. ''' while not self._word.all_clear and self.lives_left: self._start_turn() if not self.lives_left: self._output_canvas() self._output_word() self._game.end() else: self._output_current_word_state() ``` ## src/hangman/word.py ```py class Word: ''' A word being guessed. ''' __slots__ = ('value', '_character_indices', '_masked') value: str _masked: list[str] _character_indices: dict[str, list[int]] def __init__(self, value: str) -> None: self.value = value.upper() self._masked = ['_'] * len(value) self._character_indices = {} for index, character in enumerate(self.value): self._character_indices.setdefault(character, []).append(index) def __str__(self) -> str: return self.value def __contains__(self, item: str) -> bool: return item.upper() in self._character_indices @property def current_state(self) -> str: ''' The letters, space-separated; unguessed ones are replaced with underscores. ''' return ' '.join(self._masked) @property def all_clear(self) -> bool: ''' Whether all letters have been guessed correctly. ''' return all(char != '_' for char in self._masked) def count(self, guess: str) -> int: ''' Count the guess's appearances in the word and replace those with underscores in the current state. :param guess: A letter. :return: The number of its appearances. ''' if guess not in self: return 0 indices = self._character_indices[guess] for index in indices: self._masked[index] = guess return len(indices) ``` ## src/hangman/word_list.py ```py import random from enum import StrEnum from os import PathLike from typing import ClassVar, final, Self from ._static_reader import word_list_directory class Level(StrEnum): EASY = 'EASY' MEDIUM = 'MEDIUM' HARD = 'HARD' UNIX = 'UNIX' @final class WordList: _instances: ClassVar[dict[str, Self]] = {} __slots__ = tuple(['_list']) _list: list[str] def __new__(cls, filename: str | PathLike[str]) -> 'Self': # PY-62301 filename = str(filename) if filename not in cls._instances: cls._instances[filename] = instance = super().__new__(cls) with open(filename) as file: instance._list = file.read().splitlines() return cls._instances[filename] def __len__(self) -> int: return len(self._list) def get_random_word(self) -> str: return random.choice(self._list) @classmethod def from_list(cls, words: list[str]) -> Self: instance = super().__new__(cls) instance._list = words return instance @classmethod def from_level(cls, level: str) -> Self: match level.upper(): case Level.EASY: filename = 'easy.txt' case Level.MEDIUM: filename = 'medium.txt' case Level.HARD: filename = 'hard.txt' case Level.UNIX: filename = 'unix.txt' case _: raise ValueError('No such level') return cls(word_list_directory / filename) ``` [1]: https://github.com/InSyncWithFoo/hangman