~piotr-machura/sweep-ai

9979e524b3a1e7f4a2188e4402dd576e5da80f92 — Piotr Machura 10 months ago 1523d1b
Initial game logic
6 files changed, 304 insertions(+), 13 deletions(-)

M README.md
M poetry.lock
M pyproject.toml
M sweep_ai/__main__.py
M sweep_ai/logic.py
M tests/test_logic.py
M README.md => README.md +12 -1
@@ 12,8 12,19 @@ This variant enhances the classic minesweeper gameplay with AI-powered hints, co
# Installation
The safest way to clone the repo and install the game within a virtual environment
```bash
# In the project's directory
python3 -m venv .venv
source .venv/bin/activate
pip install .
python3 -m play_sweep
python3 -m sweep-ai
```

For development, use [poetry](https://python-poetry.org/):
```bash
# In the project's directory
poetry install
# Run unit tests
poetry run pytest
# Play the game
poetry run sweep-ai
```

M poetry.lock => poetry.lock +34 -2
@@ 113,6 113,14 @@ optional = false
python-versions = "*"

[[package]]
name = "numpy"
version = "1.22.0"
description = "NumPy is the fundamental package for array computing with Python."
category = "main"
optional = false
python-versions = ">=3.8"

[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"


@@ 302,8 310,8 @@ python-versions = "*"

[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "504a69dcd75f1614ae270cac6df80283a10172343545e30bfc1b0069091605ea"
python-versions = "3.8"
content-hash = "6d02df6b55ba87f647d238f9368d8b8a8b66076fd3aec616a4ed8eeca10f96c6"

[metadata.files]
astroid = [


@@ 403,6 411,30 @@ neat-python = [
    {file = "neat-python-0.92.tar.gz", hash = "sha256:be722a62d053b39fe960228e3e0baffdebe73074133ce40bbd35de650ca53a0f"},
    {file = "neat_python-0.92-py3-none-any.whl", hash = "sha256:c69f3748032cc9653b902f0cca05983b79f5288c67aed747aa47279161117059"},
]
numpy = [
    {file = "numpy-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d22662b4b10112c545c91a0741f2436f8ca979ab3d69d03d19322aa970f9695"},
    {file = "numpy-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a1f3816ea82eed4178102c56281782690ab5993251fdfd75039aad4d20385f"},
    {file = "numpy-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5dc65644f75a4c2970f21394ad8bea1a844104f0fe01f278631be1c7eae27226"},
    {file = "numpy-1.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c16cec1c8cf2728f1d539bd55aaa9d6bb48a7de2f41eb944697293ef65a559"},
    {file = "numpy-1.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97e82c39d9856fe7d4f9b86d8a1e66eff99cf3a8b7ba48202f659703d27c46f"},
    {file = "numpy-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:e41e8951749c4b5c9a2dc5fdbc1a4eec6ab2a140fdae9b460b0f557eed870f4d"},
    {file = "numpy-1.22.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bece0a4a49e60e472a6d1f70ac6cdea00f9ab80ff01132f96bd970cdd8a9e5a9"},
    {file = "numpy-1.22.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:818b9be7900e8dc23e013a92779135623476f44a0de58b40c32a15368c01d471"},
    {file = "numpy-1.22.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:47ee7a839f5885bc0c63a74aabb91f6f40d7d7b639253768c4199b37aede7982"},
    {file = "numpy-1.22.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a024181d7aef0004d76fb3bce2a4c9f2e67a609a9e2a6ff2571d30e9976aa383"},
    {file = "numpy-1.22.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f71d57cc8645f14816ae249407d309be250ad8de93ef61d9709b45a0ddf4050c"},
    {file = "numpy-1.22.0-cp38-cp38-win32.whl", hash = "sha256:283d9de87c0133ef98f93dfc09fad3fb382f2a15580de75c02b5bb36a5a159a5"},
    {file = "numpy-1.22.0-cp38-cp38-win_amd64.whl", hash = "sha256:2762331de395739c91f1abb88041f94a080cb1143aeec791b3b223976228af3f"},
    {file = "numpy-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:76ba7c40e80f9dc815c5e896330700fd6e20814e69da9c1267d65a4d051080f1"},
    {file = "numpy-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0cfe07133fd00b27edee5e6385e333e9eeb010607e8a46e1cd673f05f8596595"},
    {file = "numpy-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6ed0d073a9c54ac40c41a9c2d53fcc3d4d4ed607670b9e7b0de1ba13b4cbfe6f"},
    {file = "numpy-1.22.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41388e32e40b41dd56eb37fcaa7488b2b47b0adf77c66154d6b89622c110dfe9"},
    {file = "numpy-1.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b55b953a1bdb465f4dc181758570d321db4ac23005f90ffd2b434cc6609a63dd"},
    {file = "numpy-1.22.0-cp39-cp39-win32.whl", hash = "sha256:5a311ee4d983c487a0ab546708edbdd759393a3dc9cd30305170149fedd23c88"},
    {file = "numpy-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:a97a954a8c2f046d3817c2bce16e3c7e9a9c2afffaf0400f5c16df5172a67c9c"},
    {file = "numpy-1.22.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb02929b0d6bfab4c48a79bd805bd7419114606947ec8284476167415171f55b"},
    {file = "numpy-1.22.0.zip", hash = "sha256:a955e4128ac36797aaffd49ab44ec74a71c11d6938df83b1285492d277db5397"},
]
packaging = [
    {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
    {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},

M pyproject.toml => pyproject.toml +2 -1
@@ 9,6 9,7 @@ license = "MIT"
python = "^3.8"
neat-python = "^0.92"
pygame-menu = "^4.2.2"
numpy = "^1.22.0"

[tool.poetry.dev-dependencies]
yapf = "^0.32.0"


@@ 19,7 20,7 @@ pytest = "^6.2.5"
isort = "^5.10.1"

[tool.poetry.scripts]
play = "sweep_ai.__main__:main"
sweep-ai = "sweep_ai.__main__:main"

[tool.yapf]
BASED_ON_STYLE = "pep8"

M sweep_ai/__main__.py => sweep_ai/__main__.py +6 -1
@@ 1,5 1,10 @@
"""Game entry point."""
from .logic import start_game as main
from .logic import State


def main():
    State(4, 0.25)


if __name__ == '__main__':
    main()

M sweep_ai/logic.py => sweep_ai/logic.py +112 -4
@@ 1,7 1,115 @@
"""Game logic module."""
from .window import open_window
from random import sample
from typing import List, Optional, Tuple

import numpy as np

def start_game():
    """Start game."""
    open_window()
#pylint: disable=invalid-name

class State:
    """Represents game state at any point in time.

    Properties:
        size: size of the board,
        near[x, y]: how many bombs are near `(x, y)`
        bomb[x, y]: 1 if `(x, y)` is a bomb
        revealed[x, y]: 1 if `(x, y)` has been revealed
        flagged[x, y]: 1 if `(x, y)` has been flagged
        won: `True` if user has won, `None` if the game has not finished
    """

    def __init__(
        self,
        size: int,
        bombs: float = 0.0,
        bomb_positions: Optional[List[Tuple[int, int]]] = None,
    ):
        """Create a new game state.

        Args:
            size: board size
            bombs: percentage of bombs to place
            bomb_positions: exact bomb positions

        If `bomb_positions` are provided the `bombs` are ignored.
        """
        self.size = size
        self.bomb = np.zeros((self.size, self.size), dtype=int)
        self.revealed = np.copy(self.bomb)
        self.flagged = np.copy(self.bomb)
        self.near = np.copy(self.bomb)
        self.won: Optional[bool] = None

        # If the positions were not explicitly provided randomise them.
        if bomb_positions is None:
            bomb_positions = list(
                zip(
                    sample(range(self.size), k=int(self.size**2 * bombs)),
                    sample(range(self.size), k=int(self.size**2 * bombs)),
                ))

        # Create the board based on the positions
        for x, y in bomb_positions:
            self.bomb[x, y] = 1
            for n_x, n_y in self.neighbors(x, y):
                self.near[n_x, n_y] += 1


    @property
    def not_bomb(self):
        """The inverse of `self.bombs`."""
        return (self.bomb + 1) % 2

    @property
    def not_revealed(self):
        """The inverse of `self.revealed`."""
        return (self.revealed + 1) % 2


    def click(self, x: int, y: int):
        """Simulate a click on the `(x, y)` position.

        Sets `self.won` to `True` if the user has won and shows the
        bombs, if `(x, y)` is a bomb sets `self.won` to `False`.

        If the game has not ended performs a recursive reveal.
        """
        if self.bomb[x, y] == 1:
            # Show all the bombs
            self.revealed[self.bomb == 1] = 1
            self.won = False
            return

        if not self.revealed[x, y]:
            self.reveal(x, y)

        # Check if the player has won
        if np.array_equal(self.revealed, self.not_bomb):
            self.won = True

    def reveal(self, x: int, y: int):
        """Reveals `(x, y)`.

        If `(x, y)` is not near a bomb the reveal cascades recursively,
        revealing the neighbors too.
        """
        self.revealed[x, y] = 1
        # If there are no bombs nearby then cascade
        if self.near[x, y] == 0:
            for n_x, n_y in self.neighbors(x, y):
                if self.bomb[n_x, n_y] == 0 and self.revealed[n_x, n_y] == 0:
                    self.reveal(n_x, n_y)

    def neighbors(self, x: int, y: int) -> List[Tuple[int, int]]:
        """Return list of all valid neighbors of `(x, y)`."""
        neighbors = []
        for new_x in [x - 1, x, x + 1]:
            for new_y in [y - 1, y, y + 1]:
                if 0 <= new_x < self.size and 0 <= new_y < self.size:
                    if not (new_x == x and new_y == y):
                        neighbors.append((new_x, new_y))
        return neighbors

    def flag(self, x: int, y: int):
        """Mark `(x, y)` as flagged."""
        self.flagged[x, y] = 1

M tests/test_logic.py => tests/test_logic.py +138 -4
@@ 1,9 1,143 @@
"""Module for testing core game logic."""
import numpy as np

from sweep_ai import logic

def test_add():
    assert (1 + 1) == 2

def test_bombs():
    state = logic.State(4, bomb_positions=[(0, 0), (3, 2)])
    assert np.array_equal(
        state.bomb,
        np.array([
            [1, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 1, 0],
        ]))
    assert np.array_equal(
        state.not_bomb,
        np.array([
            [0, 1, 1, 1],
            [1, 1, 1, 1],
            [1, 1, 1, 1],
            [1, 1, 0, 1],
        ]))

def test_subtract():
    assert (1 - 1) == 0

def test_neighbors():
    state = logic.State(4)
    assert state.neighbors(0, 0) == [(0, 1), (1, 0), (1, 1)]
    assert state.neighbors(3, 3) == [(2, 2), (2, 3), (3, 2)]
    assert state.neighbors(1, 2) == [
        (0, 1),
        (0, 2),
        (0, 3),
        (1, 1),
        (1, 3),
        (2, 1),
        (2, 2),
        (2, 3),
    ]


def test_near():
    state = logic.State(4, bomb_positions=[(0, 0)])
    assert np.array_equal(
        state.near,
        np.array([
            [0, 1, 0, 0],
            [1, 1, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ]))

    state = logic.State(4, bomb_positions=[(1, 1), (3, 0)])
    assert np.array_equal(
        state.near,
        np.array([
            [1, 1, 1, 0],
            [1, 0, 1, 0],
            [2, 2, 1, 0],
            [0, 1, 0, 0],
        ]))


def test_reveal():
    state = logic.State(4, bomb_positions=[(0, 0)])
    state.reveal(3, 3)
    assert np.array_equal(
        state.revealed,
        np.array([
            [0, 1, 1, 1],
            [1, 1, 1, 1],
            [1, 1, 1, 1],
            [1, 1, 1, 1],
        ]))
    assert np.array_equal(
        state.not_revealed,
        np.array([
            [1, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ]))

    state = logic.State(4, bomb_positions=[(0, 0), (1, 1)])
    state.reveal(3, 3)
    assert np.array_equal(
        state.revealed,
        np.array([
            [0, 0, 1, 1],
            [0, 0, 1, 1],
            [1, 1, 1, 1],
            [1, 1, 1, 1],
        ]))

    state = logic.State(4, bomb_positions=[(0, 0), (1, 1), (3, 3)])
    state.reveal(3, 0)
    assert np.array_equal(
        state.revealed,
        np.array([
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [1, 1, 1, 0],
            [1, 1, 1, 0],
        ]))


def test_click():
    state = logic.State(4, bomb_positions=[(0, 0), (1, 1), (3, 3)])
    state.click(3, 0)
    assert np.array_equal(
        state.revealed,
        np.array([
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [1, 1, 1, 0],
            [1, 1, 1, 0],
        ]))
    assert state.won is None

    # The same click again - should be a no-op
    state.click(3, 0)
    assert np.array_equal(
        state.revealed,
        np.array([
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [1, 1, 1, 0],
            [1, 1, 1, 0],
        ]))
    assert state.won is None

    # Click on a bomb = defeat
    state.click(1, 1)
    assert np.array_equal(
        state.revealed,
        np.array([
            [1, 0, 0, 0],
            [0, 1, 0, 0],
            [1, 1, 1, 0],
            [1, 1, 1, 1],
        ]))
    assert not state.won