5
\$\begingroup\$

Over the holidays I was bored due to lockdown and implemented a mine sweeping game for the terminal. Any feedback is welcome.

#! /usr/bin/env python3
"""A mine sweeping game."""

from __future__ import annotations
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass
from enum import Enum
from os import linesep
from random import choice
from string import digits, ascii_lowercase
from sys import exit, stderr    # pylint: disable=W0622
from typing import Iterator, NamedTuple, Optional


__all__ = [
    'NUM_TO_STR',
    'STR_TO_NUM',
    'GameOver',
    'SteppedOnMine',
    'Cell',
    'Coordinate',
    'Minefield',
    'ActionType',
    'Action',
    'print_minefield',
    'read_action',
    'get_args',
    'visit',
    'play_round',
    'main'
]


NUM_TO_STR = dict(enumerate(digits + ascii_lowercase))
STR_TO_NUM = {value: key for key, value in NUM_TO_STR.items()}


class GameOver(Exception):
    """Indicates that the game has ended."""

    def __init__(self, message: str, returncode: int):
        super().__init__(message)
        self.message = message
        self.returncode = returncode


class SteppedOnMine(GameOver):
    """Indicates that the player stepped onto a mine."""

    def __init__(self):
        super().__init__('You stepped onto a mine. :(', 1)


@dataclass
class Cell:
    """A cell of a minefield."""

    mine: Optional[bool] = None
    marked: bool = False
    visited: bool = False

    def __str__(self) -> str:
        return self.to_string()

    def to_string(self, *, game_over: bool = False) -> str:
        """Returns a string representation."""
        if self.visited:
            return '*' if self.mine else ' '

        if self.marked:
            return ('!' if self.mine else 'x') if game_over else '?'

        if game_over and self.mine:
            return 'o'

        return ' ' if game_over else '■'

    def toggle_marked(self) -> None:
        """Toggles the marker on this field."""
        if self.visited:
            return

        self.marked = not self.marked


class Coordinate(NamedTuple):
    """A 2D coordinate on a grid."""

    x: int
    y: int

    def offset(self, delta_x: int, delta_y: int) -> Coordinate:
        """Returns a coordinate with the given offset."""
        return type(self)(self.x + delta_x, self.y + delta_y)

    @property
    def neighbors(self) -> Iterator[Coordinate]:
        """Yield fields surrounding this position."""
        for delta_y in range(-1, 2):
            for delta_x in range(-1, 2):
                if delta_x == delta_y == 0:
                    continue    # Skip the current position itself.

                yield self.offset(delta_x, delta_y)


class Minefield(list):
    """A mine field."""

    def __init__(self, width: int, height: int):
        super().__init__()
        self.width = width
        self.height = height

        for _ in range(height):
            self.append([Cell() for _ in range(width)])

    def __str__(self) -> str:
        return self.to_string()

    @property
    def uninitialized(self) -> bool:
        """Checks whether all cells are uninitalized."""
        return all(cell.mine is None for row in self for cell in row)

    @property
    def sweep_completed(self) -> bool:
        """Checks whether all cells have been visited."""
        return all(cell.visited for row in self for cell in row
                   if not cell.mine)

    def is_on_field(self, position: Coordinate) -> bool:
        """Determine whether the position is on the field."""
        return 0 <= position.x < self.width and 0 <= position.y < self.height

    def cell_at(self, position: Coordinate) -> Cell:
        """Returns the cell at the given position."""
        return self[position.y][position.x]

    def get_neighbors(self, position: Coordinate) -> Iterator[Cell]:
        """Yield cells surrounding the given position."""
        for neighbor in position.neighbors:
            if self.is_on_field(neighbor):
                yield self.cell_at(neighbor)

    def count_surrounding_mines(self, position: Coordinate) -> int:
        """Return the amount of mines surrounding the given position."""
        return sum(cell.mine for cell in self.get_neighbors(position))

    def stringify(self, cell: Cell, position: Coordinate, *,
                  game_over: bool = False) -> str:
        """Return a str representation of the cell at the given coordiate."""
        if not cell.mine and (cell.visited or game_over):
            if mines := self.count_surrounding_mines(position):
                return str(mines)

        return str(cell.to_string(game_over=game_over))

    def disable_mine(self, position: Coordinate) -> None:
        """Set the cell at the given position to not have a mine."""
        self.cell_at(position).mine = False

    def populate(self, mines: int) -> None:
        """Populate the minefield with mines."""
        cells = [cell for row in self for cell in row if cell.mine is None]

        if mines > len(cells):
            raise ValueError('Too many mines for field.')

        for _ in range(mines):
            cell = choice(cells)
            cell.mine = True
            cells.remove(cell)

        for cell in cells:
            cell.mine = False

    def toggle_marked(self, position: Coordinate) -> None:
        """Toggels the marker on the given cell."""
        self.cell_at(position).toggle_marked()

    def visit(self, position: Coordinate) -> None:
        """Visit the cell at the given position."""
        if not self.is_on_field(position):
            return

        if (cell := self.cell_at(position)).visited:
            return

        if cell.marked:
            return

        cell.visited = True

        if cell.mine:
            raise SteppedOnMine()

        if self.count_surrounding_mines(position) == 0:
            for neighbor in position.neighbors:
                self.visit(neighbor)

    def to_string(self, *, game_over: bool = False) -> str:
        """Returns a string representation of the minefield."""
        return linesep.join(
            ' '.join(
                self.stringify(cell, Coordinate(x, y), game_over=game_over)
                for x, cell in enumerate(row)
            ) for y, row in enumerate(self)
        )


class ActionType(Enum):
    """Game actions."""

    VISIT = 'visit'
    MARK = 'mark'


class Action(NamedTuple):
    """An action on a coordinate."""

    action: ActionType
    position: Coordinate


def print_minefield(minefield: Minefield, *, game_over: bool = False) -> None:
    """Prints the mine field with row and column markers."""

    print(' |', *(f'{NUM_TO_STR[index]} ' for index in range(minefield.width)),
          sep='')
    print('-+', '-' * (minefield.width * 2 - 1), sep='')
    lines = minefield.to_string(game_over=game_over).split(linesep)

    for index, line in enumerate(lines):
        print(f'{NUM_TO_STR[index]}|', line, sep='')


def read_action(minefield: Minefield, *,
                prompt: str = 'Enter action and coordinate: '
                ) -> Action:
    """Reads an Action."""

    try:
        text = input(prompt)
    except EOFError:
        print()
        return read_action(minefield, prompt=prompt)

    try:
        action, pos_x, pos_y = text.split()
        action = ActionType(action)
    except ValueError:
        print('Please enter: (visit|mark) <x> <y>', file=stderr)
        return read_action(minefield, prompt=prompt)

    try:
        position = Coordinate(STR_TO_NUM[pos_x], STR_TO_NUM[pos_y])
    except KeyError:
        print('Invalid coordinates.', file=stderr)
        return read_action(minefield, prompt=prompt)

    if minefield.is_on_field(position):
        return Action(action, position)

    print('Coordinate must lie on the minefield.', file=stderr)
    return read_action(minefield, prompt=prompt)


def get_args(description: str = __doc__) -> Namespace:
    """Parses the command line arguments."""

    parser = ArgumentParser(description=description)
    parser.add_argument('--width', type=int, metavar='x', default=8)
    parser.add_argument('--height', type=int, metavar='y', default=8)
    parser.add_argument('--mines', type=int, metavar='n', default=10)
    return parser.parse_args()


def visit(minefield: Minefield, position: Coordinate) -> None:
    """Visit a field."""

    minefield.visit(position)

    if minefield.sweep_completed:
        raise GameOver('All mines cleared. Great job.', 0)


def play_round(minefield: Minefield, mines: int) -> None:
    """Play a round."""

    print_minefield(minefield)
    action = read_action(minefield)

    if action.action == ActionType.VISIT:
        if minefield.uninitialized:
            minefield.disable_mine(action.position)
            minefield.populate(mines)

        return visit(minefield, action.position)

    return minefield.toggle_marked(action.position)


def main() -> int:
    """Test stuff."""

    args = get_args()

    if args.width > (maxsize := len(NUM_TO_STR)) or args.height > maxsize:
        print(f'Max field width and height are {maxsize}.', file=stderr)
        return 2

    if args.mines >= (args.width * args.height):
        print('Too many mines for field.', file=stderr)
        return 2

    minefield = Minefield(args.width, args.height)

    while True:
        try:
            play_round(minefield, args.mines)
        except KeyboardInterrupt:
            print('\nAborted by user.')
            return 3
        except GameOver as game_over:
            print_minefield(minefield, game_over=True)
            print(game_over.message)
            return game_over.returncode


if __name__ == '__main__':
    exit(main())
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Since this runs in the terminal, I recommend importing the readline module at the top. This will let you use the arrow keys in your terminal to edit the input you type while you play. \$\endgroup\$ Dec 27, 2021 at 17:41

2 Answers 2

5
\$\begingroup\$

Your code is thoughtful and well organized.

The user interface is needlessly burdensome. By far, the most common action is visiting. Don't make the user type "visit" every time. Similarly, users would appreciate the ability to abbreviate the action. With a couple lines of code you could easily support inputs like the following:

visit 1 4    # Explicit is fine, for purists and over-achievers.
mark 3 2
v 1 4        # For the rest of us.
m 3 2
1 4          # Super users know the default action is visit.

Import readline, if you can. A commenter noted this, and I'm endorsing it here. At least on supported operating systems, your input-providing user will get a bunch of nice conveniences for free.

Define constants for exit codes. This is just a routine suggestion to get rid of any lingering magic strings/numbers. I only noticed the exit codes, but you should give the whole program a quick once-over with this topic in mind.

Coordinate.offset() seems unnecessary. It is used in only one spot, the property that yields neighboring Coordinate instances. I would just yield the instances directly.

Minefield dubiously inherits from list. Conceptually, the inheritance seems strained: in what sense is it helpful to say a Minefield is a list, as opposed to just having a grid or a list or rows? More pragmatically, the inheritance seems to provide few concrete benefits: perhaps I overlooked a detail, but the only way I noticed you using the inheritance was to iterate over the rows in the minefield. If you want to be fancy, you could implement Minefield.__iter__(). Or you could just do the regular thing in the few spots it's needed: for row in self.grid.

The read_action() function is overly complex and awkward to test. Collecting and validating user input is always a bit of a hassle. Why? Because functions that call input() and/or print require some hoop-jumping to test in an automated fashion. To those unavoidable difficulties you've added a recursive implementation -- which "works" but at the cost of an additional mental burden on your reader merely to avoid a simple while-true loop. My advice is that you do three things: (1) segregate the input() call to the simplest possible function (so simple that it's hardly worth testing); (2) extract as much of the algorithmic detail to an easily-tested, data-oriented function; and (3) leave the surviving remainder in read_action(). The main point is to push complexity out of the functions that are bothersome to test. Here's a sketch:

def read_action(minefield: Minefield) -> Action:
    # This isn't algorithm-free, but it's close.
    prompt = 'Please enter: (visit|mark) <x> <y> '
    while True:
        text = get_input(prompt)
        result = parse_action(minefield, text)
        if isinstance(result, Action):
            return result
        else:
            print(result, file=stderr)

def get_input(prompt: str) -> str:
    # Input-collection: so simple that the need for testing almost nil.
    try:
        return input(prompt)
    except EOFError:
        return ''

def parse_action(minefield: Minefield, text: str) -> Union(Action, str):
    # Push the algorithm here, where testing is simple.
    try:
        action, pos_x, pos_y = text.split()
        action = ActionType(action)
    except ValueError:
        return 'Invalid input.'

    try:
        position = Coordinate(STR_TO_NUM[pos_x], STR_TO_NUM[pos_y])
    except KeyError:
        return 'Invalid coordinates.'

    if minefield.is_on_field(position):
        return Action(action, position)
    else:
        return 'Coordinate must lie on the minefield.'

The play_round() function knows too much about Minefield. If the action is a visit, the function knows to check whether the Minefield is in an uninitialized state, in which case certain preparatory operations are required. That's an example of class implementation details leaking out into the rest of the program. If certain steps are required before the first visit can occur, let Minefield handle them. A separate issue is that the function is implemented as though its return value matters, but it does not. Something along the following lines seems better. Notice also that these suggested edits are pushing the code in the same direction as some of the prior comments: simplifying functions that must deal with IO, and moving algorithmic complexity into pure data-oriented functions or into data-managing methods of a class.

def play_round(minefield: Minefield, mines: int) -> None:
    print_minefield(minefield)
    action = read_action(minefield)
    if action.action == ActionType.VISIT:
        minefield.visit(position)
        if minefield.sweep_completed:
            raise GameOver('All mines cleared. Great job.', 0)
    else:
        minefield.toggle_marked(action.position)

Minefield and Cell as strings. Why does print_minefield() exist rather than simply putting all of that logic in Minefield.to_string()? A stringified Minefield without the row/column numbers seems of questionable value, so I would combine everything. Relatedly, unless you can articulate a solid reason to yourself for the to_string() aliases, do that kind of work directly in the __str__() methods of Minefield and Cell.

\$\endgroup\$
1
\$\begingroup\$

In addition to @FMc's answer, I noticed some further issues with my code.

Recursion vs. iteration

On large mine fields with few mines the program raises RecursionErrors due to the recursive implementation of visiting neighboring fields with no surrounding mines. I fixed this upstream by using an iterative approach instead:

[snip]

def _unvisited_neighbors(self, position: Coordinate) \
        -> Iterator[tuple[Coordinate, Cell]]:
    """Yield coordinate / cells tuples of cells that are unvisited."""
    for neighbor in position.neighbors:
        if (cell := self.get(neighbor)) and not cell.visited:
            yield (neighbor, cell)

def _visit_neighbors(self, position: Coordinate) -> None:
    """Visits the neighbors of the given position."""
    unvisited = dict(self._unvisited_neighbors(position))

    while unvisited:
        position, cell = unvisited.popitem()
        self._visit_cell(cell)

        if self._surrounding_mines(position) == 0:
            unvisited.update(dict(self._unvisited_neighbors(position)))

def visit(self, position: Coordinate) -> None:
    """Visit the cell at the given position."""
    if self._uninitialized:
        self._initialize(position)

    self._visit_cell(self[position])

    if self._surrounding_mines(position) == 0:
        self._visit_neighbors(position)

    if self._game_over:
        raise self._game_over

Random distribution of mines

The current distribution of mines is unnecessarily complicated due to the fact that I rarely worked with the random module. I found a more elegant implementation after reading its documentation:

from random import sample

[snip]

@property
def _uninitialized_cells(self) -> Iterator[Cell]:
    """Yields cells that have not been initialized."""
    return filter(lambda cell: cell.mine is None, self)

def _initialize(self, start: Coordinate) -> None:
    """Inistialize the mine field."""
    # Ensure that we do not step on a mine on our first visit.
    self[start].mine = False

    for cell in sample(list(self._uninitialized_cells), k=self._mines):
        cell.mine = True

    for cell in self._uninitialized_cells:
        cell.mine = False
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.