diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..4240edc --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/poetry.lock b/poetry.lock index a52897a..f0a4cef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,51 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "bottleneck" version = "1.4.2" @@ -125,7 +171,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, @@ -154,7 +200,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] +groups = ["main", "dev"] markers = "(python_version < \"3.12.0.rc1\" or python_version == \"3.12\") and platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -714,6 +760,19 @@ python-dateutil = ">=2.7" [package.extras] dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + [[package]] name = "netcdf4" version = "1.7.2" @@ -906,7 +965,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, @@ -1017,6 +1076,19 @@ toolz = "*" [package.extras] complete = ["blosc", "numpy (>=1.20.0)", "pandas (>=1.3)", "pyzmq"] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "pillow" version = "11.2.1" @@ -1118,6 +1190,24 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "4.3.7" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version < \"3.12.0.rc1\" or python_version == \"3.12\"" +files = [ + {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, + {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + [[package]] name = "polars" version = "1.29.0" @@ -1666,4 +1756,4 @@ viz = ["cartopy", "matplotlib", "nc-time-axis", "seaborn"] [metadata] lock-version = "2.1" python-versions = "3.12.*" -content-hash = "2f73ed4f7ecbc35e441018704fb45694ed6b9da8d3340c3e21b83e7781286ecb" +content-hash = "d50e41f9a51c7471516283cfa58f9e1c11d9bbe006c03d3d15ba9856ca286c9c" diff --git a/pyproject.toml b/pyproject.toml index 6153b58..2636a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,8 @@ +[tool.black] +line-length = 88 +skip-string-normalization = true +target-version = ["py312"] + [tool.poetry] name = "power-flow-game" version = "0.0.0" @@ -10,3 +15,6 @@ python = "3.12.*" pypsa = "^0.34.1" pandas = "^2.2.3" numpy = "^2.2.5" + +[tool.poetry.group.dev.dependencies] +black = "^25.1.0" diff --git a/src/engine/engine.py b/src/engine/engine.py new file mode 100644 index 0000000..a132183 --- /dev/null +++ b/src/engine/engine.py @@ -0,0 +1,17 @@ +from src.models.event import Event +from src.models.game_state import GameState + + +class Engine: + def __init__(self, game_state: GameState) -> None: + self.game_state = game_state + + def handle_event(self, event: Event) -> None: + """ + Events happen every time a player takes an action or a timer runs out. + Every time an event occurs, the engine is informed and it can then: + -Update the game state + -Send messages back to the player interface if required + :param event: + """ + raise NotImplementedError() diff --git a/src/models/event.py b/src/models/event.py new file mode 100644 index 0000000..487944a --- /dev/null +++ b/src/models/event.py @@ -0,0 +1,7 @@ +from abc import ABC +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Event(ABC): + pass diff --git a/src/models/game_state.py b/src/models/game_state.py new file mode 100644 index 0000000..256af45 --- /dev/null +++ b/src/models/game_state.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from typing import Self, Optional +import json + +from src.models.player import Player, PlayerId + + +class Phase(Enum): + # Values are just placeholders + CONSTRUCTION = 0 + SNEAKY_TRICKS = 1 + DA_AUCTION = 2 + + +@dataclass +class GameState: + # A complete description of the current state of the game. + def __init__( + self, + game_id: int, + players: list[Player], + phase: Phase, + current_player: Optional[PlayerId], + ) -> None: + # Read only attributes + self._game_id = game_id + self._players = players + + # Mutable attributes + self.phase = phase + self.current_player = current_player + + @property + def game_id(self) -> int: + return self._game_id + + @property + def players(self) -> list[Player]: + return self._players + + @cached_property + def n_players(self) -> int: + return len(self.players) + + def to_simple_dict(self) -> dict: + return { + "game_id": self.game_id, + "players": [player.to_simple_dict() for player in self.players], + "phase": self.phase.value, + "current_player": ( + self.current_player.as_int() + if self.current_player is not None + else None + ), + } + + @classmethod + def from_simple_dict(cls, simple_dict: dict) -> Self: + return cls( + game_id=simple_dict["game_id"], + players=[ + Player.from_simple_dict(player) for player in simple_dict["players"] + ], + phase=Phase(simple_dict["phase"]), + current_player=( + PlayerId(simple_dict["current_player"]) + if simple_dict["current_player"] is not None + else None + ), + ) diff --git a/src/models/player.py b/src/models/player.py new file mode 100644 index 0000000..24ec2ad --- /dev/null +++ b/src/models/player.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Self + + +class PlayerId(int): + def as_int(self) -> int: + return int(self) + + +@dataclass(frozen=True) +class Player: + id: PlayerId + name: str + + def to_simple_dict(self) -> dict: + return {"id": self.id.as_int(), "name": self.name} + + @classmethod + def from_simple_dict(cls, simple_dict: dict) -> Self: + return cls( + id=PlayerId(simple_dict["id"]), + name=simple_dict["name"], + ) diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/serialization.py b/src/tools/serialization.py new file mode 100644 index 0000000..b68c27f --- /dev/null +++ b/src/tools/serialization.py @@ -0,0 +1,21 @@ +from typing import Protocol, Self, runtime_checkable, TypeVar +import json + + +@runtime_checkable +class Serializable(Protocol): + def to_simple_dict(self) -> dict: ... + + @classmethod + def from_simple_dict(cls, simple_dict: dict) -> Self: ... + + +GenericSerializable = TypeVar("GenericSerializable", bound=Serializable) + + +def serialize(x: Serializable) -> str: + return json.dumps(x.to_simple_dict()) + + +def deserialize(x: str, cls: type[GenericSerializable]) -> GenericSerializable: + return cls.from_simple_dict(json.loads(x)) diff --git a/src/tools/typing.py b/src/tools/typing.py new file mode 100644 index 0000000..46516ac --- /dev/null +++ b/src/tools/typing.py @@ -0,0 +1,5 @@ +from typing import TypeVar + +T = TypeVar("T") +U = TypeVar("U") +V = TypeVar("V") diff --git a/tests/test_models/__init__.py b/tests/test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models/test_game_state.py b/tests/test_models/test_game_state.py new file mode 100644 index 0000000..d00e794 --- /dev/null +++ b/tests/test_models/test_game_state.py @@ -0,0 +1,28 @@ +from unittest import TestCase + +from src.models.game_state import GameState, Phase +from src.models.player import Player, PlayerId +from src.tools.serialization import serialize, deserialize + + +class TestGameState(TestCase): + def test_serialization(self) -> None: + # Test the serialization of the GameState object + game_state = GameState( + game_id=1, + players=[ + Player(id=PlayerId(1), name="Alice"), + Player(id=PlayerId(2), name="Bob"), + ], + phase=Phase.CONSTRUCTION, + current_player=PlayerId(1), + ) + json_str = serialize(game_state) + re_built_state = deserialize(x=json_str, cls=GameState) + self.assertEqual(game_state.game_id, re_built_state.game_id) + for ( + p1, + p2, + ) in zip(game_state.players, re_built_state.players): + self.assertEqual(p1, p2) + self.assertEqual(game_state.phase, re_built_state.phase)