Skip to content

Commit

Permalink
Restructure the code, create installable package (#17)
Browse files Browse the repository at this point in the history
* Restructure the folder.

Change the year in the license file

* Restructure the folder.

* Fix failing ci.

* fix wrong path

* fix mypy options
  • Loading branch information
balgot authored Jan 4, 2023
1 parent 648b809 commit 4a2b66f
Show file tree
Hide file tree
Showing 28 changed files with 307 additions and 136 deletions.
27 changes: 27 additions & 0 deletions .github/workflows/lints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: code-lints
on: [push]

jobs:
build:
strategy:
matrix:
os: ['ubuntu-latest']
python-version: [3.8]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e game[lint]
- name: flake8
run: flake8 game/
- name: mypy
run: cd game && mypy . --check-untyped-defs
- name: codestyle
run: pycodestyle game
5 changes: 2 additions & 3 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
pip install -e game[test]
- name: Run Unit Tests
run: pytest
run: pytest game/
2 changes: 1 addition & 1 deletion LICENSE → game/LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2022 Michal Barnišin
Copyright (c) 2022-2023 Michal Barnišin

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
37 changes: 37 additions & 0 deletions game/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Mathematico

A classic game, for unlimited number of players. Each player keeps track of their own board
and after 25 random choices of numbers, the score is calculated for each player and the highest score wins.


## Rules of the Game

The game of Mathematico is played on a *5x5 grid*. In each round, a card with a number is drawn
from the deck consisting of cards with numbers in range 1-13, with 4 copies of each, and players
are obliged to fill the number in one of the empty cells on their board. When the boards are full,
resulting scores are computed (see below) for each board, and the player with the highest score wins.

### Scoring System

For each line, row and two longest diagonals, the points are computed based on the following table
and are summed across all rows, columns and diagonals. In addition, if the score in a diagonal
is non-zero, the player is awarded *10 bonus points* for each such diagonal.

*The numbers in the rows, columns and diagonals can be in __ANY__ order.*


| Rule | Example | Points
|--------------------------------- | -------------- | -------
| One pair | 1 2 3 4 1 | 10
| Two pairs | 1 2 2 3 1 | 20
| Three of a kind | 5 6 7 7 7 | 40
| Full House | 1 1 2 2 2 | 80
| Four of a kind (not number 1) | 1 2 2 2 2 | 160
| Four ones | 1 1 5 1 1 | 200
| Straight | 5 7 9 8 6 | 50
| Three 1s and two 13s | 1 13 1 13 1 | 100
| Numbers 1, 10, 11, 12, 13 | 12 11 13 1 10 | 150


For each row, column and diagonal, only the highest score is applied, i.e. it is forbidden
to combine two scoring rules for one line.
8 changes: 8 additions & 0 deletions game/mathematico/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .game import Arena, Board, Mathematico, Player
from .players import HumanPlayer, RandomPlayer, SimulationPlayer


__all__ = [
"Arena", "Board", "Mathematico", "Player",
"HumanPlayer", "RandomPlayer", "SimulationPlayer"
]
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Mathematico:
"""
def __init__(self, seed=None):
self.moves_played = 0
self.players = []
self.players: List[Player] = []
self._available_cards = [i for i in range(1, 14) for _ in range(4)]
self._random = Random(seed)
self._random.shuffle(self._available_cards)
Expand Down Expand Up @@ -58,7 +58,7 @@ def next_card(self) -> Union[None, int]:
"""
if self.finished():
return None
# the cards are shuffled at the beginning, it is enough to take the next
# the cards are shuffled at the beginning
card = self._available_cards[self.moves_played]
self.moves_played += 1
return card
Expand Down Expand Up @@ -92,8 +92,8 @@ def play(self, verbose=False) -> List[int]:
their move and at the end computes final scores.
:param verbose: if True, prints information about game
:return: list of final scores, the index corresponds to the index return
by <add_player>
:return: list of final scores, the index corresponds to the index
returned by `add_player`
"""
while not self.finished():
next_card = self.next_card()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from collections import Counter
from typing import Iterable, Dict, Any
from typing import Iterable, Dict, TypeVar


def rle(data: Iterable, ignore: Iterable[Any]) -> Dict[Any, int]:
T = TypeVar("T")


def rle(data: Iterable[T], ignore: Iterable[T]) -> Dict[T, int]:
"""
Performs run length encoding on the data. Does not modify the original data.
Performs run length encoding on data. Does not modify the original data.
Expected time complexity: O(n).
:param data: list of elements (in any order)
Expand Down
70 changes: 70 additions & 0 deletions game/mathematico/game/arena.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import time
from typing import List, Any

from .player import Player
from ._mathematico import Mathematico


class Arena:
"""
This class allows simulating multiple rounds of the game Mathematico.
Mehods
------
reset: reset the results so far
add_player: add a player to the arena
run: run the simulation
"""

def __init__(self):
self.players: List[Player] = []
self.results: List[List[int]] = []

def reset(self):
"""Clear the previous results, keep the players."""
for player_results in self.results:
player_results.clear()

def add_player(self, player: Player):
"""Add new player to the arena."""
self.players.append(player)
self.results.append([])

def run(self, rounds: int = 100, verbose: bool = True, seed: Any = None):
"""
Repeatedly play the game of Mathematico.
Play Mathematico for the specified number of rounds and
record the statistics (final score) for each of the players.
Arguments
---------
rounds: number of rounds to play
verbose: if True, print the elapsed time, also passed
to each round
seed: the seed to play the same game from
Returns
-------
result: 2d list, `results[idx]` is the list of scores
obtained by `idx`-th player
"""
start = time.time()

for _ in range(rounds):
# initialize a new game
game = Mathematico(seed=seed)
for player in self.players:
player.reset()
game.add_player(player)

# play the game and collect rewards
results = game.play(verbose=False)
for idx, result in enumerate(results):
self.results[idx].append(result)

if verbose:
total_time = time.time() - start
print(f"Steps run: {rounds}\tElapsed time: {total_time}")

return self.results
84 changes: 46 additions & 38 deletions src/mathematico/game/board.py → game/mathematico/game/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,55 @@
This file defines the grid of the game Mathematico alongside with the move
generation and formatting of the text output of the grid.
"""
from typing import Tuple, Iterator, Dict
import numpy as np
from typing import List, Tuple, Iterator, Dict
from collections import defaultdict

from ._utils import rle
from .eval import evaluate_line, DIAGONAL_BONUS


EMPTY_CELL = 0
Rle = Dict[int, int]


class Board:
"""
The Board of the game is 5x5 grid with integer values representing the moves
of players. We encode the moves as tuples (x, y) denoting the real position
inside our array.
The Board of the game is 5x5 grid with integer values representing the
moves of the players. We encode the moves as tuples (x, y) denoting the
real position inside our array.
Attributes
- grid: 2D array, empty values are stored as Board.EMPTY_CELL
- occupied_cells: number of occupied cells
- size: size of the board
----------
grid: 2D array, empty values are stored as Board.EMPTY_CELL
occupied_cells: number of occupied cells
size: size of the board
Methods
- integrity_check: returns True if the integrity holds
- row(n): n-th row as a list
- col(n): n-th column as a list
- diag: main/anti diagonal as a list
- row_rle(n): rle encoding of the n-th row
- col_rle(n): rle encoding of the n-th column
- diag_rle: rle encoding of the diagonal
- **make_move**: updates a grid with the move
- **unmake_move**: undos the specified move
- **possible_moves**: iterates over all possible moves
- **score**: score of the filled up board
-------
integrity_check: returns True if the integrity holds
row: n-th row as a list
col: n-th column as a list
diag: main/anti diagonal as a list
row_rle: rle encoding of the n-th row
col_rle: rle encoding of the n-th column
diag_rle: rle encoding of the diagonal
make_move: updates a grid with the move
unmake_move: undos the specified move
possible_moves: iterates over all possible moves
score: score of the filled up board
"""

def __init__(self, size: int = 5):
self.grid: np.ndarray = np.full((size, size), EMPTY_CELL)
self.rows_rle = [defaultdict(int) for _ in range(size)]
self.cols_rle = [defaultdict(int) for _ in range(size)]
self.main_diagonal_rle = defaultdict(int)
self.anti_diagonal_rle = defaultdict(int)
self.grid = [[EMPTY_CELL]*size for _ in range(size)]
self.rows_rle: List[Rle] = [defaultdict(int) for _ in range(size)]
self.cols_rle: List[Rle] = [defaultdict(int) for _ in range(size)]
self.main_diagonal_rle: Rle = defaultdict(int)
self.anti_diagonal_rle: Rle = defaultdict(int)
self.occupied_cells: int = 0

@staticmethod
def cell_to_str(cell: int) -> str:
"""Return string representation of a cell"""
def _cell_to_str(cell: int) -> str:
"""Return string representation of a cell."""
if cell == EMPTY_CELL:
return " "
return f"{cell:2d}"
Expand All @@ -69,12 +71,14 @@ def __str__(self) -> str:
| 1| 9| 2| 6| 3|
+--+--+--+--+--+
:return: string representation of the grid
Returns
-------
string representation of the grid
"""
long_line = "+--" * len(self.grid) + "+"
result = [long_line]
for row in self.grid:
line = "|" + "|".join(map(self.cell_to_str, row)) + "|"
line = "|" + "|".join(map(self._cell_to_str, row)) + "|"
result.append(line)
result.append(long_line)
return "\n".join(result)
Expand All @@ -92,7 +96,7 @@ def integrity_check(self) -> None:
:raises RuntimeError: if data mismatch
"""
non_empty = sum([x != EMPTY_CELL for x in self.grid.flatten()])
non_empty = sum([x != EMPTY_CELL for row in self.grid for x in row])
if non_empty != self.occupied_cells:
raise RuntimeError("Occupied cells mismatch")

Expand All @@ -107,32 +111,36 @@ def integrity_check(self) -> None:
if self.anti_diagonal_rle != rle(self.diag(False), [EMPTY_CELL]):
raise RuntimeError("Rle of anti diagonal mismatch")

def row(self, n: int) -> np.ndarray:
def row(self, n: int) -> List[int]:
"""Return n-th row."""
return self.grid[n]

def row_rle(self, n: int) -> Dict[int, int]:
"""Return RLE of n-th row."""
return self.rows_rle[n]

def col(self, n: int) -> np.ndarray:
def col(self, n: int) -> List[int]:
"""Return n-th column."""
return self.grid.T[n]
return [row[n] for row in self.grid]

def col_rle(self, n: int) -> Dict[int, int]:
"""Return RLE of n-th column."""
return self.cols_rle[n]

def diag(self, main_diagonal: bool = True) -> np.ndarray:
def diag(self, main_diagonal: bool = True) -> List[int]:
"""
Returns main diagonal or main anti diagonal of the grid.
:param main_diagonal: if True returns main diagonal, else anti diagonal
:return: array with elements on the corresponding diagonal
"""
if main_diagonal:
return self.grid.diagonal()
return np.flipud(self.grid).diagonal()
col = 0
dx = 1
else:
col = self.size - 1
dx = -1
return [self.grid[i][col + dx*i] for i in range(self.size)]

def diag_rle(self, main_diagonal: bool = True) -> Dict[int, int]:
"""Return RLE of a diagonal.
Expand All @@ -157,7 +165,7 @@ def make_move(self, position: Tuple[int, int], move: int) -> None:
if not self.is_empty(row, col):
raise ValueError(f"The position {position} is invalid")

self.grid[position] = move
self.grid[row][col] = move
self.occupied_cells += 1

self.rows_rle[row][move] += 1
Expand All @@ -177,8 +185,8 @@ def unmake_move(self, position: Tuple[int, int]) -> int:
if self.is_empty(row, col):
raise ValueError(f"Undoing empty square {position}")

cell = self.grid[position]
self.grid[position] = EMPTY_CELL
cell = self.grid[row][col]
self.grid[row][col] = EMPTY_CELL
self.occupied_cells -= 1

self.rows_rle[row][cell] -= 1
Expand Down
File renamed without changes.
File renamed without changes.
6 changes: 6 additions & 0 deletions game/mathematico/players/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ._human_player import HumanPlayer
from ._random_player import RandomPlayer
from ._random_simulations import SimulationPlayer


__all__ = ["RandomPlayer", "HumanPlayer", "SimulationPlayer"]
Loading

0 comments on commit 4a2b66f

Please sign in to comment.