diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d5071..df76877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Test + - name: Check all code + run: | + make check_all_code + - name: Run test suite run: | make test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c63db27 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: check-yaml +- repo: https://github.com/PyCQA/autoflake + rev: v2.0.1 + hooks: + - id: autoflake + args: + - -i + - -r + - --remove-all-unused-imports + - --expand-star-imports + - --ignore-init-module-imports +- repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort +- repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + args: [--python-version=3.11] diff --git a/Makefile b/Makefile index e6871ae..86351d7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ play: - python main.py + python game/main.py + +check_all_code: + pre-commit run -a test: python -m unittest diff --git a/README.md b/README.md index b4d2479..8bb059d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ During the festive holidays 2022 I was scrolling on Insta and I bumped into [this reel](https://www.instagram.com/reel/CmK8aKQDvTm/?igshid=YmMyMTA2M2Y=). Entertaining! It definitely tickled my engineering mind and I thought I could try to reverse-engineer its logic and give a go at trying to reproduce the whole game. Here is my attempt! -![](rps.gif) +![](docs/rps.gif) ## Basic Rules diff --git a/rps.gif b/docs/rps.gif similarity index 100% rename from rps.gif rename to docs/rps.gif diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/game/main.py similarity index 82% rename from main.py rename to game/main.py index c0c978b..3f7551b 100644 --- a/main.py +++ b/game/main.py @@ -3,7 +3,7 @@ import sys from enum import Enum from time import sleep -from typing import Self +from typing import Self # type: ignore class GestureSuit(Enum): @@ -23,7 +23,7 @@ class Gesture: GestureSuit.SCISSOR: "✂️ ", } - SUIT_TO_WEAKER_SUIT: [GestureSuit, GestureSuit] = { + SUIT_TO_WEAKER_SUIT: dict[GestureSuit, GestureSuit] = { GestureSuit.ROCK: GestureSuit.SCISSOR, GestureSuit.PAPER: GestureSuit.ROCK, GestureSuit.SCISSOR: GestureSuit.PAPER, @@ -31,31 +31,29 @@ class Gesture: def __init__(self, suit: GestureSuit): self.suit: GestureSuit = suit - self.cell: Cell | None = None + self.cell: Cell self.alive = True def __str__(self) -> str: return self.SUIT_TO_EMOJI[self.suit] - def __gt__(self, other: Self): - return self.SUIT_TO_WEAKER_SUIT[self.suit] == other.suit + def __gt__(self, other: Self): # type: ignore + return self.SUIT_TO_WEAKER_SUIT[self.suit] == other.suit # type: ignore - def equals(self, other: Self): - return self.suit == other.suit + def equals(self, other: Self): # type: ignore + return self.suit == other.suit # type: ignore def transform(self, suit: GestureSuit): self.suit = suit class Cell: - GAME_MODE_TO_FUNCTION_NAME = { - item: f"_challenge_{item.value}" for item in GameMode - } + GAME_MODE_TO_FUNCTION_NAME = {item: f"_challenge_{item.value}" for item in GameMode} def __init__(self, game: "RockPaperScissor", gesture: Gesture | None = None): self.game = game - self.m = None # y coordinate - self.n = None # x coordinate + self.m: int # y coordinate + self.n: int # x coordinate self.gesture: Gesture | None = gesture @@ -79,6 +77,9 @@ def remove_gesture(self): self.gesture = None def _challenge_transform(self, incoming: Gesture): + if self.gesture is None: + raise Exception("This cell does not have a gesture") + if self.gesture > incoming: self.stats[f"remaining_{incoming.suit.value}"] -= 1 incoming.transform(self.gesture.suit) @@ -143,13 +144,11 @@ def __init__( } def _init_gestures(self): - self.gestures = [ - Gesture(GestureSuit.ROCK) for _ in range(self.COUNT_ROCK) - ] + [ - Gesture(GestureSuit.PAPER) for _ in range(self.COUNT_PAPER) - ] + [ - Gesture(GestureSuit.SCISSOR) for _ in range(self.COUNT_SCISSOR) - ] + self.gestures = ( + [Gesture(GestureSuit.ROCK) for _ in range(self.COUNT_ROCK)] + + [Gesture(GestureSuit.PAPER) for _ in range(self.COUNT_PAPER)] + + [Gesture(GestureSuit.SCISSOR) for _ in range(self.COUNT_SCISSOR)] + ) def _init_matrix(self): cells = [Cell(self, gesture) for gesture in self.gestures] @@ -173,19 +172,28 @@ def _get_all_surrounding_cells(self, cell: Cell): n = cell.n coords_list = ( - (m - 1, n - 1), (m - 1, n), (m - 1, n + 1), - (m, n - 1), (m, n + 1), - (m + 1, n - 1), (m + 1, n), (m + 1, n + 1), + (m - 1, n - 1), + (m - 1, n), + (m - 1, n + 1), + (m, n - 1), + (m, n + 1), + (m + 1, n - 1), + (m + 1, n), + (m + 1, n + 1), ) return [ - self.matrix[i][j] for i, j in coords_list if 0 <= i < self.M and 0 <= j < self.N + self.matrix[i][j] + for i, j in coords_list + if 0 <= i < self.M and 0 <= j < self.N ] def _get_available_cells_to_move_to(self, gesture: Gesture): all_surrounding_cells = self._get_all_surrounding_cells(gesture.cell) filtered_cells = [ - c for c in all_surrounding_cells if not c.gesture or not c.gesture.equals(gesture) + c + for c in all_surrounding_cells + if not c.gesture or not c.gesture.equals(gesture) ] return filtered_cells @@ -219,7 +227,8 @@ def _clear_screen(): def _print_board(self): self._clear_screen() - sys.stdout.write(f""" + sys.stdout.write( + f""" [=========================] [ Rock-Paper-Scissor ] [=========================] @@ -227,7 +236,8 @@ def _print_board(self): Mode: {self.GAME_MODE.value.title()} Round: {self.stats["round_number"] or "-":<4} {GestureSuit.ROCK.value.title()}: {self.stats[f"remaining_{GestureSuit.ROCK.value}"]:<3} {GestureSuit.PAPER.value.title()}: {self.stats[f"remaining_{GestureSuit.PAPER.value}"]:<3} {GestureSuit.SCISSOR.value.title()}: {self.stats[f"remaining_{GestureSuit.SCISSOR.value}"]:<3} - \n""") + \n""" + ) for row in self.matrix: sys.stdout.write("|" + "·".join(f"{str(cell):^0}" for cell in row) + "|\n") @@ -258,9 +268,11 @@ def play(self): sleep(self.ROUND_DELAY) - sys.stdout.write(f""" + sys.stdout.write( + f""" The winner is {self.get_winning_suit().value.title()}!!! - \n\n""") + \n\n""" + ) if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index bdac70f..db0db5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -parameterized ipdb +parameterized +pre-commit diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests.py b/tests/tests.py similarity index 81% rename from tests.py rename to tests/tests.py index d8e9568..34bd69a 100644 --- a/tests.py +++ b/tests/tests.py @@ -1,11 +1,11 @@ import signal from io import StringIO from unittest import TestCase -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch -from parameterized import parameterized +from parameterized import parameterized # type: ignore -from main import Gesture, GestureSuit, Cell, GameMode, RockPaperScissor +from game.main import Cell, GameMode, Gesture, GestureSuit, RockPaperScissor def abort_after_timeout(timeout): @@ -22,6 +22,7 @@ def signal_handler(signum, frame): signal.alarm(0) return wrapper + return decorator @@ -93,9 +94,11 @@ def test_remove_gesture(self): self.assertIsNone(cell.gesture) self.assertIsNone(g.cell) - @parameterized.expand([ - ("_challenge_transform", GameMode.TRANSFORM), - ]) + @parameterized.expand( + [ + ("_challenge_transform", GameMode.TRANSFORM), + ] + ) def test_get_challenge_function(self, function_name, mode): game = MagicMock(GAME_MODE=mode) cell = Cell(game) @@ -198,7 +201,7 @@ def test_challenge_transform_incoming_is_equal(self): class RockPaperScissorTests(TestCase): - @patch("main.random.shuffle") + @patch("game.main.random.shuffle") def test_init(self, shuffle): game = RockPaperScissor( height=3, @@ -221,7 +224,10 @@ def test_init(self, shuffle): [s, s, s, s, n], [n, n, n, n, n], ], - [[cell.gesture and cell.gesture.suit for cell in row] for row in game.matrix], + [ + [cell.gesture and cell.gesture.suit for cell in row] + for row in game.matrix + ], ) self.assertEqual(0, game.stats["round_number"]) @@ -236,43 +242,50 @@ def test_get_all_surrounding_cells(self): self.assertEqual( [ game.matrix[0][1], - game.matrix[1][0], game.matrix[1][1], + game.matrix[1][0], + game.matrix[1][1], ], - game._get_all_surrounding_cells(game.matrix[0][0]) + game._get_all_surrounding_cells(game.matrix[0][0]), ) # top-right corner self.assertEqual( [ game.matrix[0][-2], - game.matrix[1][-2], game.matrix[1][-1], + game.matrix[1][-2], + game.matrix[1][-1], ], - game._get_all_surrounding_cells(game.matrix[0][-1]) + game._get_all_surrounding_cells(game.matrix[0][-1]), ) # bottom-right corner self.assertEqual( [ - game.matrix[-2][-2], game.matrix[-2][-1], + game.matrix[-2][-2], + game.matrix[-2][-1], game.matrix[-1][-2], ], - game._get_all_surrounding_cells(game.matrix[-1][-1]) + game._get_all_surrounding_cells(game.matrix[-1][-1]), ) # bottom-left corner self.assertEqual( [ - game.matrix[-2][0], game.matrix[-2][1], + game.matrix[-2][0], + game.matrix[-2][1], game.matrix[-1][1], ], - game._get_all_surrounding_cells(game.matrix[-1][0]) + game._get_all_surrounding_cells(game.matrix[-1][0]), ) # top side self.assertEqual( [ - game.matrix[0][4], game.matrix[0][6], - game.matrix[1][4], game.matrix[1][5], game.matrix[1][6], + game.matrix[0][4], + game.matrix[0][6], + game.matrix[1][4], + game.matrix[1][5], + game.matrix[1][6], ], game._get_all_surrounding_cells(game.matrix[0][5]), ) @@ -280,40 +293,52 @@ def test_get_all_surrounding_cells(self): # right side self.assertEqual( [ - game.matrix[4][-2], game.matrix[4][-1], + game.matrix[4][-2], + game.matrix[4][-1], game.matrix[5][-2], - game.matrix[6][-2], game.matrix[6][-1], + game.matrix[6][-2], + game.matrix[6][-1], ], - game._get_all_surrounding_cells(game.matrix[5][-1]) + game._get_all_surrounding_cells(game.matrix[5][-1]), ) # bottom side self.assertEqual( [ - game.matrix[-2][4], game.matrix[-2][5], game.matrix[-2][6], - game.matrix[-1][4], game.matrix[-1][6], + game.matrix[-2][4], + game.matrix[-2][5], + game.matrix[-2][6], + game.matrix[-1][4], + game.matrix[-1][6], ], - game._get_all_surrounding_cells(game.matrix[-1][5]) + game._get_all_surrounding_cells(game.matrix[-1][5]), ) # left side self.assertEqual( [ - game.matrix[4][0], game.matrix[4][1], + game.matrix[4][0], + game.matrix[4][1], game.matrix[5][1], - game.matrix[6][0], game.matrix[6][1], + game.matrix[6][0], + game.matrix[6][1], ], - game._get_all_surrounding_cells(game.matrix[5][0]) + game._get_all_surrounding_cells(game.matrix[5][0]), ) # Central self.assertEqual( [ - game.matrix[4][2], game.matrix[4][3], game.matrix[4][4], - game.matrix[5][2], game.matrix[5][4], - game.matrix[6][2], game.matrix[6][3], game.matrix[6][4], + game.matrix[4][2], + game.matrix[4][3], + game.matrix[4][4], + game.matrix[5][2], + game.matrix[5][4], + game.matrix[6][2], + game.matrix[6][3], + game.matrix[6][4], ], - game._get_all_surrounding_cells(game.matrix[5][3]) + game._get_all_surrounding_cells(game.matrix[5][3]), ) @patch.object(RockPaperScissor, "_get_all_surrounding_cells") @@ -336,7 +361,7 @@ def test_get_available_cells_to_move_to(self, _get_all_surrounding_cells): self.assertEqual([c0, c2, c3, c4, c5, c6], filtered_cells) _get_all_surrounding_cells.assert_called_once_with(cell) - @patch("main.random.choice") + @patch("game.main.random.choice") @patch.object(RockPaperScissor, "_get_available_cells_to_move_to") def test_move_gesture(self, _get_available_cells_to_move_to, random_choice): game = RockPaperScissor() @@ -358,7 +383,9 @@ def test_move_gesture(self, _get_available_cells_to_move_to, random_choice): @patch.object(Cell, "run_challenge") @patch.object(RockPaperScissor, "_get_available_cells_to_move_to") - def test_move_gesture_no_available_cells(self, _get_available_cells_to_move_to, run_challenge): + def test_move_gesture_no_available_cells( + self, _get_available_cells_to_move_to, run_challenge + ): game = RockPaperScissor() _get_available_cells_to_move_to.return_value = [] @@ -387,15 +414,15 @@ def test_play_round(self, _print_board, _move_gestures): _print_board.assert_called() _move_gestures.assert_called() - @patch("main.os.name", "posix") - @patch("main.os.system") + @patch("game.main.os.name", "posix") + @patch("game.main.os.system") def test_clear_screen_posix(self, os_system): game = RockPaperScissor() game._clear_screen() os_system.assert_called_once_with("clear") - @patch("main.os.name", "abc") - @patch("main.os.system") + @patch("game.main.os.name", "abc") + @patch("game.main.os.system") def test_clear_screen_not_posix(self, os_system): game = RockPaperScissor() game._clear_screen() @@ -415,20 +442,24 @@ def test_print_board(self, _clear_screen, mock_out): [ Rock-Paper-Scissor ] [=========================] """.strip(), - mock_out.getvalue() + mock_out.getvalue(), ) - @parameterized.expand([ - (0, 0, 0, False), - (9, 9, 9, False), - (9, 9, 0, False), - (9, 0, 9, False), - (0, 9, 9, False), - (9, 0, 0, True), - (0, 9, 0, True), - (0, 0, 9, True), - ]) - def test_is_game_over(self, remaining_rock, remaining_paper, remaining_scissor, is_game_over): + @parameterized.expand( + [ + (0, 0, 0, False), + (9, 9, 9, False), + (9, 9, 0, False), + (9, 0, 9, False), + (0, 9, 9, False), + (9, 0, 0, True), + (0, 9, 0, True), + (0, 0, 9, True), + ] + ) + def test_is_game_over( + self, remaining_rock, remaining_paper, remaining_scissor, is_game_over + ): game = RockPaperScissor() game.stats[f"remaining_{GestureSuit.ROCK.value}"] = remaining_rock