diff --git a/Makefile b/Makefile index f76114e..53d1247 100644 --- a/Makefile +++ b/Makefile @@ -44,3 +44,6 @@ bandit: ##@Linting Run bandit mypy: ##@Linting Run mypy .venv/bin/mypy --config-file ./pyproject.toml $(PROJECT_PATH) + +upgrade-head: + docker compose exec rest python -m industry_game.db upgrade head \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index b533a8e..7cc83e2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,13 +9,15 @@ services: POSTGRES_USER: $POSTGRES_USER POSTGRES_PASSWORD: $POSTGRES_PASSWORD POSTGRES_DB: $POSTGRES_DB + volumes: + - postgres_data:/var/lib/postgresql/data rest: restart: on-failure - # image: andytakker/industry-game-rest:latest - build: - dockerfile: ./docker/rest.dockerfile - context: . + image: andytakker/industry-game-rest:latest + # build: + # dockerfile: ./docker/rest.dockerfile + # context: . entrypoint: python -m industry_game environment: APP_API_ADDRESS: 0.0.0.0 @@ -30,18 +32,21 @@ services: APP_SECRET: $APP_SECRET APP_PRIVATE_KEY: $APP_PRIVATE_KEY - APP_PUBLIC_KEY: $APP_PUBLIC_KEY frontend: restart: on-failure - # image: andytakker/industry-game-frontend:latest - build: - dockerfile: ./docker/frontend.dockerfile - context: . - args: - BASE_URL: https://vk.com + image: andytakker/industry-game-frontend:latest + # # build: + # # dockerfile: ./docker/frontend.dockerfile + # # context: . + # args: + # BASE_URL: https://vk.com ports: - 80:80 - 443:443 volumes: - ./ssl_keys:/etc/ssl_keys:ro + +volumes: + postgres_data: + driver: local diff --git a/frontend/.idea/.gitignore b/frontend/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/frontend/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/frontend/.idea/GitLink.xml b/frontend/.idea/GitLink.xml deleted file mode 100644 index 009597c..0000000 --- a/frontend/.idea/GitLink.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/.idea/frontend.iml b/frontend/.idea/frontend.iml deleted file mode 100644 index 24643cc..0000000 --- a/frontend/.idea/frontend.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/.idea/inspectionProfiles/Project_Default.xml b/frontend/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/frontend/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/.idea/jsLinters/eslint.xml b/frontend/.idea/jsLinters/eslint.xml deleted file mode 100644 index 541945b..0000000 --- a/frontend/.idea/jsLinters/eslint.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/.idea/modules.xml b/frontend/.idea/modules.xml deleted file mode 100644 index f3d93d7..0000000 --- a/frontend/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/frontend/.idea/vcs.xml b/frontend/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/frontend/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/industry_game/handlers/games/create_game.py b/industry_game/handlers/games/create_game.py deleted file mode 100644 index eb06f57..0000000 --- a/industry_game/handlers/games/create_game.py +++ /dev/null @@ -1,26 +0,0 @@ -from http import HTTPStatus - -from aiohttp.web import Response, View - -from industry_game.utils.games.models import NewGameModel -from industry_game.utils.http.auth.base import ( - AuthMixin, - require_admin_authorization, -) -from industry_game.utils.http.deps import DependenciesMixin -from industry_game.utils.http.params import parse_json_model -from industry_game.utils.http.response import msgspec_json_response - - -class CreateGameHandler(View, DependenciesMixin, AuthMixin): - @require_admin_authorization - async def post(self) -> Response: - body = await self.request.read() - new_game_data = parse_json_model(model=NewGameModel, data=body) - - game = await self.game_storage.create( - name=new_game_data.name, - description=new_game_data.description, - created_by_id=self.user.id, - ) - return msgspec_json_response(game, status=HTTPStatus.CREATED) diff --git a/industry_game/handlers/games/game_create.py b/industry_game/handlers/games/game_create.py index e69de29..eb06f57 100644 --- a/industry_game/handlers/games/game_create.py +++ b/industry_game/handlers/games/game_create.py @@ -0,0 +1,26 @@ +from http import HTTPStatus + +from aiohttp.web import Response, View + +from industry_game.utils.games.models import NewGameModel +from industry_game.utils.http.auth.base import ( + AuthMixin, + require_admin_authorization, +) +from industry_game.utils.http.deps import DependenciesMixin +from industry_game.utils.http.params import parse_json_model +from industry_game.utils.http.response import msgspec_json_response + + +class CreateGameHandler(View, DependenciesMixin, AuthMixin): + @require_admin_authorization + async def post(self) -> Response: + body = await self.request.read() + new_game_data = parse_json_model(model=NewGameModel, data=body) + + game = await self.game_storage.create( + name=new_game_data.name, + description=new_game_data.description, + created_by_id=self.user.id, + ) + return msgspec_json_response(game, status=HTTPStatus.CREATED) diff --git a/industry_game/handlers/games/game_start.py b/industry_game/handlers/games/game_start.py new file mode 100644 index 0000000..128b893 --- /dev/null +++ b/industry_game/handlers/games/game_start.py @@ -0,0 +1,20 @@ +from aiohttp.web import Response, View + +from industry_game.utils.http.auth.base import ( + AuthMixin, + require_admin_authorization, +) +from industry_game.utils.http.deps import DependenciesMixin +from industry_game.utils.http.models import StatusResponse +from industry_game.utils.http.params import parse_path_param +from industry_game.utils.http.response import msgspec_json_response + + +class StartGameHandler(View, DependenciesMixin, AuthMixin): + @require_admin_authorization + async def post(self) -> Response: + game_id = parse_path_param(self.request, "game_id", int) + await self.game_store.start_game(game_id=game_id) + return msgspec_json_response( + StatusResponse(message=f"Game {game_id} was started") + ) diff --git a/industry_game/handlers/games/game_update.py b/industry_game/handlers/games/game_update.py index 5e95f81..aefc4e1 100644 --- a/industry_game/handlers/games/game_update.py +++ b/industry_game/handlers/games/game_update.py @@ -3,20 +3,24 @@ from aiohttp.web_response import Response from industry_game.utils.games.models import UpdateGameModel -from industry_game.utils.http.auth.base import AuthMixin +from industry_game.utils.http.auth.base import ( + AuthMixin, + require_admin_authorization, +) from industry_game.utils.http.deps import DependenciesMixin from industry_game.utils.http.params import parse_json_model, parse_path_param from industry_game.utils.http.response import msgspec_json_response class UpdateGameHandler(View, DependenciesMixin, AuthMixin): + @require_admin_authorization async def post(self) -> Response: body = await self.request.read() update_game_data = parse_json_model(model=UpdateGameModel, data=body) if not update_game_data.name and not update_game_data.description: raise HTTPBadRequest game_id = parse_path_param(self.request, "game_id", int) - game = await self.game_storage.update( + game = await self.game_storage.update_text( game_id=game_id, name=update_game_data.name, description=update_game_data.description, diff --git a/industry_game/handlers/games/lobby/add_user_to_lobby.py b/industry_game/handlers/games/lobby/lobby_add_user.py similarity index 100% rename from industry_game/handlers/games/lobby/add_user_to_lobby.py rename to industry_game/handlers/games/lobby/lobby_add_user.py diff --git a/industry_game/handlers/games/lobby/delete_user_from_lobby.py b/industry_game/handlers/games/lobby/lobby_delete_user.py similarity index 100% rename from industry_game/handlers/games/lobby/delete_user_from_lobby.py rename to industry_game/handlers/games/lobby/lobby_delete_user.py diff --git a/industry_game/handlers/games/lobby/list_lobby.py b/industry_game/handlers/games/lobby/lobby_list.py similarity index 100% rename from industry_game/handlers/games/lobby/list_lobby.py rename to industry_game/handlers/games/lobby/lobby_list.py diff --git a/industry_game/handlers/games/lobby/read_lobby.py b/industry_game/handlers/games/lobby/lobby_read.py similarity index 100% rename from industry_game/handlers/games/lobby/read_lobby.py rename to industry_game/handlers/games/lobby/lobby_read.py diff --git a/industry_game/services/event.py b/industry_game/services/event.py index f9dc72d..f19f5a9 100644 --- a/industry_game/services/event.py +++ b/industry_game/services/event.py @@ -1,20 +1,21 @@ import asyncio import logging +from collections import deque from contextlib import suppress -from datetime import datetime, timedelta +from datetime import UTC, datetime from typing import Any from aiomisc import Service +from industry_game.utils.events.base import MAX_EVENT_PROGRESS from industry_game.utils.events.models import AbstractEvent from industry_game.utils.events.processor import EventProcessor -from industry_game.utils.typed import not_none log = logging.getLogger(__name__) class EventService(Service): - event_queue: asyncio.Queue + event_queue: deque[AbstractEvent] worker_task: asyncio.Task processor: EventProcessor @@ -31,20 +32,19 @@ async def stop(self, exception: Exception | None = None) -> Any: async def work(self) -> None: while True: - event: AbstractEvent = await self.event_queue.get() + if not self.event_queue: + continue + event = self.event_queue.popleft() log.debug("Got event: %s", event) - if await self.is_rescheduled(event, datetime.now()): - await self.event_queue.put(event) - await self.process(event) + now = datetime.now(tz=UTC) + if event.is_active: + event.update_progress(now=now) + if event.progress >= MAX_EVENT_PROGRESS: + self.process(event) + else: + self.event_queue.append(event) await asyncio.sleep(0.01) - async def is_rescheduled(self, event: AbstractEvent, now: datetime) -> bool: - if event.game.is_paused: - return True - return ( - not_none(event.started_at) + timedelta(seconds=event.during_sec) - < now - ) - - async def process(self, event: AbstractEvent) -> None: - asyncio.shield(asyncio.create_task(self.processor.process_event(event))) + def process(self, event: AbstractEvent) -> None: + task = asyncio.shield(asyncio.create_task(event.execute())) + task.add_done_callback(event.post_hook()) diff --git a/industry_game/services/rest.py b/industry_game/services/rest.py index dee003b..ff0bdee 100644 --- a/industry_game/services/rest.py +++ b/industry_game/services/rest.py @@ -10,18 +10,19 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from yarl import URL -from industry_game.handlers.games.create_game import CreateGameHandler +from industry_game.handlers.games.game_create import CreateGameHandler from industry_game.handlers.games.game_details import GameDetailsHandler from industry_game.handlers.games.game_list import ListGameHandler +from industry_game.handlers.games.game_start import StartGameHandler from industry_game.handlers.games.game_update import UpdateGameHandler -from industry_game.handlers.games.lobby.add_user_to_lobby import ( +from industry_game.handlers.games.lobby.lobby_add_user import ( AddUserToGameLobbyHandler, ) -from industry_game.handlers.games.lobby.delete_user_from_lobby import ( +from industry_game.handlers.games.lobby.lobby_delete_user import ( DeleteUserFromLobbyHandler, ) -from industry_game.handlers.games.lobby.list_lobby import ListGameLobbyHandler -from industry_game.handlers.games.lobby.read_lobby import ( +from industry_game.handlers.games.lobby.lobby_list import ListGameLobbyHandler +from industry_game.handlers.games.lobby.lobby_read import ( ReadGameUserLobbyHandler, ) from industry_game.handlers.ping import PingHandler @@ -85,6 +86,7 @@ class REST(AIOHTTPService): (hdrs.METH_POST, "/api/v1/games/", CreateGameHandler), (hdrs.METH_GET, "/api/v1/games/{game_id}/", GameDetailsHandler), (hdrs.METH_POST, "/api/v1/games/{game_id}/", UpdateGameHandler), + (hdrs.METH_POST, "/api/v1/games/{game_id}/start/", StartGameHandler), # lobby handlers (hdrs.METH_GET, "/api/v1/games/{game_id}/lobby/", ListGameLobbyHandler), ( diff --git a/industry_game/utils/buildings/models.py b/industry_game/utils/buildings/models.py index 4f8bc1d..7a8d13c 100644 --- a/industry_game/utils/buildings/models.py +++ b/industry_game/utils/buildings/models.py @@ -1,3 +1,5 @@ +import msgspec + from industry_game.utils.games.models import Game @@ -8,3 +10,12 @@ class BuildingType: class Building: type: BuildingType game: Game + + +class BuildingTypeStruct(msgspec.Struct, frozen=True): + name: str + + +class BuildingStruct(msgspec.Struct, frozen=True): + type: BuildingType + game_id: int diff --git a/industry_game/utils/districts/__init__.py b/industry_game/utils/districts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/industry_game/utils/districts/models.py b/industry_game/utils/districts/models.py new file mode 100644 index 0000000..97a2c95 --- /dev/null +++ b/industry_game/utils/districts/models.py @@ -0,0 +1,2 @@ +class District: + pass diff --git a/industry_game/utils/events/base.py b/industry_game/utils/events/base.py index e6ad836..75d3f7a 100644 --- a/industry_game/utils/events/base.py +++ b/industry_game/utils/events/base.py @@ -1,53 +1,93 @@ import abc -from collections.abc import Mapping -from dataclasses import dataclass from datetime import datetime -from enum import StrEnum -from typing import Any -from uuid import UUID +from enum import StrEnum, unique -from industry_game.utils.games.models import Game +MAX_EVENT_PROGRESS = 1 +@unique class EventType(StrEnum): BUILDING = "BUILDING" PRODUCTION = "PRODUCTION" +@unique class EventStatus(StrEnum): CREATED = "CREATED" SCHEDULED = "SCHEDULED" + PAUSED = "PAUSED" FINISHED = "FINISHED" -@dataclass class AbstractEvent(abc.ABC): - uuid: UUID - status: EventStatus - type: EventType - game: Game - created_at: datetime - started_at: datetime | None - ended_at: datetime | None - during_sec: int + _created_at: datetime + _delay: int + _finished_at: datetime | None + _is_active: bool + _last_updated_at: datetime + _status: EventStatus + _progress: float + + _name: str + _game_id: int + + def __init__( + self, + name: str, + game_id: int, + delay: int, + created_at: datetime, + speed: float, + is_active: bool, + ) -> None: + self._name = name + self._game_id = game_id + self._created_at = created_at + self._delay = delay + self._finished_at = None + self._is_active = is_active + self._last_updated_at = created_at + self._progress = 0 + self._speed = speed + self._status = EventStatus.CREATED + + def __repr__(self) -> str: + return self._name @property - def properties(self) -> Mapping[str, Any]: + def progress(self) -> float: + return self._progress + + @property + def game_id(self) -> int: + return self._game_id + + def update_progress(self, dt: datetime) -> None: + new_progres = ( + self._progress + + (dt - self._last_updated_at).total_seconds() + / self._delay + * self._speed + ) + self._progress = min(MAX_EVENT_PROGRESS, new_progres) + + def set_last_updated_at(self, dt: datetime) -> None: + self._last_updated_at = dt + + def set_status(self, status: EventStatus) -> None: + self._status = status + + @abc.abstractmethod + async def pre_hook(self) -> None: + raise NotImplementedError + + @abc.abstractmethod + async def execute(self) -> None: raise NotImplementedError @abc.abstractmethod - async def hook(self, *args: Any, **kwargs: Any) -> None: + async def post_hook(self) -> None: raise NotImplementedError - def model2dict(self) -> Mapping[str, Any]: - return { - "uuid": self.uuid, - "status": self.status, - "type": self.type, - "game_id": self.game.id, - "created_at": self.created_at, - "started_at": self.started_at, - "ended_at": self.ended_at, - "during_sec": self.during_sec, - "properties": self.properties, - } + def is_active(self) -> bool: + return self._is_active diff --git a/industry_game/utils/events/game.py b/industry_game/utils/events/game.py new file mode 100644 index 0000000..1bd3c3f --- /dev/null +++ b/industry_game/utils/events/game.py @@ -0,0 +1,150 @@ +import logging +from collections import deque +from datetime import UTC, datetime + +from industry_game.db.models import GameStatus +from industry_game.utils.events.base import AbstractEvent, EventStatus +from industry_game.utils.games.session import SessionController +from industry_game.utils.games.storage import GameStorage + +log = logging.getLogger(__name__) + + +class StartGameSessionEvent(AbstractEvent): + _event_queue: deque[AbstractEvent] + _session_controller: SessionController + _game_storage: GameStorage + + def __init__( + self, + game_id: int, + delay: int, + created_at: datetime, + event_queue: deque[AbstractEvent], + session_controller: SessionController, + game_storage: GameStorage, + name: str = "StartGameSessionEvent", + speed: float = 1.0, + is_active: bool = True, + ) -> None: + super().__init__( + name=name, + game_id=game_id, + delay=delay, + created_at=created_at, + speed=speed, + is_active=is_active, + ) + self._event_queue = event_queue + self._session_controller = session_controller + self._game_storage = game_storage + + async def pre_hook(self) -> None: + return + + async def execute(self) -> None: + await self._game_storage.update_status( + game_id=self._game_id, + status=GameStatus.STARTED, + ) + now = datetime.now(UTC) + for event in self._event_queue: + if event.game_id == self.game_id and not event.is_active: + event.set_status(EventStatus.SCHEDULED) + event.set_last_updated_at(dt=now) + + async def post_hook(self) -> None: + self._event_queue.append( + EndGameSessionEvent( + delay=self._session_controller.current_session.duration_seconds, + game_id=self.game_id, + event_queue=self._event_queue, + session_controller=self._session_controller, + ) + ) + + +class EndGameSessionEvent(AbstractEvent): + _event_queue: deque[AbstractEvent] + _session_controller: SessionController + + def __init__( + self, + event_queue: deque[AbstractEvent], + session_controller: SessionController, + game_id: int, + delay: int, + created_at: datetime, + speed: float, + is_active: bool, + name: str = "StopGameSessionEvent", + ) -> None: + super().__init__( + name=name, + game_id=game_id, + delay=delay, + created_at=created_at, + speed=speed, + is_active=is_active, + ) + self._event_queue = event_queue + self._session_controller = session_controller + + async def pre_hook(self) -> None: + pass + + async def execute(self) -> None: + for event in self._event_queue: + if event.game_id == self.game_id and event.is_active: + event.set_status(EventStatus.PAUSED) + + async def post_hook(self) -> None: + if self._session_controller.is_last: + while self._event_queue: + event = self._event_queue.popleft() + log.info("event %s was destroyed before finished", event) + else: + next_session = self._session_controller.next() + self._event_queue.append( + StartGameSessionEvent( + delay=next_session.pause_seconds, + game_id=self._game_id, + event_queue=self._event_queue, + session_controller=self._session_controller, + ) + ) + + +class PauseGameSessionEvent(AbstractEvent): + _event_queue: deque[AbstractEvent] + + def __init__( + self, + event_queue: deque[AbstractEvent], + game_id: int, + delay: int, + created_at: datetime, + speed: float, + is_active: bool, + name: str = "PauseGameSessionEvent", + ) -> None: + self._event_queue = event_queue + super().__init__( + name=name, + game_id=game_id, + delay=delay, + created_at=created_at, + speed=speed, + is_active=is_active, + ) + + async def pre_hook(self) -> None: + pass + + async def execute(self) -> None: + for event in self._event_queue: + if event.game_id == self.game_id and event.is_active: + event.set_status(EventStatus.PAUSED) + + async def post_hook(self) -> None: + pass diff --git a/industry_game/utils/games/exceptions.py b/industry_game/utils/games/exceptions.py new file mode 100644 index 0000000..2702ae8 --- /dev/null +++ b/industry_game/utils/games/exceptions.py @@ -0,0 +1,6 @@ +from industry_game.utils.exceptions import IndustryGameException + + +class GameNotFoundException(IndustryGameException): + def __init__(self, game_id: int) -> None: + super().__init__(f"Game with id {game_id} not found") diff --git a/industry_game/utils/games/models.py b/industry_game/utils/games/models.py index 598e9ae..2a81588 100644 --- a/industry_game/utils/games/models.py +++ b/industry_game/utils/games/models.py @@ -1,3 +1,6 @@ +from collections import deque +from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime from typing import Annotated @@ -6,10 +9,13 @@ from industry_game.db.models import Game as GameDb from industry_game.db.models import GameStatus +from industry_game.utils.events.base import AbstractEvent +from industry_game.utils.games.session import SessionController +from industry_game.utils.maps.models import Hexagon from industry_game.utils.pagination import MetaPagination -class Game(msgspec.Struct, frozen=True): +class GameResponse(msgspec.Struct, frozen=True): id: int name: str description: str @@ -39,9 +45,9 @@ def from_model(cls, obj: GameDb) -> "Game": ) -class GamePagination(msgspec.Struct, frozen=True): +class GamePaginationResponse(msgspec.Struct, frozen=True): meta: MetaPagination - items: list[Game] + items: list[GameResponse] class NewGameModel(BaseModel): @@ -60,3 +66,37 @@ class UpdateGameModel(BaseModel): description: Annotated[ str, StringConstraints(strip_whitespace=True, max_length=512) ] | None = None + + +@dataclass(frozen=True) +class Game: + id: int + name: str + description: str + created_by_id: int + status: GameStatus + started_at: datetime | None + finished_at: datetime | None + created_at: datetime + updated_at: datetime + + def from_model(self, obj: GameDb) -> "Game": + return Game( + id=obj.id, + name=obj.name, + description=obj.description, + created_by_id=obj.created_by_id, + status=obj.status, + started_at=obj.started_at, + finished_at=obj.finished_at, + created_at=obj.created_at, + updated_at=obj.updated_at, + ) + + +@dataclass +class MasterGame: + game: Game + session_controller: SessionController + event_queue: deque[AbstractEvent] + map: Mapping[tuple[int, int], Hexagon] diff --git a/industry_game/utils/games/session.py b/industry_game/utils/games/session.py new file mode 100644 index 0000000..018bd49 --- /dev/null +++ b/industry_game/utils/games/session.py @@ -0,0 +1,78 @@ +from collections.abc import Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Session: + duration_seconds: int + pause_seconds: int | None + + @property + def is_last(self) -> bool: + return self.pause_seconds is None + + +class SessionController: + def __init__(self, sessions: Sequence[Session]): + if len(sessions) == 0: + raise ValueError("Sessions must not be empty") + self._sessions = sessions + self._session_index = 0 + + @property + def current_session(self) -> Session: + return self._sessions[self._session_index] + + @property + def is_last(self) -> bool: + return self.current_session.is_last + + @property + def is_first(self) -> bool: + return self._session_index == 0 + + def next(self) -> Session: + self._session_index = (self._session_index + 1) % len(self._sessions) + return self.current_session + + +SESSIONS = ( + Session( # 1 + duration_seconds=10 * 60, pause_seconds=0 + ), + Session( # 2 + duration_seconds=10 * 60, pause_seconds=0 + ), + Session( # 3 + duration_seconds=9 * 60, + pause_seconds=4 * 60, + ), + Session( # 4 + duration_seconds=9 * 60, + pause_seconds=0, + ), + Session( # 5 + duration_seconds=9 * 60, + pause_seconds=4 * 60, + ), + Session( # 6 + duration_seconds=9 * 60, + pause_seconds=0, + ), + Session( # 7 + duration_seconds=9 * 60, + pause_seconds=4 * 60, + ), + Session( # 8 + duration_seconds=9 * 60, + pause_seconds=0, + ), + Session( # 9 + duration_seconds=12 * 60, + pause_seconds=0, + ), + Session( # 10 + duration_seconds=12 * 60, + pause_seconds=None, + ), +) diff --git a/industry_game/utils/games/storage.py b/industry_game/utils/games/storage.py index f934e00..a4a031d 100644 --- a/industry_game/utils/games/storage.py +++ b/industry_game/utils/games/storage.py @@ -6,13 +6,13 @@ from industry_game.db.models import Game as GameDb from industry_game.db.models import GameStatus from industry_game.utils.db import AbstractStorage, inject_session -from industry_game.utils.games.models import Game, GamePagination +from industry_game.utils.games.models import Game, GamePaginationResponse from industry_game.utils.pagination import MetaPagination class GameStorage(AbstractStorage): @inject_session - async def read_by_id( + async def get_by_id( self, session: AsyncSession, game_id: int ) -> Game | None: game = await session.get(GameDb, game_id) @@ -25,12 +25,12 @@ async def pagination( page: int, page_size: int, status: GameStatus | None = None, - ) -> GamePagination: + ) -> GamePaginationResponse: total, items = await asyncio.gather( self.count(status=status), self.get_items(page=page, page_size=page_size, status=status), ) - return GamePagination( + return GamePaginationResponse( meta=MetaPagination.create( total=total, page=page, @@ -92,7 +92,7 @@ async def create( return Game.from_model(game) @inject_session - async def update( + async def update_text( self, session: AsyncSession, game_id: int, @@ -116,3 +116,22 @@ async def update( if commit: await session.commit() return Game.from_model(game) + + @inject_session + async def update_status( + self, + session: AsyncSession, + game_id: int, + status: GameStatus, + commit: bool = True, + ) -> Game: + stmt = ( + update(GameDb) + .where(GameDb.id == game_id) + .values(status=status) + .returning(GameDb) + ) + game = (await session.scalars(stmt)).one() + if commit: + await session.commit() + return Game.from_model(game) diff --git a/industry_game/utils/games/store.py b/industry_game/utils/games/store.py new file mode 100644 index 0000000..18523bc --- /dev/null +++ b/industry_game/utils/games/store.py @@ -0,0 +1,52 @@ +import logging +from collections import deque +from collections.abc import MutableMapping +from dataclasses import dataclass +from datetime import UTC, datetime + +from industry_game.utils.events.base import AbstractEvent +from industry_game.utils.events.game import StartGameSessionEvent +from industry_game.utils.games.exceptions import GameNotFoundException +from industry_game.utils.games.models import Game, MasterGame +from industry_game.utils.games.session import SESSIONS, SessionController +from industry_game.utils.games.storage import GameStorage + +log = logging.getLogger(__name__) + +START_GAME_DELAY = 3 * 60 # 3 minutes + + +@dataclass(frozen=True) +class GameStore: + game_storage: GameStorage + current_games: MutableMapping[int, MasterGame] + event_queue: deque[AbstractEvent] + + async def start_game(self, game_id: int) -> None: + game = await self.game_storage.get_by_id(game_id) + if game is None: + raise GameNotFoundException(game_id) + self.add_new_game( + game=game, + event_queue=self.event_queue, + ) + event = StartGameSessionEvent( + game_id=game_id, + delay=START_GAME_DELAY, + created_at=datetime.now(tz=UTC), + event_queue=self.event_queue, + session_controller=self.current_games[game_id].session_controller, + ) + self.event_queue.append(event) + log.info("Game was %s started", game_id) + + def add_new_game( + self, + game: Game, + event_queue: deque[AbstractEvent], + ) -> None: + self.current_games[game.id] = MasterGame( + game=game, + event_queue=event_queue, + session_controller=SessionController(sessions=SESSIONS), + ) diff --git a/industry_game/utils/http/deps.py b/industry_game/utils/http/deps.py index 87ee0ec..ecab989 100644 --- a/industry_game/utils/http/deps.py +++ b/industry_game/utils/http/deps.py @@ -1,6 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from industry_game.utils.games.storage import GameStorage +from industry_game.utils.games.store import GameStore from industry_game.utils.http.base import BaseHttpMixin from industry_game.utils.lobby.storage import LobbyStorage from industry_game.utils.users.processor import PlayerProcessor @@ -27,3 +28,7 @@ def player_storage(self) -> PlayerStorage: @property def player_processor(self) -> PlayerProcessor: return self.request.app["player_processor"] + + @property + def game_store(self) -> GameStore: + return self.request.app["game_store"] diff --git a/industry_game/utils/http/models.py b/industry_game/utils/http/models.py new file mode 100644 index 0000000..bc80db1 --- /dev/null +++ b/industry_game/utils/http/models.py @@ -0,0 +1,5 @@ +import msgspec + + +class StatusResponse(msgspec.Struct): + message: str diff --git a/industry_game/utils/maps/__init__.py b/industry_game/utils/maps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/industry_game/utils/maps/models.py b/industry_game/utils/maps/models.py new file mode 100644 index 0000000..12d16fd --- /dev/null +++ b/industry_game/utils/maps/models.py @@ -0,0 +1,30 @@ +from collections.abc import Mapping +from dataclasses import dataclass + +import msgspec + +from industry_game.utils.buildings.models import Building, BuildingStruct +from industry_game.utils.districts.models import District, DistrictStruct +from industry_game.utils.resources.models import Resource, ResourceStruct + + +class HexagonStruct(msgspec.Struct, frozen=True): + x: int + y: int + building: BuildingStruct | None = None + district: DistrictStruct | None = None + resource: ResourceStruct | None = None + + +@dataclass +class Hexagon: + x: int + y: int + building: Building | None + district: District | None + resource: Resource | None + + +@dataclass(frozen=True) +class HexagonMap: + map: Mapping[tuple[int, int], Hexagon] diff --git a/industry_game/utils/resources/__init__.py b/industry_game/utils/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/industry_game/utils/resources/models.py b/industry_game/utils/resources/models.py new file mode 100644 index 0000000..7b4f9e0 --- /dev/null +++ b/industry_game/utils/resources/models.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import StrEnum, unique + +import msgspec + + +@unique +class ResourceType(StrEnum): + METALL = "METALL" + CHEMICAL = "CHEMICAL" + MACHINE = "MACHINE" + LIGHT_INDUSTRY = "LIGHT_INDUSTRY" + FOOD_INDUSTRY = "FOOD_INDUSTRY" + BITCOIN = "BITCOIN" + + +@dataclass(frozen=True) +class Resource: + type: ResourceType + count: int + + +class ResourceStruct(msgspec.Struct, frozen=True): + type: ResourceType + count: int