...with properly packaged/type-hinted code, automated testing, and no dependencies. Do note that this package requires Python 3.12 or later.
Only pyproject.toml
and non-test .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.
Please ignore my non-PEP8/PEP257-compliant code style. Other than that, suggestions regarding design patterns, naming conventions, APIs, etc. are all welcomed.
The project structure
.
├── 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
[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
from hangman import Game
def main():
Game().start()
if __name__ == '__main__':
main()
src/hangman/init.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
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
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
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
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
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
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
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
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)