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