diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12a8b2..e90f83a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,30 +8,24 @@ jobs: steps: - uses: actions/checkout@v4 - - name: add secrets - run: | - echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml - echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml - echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + - name: install ruff + run: curl -LsSf https://astral.sh/ruff/0.13.0/install.sh | sh - name: ruff - run: docker compose -f deploy/dev/docker-compose.yaml run ttt ruff check src tests + run: ruff check src tests mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: add secrets + - name: install dependencies run: | - echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml - echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml - echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + curl -LsSf https://astral.sh/uv/install.sh | sh + uv sync - name: mypy - run: docker compose -f deploy/dev/docker-compose.yaml run ttt mypy src tests + run: uv run mypy src tests pytest: runs-on: ubuntu-latest @@ -44,6 +38,7 @@ jobs: echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + echo "sentry_dsn: ${{ secrets.ADMIN_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - name: pytest run: docker compose -f deploy/dev/docker-compose.yaml run ttt pytest tests --cov --cov-report=xml diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index b7c39e4..1235935 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -35,6 +35,16 @@ services: TTT_NATS_URL: nats://nats:4222 TTT_GEMINI_URL: https://my-openai-gemini-sigma-sandy.vercel.app + + TTT_DIALOG_TTL: 259200 # 3 days + + TTT_MATCHMAKING_MAX_WORKERS: 4 + TTT_MATCHMAKING_WORKER_MAX_USERS: 100 + TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 + + TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 + + TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10 secrets: - secrets command: ttt-dev @@ -84,15 +94,16 @@ services: interval: 3s nats_streams: - image: bitnami/natscli:0.2.3-debian-12-r4 + image: n255/natscli:0.3.0-bookworm-slim container_name: ttt-nats-streams depends_on: nats: condition: service_healthy volumes: - ./nats:/mnt - entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] + environment: + NATS_URL: nats://nats:4222 volumes: backend-data: diff --git a/deploy/dev/nats/add_streams.sh b/deploy/dev/nats/add_streams.sh index 0ea074a..12c14a7 100644 --- a/deploy/dev/nats/add_streams.sh +++ b/deploy/dev/nats/add_streams.sh @@ -1,3 +1,5 @@ #!/bin/bash -nats -s nats://nats:4222 stream add --config /mnt/streams/user.json +for stream_config_file in `ls -lx /mnt/streams`; do + nats stream add --config /mnt/streams/$stream_config_file +done diff --git a/deploy/dev/nats/streams/game.json b/deploy/dev/nats/streams/game.json new file mode 100644 index 0000000..786ef94 --- /dev/null +++ b/deploy/dev/nats/streams/game.json @@ -0,0 +1,14 @@ +{ + "name": "GAME", + "subjects": ["game.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/dev/nats/streams/stars_purchase.json b/deploy/dev/nats/streams/stars_purchase.json new file mode 100644 index 0000000..31fa3e0 --- /dev/null +++ b/deploy/dev/nats/streams/stars_purchase.json @@ -0,0 +1,14 @@ +{ + "name": "STARS_PURCHASE", + "subjects": ["stars_purchase.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index eb6f70a..ac5ba94 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -35,6 +35,16 @@ services: TTT_NATS_URL: nats://${NATS_TOKEN}@nats:4222 TTT_GEMINI_URL: ${GEMINI_URL} + + TTT_DIALOG_TTL: 259200 # 3 days + + TTT_MATCHMAKING_MAX_WORKERS: 4 + TTT_MATCHMAKING_WORKER_MAX_USERS: 100 + TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 + + TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 + + TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10 secrets: - secrets networks: @@ -138,7 +148,7 @@ services: interval: 3s nats_streams: - image: bitnami/natscli:0.2.3-debian-12-r4 + image: n255/natscli:0.3.0-bookworm-slim container_name: ttt-nats-streams depends_on: nats: @@ -148,8 +158,8 @@ services: networks: - nats environment: + NATS_URL: nats://nats:4222 NATS_TOKEN: ${NATS_TOKEN} - entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] volumes: diff --git a/deploy/prod/nats/add_streams.sh b/deploy/prod/nats/add_streams.sh index 0ea074a..12c14a7 100644 --- a/deploy/prod/nats/add_streams.sh +++ b/deploy/prod/nats/add_streams.sh @@ -1,3 +1,5 @@ #!/bin/bash -nats -s nats://nats:4222 stream add --config /mnt/streams/user.json +for stream_config_file in `ls -lx /mnt/streams`; do + nats stream add --config /mnt/streams/$stream_config_file +done diff --git a/deploy/prod/nats/streams/game.json b/deploy/prod/nats/streams/game.json new file mode 100644 index 0000000..786ef94 --- /dev/null +++ b/deploy/prod/nats/streams/game.json @@ -0,0 +1,14 @@ +{ + "name": "GAME", + "subjects": ["game.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/prod/nats/streams/stars_purchase.json b/deploy/prod/nats/streams/stars_purchase.json new file mode 100644 index 0000000..31fa3e0 --- /dev/null +++ b/deploy/prod/nats/streams/stars_purchase.json @@ -0,0 +1,14 @@ +{ + "name": "STARS_PURCHASE", + "subjects": ["stars_purchase.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/pyproject.toml b/pyproject.toml index e98ce6a..942dfa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttt" -version = "0.4.2" +version = "0.5.0" description = "Tic-Tac-Toe Telegram Bot" authors = [ {name = "Alexander Smolin", email = "88573504+emptybutton@users.noreply.github.com"} @@ -29,13 +29,13 @@ dependencies = [ [dependency-groups] dev = [ - "mypy[faster-cache]==1.16.0", - "ruff==0.11.13", + "mypy[faster-cache]==1.18.1", + "ruff==0.13.0", "pytest==8.4.0", "pytest-cov==6.1.1", "pytest-asyncio==1.0.0", "dirty-equals==0.9.0", - "better-exceptions==0.3.3", + "rich==14.1.0", ] [project.urls] @@ -111,6 +111,7 @@ ignore = [ "EM101", "N807", "FURB118", + "DOC402", ] [tool.ruff.lint.per-file-ignores] diff --git a/src/ttt/application/matchmaking_queue/__init__.py b/src/ttt/application/common/errors/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/__init__.py rename to src/ttt/application/common/errors/__init__.py diff --git a/src/ttt/application/common/errors/serialization_error.py b/src/ttt/application/common/errors/serialization_error.py new file mode 100644 index 0000000..107e3d3 --- /dev/null +++ b/src/ttt/application/common/errors/serialization_error.py @@ -0,0 +1 @@ +class SerializationError(Exception): ... diff --git a/src/ttt/application/common/ports/map.py b/src/ttt/application/common/ports/map.py index 6a46f8a..87fb8d9 100644 --- a/src/ttt/application/common/ports/map.py +++ b/src/ttt/application/common/ports/map.py @@ -23,4 +23,5 @@ async def __call__( """ :raises ttt.application.common.ports.map.NotUniqueUserIdError: :raises ttt.application.common.ports.map.NotUniqueActiveInvitationToGameUserIdsError: + :raises ttt.application.common.errors.serialization_error.SerializationError: """ # noqa: E501 diff --git a/src/ttt/application/common/ports/retry.py b/src/ttt/application/common/ports/retry.py new file mode 100644 index 0000000..d50e8da --- /dev/null +++ b/src/ttt/application/common/ports/retry.py @@ -0,0 +1,6 @@ +from abc import ABC, abstractmethod + + +class Retry(ABC): + @abstractmethod + def __bool__(self) -> bool: ... diff --git a/src/ttt/application/common/ports/transaction.py b/src/ttt/application/common/ports/transaction.py index 1a8f423..85fc329 100644 --- a/src/ttt/application/common/ports/transaction.py +++ b/src/ttt/application/common/ports/transaction.py @@ -1,5 +1,33 @@ +from abc import ABC, abstractmethod from contextlib import AbstractAsyncContextManager +from types import TracebackType from typing import Any -Transaction = AbstractAsyncContextManager[Any] +class SerializableTransaction(AbstractAsyncContextManager[Any], ABC): + @abstractmethod + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + return None + + @abstractmethod + async def commit(self) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + +class NotSerializableTransaction(AbstractAsyncContextManager[Any], ABC): + @abstractmethod + async def commit(self) -> None: ... + + +class ReadonlyTransaction(AbstractAsyncContextManager[Any], ABC): ... diff --git a/src/ttt/application/game/game/cancel_game.py b/src/ttt/application/game/game/cancel_game.py index 7f6d74a..b00fd46 100644 --- a/src/ttt/application/game/game/cancel_game.py +++ b/src/ttt/application/game/game/cancel_game.py @@ -1,15 +1,12 @@ -from asyncio import gather from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games from ttt.entities.core.game.game import AlreadyCompletedGameError -from ttt.entities.core.user.user import User -from ttt.entities.tools.assertion import not_none from ttt.entities.tools.tracking import Tracking @@ -19,50 +16,38 @@ class CancelGame: games: Games game_views: GameViews uuids: UUIDs - transaction: Transaction + transaction: SerializableTransaction log: GameLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: - ( - game, - user1_last_game_id, - user2_last_game_id, - ) = await gather( - self.games.game_with_game_location(user_id), - self.uuids.random_uuid(), - self.uuids.random_uuid(), - ) + game = await self.games.current_user_game(user_id) + if game is None: - await self.game_views.no_game_view(user_id) + await self.log.no_current_game_to_cancel_game(user_id) + await self.transaction.commit() + await self.game_views.no_current_game_view(user_id) return - locations = tuple( - not_none(user.game_location) - for user in (game.player1, game.player2) - if isinstance(user, User) - ) - try: tracking = Tracking() - game.cancel( - user_id, - user1_last_game_id, - user2_last_game_id, - tracking, - ) + game.cancel(user_id, tracking) except AlreadyCompletedGameError: await self.log.already_completed_game_to_cancel(game, user_id) + await self.transaction.commit() await self.game_views.game_already_complteted_view( user_id, game, ) return + else: + await self.log.game_cancelled(user_id, game) - await self.log.game_cancelled(user_id, game) + await self.map_(tracking) + await self.transaction.commit() - await self.map_(tracking) - await self.game_views.game_view_with_locations( - locations, - game, - ) + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/make_ai_move_in_game.py b/src/ttt/application/game/game/make_ai_move_in_game.py new file mode 100644 index 0000000..117dbc8 --- /dev/null +++ b/src/ttt/application/game/game/make_ai_move_in_game.py @@ -0,0 +1,78 @@ +from asyncio import gather +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.randoms import Randoms +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, +) +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao +from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_views import GameViews +from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks +from ttt.entities.core.game.game import ( + AlreadyCompletedGameError, + NotAiCurrentMoveError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class MakeAiMoveInGame: + map_: Map + games: Games + views: GameViews + uuids: UUIDs + randoms: Randoms + ai_gateway: GameAiGateway + transaction: NotSerializableTransaction + log: GameLog + dao: GameDao + locks: UserLocks + + async def __call__(self, user_id: int, game_id: UUID, ai_id: UUID) -> None: + async with self.transaction: + await self.locks.lock_user_by_id(user_id) + game = await self.games.not_locked_game_with_id(game_id) + + if game is None: + await self.log.no_game_to_make_ai_move(game_id) + return + + ( + free_cell_random, + ai_move_cell_number_int, + ) = await gather( + self.randoms.random(), + self.ai_gateway.next_move_cell_number_int(game, ai_id), + ) + try: + tracking = Tracking() + ai_move = game.make_ai_move( + ai_id, + ai_move_cell_number_int, + free_cell_random, + tracking, + ) + except AlreadyCompletedGameError: + await self.log.already_completed_game_to_make_ai_move( + game, ai_id, + ) + except NotAiCurrentMoveError: + await self.log.not_ai_current_move_to_make_ai_move( + game, ai_id, + ) + else: + await self.log.ai_move_maked(game, ai_move, ai_id) + + if game.is_completed(): + await self.log.game_was_completed_by_ai(ai_id, game) + + await self.map_(tracking) + await self.transaction.commit() + + await self.views.game_view(game) diff --git a/src/ttt/application/game/game/make_move_in_game.py b/src/ttt/application/game/game/make_move_in_game.py index 2969519..1a929b6 100644 --- a/src/ttt/application/game/game/make_move_in_game.py +++ b/src/ttt/application/game/game/make_move_in_game.py @@ -3,12 +3,15 @@ from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.cell import AlreadyFilledCellError from ttt.entities.core.game.game import ( @@ -16,8 +19,6 @@ NoCellError, NotCurrentPlayerError, ) -from ttt.entities.core.user.user import User -from ttt.entities.tools.assertion import not_none from ttt.entities.tools.tracking import Tracking @@ -30,34 +31,34 @@ class MakeMoveInGame: uuids: UUIDs randoms: Randoms ai_gateway: GameAiGateway - transaction: Transaction + transaction: SerializableTransaction log: GameLog + dao: GameDao + tasks: GameTasks + locks: UserLocks async def __call__( self, user_id: int, cell_number_int: int, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: - game = await self.games.game_with_game_location(user_id) + game = await self.games.current_user_game(user_id) if game is None: - await self.game_views.no_game_view(user_id) + await self.game_views.no_current_game_view(user_id) return - locations = tuple( - not_none(user.game_location) - for user in (game.player1, game.player2) - if isinstance(user, User) - ) ( random, - current_user_last_game_id, - not_current_user_last_game_id, + games_played_by_player_id, ) = await gather( self.randoms.random(), - self.uuids.random_uuid(), - self.uuids.random_uuid(), + self.dao.games_played_by_player_id(game), ) try: @@ -65,8 +66,7 @@ async def __call__( user_move = game.make_user_move( user_id, cell_number_int, - current_user_last_game_id, - not_current_user_last_game_id, + games_played_by_player_id, random, tracking, ) @@ -76,6 +76,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.game_already_complteted_view( user_id, game, @@ -86,6 +87,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.not_current_user_view( user_id, game, @@ -96,6 +98,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.no_cell_view(user_id, game) except AlreadyFilledCellError: await self.log.already_filled_cell_to_make_move( @@ -103,6 +106,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.already_filled_cell_error( user_id, game, @@ -110,43 +114,16 @@ async def __call__( else: await self.log.user_move_maked(user_id, game, user_move) - if user_move.next_move_ai_id is not None: - await self.game_views.game_view_with_locations( - locations, - game, - ) + if game.is_completed(): + await self.log.game_was_completed_by_user(user_id, game) - ( - free_cell_random, - ai_move_cell_number_int, - not_current_user_last_game_id, - ) = await gather( - self.randoms.random(), - self.ai_gateway.next_move_cell_number_int( - game, - user_move.next_move_ai_id, - ), - self.uuids.random_uuid(), - ) - ai_move = game.make_ai_move( - user_move.next_move_ai_id, - ai_move_cell_number_int, - not_current_user_last_game_id, - free_cell_random, - tracking, - ) + await self.map_(tracking) - await self.log.ai_move_maked( - user_id, - game, - ai_move, + if user_move.next_move_ai_id is not None: + await self.locks.lock_user_by_id(user_id) + await self.tasks.make_ai_move( + user_id, game.id, user_move.next_move_ai_id, ) - if game.is_completed(): - await self.log.game_completed(user_id, game) - - await self.map_(tracking) - await self.game_views.game_view_with_locations( - locations, - game, - ) + await self.transaction.commit() + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/ports/game_dao.py b/src/ttt/application/game/game/ports/game_dao.py new file mode 100644 index 0000000..d28e5f7 --- /dev/null +++ b/src/ttt/application/game/game/ports/game_dao.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.elo.rating import GamesPlayed + + +class GameDao(ABC): + @abstractmethod + async def games_played_by_player_id( + self, + game: Game, + /, + ) -> dict[int, GamesPlayed]: ... diff --git a/src/ttt/application/game/game/ports/game_log.py b/src/ttt/application/game/game/ports/game_log.py index 6cf067f..419283b 100644 --- a/src/ttt/application/game/game/ports/game_log.py +++ b/src/ttt/application/game/game/ports/game_log.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from uuid import UUID from ttt.entities.core.game.game import Game from ttt.entities.core.game.move import AiMove, UserMove @@ -7,9 +8,23 @@ class GameLog(ABC): @abstractmethod - async def game_against_user_started( + async def no_current_game_to_make_move( self, - game: Game, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def no_current_game_to_cancel_game( + self, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def no_game_to_make_ai_move( + self, + game_id: UUID, /, ) -> None: ... @@ -40,20 +55,28 @@ async def user_move_maked( @abstractmethod async def ai_move_maked( self, - user_id: int, game: Game, move: AiMove, + ai_id: UUID, /, ) -> None: ... @abstractmethod - async def game_completed( + async def game_was_completed_by_user( self, user_id: int, game: Game, /, ) -> None: ... + @abstractmethod + async def game_was_completed_by_ai( + self, + ai_id: UUID, + game: Game, + /, + ) -> None: ... + @abstractmethod async def user_already_in_game_to_start_game_against_ai( self, user: User, /, @@ -103,3 +126,19 @@ async def already_completed_game_to_cancel( user_id: int, /, ) -> None: ... + + @abstractmethod + async def already_completed_game_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: ... + + @abstractmethod + async def not_ai_current_move_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: ... diff --git a/src/ttt/application/game/game/ports/game_tasks.py b/src/ttt/application/game/game/ports/game_tasks.py new file mode 100644 index 0000000..2284bc6 --- /dev/null +++ b/src/ttt/application/game/game/ports/game_tasks.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from uuid import UUID + + +class GameTasks(ABC): + @abstractmethod + async def make_ai_move( + self, + user_id: int, + game_id: UUID, + ai_id: UUID, + /, + ) -> None: ... diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index a06fb88..52213a6 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -1,8 +1,6 @@ from abc import ABC, abstractmethod -from collections.abc import Sequence from ttt.entities.core.game.game import Game -from ttt.entities.core.user.location import UserGameLocation class GameViews(ABC): @@ -10,23 +8,17 @@ class GameViews(ABC): async def current_game_view_with_user_id(self, user_id: int, /) -> None: ... @abstractmethod - async def game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: ... + async def game_view(self, game: Game, /) -> None: ... @abstractmethod - async def started_game_view_with_locations( + async def started_game_view( self, - user_locations: Sequence[UserGameLocation], game: Game, /, ) -> None: ... @abstractmethod - async def no_game_view( + async def no_current_game_view( self, user_id: int, /, diff --git a/src/ttt/application/game/game/ports/games.py b/src/ttt/application/game/game/ports/games.py index b339f96..eec98a7 100644 --- a/src/ttt/application/game/game/ports/games.py +++ b/src/ttt/application/game/game/ports/games.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from uuid import UUID from ttt.entities.core.game.game import Game @@ -8,8 +9,8 @@ class NoGameError(Exception): ... class Games(ABC): @abstractmethod - async def game_with_game_location( - self, - game_location_user_id: int, - /, - ) -> Game | None: ... + async def current_user_game(self, user_id: int, /) -> Game | None: ... + + @abstractmethod + async def not_locked_game_with_id(self, game_id: UUID, /) -> Game | None: + ... diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index d700576..e501e77 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -1,20 +1,20 @@ -from asyncio import gather from dataclasses import dataclass from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.ai import AiType from ttt.entities.core.game.game import start_game_with_ai -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import UserAlreadyInGameError from ttt.entities.tools.tracking import Tracking @@ -29,11 +29,17 @@ class StartGameWithAi: user_views: CommonUserViews games: Games game_views: GameViews - transaction: Transaction + transaction: SerializableTransaction ai_gateway: GameAiGateway log: GameLog + tasks: GameTasks + locks: UserLocks async def __call__(self, user_id: int, ai_type: AiType) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + game_id = await self.uuids.random_uuid() ai_id = await self.uuids.random_uuid() cell_id_matrix = await self.uuids.random_uuid_matrix((3, 3)) @@ -65,49 +71,19 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: await self.log.user_already_in_game_to_start_game_against_ai( user, ) + await self.transaction.commit() await self.game_views.user_already_in_game_view(user_id) else: await self.log.game_against_ai_started(started_game.game) + await self.map_(tracking) - if started_game.next_move_ai_id is None: - await self.map_(tracking) - await self.game_views.started_game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) - else: - await self.game_views.started_game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) - - ( - free_cell_random, - ai_move_cell_number_int, - not_current_user_last_game_id, - ) = await gather( - self.randoms.random(), - self.ai_gateway.next_move_cell_number_int( - started_game.game, - started_game.next_move_ai_id, - ), - self.uuids.random_uuid(), - ) - ai_move = started_game.game.make_ai_move( - started_game.next_move_ai_id, - ai_move_cell_number_int, - not_current_user_last_game_id, - free_cell_random, - tracking, - ) - await self.log.ai_move_maked( + if started_game.next_move_ai_id is not None: + await self.locks.lock_user_by_id(user_id) + await self.tasks.make_ai_move( user_id, - started_game.game, - ai_move, + started_game.game.id, + started_game.next_move_ai_id, ) - await self.map_(tracking) - await self.game_views.game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) + await self.transaction.commit() + await self.game_views.started_game_view(started_game.game) diff --git a/src/ttt/application/game/game/view_game.py b/src/ttt/application/game/game/view_game.py index ba289b8..d436eac 100644 --- a/src/ttt/application/game/game/view_game.py +++ b/src/ttt/application/game/game/view_game.py @@ -1,13 +1,15 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews @dataclass(frozen=True, unsafe_hash=False) class ViewGame: - transaction: Transaction + transaction: ReadonlyTransaction game_views: GameViews log: GameLog diff --git a/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py index f4093a2..225ded6 100644 --- a/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py @@ -5,7 +5,7 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, @@ -27,7 +27,7 @@ @dataclass(frozen=True, unsafe_hash=False) class AcceptInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -40,6 +40,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: ( invitation_to_game, @@ -63,6 +67,7 @@ async def __call__( await self.log.no_invitation_to_game_to_accept( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_accept_view( user_id, invitation_to_game_id, ) @@ -86,6 +91,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_invited_user_to_accept_invitation_to_game_view( @@ -96,6 +102,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_accept( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_accept_view( invitation_to_game, user_id, @@ -107,6 +114,7 @@ async def __call__( invitation_to_game, error.users, ) ) + await self.transaction.commit() await ( self.views .users_already_in_game_to_accept_invitation_to_game_view( @@ -118,6 +126,7 @@ async def __call__( invitation_to_game, game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.accepted_invitation_to_game_view( invitation_to_game, game, ) diff --git a/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py index d2de8a4..b3430ae 100644 --- a/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 InvitationToGameDao, ) @@ -15,12 +15,16 @@ @dataclass(frozen=True, unsafe_hash=False) class AutoCancelInvitationsToGame: - transaction: Transaction + transaction: SerializableTransaction log: InvitationToGameLog clock: Clock invitation_to_game_dao: InvitationToGameDao async def __call__(self) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: expiration_datetime = await self.clock.current_datetime() auto_cancelled_invitations_to_game_ids = await ( diff --git a/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py index 7586559..ea0b313 100644 --- a/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py @@ -2,7 +2,7 @@ from uuid import UUID from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, ) @@ -22,7 +22,7 @@ @dataclass(frozen=True, unsafe_hash=False) class CancelInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -32,6 +32,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: invitation_to_game = await ( self.invitations_to_game.invitation_to_game_with_id( @@ -43,6 +47,7 @@ async def __call__( await self.log.no_invitation_to_game_to_cancel( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_cancel_view( user_id, invitation_to_game_id, ) @@ -58,6 +63,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_inviting_user_to_cancel_invitation_to_game_view( @@ -68,6 +74,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_cancel( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_cancel_view( invitation_to_game, user_id, @@ -78,6 +85,7 @@ async def __call__( invitation_to_game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.cancelled_invitation_to_game_view( invitation_to_game, ) diff --git a/src/ttt/application/invitation_to_game/game/invite_to_game.py b/src/ttt/application/invitation_to_game/game/invite_to_game.py index 494b932..363be5b 100644 --- a/src/ttt/application/invitation_to_game/game/invite_to_game.py +++ b/src/ttt/application/invitation_to_game/game/invite_to_game.py @@ -6,7 +6,7 @@ Map, NotUniqueActiveInvitationToGameUserIdsError, ) -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, @@ -27,20 +27,31 @@ class InviteToGame: map_: Map uuids: UUIDs - transaction: Transaction + transaction: SerializableTransaction clock: Clock users: Users user_views: CommonUserViews views: InvitationToGameViews log: InvitationToGameLog - async def __call__(self, user_id: int, invited_user_id: int) -> None: + async def __call__( + self, + user_id: int, + user_username: str | None, + invited_user_id: int, + invited_user_username: str | None, + ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, invited_user = await self.users.users_with_ids( (user_id, invited_user_id), ) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -56,14 +67,17 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: tracking = Tracking() invitation_to_game = invite_to_game( user, + user_username, invited_user, invited_user_id, + invited_user_username, invitation_to_game_id, current_datetime, tracking, ) except InvitationSelfToGameError: await self.log.invitation_self_to_game(user) + await self.transaction.commit() await self.views.invitation_self_to_game_view(user) return @@ -71,6 +85,7 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: await self.map_(tracking) except NotUniqueActiveInvitationToGameUserIdsError: await self.log.double_invitation_to_game(invitation_to_game) + await self.transaction.commit() await self.views.double_invitation_to_game_view( invitation_to_game, ) @@ -78,4 +93,5 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: await self.log.user_invited_other_user_to_game( invitation_to_game, ) + await self.transaction.commit() await self.views.invitation_to_game_view(invitation_to_game) diff --git a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py index 7c770c8..47ee6c5 100644 --- a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py +++ b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py @@ -10,4 +10,7 @@ async def set_auto_cancelled_where_invitation_datetime_le_and_active( self, datetime: datetime, /, - ) -> Sequence[UUID]: ... + ) -> Sequence[UUID]: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 diff --git a/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py index 7117aa7..6372e11 100644 --- a/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py @@ -2,7 +2,7 @@ from uuid import UUID from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, ) @@ -22,7 +22,7 @@ @dataclass(frozen=True, unsafe_hash=False) class RejectInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -32,6 +32,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: invitation_to_game = await ( self.invitations_to_game.invitation_to_game_with_id( @@ -43,6 +47,7 @@ async def __call__( await self.log.no_invitation_to_game_to_reject( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_reject_view( user_id, invitation_to_game_id, ) @@ -58,6 +63,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_invited_user_to_reject_invitation_to_game_view( @@ -68,6 +74,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_reject( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_reject_view( invitation_to_game, user_id, @@ -78,6 +85,7 @@ async def __call__( invitation_to_game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.rejected_invitation_to_game_view( invitation_to_game, ) diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py index 927ed49..b778869 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from uuid import UUID -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -10,7 +12,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewIncomingInvitationToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int, invitation_to_game_id: UUID) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py index 1a53067..f0f0086 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +11,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewIncomingInvitationsToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py index 28d21f5..e21adcb 100644 --- a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +11,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewOneIncomingInvitationToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py index 86cc1d3..a20cc66 100644 --- a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +11,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewOutcomingInvitationsToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py deleted file mode 100644 index d1c76c1..0000000 --- a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod - - -class CommonMatchmakingQueueLog(ABC): - @abstractmethod - async def waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: ... - - @abstractmethod - async def double_waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: ... - - @abstractmethod - async def user_already_in_game_to_add_to_matchmaking_queue( - self, user_id: int, /, - ) -> None: ... diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py deleted file mode 100644 index b525bde..0000000 --- a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC, abstractmethod - - -class CommonMatchmakingQueueViews(ABC): - @abstractmethod - async def waiting_for_game_view(self, user_id: int, /) -> None: ... - - @abstractmethod - async def double_waiting_for_game_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py b/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py deleted file mode 100644 index cf60682..0000000 --- a/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC -from collections.abc import Awaitable - -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, -) - - -class SharedMatchmakingQueue(ABC, Awaitable[MatchmakingQueue]): ... diff --git a/src/ttt/application/matchmaking_queue/game/wait_game.py b/src/ttt/application/matchmaking_queue/game/wait_game.py deleted file mode 100644 index 436455e..0000000 --- a/src/ttt/application/matchmaking_queue/game/wait_game.py +++ /dev/null @@ -1,103 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.emojis import Emojis -from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction -from ttt.application.common.ports.uuids import UUIDs -from ttt.application.game.game.ports.game_log import GameLog -from ttt.application.game.game.ports.game_views import GameViews -from ttt.application.game.game.ports.games import Games -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, -) -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, -) -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, -) -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - UserAlreadyWaitingForGameError, -) -from ttt.entities.core.user.user import UserAlreadyInGameError -from ttt.entities.tools.tracking import Tracking - - -@dataclass(frozen=True, unsafe_hash=False) -class WaitGame: - map_: Map - uuids: UUIDs - emojis: Emojis - transaction: Transaction - clock: Clock - users: Users - user_views: CommonUserViews - games: Games - game_views: GameViews - game_log: GameLog - shared_matchmaking_queue: SharedMatchmakingQueue - matchmaking_queue_views: CommonMatchmakingQueueViews - matchmaking_queue_log: CommonMatchmakingQueueLog - - async def __call__(self, user_id: int) -> None: - async with self.transaction: - user = await self.users.user_with_id(user_id) - - if user is None: - await self.user_views.user_is_not_registered_view(user_id) - return - - matchmaking_queue = await self.shared_matchmaking_queue - user_waiting_id = await self.uuids.random_uuid() - game_id = await self.uuids.random_uuid() - cell_id_matrix = await self.uuids.random_uuid_matrix((3, 3)) - user1_emoji = await self.emojis.random_emoji() - user2_emoji = await self.emojis.random_emoji() - current_datetime = await self.clock.current_datetime() - - try: - tracking = Tracking() - game = matchmaking_queue.add_user( - user, - user_waiting_id, - cell_id_matrix, - game_id, - user1_emoji, - user2_emoji, - current_datetime, - tracking, - ) - except UserAlreadyWaitingForGameError: - await self.matchmaking_queue_log.double_waiting_for_game_start( - user_id, - ) - await self.matchmaking_queue_views.double_waiting_for_game_view( - user_id, - ) - except UserAlreadyInGameError: - await ( - self.matchmaking_queue_log - .user_already_in_game_to_add_to_matchmaking_queue(user_id) - ) - await self.game_views.user_already_in_game_view(user_id) - else: - if game is None: - await self.matchmaking_queue_log.waiting_for_game_start( - user_id, - ) - await self.matchmaking_queue_views.waiting_for_game_view( - user_id, - ) - await self.map_(tracking) - return - - await self.game_log.game_against_user_started(game) - await self.map_(tracking) - - await self.game_views.started_game_view_with_locations( - game.locations(), - game, - ) diff --git a/src/ttt/application/matchmaking_queue/common/__init__.py b/src/ttt/application/stars_purchase/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/common/__init__.py rename to src/ttt/application/stars_purchase/__init__.py diff --git a/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py new file mode 100644 index 0000000..c8f36b4 --- /dev/null +++ b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.entities.finance.payment.payment import PaymentIsNotInProcessError +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class CompleteStarsPurchasePayment: + clock: Clock + users: Users + transaction: SerializableTransaction + map_: Map + common_views: CommonUserViews + stars_purchase_views: StarsPurchaseViews + log: StarsPurchaseLog + stars_purchases: StarsPurchases + + async def __call__( + self, + purchase_id: UUID, + success: PaymentSuccess, + ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + current_datetime = await self.clock.current_datetime() + + async with self.transaction: + stars_purchase = ( + await self.stars_purchases.stars_purchase_with_id( + purchase_id, + ) + ) + + if stars_purchase is None: + await self.log.no_stars_purchase_to_complete_payment( + purchase_id, + ) + await self.transaction.commit() + return + + try: + tracking = Tracking() + stars_purchase.complete_payment( + success, + current_datetime, + tracking, + ) + except PaymentIsNotInProcessError: + await self.log.double_stars_purchase_payment_completion( + stars_purchase, + success, + ) + await self.transaction.commit() + else: + await self.log.stars_purchase_payment_completed( + stars_purchase, + success, + ) + await self.map_(tracking) + await self.transaction.commit() + await ( + self.stars_purchase_views.completed_stars_purchase_view( + stars_purchase, + ) + ) diff --git a/src/ttt/application/matchmaking_queue/game/__init__.py b/src/ttt/application/stars_purchase/ports/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/game/__init__.py rename to src/ttt/application/stars_purchase/ports/__init__.py diff --git a/src/ttt/application/user/stars_purchase/ports/user_log.py b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py similarity index 61% rename from src/ttt/application/user/stars_purchase/ports/user_log.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_log.py index d4deca5..357e8a1 100644 --- a/src/ttt/application/user/stars_purchase/ports/user_log.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py @@ -1,53 +1,49 @@ from abc import ABC, abstractmethod from uuid import UUID -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment from ttt.entities.core.stars import Stars +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.success import PaymentSuccess -class StarsPurchaseUserLog(ABC): +class StarsPurchaseLog(ABC): @abstractmethod - async def user_intends_to_buy_stars( + async def stars_puchase_started( self, - user_id: int, + stars_purchase: StarsPurchase, /, ) -> None: ... @abstractmethod - async def user_started_stars_puchase( + async def stars_puchase_payment_started( self, - user: User, - /, - ) -> None: ... - - @abstractmethod - async def user_started_stars_puchase_payment( - self, - user: User, + stars_purchase: StarsPurchase, /, ) -> None: ... @abstractmethod async def stars_purchase_payment_completion_started( self, - payment: PaidStarsPurchasePayment, + purchase_id: UUID, + success: PaymentSuccess, /, ) -> None: ... @abstractmethod async def stars_purchase_payment_completed( self, - user: User, - payment: PaidStarsPurchasePayment, + stars_purchase: StarsPurchase, + success: PaymentSuccess, /, ) -> None: ... @abstractmethod async def double_stars_purchase_payment_completion( self, - user: User, - paid_payment: PaidStarsPurchasePayment, + stars_purchase: StarsPurchase, + success: PaymentSuccess, + /, ) -> None: ... @abstractmethod @@ -61,15 +57,20 @@ async def invalid_stars_for_stars_purchase( @abstractmethod async def double_stars_purchase_payment_start( self, - user: User, + stars_purchase: StarsPurchase, + /, + ) -> None: ... + + @abstractmethod + async def no_stars_purchase_to_start_payment( + self, purchase_id: UUID, /, ) -> None: ... @abstractmethod - async def no_purchase_to_start_stars_purchase_payment( + async def no_stars_purchase_to_complete_payment( self, - user: User, purchase_id: UUID, /, ) -> None: ... diff --git a/src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py similarity index 61% rename from src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py index 9f3e05c..013e457 100644 --- a/src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py @@ -1,9 +1,7 @@ from abc import ABC, abstractmethod -from collections.abc import AsyncIterable from uuid import UUID -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase class StarsPurchasePaymentGateway(ABC): @@ -18,8 +16,3 @@ async def stop_payment_due_to_dublicate(self, payment_id: UUID) -> None: ... @abstractmethod async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ... - - @abstractmethod - def paid_payment_stream( - self, - ) -> AsyncIterable[PaidStarsPurchasePayment]: ... diff --git a/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py b/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py new file mode 100644 index 0000000..62f16b8 --- /dev/null +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from ttt.entities.finance.payment.success import PaymentSuccess + + +class StarsPurchaseTasks(ABC): + @abstractmethod + async def complete_stars_purchase_payment( + self, + purchase_id: UUID, + success: PaymentSuccess, + /, + ) -> None: ... diff --git a/src/ttt/application/user/stars_purchase/ports/user_views.py b/src/ttt/application/stars_purchase/ports/stars_purchase_views.py similarity index 74% rename from src/ttt/application/user/stars_purchase/ports/user_views.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_views.py index 3469ec9..6c66381 100644 --- a/src/ttt/application/user/stars_purchase/ports/user_views.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_views.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from uuid import UUID -from ttt.entities.core.user.user import User +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase -class StarsPurchaseUserViews(ABC): +class StarsPurchaseViews(ABC): @abstractmethod async def invalid_stars_for_stars_purchase_view( self, @@ -22,7 +21,6 @@ async def stars_purchase_will_be_completed_view( @abstractmethod async def completed_stars_purchase_view( self, - user: User, - purchase_id: UUID, + stars_purchase: StarsPurchase, /, ) -> None: ... diff --git a/src/ttt/application/stars_purchase/ports/stars_purchases.py b/src/ttt/application/stars_purchase/ports/stars_purchases.py new file mode 100644 index 0000000..fab2882 --- /dev/null +++ b/src/ttt/application/stars_purchase/ports/stars_purchases.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase + + +class StarsPurchases(ABC): + @abstractmethod + async def stars_purchase_with_id( + self, + id_: UUID, + /, + ) -> StarsPurchase | None: ... diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase.py b/src/ttt/application/stars_purchase/start_stars_purchase.py similarity index 57% rename from src/ttt/application/user/stars_purchase/start_stars_purchase.py rename to src/ttt/application/stars_purchase/start_stars_purchase.py index bc69137..9fbf468 100644 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase.py @@ -3,21 +3,21 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, ) +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users from ttt.entities.core.stars import Stars -from ttt.entities.core.user.stars_purchase import ( +from ttt.entities.core.stars_purchase.stars_purchase import ( InvalidStarsForStarsPurchaseError, StarsPurchase, ) @@ -26,17 +26,21 @@ @dataclass(frozen=True, unsafe_hash=False) class StartStarsPurchase: - transaction: Transaction + transaction: SerializableTransaction users: Users uuids: UUIDs clock: Clock common_views: CommonUserViews - stars_purchase_views: StarsPurchaseUserViews + stars_purchase_views: StarsPurchaseViews payment_gateway: StarsPurchasePaymentGateway map_: Map - log: StarsPurchaseUserLog + log: StarsPurchaseLog async def __call__(self, user_id: int, stars: Stars) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, purchase_id = await gather( self.users.user_with_id(user_id), @@ -44,32 +48,28 @@ async def __call__(self, user_id: int, stars: Stars) -> None: ) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return - tracking = Tracking() try: - user.start_stars_purchase( - purchase_id, - stars, - tracking, + tracking = Tracking() + stars_purchase = ( + StarsPurchase.start(purchase_id, user, stars, tracking) ) except InvalidStarsForStarsPurchaseError: await self.log.invalid_stars_for_stars_purchase( user, stars, ) + await self.transaction.commit() await ( self.stars_purchase_views .invalid_stars_for_stars_purchase_view(user_id) ) return - - await self.log.user_started_stars_puchase(user) - - await self.map_(tracking) - await gather(*[ - self.payment_gateway.send_invoice(it) - for it in tracking.new - if isinstance(it, StarsPurchase) - ]) + else: + await self.log.stars_puchase_started(stars_purchase) + await self.map_(tracking) + await self.payment_gateway.send_invoice(stars_purchase) + await self.transaction.commit() diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py new file mode 100644 index 0000000..37fa8b4 --- /dev/null +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py @@ -0,0 +1,74 @@ +from asyncio import gather +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.retry import Retry +from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, +) +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases +from ttt.entities.finance.payment.payment import PaymentIsAlreadyBeingMadeError +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class StartStarsPurchasePayment: + transaction: SerializableTransaction + uuids: UUIDs + clock: Clock + stars_purchases: StarsPurchases + payment_gateway: StarsPurchasePaymentGateway + map_: Map + log: StarsPurchaseLog + retry: Retry + + async def __call__(self, purchase_id: UUID) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + async with self.transaction: + stars_purchase, payment_id, current_datetime = await gather( + self.stars_purchases.stars_purchase_with_id(purchase_id), + self.uuids.random_uuid(), + self.clock.current_datetime(), + ) + + if stars_purchase is None: + await self.log.no_stars_purchase_to_start_payment(purchase_id) + await self.payment_gateway.stop_payment_due_to_error(payment_id) + return + + try: + tracking = Tracking() + stars_purchase.start_payment( + payment_id, + current_datetime, + tracking, + ) + except PaymentIsAlreadyBeingMadeError: + await self.log.double_stars_purchase_payment_start( + stars_purchase, + ) + await self.transaction.commit() + + if self.retry: + await self.payment_gateway.start_payment(payment_id) + else: + await self.payment_gateway.stop_payment_due_to_dublicate( + payment_id, + ) + else: + await self.log.stars_puchase_payment_started( + stars_purchase, + ) + await self.map_(tracking) + await self.transaction.commit() + await self.payment_gateway.start_payment(payment_id) diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py new file mode 100644 index 0000000..ddbb4b3 --- /dev/null +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, +) +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.entities.finance.payment.success import PaymentSuccess + + +@dataclass(frozen=True, unsafe_hash=False) +class StartStarsPurchasePaymentCompletion: + tasks: StarsPurchaseTasks + views: StarsPurchaseViews + log: StarsPurchaseLog + + async def __call__( + self, + user_id: int, + purchase_id: UUID, + success: PaymentSuccess, + ) -> None: + await self.tasks.complete_stars_purchase_payment( + purchase_id, success, + ) + await self.log.stars_purchase_payment_completion_started( + purchase_id, success, + ) + await self.views.stars_purchase_will_be_completed_view(user_id) diff --git a/src/ttt/application/user/authorize_as_admin.py b/src/ttt/application/user/authorize_as_admin.py index f98e4e0..dcdfb93 100644 --- a/src/ttt/application/user/authorize_as_admin.py +++ b/src/ttt/application/user/authorize_as_admin.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.original_admin_token import ( OriginalAdminToken, ) @@ -19,7 +19,7 @@ @dataclass(frozen=True, unsafe_hash=False) class AuthorizeAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog @@ -27,6 +27,10 @@ class AuthorizeAsAdmin: views: CommonUserViews async def __call__(self, user_id: int, admin_token: Token) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, original_admin_token = await gather( self.users.user_with_id(user_id), @@ -34,6 +38,7 @@ async def __call__(self, user_id: int, admin_token: Token) -> None: ) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -44,15 +49,18 @@ async def __call__(self, user_id: int, admin_token: Token) -> None: ) except UserAlreadyAdminError: await self.log.user_already_admin_to_get_admin_rights(user) + await self.transaction.commit() await self.views.user_already_admin_to_get_admin_rights_view( user, ) except AdminTokenMismatchError: await self.log.admin_token_mismatch_to_get_admin_rights(user) + await self.transaction.commit() await self.views.admin_token_mismatch_to_get_admin_rights_view( user, ) else: await self.log.user_authorized_as_admin(user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_authorized_as_admin_view(user) diff --git a/src/ttt/application/user/authorize_other_user_as_admin.py b/src/ttt/application/user/authorize_other_user_as_admin.py index 17bce3a..436e53f 100644 --- a/src/ttt/application/user/authorize_other_user_as_admin.py +++ b/src/ttt/application/user/authorize_other_user_as_admin.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -14,19 +14,24 @@ @dataclass(frozen=True, unsafe_hash=False) class AuthorizeOtherUserAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int, other_user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), ) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -42,6 +47,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin_view( @@ -55,6 +61,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .other_user_already_admin_to_authorize_other_user_as_admin_view( @@ -66,6 +73,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_authorized_other_user_as_admin_view( user, other_user, ) diff --git a/src/ttt/application/user/change_other_user_account/change_other_user_account.py b/src/ttt/application/user/change_other_user_account/change_other_user_account.py index 367a8e9..d8223d3 100644 --- a/src/ttt/application/user/change_other_user_account/change_other_user_account.py +++ b/src/ttt/application/user/change_other_user_account/change_other_user_account.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) @@ -20,7 +20,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ChangeOtherUserAccount: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: ChangeOtherUserAccountLog @@ -33,12 +33,17 @@ async def __call__( other_user_id: int, other_user_account_stars_vector: Stars, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), ) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return @@ -57,6 +62,7 @@ async def __call__( other_user_id, other_user_account_stars_vector, ) + await self.transaction.commit() await self.common_views.user_is_not_admin_view(user) except NegativeAccountError: await self.log.negative_account_on_change_other_user_account( @@ -65,6 +71,7 @@ async def __call__( other_user_id, other_user_account_stars_vector, ) + await self.transaction.commit() await ( self.views .negative_account_on_change_other_user_account_view( @@ -79,6 +86,7 @@ async def __call__( user, other_user, other_user_account_stars_vector, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_changed_other_user_account_view( user, other_user, other_user_account_stars_vector, ) diff --git a/src/ttt/application/user/change_other_user_account/set_other_user_account.py b/src/ttt/application/user/change_other_user_account/set_other_user_account.py index 63bc1db..9c6216b 100644 --- a/src/ttt/application/user/change_other_user_account/set_other_user_account.py +++ b/src/ttt/application/user/change_other_user_account/set_other_user_account.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) @@ -18,7 +18,7 @@ @dataclass(frozen=True, unsafe_hash=False) class SetOtherUserAccount: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: ChangeOtherUserAccountLog @@ -31,6 +31,10 @@ async def __call__( other_user_id: int, other_user_account_stars: Stars, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), @@ -55,6 +59,7 @@ async def __call__( other_user_id, other_user_account_stars, ) + await self.transaction.commit() await self.common_views.user_is_not_admin_view(user) except NegativeAccountError: await self.log.negative_account_on_set_other_user_account( @@ -63,6 +68,7 @@ async def __call__( other_user_id, other_user_account_stars, ) + await self.transaction.commit() await ( self.views.negative_account_on_set_other_user_account_view( user, @@ -74,6 +80,7 @@ async def __call__( else: await self.log.user_set_other_user_account(user, other_user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_set_other_user_account_view( user, other_user, diff --git a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py index 3848008..eb97d85 100644 --- a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py +++ b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.change_other_user_account.ports.user_views import ( ChangeOtherUserAccountViews, ) @@ -8,7 +10,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewUserAccountToChange: - transaction: Transaction + transaction: ReadonlyTransaction views: ChangeOtherUserAccountViews async def __call__( diff --git a/src/ttt/application/user/common/dto/common.py b/src/ttt/application/user/common/dto/common.py deleted file mode 100644 index b0711f9..0000000 --- a/src/ttt/application/user/common/dto/common.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from ttt.entities.finance.payment.success import PaymentSuccess - - -@dataclass(frozen=True) -class PaidStarsPurchasePayment: - purchase_id: UUID - user_id: int - success: PaymentSuccess diff --git a/src/ttt/application/user/common/ports/user_locks.py b/src/ttt/application/user/common/ports/user_locks.py new file mode 100644 index 0000000..bfc811e --- /dev/null +++ b/src/ttt/application/user/common/ports/user_locks.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + + +class UserLocks(ABC): + @abstractmethod + async def lock_user_by_id( + self, + user_id: int, + /, + ) -> None: ... diff --git a/src/ttt/application/user/common/ports/users.py b/src/ttt/application/user/common/ports/users.py index 35d470b..4dab335 100644 --- a/src/ttt/application/user/common/ports/users.py +++ b/src/ttt/application/user/common/ports/users.py @@ -2,7 +2,9 @@ from collections.abc import Sequence from typing import overload +from ttt.entities.core.user.rank import UsersWithMaxRating from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating class Users(ABC): @@ -38,3 +40,13 @@ async def users_with_ids( ids: Sequence[int], /, ) -> tuple[User | None, ...]: ... + + @abstractmethod + async def some_users_waiting_for_matchmaking_to_matchmake( + self, + ) -> list[User]: ... + + @abstractmethod + async def max_rating_and_users_with_max_rating( + self, + ) -> tuple[EloRating, UsersWithMaxRating]: ... diff --git a/src/ttt/application/user/deauthorize_other_user_as_admin.py b/src/ttt/application/user/deauthorize_other_user_as_admin.py index 5ce8ea7..0e46cc5 100644 --- a/src/ttt/application/user/deauthorize_other_user_as_admin.py +++ b/src/ttt/application/user/deauthorize_other_user_as_admin.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -14,13 +14,17 @@ @dataclass(frozen=True, unsafe_hash=False) class DeauthorizeOtherUserAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int, other_user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), @@ -40,6 +44,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin_view( @@ -53,6 +58,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize_view( @@ -64,6 +70,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_deauthorized_other_user_as_admin_view( user, other_user, ) diff --git a/src/ttt/application/user/emoji_purchase/buy_emoji.py b/src/ttt/application/user/emoji_purchase/buy_emoji.py index a431b54..d059246 100644 --- a/src/ttt/application/user/emoji_purchase/buy_emoji.py +++ b/src/ttt/application/user/emoji_purchase/buy_emoji.py @@ -3,7 +3,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -25,7 +25,7 @@ class BuyEmoji: uuids: UUIDs clock: Clock - transaction: Transaction + transaction: SerializableTransaction users: Users common_views: CommonUserViews emoji_purchase_views: EmojiPurchaseUserViews @@ -37,6 +37,10 @@ async def __call__( user_id: int, emoji_str: str | None, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + if emoji_str is None: await self.emoji_purchase_views.invalid_emoji_to_buy_view(user_id) return @@ -56,6 +60,7 @@ async def __call__( user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return @@ -69,10 +74,12 @@ async def __call__( ) except EmojiAlreadyPurchasedError: await self.log.emoji_already_purchased_to_buy(user, emoji) + await self.transaction.commit() await self.emoji_purchase_views.emoji_already_purchased_view( user_id, ) except NotEnoughStarsError as error: + await self.transaction.commit() await ( self.emoji_purchase_views .not_enough_stars_to_buy_emoji_view( @@ -82,8 +89,8 @@ async def __call__( ) else: await self.log.user_bought_emoji(user, emoji) - await self.map_(tracking) + await self.transaction.commit() await self.emoji_purchase_views.emoji_was_purchased_view( user_id, ) diff --git a/src/ttt/application/user/emoji_selection/select_emoji.py b/src/ttt/application/user/emoji_selection/select_emoji.py index c868094..cebfbce 100644 --- a/src/ttt/application/user/emoji_selection/select_emoji.py +++ b/src/ttt/application/user/emoji_selection/select_emoji.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.application.user.emoji_selection.ports.user_log import ( @@ -17,7 +17,7 @@ @dataclass(frozen=True, unsafe_hash=False) class SelectEmoji: - transaction: Transaction + transaction: SerializableTransaction users: Users user_views: CommonUserViews emoji_selection_views: EmojiSelectionUserViews @@ -29,6 +29,10 @@ async def __call__( user_id: int, emoji_str: str, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + try: emoji = Emoji(emoji_str) except InvalidEmojiError: @@ -42,6 +46,7 @@ async def __call__( user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -50,11 +55,12 @@ async def __call__( user.select_emoji(emoji, tracking) except EmojiNotPurchasedError: await self.log.emoji_not_purchased_to_select(user, emoji) + await self.transaction.commit() await ( self.emoji_selection_views .emoji_not_purchased_to_select_view(user_id) ) else: await self.log.user_selected_emoji(user, emoji) - await self.map_(tracking) + await self.transaction.commit() diff --git a/src/ttt/application/user/common/dto/__init__.py b/src/ttt/application/user/game/__init__.py similarity index 100% rename from src/ttt/application/user/common/dto/__init__.py rename to src/ttt/application/user/game/__init__.py diff --git a/src/ttt/application/user/game/dont_wait_for_matchmaking.py b/src/ttt/application/user/game/dont_wait_for_matchmaking.py new file mode 100644 index 0000000..70dafbd --- /dev/null +++ b/src/ttt/application/user/game/dont_wait_for_matchmaking.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.user.user import UserIsNotWaitingForMatchmakingError +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class DontWaitForMatchmaking: + map_: Map + transaction: SerializableTransaction + users: Users + user_views: CommonUserViews + views: GameUserViews + log: GameUserLog + + async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + async with self.transaction: + user = await self.users.user_with_id(user_id) + + if user is None: + await self.transaction.commit() + await self.user_views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.dont_wait_for_matchmaking(tracking) + except UserIsNotWaitingForMatchmakingError: + await self.log.user_is_not_waiting_for_matchmaking_to_dont_wait( + user, + ) + await self.transaction.commit() + await ( + self.views + .user_is_not_waiting_for_matchmaking_to_dont_wait_view(user) + ) + else: + await self.log.user_is_not_waiting_for_matchmaking(user) + await self.map_(tracking) + await self.transaction.commit() + await self.views.user_is_not_waiting_for_matchmaking_view(user) diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py new file mode 100644 index 0000000..9d5c9e7 --- /dev/null +++ b/src/ttt/application/user/game/matchmake.py @@ -0,0 +1,81 @@ +from asyncio import gather +from contextlib import suppress +from dataclasses import dataclass + +from ttt.application.common.ports.emojis import Emojis +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import NotSerializableTransaction +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.matchmaking import MatchmakingInput, matchmaking +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class Matchmake: + map_: Map + transaction: NotSerializableTransaction + users: Users + log: GameUserLog + views: GameUserViews + uuids: UUIDs + emojis: Emojis + + async def __call__(self) -> None: + async with self.transaction: + tracking = Tracking() + games = await self._result(tracking) + + await self.log.games_were_matched(games) + await gather( + self.views.matched_games_view(games), + self._output_tracking(tracking), + ) + + async def _output_tracking(self, tracking: Tracking) -> None: + await self.map_(tracking) + await self.transaction.commit() + + async def _result(self, tracking: Tracking) -> list[Game]: + users, input_ = await gather( + self.users.some_users_waiting_for_matchmaking_to_matchmake(), + self._matchmaking_input(), + ) + max_rating, users_with_max_rating = ( + await self.users.max_rating_and_users_with_max_rating() + ) + + games = list[Game]() + matchmaking_ = matchmaking( + users, input_, max_rating, users_with_max_rating, tracking, + ) + + with suppress(StopIteration): + games.append(next(matchmaking_)) + + while True: + games.append(matchmaking_.send(await self._matchmaking_input())) + + return games + + async def _matchmaking_input(self) -> MatchmakingInput: + ( + cell_id_matrix, + game_id, + player1_random_emoji, + player2_random_emoji, + ) = await gather( + self.uuids.random_uuid_matrix((3, 3)), + self.uuids.random_uuid(), + self.emojis.random_emoji(), + self.emojis.random_emoji(), + ) + return MatchmakingInput( + cell_id_matrix, + game_id, + player1_random_emoji, + player2_random_emoji, + ) diff --git a/src/ttt/application/user/stars_purchase/__init__.py b/src/ttt/application/user/game/ports/__init__.py similarity index 100% rename from src/ttt/application/user/stars_purchase/__init__.py rename to src/ttt/application/user/game/ports/__init__.py diff --git a/src/ttt/application/user/game/ports/user_log.py b/src/ttt/application/user/game/ports/user_log.py new file mode 100644 index 0000000..5fefeb0 --- /dev/null +++ b/src/ttt/application/user/game/ports/user_log.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User + + +class GameUserLog(ABC): + @abstractmethod + async def user_is_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_already_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_in_game_to_wait_for_matchmaking( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def games_were_matched(self, games: list[Game], /) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_to_dont_wait( + self, user: User, /, + ) -> None: ... diff --git a/src/ttt/application/user/game/ports/user_views.py b/src/ttt/application/user/game/ports/user_views.py new file mode 100644 index 0000000..fc1b912 --- /dev/null +++ b/src/ttt/application/user/game/ports/user_views.py @@ -0,0 +1,41 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User + + +class GameUserViews(ABC): + @abstractmethod + async def user_is_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_already_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_in_game_to_wait_for_matchmaking_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def matched_games_view(self, games: list[Game], /) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_to_dont_wait_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def matchmaking_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/user/game/view_matchmaking.py b/src/ttt/application/user/game/view_matchmaking.py new file mode 100644 index 0000000..32d24d1 --- /dev/null +++ b/src/ttt/application/user/game/view_matchmaking.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) +from ttt.application.user.game.ports.user_views import GameUserViews + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewMatchmaking: + transaction: ReadonlyTransaction + views: GameUserViews + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + await self.views.matchmaking_view(user_id) diff --git a/src/ttt/application/user/game/wait_for_matchmaking.py b/src/ttt/application/user/game/wait_for_matchmaking.py new file mode 100644 index 0000000..df1ca08 --- /dev/null +++ b/src/ttt/application/user/game/wait_for_matchmaking.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.user.user import ( + UserAlreadyWaitingForMatchmakingError, + UserIsInGameError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class WaitForMatchmaking: + map_: Map + transaction: SerializableTransaction + clock: Clock + users: Users + user_views: CommonUserViews + views: GameUserViews + log: GameUserLog + + async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + async with self.transaction: + user = await self.users.user_with_id(user_id) + + if user is None: + await self.transaction.commit() + await self.user_views.user_is_not_registered_view(user_id) + return + + current_datetime = await self.clock.current_datetime() + + try: + tracking = Tracking() + user.wait_for_matchmaking(current_datetime, tracking) + except UserAlreadyWaitingForMatchmakingError: + await self.log.user_is_already_waiting_for_matchmaking(user) + await self.transaction.commit() + await self.views.user_is_already_waiting_for_matchmaking_view( + user, + ) + except UserIsInGameError: + await self.log.user_is_in_game_to_wait_for_matchmaking(user) + await self.transaction.commit() + await self.views.user_is_in_game_to_wait_for_matchmaking_view( + user, + ) + else: + await self.log.user_is_waiting_for_matchmaking(user) + await self.map_(tracking) + await self.transaction.commit() + await self.views.user_is_waiting_for_matchmaking_view(user) diff --git a/src/ttt/application/user/register_user.py b/src/ttt/application/user/register_user.py index f54efd0..ac6eb92 100644 --- a/src/ttt/application/user/register_user.py +++ b/src/ttt/application/user/register_user.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map, NotUniqueUserIdError -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.entities.core.user.user import register_user from ttt.entities.tools.tracking import Tracking @@ -9,11 +9,15 @@ @dataclass(frozen=True, unsafe_hash=False) class RegisterUser: - transaction: Transaction + transaction: SerializableTransaction map_: Map log: CommonUserLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + tracking = Tracking() user = register_user(user_id, tracking) diff --git a/src/ttt/application/user/relinquish_admin_right.py b/src/ttt/application/user/relinquish_admin_right.py index bd17fd2..8b40534 100644 --- a/src/ttt/application/user/relinquish_admin_right.py +++ b/src/ttt/application/user/relinquish_admin_right.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -11,17 +11,22 @@ @dataclass(frozen=True, unsafe_hash=False) class RelinquishAdminRight: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -30,8 +35,10 @@ async def __call__(self, user_id: int) -> None: user.relinquish_admin_right(tracking) except NotAdminError: await self.log.not_admin_to_relinquish_admin_right(user) + await self.transaction.commit() await self.views.not_admin_to_relinquish_admin_right_view(user) else: await self.log.user_relinquished_admin_rights(user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_relinquished_admin_rights_view(user) diff --git a/src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py b/src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py deleted file mode 100644 index 1d04221..0000000 --- a/src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py +++ /dev/null @@ -1,72 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) -from ttt.entities.finance.payment.payment import PaymentIsNotInProcessError -from ttt.entities.tools.tracking import Tracking - - -@dataclass(frozen=True, unsafe_hash=False) -class CompleteStarsPurchasePayment: - clock: Clock - inbox: PaidStarsPurchasePaymentInbox - users: Users - transaction: Transaction - map_: Map - common_views: CommonUserViews - stars_purchase_views: StarsPurchaseUserViews - log: StarsPurchaseUserLog - - async def __call__(self) -> None: - async for paid_payment in self.inbox.stream(): - current_datetime = await self.clock.current_datetime() - - async with self.transaction: - user = await self.users.user_with_id( - paid_payment.user_id, - ) - - if user is None: - await self.common_views.user_is_not_registered_view( - paid_payment.user_id, - ) - continue - - tracking = Tracking() - try: - user.complete_stars_purchase_payment( - paid_payment.purchase_id, - paid_payment.success, - current_datetime, - tracking, - ) - except PaymentIsNotInProcessError: - await self.log.double_stars_purchase_payment_completion( - user, - paid_payment, - ) - else: - await self.log.stars_purchase_payment_completed( - user, - paid_payment, - ) - - await self.map_(tracking) - await ( - self.stars_purchase_views.completed_stars_purchase_view( - user, - paid_payment.purchase_id, - ) - ) diff --git a/src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py b/src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index 11c96f1..0000000 --- a/src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,12 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import AsyncIterable - -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment - - -class PaidStarsPurchasePaymentInbox(ABC): - @abstractmethod - async def push(self, payment: PaidStarsPurchasePayment) -> None: ... - - @abstractmethod - def stream(self) -> AsyncIterable[PaidStarsPurchasePayment]: ... diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py b/src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py deleted file mode 100644 index 8e41b4d..0000000 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py +++ /dev/null @@ -1,68 +0,0 @@ -from asyncio import gather -from dataclasses import dataclass -from uuid import UUID - -from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction -from ttt.application.common.ports.uuids import UUIDs -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, -) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) -from ttt.entities.core.user.user import NoPurchaseError -from ttt.entities.finance.payment.payment import PaymentIsAlreadyBeingMadeError -from ttt.entities.tools.assertion import not_none -from ttt.entities.tools.tracking import Tracking - - -@dataclass(frozen=True, unsafe_hash=False) -class StartStarsPurchasePayment: - transaction: Transaction - uuids: UUIDs - clock: Clock - users: Users - payment_gateway: StarsPurchasePaymentGateway - map_: Map - log: StarsPurchaseUserLog - - async def __call__(self, user_id: int, purchase_id: UUID) -> None: - async with self.transaction: - user, payment_id, current_datetime = await gather( - self.users.user_with_id(user_id), - self.uuids.random_uuid(), - self.clock.current_datetime(), - ) - user = not_none(user) - - tracking = Tracking() - try: - user.start_stars_purchase_payment( - purchase_id, - payment_id, - current_datetime, - tracking, - ) - except PaymentIsAlreadyBeingMadeError: - await self.log.double_stars_purchase_payment_start( - user, - purchase_id, - ) - await self.payment_gateway.stop_payment_due_to_dublicate( - payment_id, - ) - except NoPurchaseError: - await self.log.no_purchase_to_start_stars_purchase_payment( - user, - purchase_id, - ) - await self.payment_gateway.stop_payment_due_to_error( - payment_id, - ) - else: - await self.log.user_started_stars_puchase_payment(user) - await self.map_(tracking) - await self.payment_gateway.start_payment(payment_id) diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py deleted file mode 100644 index 914ced8..0000000 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py +++ /dev/null @@ -1,32 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, -) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class StartStarsPurchasePaymentCompletion: - inbox: PaidStarsPurchasePaymentInbox - payment_gateway: StarsPurchasePaymentGateway - views: StarsPurchaseUserViews - log: StarsPurchaseUserLog - - async def __call__(self) -> None: - async for paid_payment in self.payment_gateway.paid_payment_stream(): - await self.inbox.push(paid_payment) - await self.views.stars_purchase_will_be_completed_view( - paid_payment.user_id, - ) - await self.log.stars_purchase_payment_completion_started( - paid_payment, - ) diff --git a/src/ttt/application/user/view_admin_menu.py b/src/ttt/application/user/view_admin_menu.py index f9e51b8..0d749fa 100644 --- a/src/ttt/application/user/view_admin_menu.py +++ b/src/ttt/application/user/view_admin_menu.py @@ -1,13 +1,15 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewAdminMenu: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/user/view_main_menu.py b/src/ttt/application/user/view_main_menu.py index e798f21..1bef4a5 100644 --- a/src/ttt/application/user/view_main_menu.py +++ b/src/ttt/application/user/view_main_menu.py @@ -1,13 +1,15 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewMainMenu: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/user/view_other_user.py b/src/ttt/application/user/view_other_user.py index 58fbd75..9490d9d 100644 --- a/src/ttt/application/user/view_other_user.py +++ b/src/ttt/application/user/view_other_user.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -10,7 +12,7 @@ class ViewOtherUser: views: CommonUserViews users: Users - transaction: Transaction + transaction: ReadonlyTransaction log: CommonUserLog async def __call__(self, user_id: int, other_user_id: int) -> None: diff --git a/src/ttt/application/user/view_user.py b/src/ttt/application/user/view_user.py index 1cc5020..0df060a 100644 --- a/src/ttt/application/user/view_user.py +++ b/src/ttt/application/user/view_user.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews @@ -8,7 +10,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewUser: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction log: CommonUserLog async def __call__(self, user_id: int) -> None: diff --git a/src/ttt/application/user/view_user_emojis.py b/src/ttt/application/user/view_user_emojis.py index 27925a0..6e644f1 100644 --- a/src/ttt/application/user/view_user_emojis.py +++ b/src/ttt/application/user/view_user_emojis.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewUserEmojis: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/entities/atomic.py b/src/ttt/entities/atomic.py index 1b87167..abe044a 100644 --- a/src/ttt/entities/atomic.py +++ b/src/ttt/entities/atomic.py @@ -2,9 +2,7 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueueAtomic, -) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import PaymentAtomic @@ -12,7 +10,7 @@ type Atomic = ( GameAtomic | UserAtomic - | MatchmakingQueueAtomic + | StarsPurchaseAtomic | InvitationToGameAtomic | PaymentAtomic ) diff --git a/src/ttt/entities/core/game/cell_number.py b/src/ttt/entities/core/game/cell_number.py index da24c0f..3c2c31f 100644 --- a/src/ttt/entities/core/game/cell_number.py +++ b/src/ttt/entities/core/game/cell_number.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import ClassVar, cast +from typing import ClassVar from ttt.entities.math.vector import Vector from ttt.entities.tools.assertion import assert_, not_none @@ -44,9 +44,9 @@ def __int__(self) -> int: @classmethod def of_board_position(cls, board_position: Vector) -> "CellNumber": int_ = CellNumber._int_by_board_position.get(board_position) - int_ = cast(int, not_none(int_, InvalidCellNumberError)) + int_ = not_none(int_, InvalidCellNumberError) return CellNumber(int_) def board_position(self) -> "Vector": - return cast(Vector, CellNumber._board_position_by_int[int(self)]) + return CellNumber._board_position_by_int[int(self)] diff --git a/src/ttt/entities/core/game/game.py b/src/ttt/entities/core/game/game.py index 3fe57cf..1c96b8c 100644 --- a/src/ttt/entities/core/game/game.py +++ b/src/ttt/entities/core/game/game.py @@ -28,11 +28,11 @@ PlayerLoss, PlayerWin, ) -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import ( User, UserAlreadyInGameError, ) +from ttt.entities.elo.rating import GamesPlayed from ttt.entities.math.matrix import Matrix from ttt.entities.math.random import Random, choice from ttt.entities.math.vector import Vector @@ -83,10 +83,6 @@ class OnlyAiGameError(Exception): ... class NotAiCurrentMoveError(Exception): ... -def number_of_unfilled_cells(board: Matrix[Cell]) -> int: - return sum(int(not cell.is_filled()) for cell in chain.from_iterable(board)) - - @dataclass class Game: """ @@ -104,13 +100,12 @@ class Game: player2: Player player2_emoji: Emoji board: Board - number_of_unfilled_cells: int result: GameResult | None state: GameState def __post_init__(self) -> None: assert_( - not all(isinstance(player, Ai) for player in self._players()), + not all(isinstance(player, Ai) for player in self.players()), else_=OnlyAiGameError, ) @@ -127,12 +122,6 @@ def __post_init__(self) -> None: ) assert_(is_cell_order_ok, else_=InvalidCellOrderError) - board = self.board - assert_( - number_of_unfilled_cells(board) == self.number_of_unfilled_cells, - else_=InvalidNumberOfUnfilledCellsError, - ) - def is_against_ai(self) -> bool: return isinstance(self.player1, Ai) or isinstance(self.player2, Ai) @@ -140,7 +129,7 @@ def is_against_user(self) -> bool: return not self.is_against_ai() def user(self, user_id: int) -> User | None: - for user in self._users(): + for user in self.users(): if user.id == user_id: return user @@ -152,34 +141,30 @@ def is_completed(self) -> bool: def cancel( self, user_id: int, - user1_last_game_id: UUID, - user2_last_game_id: UUID, tracking: Tracking, ) -> None: """ :raises ttt.entities.core.game.game.AlreadyCompletedGameError: :raises ttt.entities.core.game.game.NotPlayerError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ none(self.result, else_=AlreadyCompletedGameError) canceler = not_none(self.user(user_id), else_=NotPlayerError) if isinstance(self.player1, User): - self.player1.leave_game(user1_last_game_id, self.id, tracking) + self.player1.leave_game(tracking) if isinstance(self.player2, User): - self.player2.leave_game(user2_last_game_id, self.id, tracking) + self.player2.leave_game(tracking) self.result = CancelledGameResult(canceler.id) self.state = GameState.completed tracking.register_mutated(self) - def make_user_move( # noqa: C901, PLR0913, PLR0917 + def make_user_move( # noqa: C901 self, user_id: int, cell_number_int: int, - current_user_last_game_id: UUID, - not_current_user_last_game_id: UUID, + games_played_by_player_id: dict[int, GamesPlayed], player_win_random: Random, tracking: Tracking, ) -> UserMove: @@ -198,10 +183,10 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 if not isinstance(current_player, User): raise TypeError - not_current_player = not_none(self._not_current_player()) + not_current_player = not_none(self.not_current_player()) assert_( - user_id in {user.id for user in self._users()}, + user_id in {user.id for user in self.users()}, else_=NotPlayerError(), ) assert_(current_player.id == user_id, else_=NotCurrentPlayerError()) @@ -219,7 +204,6 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 raise NoCellError from error cell.fill_as_user(user_id, tracking) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell.board_position): @@ -230,21 +214,17 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 case User(): win = current_player.win_against_user( not_current_player.rating, + games_played_by_player_id[current_player.id], player_win_random, - current_user_last_game_id, - self.id, tracking, ) loss = not_current_player.lose_to_user( current_player.rating, - not_current_user_last_game_id, - self.id, + games_played_by_player_id[not_current_player.id], tracking, ) case Ai(): - win = current_player.win_against_ai( - current_user_last_game_id, self.id, tracking, - ) + win = current_player.win_against_ai(tracking) loss = not_current_player.lose() self._complete_as_decided(win, loss, tracking) @@ -257,20 +237,16 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 case User(): draw1 = current_player.be_draw_against_user( not_current_player.rating, - current_user_last_game_id, - self.id, + games_played_by_player_id[current_player.id], tracking, ) draw2 = not_current_player.be_draw_against_user( not_current_player.rating, - not_current_user_last_game_id, - self.id, + games_played_by_player_id[not_current_player.id], tracking, ) case Ai(): - draw1 = current_player.be_draw_against_ai( - current_user_last_game_id, self.id, tracking, - ) + draw1 = current_player.be_draw_against_ai(tracking) draw2 = not_current_player.be_draw() self._complete_as_draw(draw1, draw2, tracking) @@ -292,7 +268,6 @@ def make_ai_move( self, ai_id: UUID, cell_number_int: int | None, - not_current_user_last_game_id: UUID, free_cell_random: Random, tracking: Tracking, ) -> AiMove: @@ -308,7 +283,7 @@ def make_ai_move( if not isinstance(current_player, Ai): raise NotAiCurrentMoveError - not_current_player = self._not_current_player() + not_current_player = self.not_current_player() if not isinstance(not_current_player, User): raise TypeError @@ -316,7 +291,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -327,7 +301,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -340,7 +313,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -351,26 +323,20 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell_position): win = current_player.win() - loss = not_current_player.lose_to_ai( - not_current_user_last_game_id, self.id, tracking, - ) + loss = not_current_player.lose_to_ai(tracking) self._complete_as_decided(win, loss, tracking) elif not self._can_continue(): draw1 = current_player.be_draw() - draw2 = not_current_player.be_draw_against_ai( - not_current_user_last_game_id, self.id, tracking, - ) + draw2 = not_current_player.be_draw_against_ai(tracking) self._complete_as_draw(draw1, draw2, tracking) else: @@ -404,34 +370,25 @@ def is_player_move_expected(self, player_id: int | UUID) -> bool: case _: raise ValueError(self.state, player_id) - def locations(self) -> tuple[UserGameLocation, ...]: - return tuple(not_none(user.game_location) for user in self._users()) - def _make_random_ai_move( self, current_player: Ai, not_current_player: User, - not_current_user_last_game_id: UUID, free_cell_random: Random, tracking: Tracking, ) -> AiMove: cell = choice(self._free_cells(), random=free_cell_random) cell.fill_as_ai(current_player.id, tracking) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell.board_position): win = current_player.win() - loss = not_current_player.lose_to_ai( - not_current_user_last_game_id, self.id, tracking, - ) + loss = not_current_player.lose_to_ai(tracking) self._complete_as_decided(win, loss, tracking) elif not self._can_continue(): draw1 = current_player.be_draw() - draw2 = not_current_player.be_draw_against_ai( - not_current_user_last_game_id, self.id, tracking, - ) + draw2 = not_current_player.be_draw_against_ai(tracking) self._complete_as_draw(draw1, draw2, tracking) else: @@ -448,7 +405,7 @@ def _can_continue(self) -> bool: return not self._is_board_filled() def _is_board_filled(self) -> bool: - return self.number_of_unfilled_cells <= 0 + return all(cell.is_filled() for cell in chain.from_iterable(self.board)) def _is_player_winner(self, player: Player, cell_position: Vector) -> bool: cell_x, cell_y = cell_position @@ -484,15 +441,15 @@ def _current_player(self) -> Player | None: case GameState.completed: return None - def _players(self) -> tuple[Player, ...]: + def players(self) -> tuple[Player, ...]: return self.player1, self.player2 - def _users(self) -> tuple[User, ...]: + def users(self) -> tuple[User, ...]: return tuple( - player for player in self._players() if isinstance(player, User) + player for player in self.players() if isinstance(player, User) ) - def _not_current_player(self) -> Player | None: + def not_current_player(self) -> Player | None: match self.state: case GameState.wait_player1: return self.player2 @@ -574,7 +531,6 @@ def start_game( # noqa: PLR0913, PLR0917 player2, player2_emoji, board, - number_of_unfilled_cells(board), None, GameState.wait_player1, ) @@ -651,7 +607,6 @@ def start_game_with_ai( # noqa: PLR0913, PLR0917 player2, player2_emoji, board, - number_of_unfilled_cells(board), None, GameState.wait_player1, ) diff --git a/src/ttt/entities/core/invitation_to_game/invitation_to_game.py b/src/ttt/entities/core/invitation_to_game/invitation_to_game.py index 8462c75..74d2d84 100644 --- a/src/ttt/entities/core/invitation_to_game/invitation_to_game.py +++ b/src/ttt/entities/core/invitation_to_game/invitation_to_game.py @@ -41,7 +41,9 @@ class InvitationToGame: id_: UUID inviting_user: User + inviting_user_username: str | None invited_user: User + invited_user_username: str | None invitation_datetime: datetime state: InvitationToGameState @@ -155,8 +157,10 @@ def invitation_to_game_datetime(expiration_datetime: datetime) -> datetime: def invite_to_game( # noqa: PLR0913, PLR0917 user: User, + user_username: str | None, invited_user: User | None, invited_user_id: int, + invited_user_username: str | None, invitation_to_game_id: UUID, current_datetime: datetime, tracking: Tracking, @@ -170,7 +174,9 @@ def invite_to_game( # noqa: PLR0913, PLR0917 invitation_to_game = InvitationToGame( id_=invitation_to_game_id, + inviting_user_username=user_username, inviting_user=user, + invited_user_username=invited_user_username, invited_user=invited_user, invitation_datetime=current_datetime, state=InvitationToGameState.active, diff --git a/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py b/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py deleted file mode 100644 index f928d52..0000000 --- a/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py +++ /dev/null @@ -1,88 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from itertools import combinations -from uuid import UUID - -from ttt.entities.core.game.game import Game, start_game -from ttt.entities.core.matchmaking_queue.user_waiting import UserWaiting -from ttt.entities.core.user.rank import are_ranks_adjacent -from ttt.entities.core.user.user import User, UserAlreadyInGameError -from ttt.entities.math.matrix import Matrix -from ttt.entities.text.emoji import Emoji -from ttt.entities.tools.assertion import assert_ -from ttt.entities.tools.tracking import Tracking - - -class UserAlreadyWaitingForGameError(Exception): ... - - -@dataclass -class MatchmakingQueue: - user_waitings: list[UserWaiting] - - def __contains__(self, user: User) -> bool: - waiting_user_ids = (waiting.user.id for waiting in self.user_waitings) - return user.id in waiting_user_ids - - def add_user( # noqa: PLR0913, PLR0917 - self, - user: User, - user_waiting_id: UUID, - cell_id_matrix: Matrix[UUID], - game_id: UUID, - player1_random_emoji: Emoji, - player2_random_emoji: Emoji, - current_datetime: datetime, - tracking: Tracking, - ) -> Game | None: - """ - :raises ttt.entities.core.matchmaking_queue.matchmaking_queue.UserAlreadyWaitingForGameError: - :raises ttt.entities.core.game.game.UserAlreadyInGameError: - :raises ttt.entities.core.game.game.SameRandomEmojiError: - :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: - """ # noqa: E501 - - assert_(user not in self, else_=UserAlreadyWaitingForGameError) - assert_(not user.is_in_game(), else_=UserAlreadyInGameError) - - user_waiting = UserWaiting( - id_=user_waiting_id, - start_datetime=current_datetime, - user=user, - ) - tracking.register_new(user_waiting) - self.user_waitings.append(user_waiting) - - for user_waiting1, user_waiting2 in combinations(self.user_waitings, 2): - if self._is_game_allowed(user_waiting1, user_waiting2): - game = start_game( - cell_id_matrix, - game_id, - user_waiting1.user, - player1_random_emoji, - user_waiting2.user, - player2_random_emoji, - tracking, - ) - - self.user_waitings.remove(user_waiting1) - self.user_waitings.remove(user_waiting2) - tracking.register_unused(user_waiting1) - tracking.register_unused(user_waiting2) - - return game - - return None - - def _is_game_allowed( - self, - user_waiting1: UserWaiting, - user_waiting2: UserWaiting, - ) -> bool: - rank1 = user_waiting1.user.rank() - rank2 = user_waiting2.user.rank() - - return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) - - -MatchmakingQueueAtomic = MatchmakingQueue | UserWaiting diff --git a/src/ttt/entities/core/matchmaking_queue/user_waiting.py b/src/ttt/entities/core/matchmaking_queue/user_waiting.py deleted file mode 100644 index 19bc224..0000000 --- a/src/ttt/entities/core/matchmaking_queue/user_waiting.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from uuid import UUID - -from ttt.entities.core.user.user import User - - -@dataclass -class UserWaiting: - id_: UUID - start_datetime: datetime - user: User diff --git a/src/ttt/application/user/stars_purchase/ports/__init__.py b/src/ttt/entities/core/stars_purchase/__init__.py similarity index 100% rename from src/ttt/application/user/stars_purchase/ports/__init__.py rename to src/ttt/entities/core/stars_purchase/__init__.py diff --git a/src/ttt/entities/core/user/stars_purchase.py b/src/ttt/entities/core/stars_purchase/stars_purchase.py similarity index 57% rename from src/ttt/entities/core/user/stars_purchase.py rename to src/ttt/entities/core/stars_purchase/stars_purchase.py index 9b26e4d..d55ad6e 100644 --- a/src/ttt/entities/core/user/stars_purchase.py +++ b/src/ttt/entities/core/stars_purchase/stars_purchase.py @@ -7,7 +7,13 @@ has_stars_price, price_of_stars, ) -from ttt.entities.finance.payment.payment import Payment +from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.payment import ( + Payment, + cancel_payment, + complete_payment, +) +from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import assert_ from ttt.entities.tools.tracking import Tracking @@ -27,7 +33,7 @@ class StarsPurchase: """ id_: UUID - user_id: int + user: User stars: Stars payment: Payment | None @@ -37,6 +43,28 @@ def __post_init__(self) -> None: else_=InvalidStarsForStarsPurchaseError, ) + @classmethod + def start( + cls, + purchase_id: UUID, + purchase_user: User, + purchase_stars: Stars, + tracking: Tracking, + ) -> "StarsPurchase": + """ + :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: + """ + + purchase = StarsPurchase( + id_=purchase_id, + user=purchase_user, + stars=purchase_stars, + payment=None, + ) + tracking.register_new(purchase) + + return purchase + def start_payment( self, payment_id: UUID, @@ -57,24 +85,40 @@ def start_payment( self.payment = payment tracking.register_mutated(self) - @classmethod - def start( - cls, - purchase_id: UUID, - purchase_user_id: int, - purchase_stars: Stars, + def complete_payment( + self, + payment_success: PaymentSuccess, + current_datetime: datetime, tracking: Tracking, - ) -> "StarsPurchase": + ) -> None: """ - :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: + :raises ttt.entities.finance.payment.payment.NoPaymentError: + :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: """ - purchase = StarsPurchase( - id_=purchase_id, - user_id=purchase_user_id, - stars=purchase_stars, - payment=None, + self.user.account = self.user.account.map( + lambda stars: stars + self.stars, ) - tracking.register_new(purchase) + tracking.register_mutated(self.user) - return purchase + complete_payment( + self.payment, + payment_success, + current_datetime, + tracking, + ) + + def cancel( + self, + current_datetime: datetime, + tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.finance.payment.payment.NoPaymentError: + :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: + """ + + cancel_payment(self.payment, current_datetime, tracking) + + +StarsPurchaseAtomic = StarsPurchase diff --git a/src/ttt/entities/core/user/last_game.py b/src/ttt/entities/core/user/last_game.py deleted file mode 100644 index 82ada9b..0000000 --- a/src/ttt/entities/core/user/last_game.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from ttt.entities.tools.tracking import Tracking - - -@dataclass -class LastGame: - id: UUID - user_id: int - game_id: UUID - - -def last_game( - last_game_id: UUID, - last_game_user_id: int, - last_game_game_id: UUID, - tracking: Tracking, -) -> LastGame: - last_game = LastGame(last_game_id, last_game_user_id, last_game_game_id) - tracking.register_new(last_game) - - return last_game diff --git a/src/ttt/entities/core/user/location.py b/src/ttt/entities/core/user/location.py deleted file mode 100644 index d544100..0000000 --- a/src/ttt/entities/core/user/location.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - - -@dataclass -class UserGameLocation: - user_id: int - game_id: UUID diff --git a/src/ttt/entities/core/user/matchmaking.py b/src/ttt/entities/core/user/matchmaking.py new file mode 100644 index 0000000..42299c6 --- /dev/null +++ b/src/ttt/entities/core/user/matchmaking.py @@ -0,0 +1,84 @@ +from collections.abc import Generator +from dataclasses import dataclass +from uuid import UUID + +from ttt.entities.core.game.game import Game, start_game +from ttt.entities.core.user.rank import UsersWithMaxRating, are_ranks_adjacent +from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating +from ttt.entities.math.matrix import Matrix +from ttt.entities.text.emoji import Emoji +from ttt.entities.tools.combinations import Combinations +from ttt.entities.tools.tracking import Tracking + + +@dataclass +class MatchmakingInput: + cell_id_matrix: Matrix[UUID] + game_id: UUID + player1_random_emoji: Emoji + player2_random_emoji: Emoji + + +@dataclass +class UsersAreNotWaitingForMatchmakingError(Exception): + users: tuple[User, ...] + + +def matchmaking( + users: list[User], + input_: MatchmakingInput, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, + tracking: Tracking, +) -> Generator[Game, MatchmakingInput]: + """ + :raises ttt.entities.core.user.matchmaking.UsersAreNotWaitingForMatchmakingError + :raises ttt.entities.core.game.game.SameRandomEmojiError: + :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: + """ # noqa: E501 + + users_not_waiting_for_matchmaking = tuple( + user + for user in users + if not user.is_waiting_for_matchmaking() + ) + if users_not_waiting_for_matchmaking: + raise UsersAreNotWaitingForMatchmakingError( + users_not_waiting_for_matchmaking, + ) + + for user in list(users): + if user.is_in_game(): + user.dont_wait_for_matchmaking(tracking) + users.remove(user) + + combinations = Combinations(users) + for user1, user2 in combinations: + if _is_game_allowed(user1, user2, max_rating, users_with_max_rating): + user1.dont_wait_for_matchmaking(tracking) + user2.dont_wait_for_matchmaking(tracking) + combinations.cut() + + game = start_game( + input_.cell_id_matrix, + input_.game_id, + user1, + input_.player1_random_emoji, + user2, + input_.player2_random_emoji, + tracking, + ) + input_ = yield game + + +def _is_game_allowed( + user1: User, + user2: User, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, +) -> bool: + rank1 = user1.rank(max_rating, users_with_max_rating) + rank2 = user2.rank(max_rating, users_with_max_rating) + + return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) diff --git a/src/ttt/entities/core/user/matchmaking_waiting.py b/src/ttt/entities/core/user/matchmaking_waiting.py new file mode 100644 index 0000000..065e3bc --- /dev/null +++ b/src/ttt/entities/core/user/matchmaking_waiting.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class MatchmakingWaiting: + start_datetime: datetime diff --git a/src/ttt/entities/core/user/rank.py b/src/ttt/entities/core/user/rank.py index f225931..aa1f878 100644 --- a/src/ttt/entities/core/user/rank.py +++ b/src/ttt/entities/core/user/rank.py @@ -5,23 +5,46 @@ from ttt.entities.elo.rating import EloRating -type RankTier = Literal[-1, 0, 1, 2, 3, 4] +type RankTier = Literal[-1, 0, 1, 2, 3, 4, 5] @dataclass(frozen=True) -class Rank: +class IntervalRank: tier: RankTier min_rating: EloRating max_rating: EloRating +@dataclass(frozen=True) +class SpecialRank[TierT: RankTier, NameT: str]: + tier: TierT + name: NameT + + +StrongestRank = SpecialRank[Literal[5], Literal["strongest"]] +strongest_rank = StrongestRank(tier=5, name="strongest") + +special_ranks = ( + strongest_rank, +) + + +type Rank = IntervalRank | StrongestRank + + +interval_ranks = ( + IntervalRank(tier=-1, min_rating=-math.inf, max_rating=871), + IntervalRank(tier=0, min_rating=872, max_rating=1085), + IntervalRank(tier=1, min_rating=1086, max_rating=1336), + IntervalRank(tier=2, min_rating=1337, max_rating=1679), + IntervalRank(tier=3, min_rating=1680, max_rating=1999), + IntervalRank(tier=4, min_rating=2000, max_rating=math.inf), +) + + ranks = ( - Rank(tier=-1, min_rating=-math.inf, max_rating=871), - Rank(tier=0, min_rating=872, max_rating=1085), - Rank(tier=1, min_rating=1086, max_rating=1336), - Rank(tier=2, min_rating=1337, max_rating=1679), - Rank(tier=3, min_rating=1680, max_rating=1999), - Rank(tier=4, min_rating=2000, max_rating=math.inf), + *interval_ranks, + *special_ranks, ) @@ -33,8 +56,18 @@ def rank_with_tier(tier: RankTier) -> Rank: raise ValueError -def rank_for_rating(rating: EloRating) -> Rank: - for rank in ranks: +type UsersWithMaxRating = Literal["1", ">1"] + + +def rank( + rating: EloRating, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, +) -> Rank: + if rating == max_rating and users_with_max_rating == "1": + return strongest_rank + + for rank in interval_ranks: if rank.min_rating <= rating <= rank.max_rating: return rank diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index 8a3d40a..6aec231 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -12,23 +12,17 @@ ) from ttt.entities.core.user.draw import UserDraw from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.last_game import LastGame, last_game -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.loss import UserLoss -from ttt.entities.core.user.rank import Rank, rank_for_rating -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting +from ttt.entities.core.user.rank import Rank, UsersWithMaxRating, rank from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( EloRating, + GamesPlayed, initial_elo_rating, new_elo_rating, ) from ttt.entities.elo.score import WinningScore -from ttt.entities.finance.payment.payment import ( - cancel_payment, - complete_payment, -) -from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.math.random import Random, deviated_int from ttt.entities.text.emoji import Emoji from ttt.entities.text.token import Token @@ -60,9 +54,6 @@ class EmojiNotPurchasedError(Exception): ... class NoPurchaseError(Exception): ... -class UserAlreadyLeftGameError(Exception): ... - - class UserAlreadyAdminError(Exception): ... @@ -84,26 +75,32 @@ class NotAuthorizedAsAdminViaAdminTokenError(Exception): ... class UserAlredyAdminToAuthorizeAsAdminError(Exception): ... +class UserAlreadyWaitingForMatchmakingError(Exception): ... + + +class UserIsNotWaitingForMatchmakingError(Exception): ... + + +class UserIsInGameError(Exception): ... + + @dataclass # noqa: PLR0904 class User: id: int account: Account emojis: list[UserEmoji] - stars_purchases: list[StarsPurchase] - last_games: list[LastGame] + matchmaking_waiting: MatchmakingWaiting | None selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None - - number_of_wins: int - number_of_draws: int - number_of_defeats: int - game_location: UserGameLocation | None + current_game_id: UUID | None emoji_cost: ClassVar[Stars] = 1000 - def rank(self) -> Rank: - return rank_for_rating(self.rating) + def rank( + self, max_rating: EloRating, users_with_max_rating: UsersWithMaxRating, + ) -> Rank: + return rank(self.rating, max_rating, users_with_max_rating) def is_admin(self) -> bool: return is_user_admin(self.admin_right) @@ -235,11 +232,8 @@ def set_user_account( return user - def games_played(self) -> int: - return len(self.last_games) - def is_in_game(self) -> bool: - return self.game_location is not None + return self.current_game_id is not None def be_in_game( self, @@ -252,29 +246,26 @@ def be_in_game( assert_(not self.is_in_game(), else_=UserAlreadyInGameError(self)) - self.game_location = UserGameLocation(self.id, game_id) + self.current_game_id = game_id tracking.register_mutated(self) def lose_to_user( self, enemy_rating: EloRating, - last_game_id: UUID, - game_id: UUID, + games_played: GamesPlayed, tracking: Tracking, ) -> UserLoss: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) - self.number_of_defeats += 1 new_rating = new_elo_rating( self.rating, enemy_rating, WinningScore.when_losing, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -282,20 +273,12 @@ def lose_to_user( return UserLoss(user_id=self.id, rating_vector=rating_vector) - def lose_to_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserLoss: + def lose_to_ai(self, tracking: Tracking) -> UserLoss: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) - - self.number_of_defeats += 1 + self.leave_game(tracking) tracking.register_mutated(self) return UserLoss(user_id=self.id, rating_vector=None) @@ -303,25 +286,20 @@ def lose_to_ai( def win_against_user( self, enemy_rating: EloRating, + games_played: GamesPlayed, random: Random, - last_game_id: UUID, - game_id: UUID, tracking: Tracking, ) -> UserWin: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) - - self.number_of_wins += 1 - + self.leave_game(tracking) new_rating = new_elo_rating( self.rating, enemy_rating, WinningScore.when_winning, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -332,41 +310,30 @@ def win_against_user( tracking.register_mutated(self) return UserWin(self.id, new_stars, rating_vector) - def win_against_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserWin: + def win_against_ai(self, tracking: Tracking) -> UserWin: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) - + self.leave_game(tracking) return UserWin(self.id, new_stars=None, rating_vector=None) def be_draw_against_user( self, enemy_rating: EloRating, - last_game_id: UUID, - game_id: UUID, + games_played: GamesPlayed, tracking: Tracking, ) -> UserDraw: """ :raises ttt.entities.core.user.user.UserNotInGameError: """ - self.leave_game(last_game_id, game_id, tracking) - - self.number_of_draws += 1 - + self.leave_game(tracking) new_rating = new_elo_rating( self.rating, enemy_rating, WinningScore.when_winning, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -374,47 +341,26 @@ def be_draw_against_user( return UserDraw(self.id, rating_vector) - def be_draw_against_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserDraw: + def be_draw_against_ai(self, tracking: Tracking) -> UserDraw: """ :raises ttt.entities.core.user.user.UserNotInGameError: """ - self.leave_game(last_game_id, game_id, tracking) - - self.number_of_draws += 1 + self.leave_game(tracking) tracking.register_mutated(self) return UserDraw(self.id, rating_vector=None) - def leave_game( - self, last_game_id: UUID, game_id: UUID, tracking: Tracking, - ) -> None: + def leave_game(self, tracking: Tracking) -> None: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ assert_(self.is_in_game(), else_=UserNotInGameError(self)) - self.game_location = None + self.current_game_id = None tracking.register_mutated(self) - assert_( - ( - self._last_game_with_id(last_game_id) is None - and self._last_game_with_game_id(game_id) is None - ), - else_=UserAlreadyLeftGameError, - ) - - last_game_ = last_game(last_game_id, self.id, game_id, tracking) - self.last_games.append(last_game_) - def buy_emoji( self, emoji: Emoji, @@ -483,129 +429,67 @@ def select_emoji(self, emoji: Emoji, tracking: Tracking) -> None: tracking.register_mutated(self) - def start_stars_purchase( - self, - purchase_id: UUID, - purchase_stars: Stars, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: - """ - - stars_purchase = StarsPurchase.start( - purchase_id, - self.id, - purchase_stars, - tracking, - ) - self.stars_purchases.append(stars_purchase) + def is_waiting_for_matchmaking(self) -> bool: + return self.matchmaking_waiting is not None - def start_stars_purchase_payment( + def wait_for_matchmaking( self, - purchase_id: UUID, - payment_id: UUID, - current_datetime: datetime, + curreint_datetime: datetime, tracking: Tracking, - ) -> None: + ) -> "MatchmakingWaiting": """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.PaymentIsAlreadyBeingMadeError: + :raises ttt.entities.core.user.user.UserAlreadyWaitingForMatchmakingError: + :raises ttt.entities.core.user.user.UserIsInGameError: """ # noqa: E501 - purchase = self._stars_purchase(purchase_id) - purchase.start_payment(payment_id, current_datetime, tracking) - - def complete_stars_purchase_payment( - self, - purchase_id: UUID, - payment_success: PaymentSuccess, - current_datetime: datetime, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.NoPaymentError: - :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: - """ + assert_( + not self.is_waiting_for_matchmaking(), + else_=UserAlreadyWaitingForMatchmakingError, + ) + assert_(not self.is_in_game(), else_=UserIsInGameError) - purchase = self._stars_purchase(purchase_id) + waiting = MatchmakingWaiting(start_datetime=curreint_datetime) - self.account = self.account.map(lambda stars: stars + purchase.stars) + self.matchmaking_waiting = waiting tracking.register_mutated(self) - complete_payment( - purchase.payment, - payment_success, - current_datetime, - tracking, - ) - - def cancel_stars_purchase( - self, - purchase_id: UUID, - current_datetime: datetime, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.NoPaymentError: - :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: - """ - purchase = self._stars_purchase(purchase_id) - cancel_payment(purchase.payment, current_datetime, tracking) + return waiting - def _stars_purchase(self, purchase_id: UUID) -> StarsPurchase: + def dont_wait_for_matchmaking(self, tracking: Tracking) -> None: """ - :raises ttt.entities.user.user.NoPurchaseError: + :raises ttt.entities.core.user.user.UserIsNotWaitingForMatchmakingError: """ - for purchase in self.stars_purchases: - if purchase.id_ == purchase_id: - return purchase - - raise NoPurchaseError - - def _last_game_with_id(self, last_game_id: UUID) -> LastGame | None: - for last_game_ in self.last_games: - if last_game_.id == last_game_id: - return last_game_ - - return None - - def _last_game_with_game_id(self, game_id: UUID) -> LastGame | None: - for last_game_ in self.last_games: - if last_game_.game_id == game_id: - return last_game_ + assert_( + self.is_waiting_for_matchmaking(), + else_=UserIsNotWaitingForMatchmakingError, + ) - return None + self.matchmaking_waiting = None + tracking.register_mutated(self) -UserAtomic = User | UserEmoji | StarsPurchase | LastGame +UserAtomic = User | UserEmoji def register_user(user_id: int, tracking: Tracking) -> User: user = User( id=user_id, account=Account(0), - stars_purchases=[], - last_games=[], emojis=[], selected_emoji_id=None, rating=initial_elo_rating, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) tracking.register_new(user) return user -def is_user_in_game(game_location: UserGameLocation | None) -> bool: - return game_location is not None +def is_user_in_game(current_game_id: UUID | None) -> bool: + return current_game_id is not None def is_user_admin(admin_right: AdminRight | None) -> bool: diff --git a/src/ttt/entities/elo/rating.py b/src/ttt/entities/elo/rating.py index 4439655..8f51e4d 100644 --- a/src/ttt/entities/elo/rating.py +++ b/src/ttt/entities/elo/rating.py @@ -1,3 +1,5 @@ +from typing import Literal + from ttt.entities.elo.score import ExpectedScore, WinningScore @@ -7,11 +9,14 @@ initial_elo_rating: EloRating = 1000 +type GamesPlayed = Literal["<=30", ">30"] + + def new_elo_rating( rating: EloRating, other_rating: EloRating, winning_score: WinningScore, - games_played: int, + games_played: GamesPlayed, ) -> EloRating: expected_score = _expected_score(rating, other_rating) @@ -25,8 +30,8 @@ def new_elo_rating( return rating + k * (winning_score.value - expected_score) -def _is_player_newbie(games_played: int) -> bool: - return games_played <= 30 # noqa: PLR2004 +def _is_player_newbie(games_played: GamesPlayed) -> bool: + return games_played == "<=30" def _expected_score(rating_a: EloRating, rating_b: EloRating) -> ExpectedScore: diff --git a/src/ttt/entities/tools/combinations.py b/src/ttt/entities/tools/combinations.py new file mode 100644 index 0000000..92b0299 --- /dev/null +++ b/src/ttt/entities/tools/combinations.py @@ -0,0 +1,28 @@ +from collections.abc import Iterator +from dataclasses import dataclass, field + + +@dataclass +class Combinations[V](Iterator[tuple[V, V]]): + _values: list[V] + + _index1: int = field(init=False, default=0) + _index2: int = field(init=False, default=0) + + def __next__(self) -> tuple[V, V]: + self._index2 += 1 + + if self._index2 >= len(self._values): + self._index1 += 1 + self._index2 = self._index1 + 1 + + if self._index1 >= len(self._values) - 1: + raise StopIteration + + return self._values[self._index1], self._values[self._index2] + + def cut(self) -> None: + del self._values[self._index2] + del self._values[self._index1] + + self._index2 = self._index1 diff --git a/src/ttt/infrastructure/adapters/game_dao.py b/src/ttt/infrastructure/adapters/game_dao.py new file mode 100644 index 0000000..35770c3 --- /dev/null +++ b/src/ttt/infrastructure/adapters/game_dao.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.game.game.ports.game_dao import GameDao +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import GamesPlayed +from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.sqlalchemy.tables.game import TableGame, TableGameState + + +@dataclass(frozen=True, unsafe_hash=False) +class PostgresGameDao(GameDao): + _session: AsyncSession + + async def games_played_by_player_id( + self, + game: Game, + /, + ) -> dict[int, GamesPlayed]: + games_played_by_player_id = dict[int, GamesPlayed]() + + if isinstance(game.player1, User): + games_played_by_player_id[game.player1.id] = ( + await self._games_played(game.player1) + ) + + if isinstance(game.player2, User): + games_played_by_player_id[game.player2.id] = ( + await self._games_played(game.player2) + ) + + return games_played_by_player_id + + async def _games_played(self, user: User) -> GamesPlayed: + game_stmt = ( + select(1) + .where( + (TableGame.state == TableGameState.completed.value) + & ( + (TableGame.user1_id == user.id) + | (TableGame.user2_id == user.id) + ), + ) + .limit(31) + .subquery() + ) + stmt = select(func.count(1)).select_from(game_stmt) + raw_games_played = not_none(await self._session.scalar(stmt)) + + return "<=30" if raw_games_played <= 30 else ">30" # noqa: PLR2004 diff --git a/src/ttt/infrastructure/adapters/game_log.py b/src/ttt/infrastructure/adapters/game_log.py index 190a1d8..d552a57 100644 --- a/src/ttt/infrastructure/adapters/game_log.py +++ b/src/ttt/infrastructure/adapters/game_log.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from uuid import UUID from structlog.types import FilteringBoundLogger @@ -12,13 +13,95 @@ class StructlogGameLog(GameLog): _logger: FilteringBoundLogger - async def game_against_user_started( + async def no_current_game_to_make_move( + self, + user_id: int, + /, + ) -> None: + await self._logger.awarning( + "no_current_game_to_make_move", + user_id=user_id, + ) + + async def no_current_game_to_cancel_game( + self, + user_id: int, + /, + ) -> None: + await self._logger.awarning( + "no_current_game_to_cancel_game", + user_id=user_id, + ) + + async def no_game_to_make_ai_move( + self, + game_id: UUID, + /, + ) -> None: + await self._logger.ainfo( + "no_game_to_make_ai_move", + game_id=game_id.hex, + ) + + async def game_was_completed_by_user( + self, + user_id: int, + game: Game, + /, + ) -> None: + await self._logger.ainfo( + "game_was_completed_by_user", + user_id=user_id, + game_id=game.id.hex, + ) + + async def game_was_completed_by_ai( + self, + ai_id: UUID, + game: Game, + /, + ) -> None: + await self._logger.ainfo( + "game_was_completed_by_ai", + ai_id=ai_id.hex, + game_id=game.id.hex, + ) + + async def already_completed_game_to_make_move( + self, + game: Game, + user_id: int, + cell_number_int: int, + /, + ) -> None: + await self._logger.awarning( + "already_completed_game_to_make_move", + user_id=user_id, + game_id=game.id.hex, + cell_number=cell_number_int, + ) + + async def already_completed_game_to_make_ai_move( self, game: Game, + ai_id: UUID, /, ) -> None: await self._logger.ainfo( - "game_against_user_started", + "already_completed_game_to_make_ai_move", + ai_id=ai_id.hex, + game_id=game.id.hex, + ) + + async def not_ai_current_move_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: + await self._logger.ainfo( + "not_ai_current_move_to_make_ai_move", + ai_id=ai_id.hex, game_id=game.id.hex, ) @@ -60,14 +143,14 @@ async def user_move_maked( async def ai_move_maked( self, - user_id: int, game: Game, move: AiMove, + ai_id: UUID, /, ) -> None: await self._logger.ainfo( "ai_move_maked", - user_id=user_id, + ai_id=ai_id.hex, game_id=game.id.hex, filled_cell_number=int(move.filled_cell_number), was_move_random=move.was_random, @@ -85,20 +168,6 @@ async def game_completed( game_id=game.id.hex, ) - async def already_completed_game_to_make_move( - self, - game: Game, - user_id: int, - cell_number_int: int, - /, - ) -> None: - await self._logger.ainfo( - "already_completed_game_to_make_move", - user_id=user_id, - game_id=game.id.hex, - cell_number_int=cell_number_int, - ) - async def not_current_player_to_make_move( self, game: Game, @@ -162,13 +231,3 @@ async def user_already_in_game_to_start_game_against_ai( "user_already_in_game_to_start_game_against_ai", user_id=user.id, ) - - async def current_game_viewed( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "current_game_viewed", - user_id=user_id, - ) diff --git a/src/ttt/infrastructure/adapters/game_tasks.py b/src/ttt/infrastructure/adapters/game_tasks.py new file mode 100644 index 0000000..86d6569 --- /dev/null +++ b/src/ttt/infrastructure/adapters/game_tasks.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.game.game.ports.game_tasks import GameTasks +from ttt.infrastructure.remote_funcs.make_ai_move_in_game import ( + make_ai_move_in_game_remotely, +) + + +@dataclass +class NatsRemoteFuncGameTasks(GameTasks): + async def make_ai_move( + self, + user_id: int, + game_id: UUID, + ai_id: UUID, + /, + ) -> None: + await make_ai_move_in_game_remotely( + user_id=user_id, game_id=game_id.hex, ai_id=ai_id.hex, + ) diff --git a/src/ttt/infrastructure/adapters/games.py b/src/ttt/infrastructure/adapters/games.py index 4dc5a69..cad7949 100644 --- a/src/ttt/infrastructure/adapters/games.py +++ b/src/ttt/infrastructure/adapters/games.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -13,21 +14,10 @@ class InPostgresGames(Games): _session: AsyncSession - async def game_with_game_location( - self, - game_location_user_id: int, - /, - ) -> Game | None: - lock_stmt = ( - select(TableGame.id) - .where(TableUser.game_location_game_id == TableGame.id) - .with_for_update() - ) - await self._session.execute(lock_stmt) - + async def current_user_game(self, user_id: int, /) -> Game | None: join_condition = ( - (TableUser.id == game_location_user_id) - & (TableUser.game_location_game_id == TableGame.id) + (TableUser.id == user_id) + & (TableUser.current_game_id == TableGame.id) ) stmt = select(TableGame).join(TableUser, join_condition) table_game = await self._session.scalar(stmt) @@ -36,3 +26,20 @@ async def game_with_game_location( return None return table_game.entity() + + async def not_locked_game_with_id(self, game_id: UUID, /) -> Game | None: + lock_stmt = ( + select(1) + .select_from(TableGame) + .where(TableGame.id == game_id) + .with_for_update() + ) + stmt = ( + select(TableGame) + .where(TableGame.id == game_id) + ) + + await self._session.execute(lock_stmt) + table_game = await self._session.scalar(stmt) + + return None if table_game is None else table_game.entity() diff --git a/src/ttt/infrastructure/adapters/invitation_to_game_log.py b/src/ttt/infrastructure/adapters/invitation_to_game_log.py index b217c3d..a7d1fad 100644 --- a/src/ttt/infrastructure/adapters/invitation_to_game_log.py +++ b/src/ttt/infrastructure/adapters/invitation_to_game_log.py @@ -45,7 +45,6 @@ async def invitation_self_to_game( ) -> None: await self._logger.ainfo( "invitation_self_to_game", - chat_id=user.id, user_id=user.id, ) @@ -56,10 +55,9 @@ async def user_invited_other_user_to_game( ) -> None: await self._logger.ainfo( "user_invited_other_user_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, invited_user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def double_invitation_to_game( @@ -69,9 +67,9 @@ async def double_invitation_to_game( ) -> None: await self._logger.ainfo( "double_invitation_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, invited_user_id=invitation_to_game.invited_user.id, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_cancel( @@ -82,9 +80,8 @@ async def invitation_to_game_is_not_active_to_cancel( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_cancel", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -98,9 +95,8 @@ async def user_is_not_inviting_user_to_cancel_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_inviting_user_to_cancel_invitation_to_game", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_reject( @@ -111,9 +107,8 @@ async def invitation_to_game_is_not_active_to_reject( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_reject", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -127,9 +122,8 @@ async def user_is_not_invited_user_to_reject_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_invited_user_to_reject_invitation_to_game", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_accept( @@ -140,9 +134,8 @@ async def invitation_to_game_is_not_active_to_accept( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_accept", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -156,10 +149,9 @@ async def user_is_not_invited_user_to_accept_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_invited_user_to_accept_invitation_to_game", - chat_id=user_id, user_id=user_id, invited_user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def users_already_in_game_to_accept_invitation_to_game( @@ -170,9 +162,8 @@ async def users_already_in_game_to_accept_invitation_to_game( ) -> None: await self._logger.ainfo( "users_already_in_game_to_accept_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, is_invited_user_in_game=( invitation_to_game.invited_user in users_in_game ), @@ -188,9 +179,8 @@ async def user_cancelled_invitation_to_game( ) -> None: await self._logger.ainfo( "user_cancelled_invitation_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def user_rejected_invitation_to_game( @@ -200,9 +190,8 @@ async def user_rejected_invitation_to_game( ) -> None: await self._logger.ainfo( "user_rejected_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def user_accepted_invitation_to_game( @@ -213,9 +202,8 @@ async def user_accepted_invitation_to_game( ) -> None: await self._logger.ainfo( "user_accepted_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def no_invitation_to_game_to_accept( @@ -223,9 +211,8 @@ async def no_invitation_to_game_to_accept( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_accept", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def no_invitation_to_game_to_reject( @@ -233,9 +220,8 @@ async def no_invitation_to_game_to_reject( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_reject", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def no_invitation_to_game_to_cancel( @@ -243,9 +229,8 @@ async def no_invitation_to_game_to_cancel( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_cancel", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def invitations_to_game_auto_cancelled( @@ -256,7 +241,7 @@ async def invitations_to_game_auto_cancelled( await gather(*( self._logger.ainfo( "invitation_to_game_auto_cancelled", - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) for invitation_to_game_id in ids )) @@ -266,7 +251,7 @@ async def no_invitation_to_game_to_auto_cancel( ) -> None: await self._logger.awarning( "no_invitation_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def not_expired_invitation_to_game_to_auto_cancel( @@ -274,7 +259,7 @@ async def not_expired_invitation_to_game_to_auto_cancel( ) -> None: await self._logger.aerror( "not_expired_invitation_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( @@ -282,5 +267,5 @@ async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( ) -> None: await self._logger.ainfo( "invitation_to_game_state_is_not_active_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) diff --git a/src/ttt/infrastructure/adapters/map.py b/src/ttt/infrastructure/adapters/map.py index 8ef3fcb..58fd1c8 100644 --- a/src/ttt/infrastructure/adapters/map.py +++ b/src/ttt/infrastructure/adapters/map.py @@ -1,9 +1,10 @@ from dataclasses import dataclass -from psycopg.errors import UniqueViolation -from sqlalchemy.exc import IntegrityError +from psycopg.errors import SerializationFailure, UniqueViolation +from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession +from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.map import ( Map, MappableTracking, @@ -40,6 +41,8 @@ async def __call__( await self._session.flush() except IntegrityError as error: self._handle_integrity_error(error) + except OperationalError as error: + self._handle_operational_error(error) def _handle_integrity_error(self, error: IntegrityError) -> None: match error.orig: @@ -54,3 +57,9 @@ def _handle_integrity_error(self, error: IntegrityError) -> None: case _: ... raise error from error + + def _handle_operational_error(self, error: OperationalError) -> None: + if isinstance(error.orig, SerializationFailure): + raise SerializationError from error + + raise error from error diff --git a/src/ttt/infrastructure/adapters/matchmaking_queue_log.py b/src/ttt/infrastructure/adapters/matchmaking_queue_log.py deleted file mode 100644 index 221183f..0000000 --- a/src/ttt/infrastructure/adapters/matchmaking_queue_log.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass - -from structlog.types import FilteringBoundLogger - -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class StructlogCommonMatchmakingQueueLog(CommonMatchmakingQueueLog): - _logger: FilteringBoundLogger - - async def waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "waiting_for_game_start", - user_id=user_id, - ) - - async def double_waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "double_waiting_for_game_start", - user_id=user_id, - ) - - async def user_already_in_game_to_add_to_matchmaking_queue( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_already_in_game_to_add_to_matchmaking_queue", - user_id=user_id, - ) diff --git a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index 063f433..0000000 --- a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections.abc import AsyncIterable -from dataclasses import dataclass - -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox as OriginalInNatsPaidStarsPurchasePaymentInbox, # noqa: E501 -) - - -@dataclass(frozen=True) -class InNatsPaidStarsPurchasePaymentInbox(PaidStarsPurchasePaymentInbox): - _inbox: OriginalInNatsPaidStarsPurchasePaymentInbox - - async def push(self, payment: PaidStarsPurchasePayment) -> None: - await self._inbox.push(payment) - - async def stream(self) -> AsyncIterable[PaidStarsPurchasePayment]: - async for payment in self._inbox: - yield payment diff --git a/src/ttt/infrastructure/adapters/retry.py b/src/ttt/infrastructure/adapters/retry.py new file mode 100644 index 0000000..fc986ca --- /dev/null +++ b/src/ttt/infrastructure/adapters/retry.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.retry import Retry +from ttt.infrastructure.retrier import Retrier + + +@dataclass +class RetrierRetry(Retry): + _retries: Retrier + + def __bool__(self) -> bool: + return self._retries.retry() diff --git a/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py b/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py deleted file mode 100644 index f91045a..0000000 --- a/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py +++ /dev/null @@ -1,31 +0,0 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, -) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, -) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableUserWaiting, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class InPostgresSharedMatchmakingQueue(SharedMatchmakingQueue): - _session: AsyncSession - - def __await__(self) -> Generator[Any, Any, MatchmakingQueue]: - return self._matchmaking_queue().__await__() - - async def _matchmaking_queue(self) -> MatchmakingQueue: - stmt = select(TableUserWaiting).with_for_update() - result = await self._session.scalars(stmt) - table_waitings = result.all() - - return MatchmakingQueue([it.entity() for it in table_waitings]) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_log.py b/src/ttt/infrastructure/adapters/stars_purchase_log.py new file mode 100644 index 0000000..5f3da67 --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchase_log.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from uuid import UUID + +from structlog.types import FilteringBoundLogger + +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.entities.core.stars import Stars +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.success import PaymentSuccess + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogStarsPurchaseLog(StarsPurchaseLog): + _logger: FilteringBoundLogger + + async def stars_puchase_started( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + await self._logger.ainfo( + "stars_puchase_started", + user_id=stars_purchase.user.id, + stars_purchase_id=stars_purchase.id_.hex, + ) + + async def stars_puchase_payment_started( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + await self._logger.ainfo( + "stars_puchase_payment_started", + user_id=stars_purchase.user.id, + purchase_id=stars_purchase.id_.hex, + ) + + async def stars_purchase_payment_completion_started( + self, + purchase_id: UUID, + success: PaymentSuccess, + /, + ) -> None: + await self._logger.ainfo( + "stars_purchase_payment_completion_started", + purchase_id=purchase_id.hex, + ) + + async def stars_purchase_payment_completed( + self, + stars_purchase: StarsPurchase, + success: PaymentSuccess, + /, + ) -> None: + await self._logger.ainfo( + "stars_purchase_payment_completed", + purchase_id=stars_purchase.id_.hex, + ) + + async def double_stars_purchase_payment_completion( + self, + stars_purchase: StarsPurchase, + success: PaymentSuccess, + ) -> None: + await self._logger.ainfo( + "double_stars_purchase_payment_completion", + purchase_id=stars_purchase.id_.hex, + ) + + async def invalid_stars_for_stars_purchase( + self, + user: User, + stars: Stars, + ) -> None: + await self._logger.aerror( + "invalid_stars_for_stars_purchase", + stars=stars, + ) + + async def double_stars_purchase_payment_start( + self, + stars_purchase: StarsPurchase, + ) -> None: + await self._logger.ainfo( + "double_stars_purchase_payment_start", + purchase_id=stars_purchase.id_.hex, + ) + + async def no_stars_purchase_to_start_payment( + self, + purchase_id: UUID, + /, + ) -> None: + await self._logger.aerror( + "no_stars_purchase_to_start_payment", + purchase_id=purchase_id.hex, + ) + + async def no_stars_purchase_to_complete_payment( + self, + purchase_id: UUID, + /, + ) -> None: + await self._logger.aerror( + "no_stars_purchase_to_complete_payment", + purchase_id=purchase_id.hex, + ) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py new file mode 100644 index 0000000..eb14ed3 --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, +) +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.remote_funcs.complete_stars_purchase_payment import ( + complete_stars_purchase_payment_remotely, +) + + +@dataclass +class NatsRemoteFuncStarsPurchaseTasks(StarsPurchaseTasks): + async def complete_stars_purchase_payment( + self, + purchase_id: UUID, + success: PaymentSuccess, + /, + ) -> None: + await complete_stars_purchase_payment_remotely( + purchase_id=purchase_id.hex, + payment_success_id=success.id, + payment_success_gateway_id=success.gateway_id, + ) diff --git a/src/ttt/infrastructure/adapters/stars_purchases.py b/src/ttt/infrastructure/adapters/stars_purchases.py new file mode 100644 index 0000000..32bd0de --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchases.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import select + +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchase, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class PostgresStarsPurchases(StarsPurchases): + _session: AsyncSession + + async def stars_purchase_with_id( + self, + id_: UUID, + /, + ) -> StarsPurchase | None: + stmt = ( + select(TableStarsPurchase) + .where(TableStarsPurchase.id == id_) + ) + table_stars_purchase = await self._session.scalar(stmt) + + if table_stars_purchase is None: + return None + + return table_stars_purchase.entity() diff --git a/src/ttt/infrastructure/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index 017bb9f..4613203 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -1,34 +1,68 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from types import TracebackType from typing import Self -from sqlalchemy.ext.asyncio import AsyncSession, AsyncSessionTransaction +from sqlalchemy.ext.asyncio import AsyncSession -from ttt.application.common.ports.transaction import Transaction -from ttt.entities.tools.assertion import not_none +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, + ReadonlyTransaction, + SerializableTransaction, +) +from ttt.entities.tools.assertion import assert_, not_none +from ttt.infrastructure.sqlalchemy.serialization import ( + reraise_serialization_error, +) @dataclass -class InPostgresTransaction(Transaction): +class InPostgresSerializableTransaction(SerializableTransaction): _session: AsyncSession - _transaction: AsyncSessionTransaction | None = field( - init=False, - default=None, - ) - _nesting_counter: int = field( - init=False, - default=0, - ) async def __aenter__(self) -> Self: - self._nesting_counter += 1 + assert_(not self._session.in_transaction()) + await self._session.connection( + execution_options={"isolation_level": "SERIALIZABLE"}, + ) + return self + + async def __aexit__( + self, + error_type: type[BaseException] | None, + error: BaseException | None, + traceback: TracebackType | None, + ) -> None: + transaction = self._session.get_transaction() + + if transaction is None: + return - if self._transaction is None: - self._transaction = await self._session.begin() - elif not self._transaction.is_active: - await self._session.rollback() - self._transaction = await self._session.begin() + with reraise_serialization_error(): + if error is None and transaction.is_active: + await transaction.commit() + else: + await transaction.rollback() + async def commit(self) -> None: + transaction = not_none(self._session.get_transaction()) + + if not transaction.is_active: + await transaction.rollback() + return + + with reraise_serialization_error(): + await transaction.commit() + + +@dataclass +class InPostgresNotSerializableTransaction(NotSerializableTransaction): + _session: AsyncSession + + async def __aenter__(self) -> Self: + assert_(not self._session.in_transaction()) + await self._session.connection( + execution_options={"isolation_level": "READ COMMITTED"}, + ) return self async def __aexit__( @@ -37,10 +71,47 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - self._nesting_counter -= 1 + transaction = self._session.get_transaction() - if self._nesting_counter == 0: - transaction = not_none(self._transaction) - await transaction.__aexit__(error_type, error, traceback) - self._transaction = None + if transaction is None: return + + if error is None and transaction.is_active: + await transaction.commit() + else: + await transaction.rollback() + + async def commit(self) -> None: + transaction = not_none(self._session.get_transaction()) + + if transaction.is_active: + await transaction.commit() + else: + await transaction.rollback() + + +@dataclass +class InPostgresReadonlyTransaction(ReadonlyTransaction): + _session: AsyncSession + + async def __aenter__(self) -> Self: + assert_(not self._session.in_transaction()) + options = {"isolation_level": "SERIALIZABLE", "readonly": True} + await self._session.connection(execution_options=options) + return self + + async def __aexit__( + self, + error_type: type[BaseException] | None, + error: BaseException | None, + traceback: TracebackType | None, + ) -> None: + transaction = self._session.get_transaction() + + if transaction is None: + return + + if error is None and transaction.is_active: + await transaction.commit() + else: + await transaction.rollback() diff --git a/src/ttt/infrastructure/adapters/user_locks.py b/src/ttt/infrastructure/adapters/user_locks.py new file mode 100644 index 0000000..c3d96fe --- /dev/null +++ b/src/ttt/infrastructure/adapters/user_locks.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.user.common.ports.user_locks import UserLocks +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +@dataclass(frozen=True) +class InPostgresUserLocks(UserLocks): + _session: AsyncSession + + async def lock_user_by_id( + self, + user_id: int, + /, + ) -> None: + stmt = select(1).select_from(TableUser).where(TableUser.id == user_id) + await self._session.execute(stmt) diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index b68f517..7312037 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -1,12 +1,11 @@ +from asyncio import gather from dataclasses import dataclass -from uuid import UUID from structlog.types import FilteringBoundLogger from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.emoji_purchase.ports.user_log import ( EmojiPurchaseUserLog, @@ -14,9 +13,8 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.entities.core.game.game import Game from ttt.entities.core.stars import Stars from ttt.entities.core.user.user import User from ttt.entities.text.emoji import Emoji @@ -33,7 +31,6 @@ async def user_registered( ) -> None: await self._logger.ainfo( "user_registered", - chat_id=user.id, user_id=user.id, ) @@ -44,14 +41,12 @@ async def user_double_registration( ) -> None: await self._logger.ainfo( "user_double_registration", - chat_id=user.id, user_id=user.id, ) async def user_viewed(self, user_id: int, /) -> None: await self._logger.ainfo( "user_viewed", - chat_id=user_id, user_id=user_id, ) @@ -62,21 +57,18 @@ async def user_removed_emoji( ) -> None: await self._logger.ainfo( "user_removed_emoji", - chat_id=user.id, user_id=user.id, ) async def menu_viewed(self, user_id: int) -> None: await self._logger.ainfo( "menu_viewed", - chat_id=user_id, user_id=user_id, ) async def emoji_menu_viewed(self, user_id: int) -> None: await self._logger.ainfo( "emoji_menu_viewed", - chat_id=user_id, user_id=user_id, ) @@ -87,7 +79,6 @@ async def user_authorized_as_admin( ) -> None: await self._logger.ainfo( "user_authorized_as_admin", - chat_id=user.id, user_id=user.id, ) @@ -98,7 +89,6 @@ async def user_already_admin_to_get_admin_rights( ) -> None: await self._logger.ainfo( "user_already_admin_to_get_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -109,7 +99,6 @@ async def admin_token_mismatch_to_get_admin_rights( ) -> None: await self._logger.ainfo( "admin_token_mismatch_to_get_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -120,14 +109,12 @@ async def not_admin_to_relinquish_admin_right( ) -> None: await self._logger.ainfo( "not_admin_to_relinquish_admin_right", - chat_id=user.id, user_id=user.id, ) async def user_relinquished_admin_rights(self, user: User, /) -> None: await self._logger.ainfo( "user_relinquished_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -136,7 +123,6 @@ async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_adm ) -> None: await self._logger.ainfo( "not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -146,7 +132,6 @@ async def other_user_already_admin_to_authorize_other_user_as_admin( ) -> None: await self._logger.ainfo( "other_user_already_admin_to_authorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -156,7 +141,6 @@ async def user_authorized_other_user_as_admin( ) -> None: await self._logger.ainfo( "user_authorized_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -166,7 +150,6 @@ async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_a ) -> None: await self._logger.ainfo( "not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -176,7 +159,6 @@ async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize( ) -> None: await self._logger.ainfo( "other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -186,7 +168,6 @@ async def user_deauthorized_other_user_as_admin( ) -> None: await self._logger.ainfo( "user_deauthorized_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -204,7 +185,6 @@ async def user_bought_emoji( ) -> None: await self._logger.ainfo( "user_bought_emoji", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -216,7 +196,6 @@ async def user_intends_to_buy_emoji( ) -> None: await self._logger.ainfo( "user_intends_to_buy_emoji", - chat_id=user_id, user_id=user_id, ) @@ -227,7 +206,6 @@ async def emoji_already_purchased_to_buy( ) -> None: await self._logger.ainfo( "emoji_already_purchased_to_buy", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -245,7 +223,6 @@ async def user_selected_emoji( ) -> None: await self._logger.ainfo( "user_selected_emoji", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -257,7 +234,6 @@ async def user_intends_to_select_emoji( ) -> None: await self._logger.ainfo( "user_intends_to_select_emoji", - chat_id=user_id, user_id=user_id, ) @@ -268,116 +244,7 @@ async def emoji_not_purchased_to_select( ) -> None: await self._logger.ainfo( "emoji_not_purchased_to_select", - chat_id=user.id, - user_id=user.id, - ) - - -@dataclass(frozen=True, unsafe_hash=False) -class StructlogStarsPurchaseUserLog(StarsPurchaseUserLog): - _logger: FilteringBoundLogger - - async def user_intends_to_buy_stars( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_intends_to_buy_stars", - chat_id=user_id, - user_id=user_id, - ) - - async def user_started_stars_puchase( - self, - user: User, - /, - ) -> None: - await self._logger.ainfo( - "user_started_stars_puchase", - chat_id=user.id, - user_id=user.id, - ) - - async def user_started_stars_puchase_payment( - self, - user: User, - /, - ) -> None: - await self._logger.ainfo( - "user_started_stars_puchase_payment", - user_id=user.id, - ) - - async def stars_purchase_payment_completion_started( - self, - payment: PaidStarsPurchasePayment, - /, - ) -> None: - await self._logger.ainfo( - "stars_purchase_payment_completion_started", - user_id=payment.user_id, - chat_id=payment.user_id, - purchase_id=payment.purchase_id.hex, - ) - - async def stars_purchase_payment_completed( - self, - user: User, - payment: PaidStarsPurchasePayment, - /, - ) -> None: - await self._logger.ainfo( - "stars_purchase_payment_completed", - user_id=payment.user_id, - chat_id=payment.user_id, - purchase_id=payment.purchase_id.hex, - ) - - async def double_stars_purchase_payment_completion( - self, - user: User, - paid_payment: PaidStarsPurchasePayment, - ) -> None: - await self._logger.awarning( - "double_stars_purchase_payment_completion", - user_id=paid_payment.user_id, - chat_id=paid_payment.user_id, - purchase_id=paid_payment.purchase_id.hex, - ) - - async def invalid_stars_for_stars_purchase( - self, - user: User, - stars: Stars, - ) -> None: - await self._logger.aerror( - "invalid_stars_for_stars_purchase", - user_id=user.id, - chat_id=user.id, - stars=stars, - ) - - async def double_stars_purchase_payment_start( - self, - user: User, - purchase_id: UUID, - ) -> None: - await self._logger.ainfo( - "double_stars_purchase_payment_start", - user_id=user.id, - purchase_id=purchase_id.hex, - ) - - async def no_purchase_to_start_stars_purchase_payment( - self, - user: User, - purchase_id: UUID, - ) -> None: - await self._logger.aerror( - "no_purchase_to_start_stars_purchase_payment", user_id=user.id, - purchase_id=purchase_id.hex, ) @@ -475,3 +342,63 @@ async def negative_account_on_set_other_user_account( other_user_id=other_user_id, other_user_account_stars=other_user_account_stars, ) + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogGameUserLog(GameUserLog): + _logger: FilteringBoundLogger + + async def user_is_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_not_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_not_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_already_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_already_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_in_game_to_wait_for_matchmaking( + self, user: User, /, + ) -> None: + await self._logger.ainfo( + "user_is_in_game_to_wait_for_matchmaking", + user_id=user.id, + ) + + async def games_were_matched(self, games: list[Game], /) -> None: + await gather(*map(self._game_was_matched, games)) + + async def _game_was_matched(self, game: Game) -> None: + await self._logger.ainfo( + "game_was_matched", + game_id=game.id.hex, + ) + + async def user_is_not_waiting_for_matchmaking_to_dont_wait( + self, user: User, /, + ) -> None: + await self._logger.ainfo( + "user_is_not_waiting_for_matchmaking_to_dont_wait", + user_id=user.id, + ) diff --git a/src/ttt/infrastructure/adapters/users.py b/src/ttt/infrastructure/adapters/users.py index d001a07..ef7ccf4 100644 --- a/src/ttt/infrastructure/adapters/users.py +++ b/src/ttt/infrastructure/adapters/users.py @@ -6,13 +6,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from ttt.application.user.common.ports.users import Users +from ttt.entities.core.user.rank import UsersWithMaxRating from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating +from ttt.infrastructure.sqlalchemy.stmts import ( + max_rating_and_users_with_max_rating_from_postgres, +) from ttt.infrastructure.sqlalchemy.tables.user import TableUser @dataclass(frozen=True, unsafe_hash=False) class InPostgresUsers(Users): _session: AsyncSession + _users_to_matchmake_limit: int async def contains_user_with_id( self, @@ -54,3 +60,24 @@ async def user_with_id(self, id_: int, /) -> User | None: table_user = await self._session.scalar(stmt) return None if table_user is None else table_user.entity() + + async def some_users_waiting_for_matchmaking_to_matchmake( + self, + ) -> list[User]: + stmt = ( + select(TableUser) + .where(TableUser.has_matchmaking_waiting) + .limit(self._users_to_matchmake_limit) + .with_for_update(skip_locked=True) + ) + result = await self._session.scalars(stmt) + table_users = result.all() + + return [table_user.entity() for table_user in table_users] + + async def max_rating_and_users_with_max_rating( + self, + ) -> tuple[EloRating, UsersWithMaxRating]: + return await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) diff --git a/src/ttt/infrastructure/alembic/env.py b/src/ttt/infrastructure/alembic/env.py index 153c9b9..cdfc1b9 100644 --- a/src/ttt/infrastructure/alembic/env.py +++ b/src/ttt/infrastructure/alembic/env.py @@ -1,3 +1,4 @@ +import warnings from logging.config import fileConfig import alembic_postgresql_enum # noqa: F401 @@ -69,7 +70,10 @@ def run_migrations_online() -> None: context.run_migrations() -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "Cannot correctly sort tables") + + if context.is_offline_mode(): + run_migrations_offline() + else: + run_migrations_online() diff --git a/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py b/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py new file mode 100644 index 0000000..5f4665e --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py @@ -0,0 +1,56 @@ +""" +rename `matchmaking_queue_user_waitings` to `matchmaking_user_waitings`. + +Revision ID: 051831b12f05 +Revises: c122fc5b8ec6 +Create Date: 2025-09-17 08:31:45.586302 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "051831b12f05" +down_revision: str | None = "c122fc5b8ec6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.rename_table( + "matchmaking_queue_user_waitings", "matchmaking_user_waitings", + ) + op.execute(""" + ALTER INDEX ix_matchmaking_queue_user_waitings_user_id + RENAME TO ix_matchmaking_user_waitings_user_id + """) + op.execute(""" + ALTER INDEX matchmaking_queue_user_waitings_pkey + RENAME TO matchmaking_user_waitings_pkey + """) + op.execute(""" + ALTER TABLE matchmaking_user_waitings + RENAME CONSTRAINT matchmaking_queue_user_waitings_user_id_fkey + TO matchmaking_user_waitings_user_id_fkey + """) + + +def downgrade() -> None: + op.rename_table( + "matchmaking_user_waitings", "matchmaking_queue_user_waitings", + ) + op.execute(""" + ALTER INDEX ix_matchmaking_user_waitings_user_id + RENAME TO ix_matchmaking_queue_user_waitings_user_id + """) + op.execute(""" + ALTER INDEX matchmaking_user_waitings_pkey + RENAME TO matchmaking_queue_user_waitings_pkey + """) + op.execute(""" + ALTER TABLE matchmaking_queue_user_waitings + RENAME CONSTRAINT matchmaking_user_waitings_user_id_fkey + TO matchmaking_queue_user_waitings_user_id_fkey + """) diff --git a/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py b/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py new file mode 100644 index 0000000..8acfced --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py @@ -0,0 +1,44 @@ +""" +remove `IS TRUE` from `ix_users_has_matchmaking_waiting`. + +Revision ID: 2dcb2be9e277 +Revises: 6642791b3bf8 +Create Date: 2025-09-24 13:46:33.322702 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "2dcb2be9e277" +down_revision: str | None = "6642791b3bf8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index( + op.f("ix_users_has_matchmaking_waiting"), + table_name="users", + postgresql_where="(has_matchmaking_waiting IS TRUE)", + ) + op.create_index( + op.f("ix_users_has_matchmaking_waiting"), + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where="has_matchmaking_waiting", + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_has_matchmaking_waiting"), table_name="users") + op.create_index( + op.f("ix_users_has_matchmaking_waiting"), + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where="(has_matchmaking_waiting IS TRUE)", + ) diff --git a/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py b/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py new file mode 100644 index 0000000..9ea34d9 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py @@ -0,0 +1,30 @@ +""" +index `users.rating`. + +Revision ID: 6642791b3bf8 +Revises: 7ce97d6efd2c +Create Date: 2025-09-20 08:16:00.242481 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "6642791b3bf8" +down_revision: str | None = "7ce97d6efd2c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_users_rating"), "users", ["rating"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_rating"), table_name="users") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py b/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py new file mode 100644 index 0000000..58cdb81 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py @@ -0,0 +1,59 @@ +""" +remove aggregation data from `users`. + +Revision ID: 679c935495d1 +Revises: dc59db88676c +Create Date: 2025-09-16 04:42:25.995128 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "679c935495d1" +down_revision: str | None = "dc59db88676c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "number_of_draws") + op.drop_column("users", "number_of_wins") + op.drop_column("users", "number_of_defeats") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column( + "number_of_defeats", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "number_of_wins", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "number_of_draws", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py b/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py new file mode 100644 index 0000000..6617a1f --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py @@ -0,0 +1,90 @@ +""" +don't index `null`s. + +Revision ID: 7ce97d6efd2c +Revises: ba0f7132baef +Create Date: 2025-09-19 11:58:53.188572 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "7ce97d6efd2c" +down_revision: str | None = "ba0f7132baef" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index(op.f("ix_users_selected_emoji_id"), table_name="users") + op.create_index( + op.f("ix_users_selected_emoji_id"), + "users", + ["selected_emoji_id"], + unique=False, + postgresql_where="(selected_emoji_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_users_current_game_id"), table_name="users") + op.create_index( + op.f("ix_users_current_game_id"), + "users", + ["current_game_id"], + unique=False, + postgresql_where="(current_game_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_cells_user_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_user_filler_id"), + "cells", + ["user_filler_id"], + unique=False, + postgresql_where="(user_filler_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_cells_ai_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_ai_filler_id"), + "cells", + ["ai_filler_id"], + unique=False, + postgresql_where="(ai_filler_id IS NOT NULL)", + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_selected_emoji_id"), table_name="users") + op.create_index( + op.f("ix_users_selected_emoji_id"), + "users", + ["selected_emoji_id"], + unique=False, + ) + + op.drop_index(op.f("ix_users_current_game_id"), table_name="users") + op.create_index( + op.f("ix_users_current_game_id"), + "users", + ["current_game_id"], + unique=False, + ) + + op.drop_index(op.f("ix_cells_user_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_user_filler_id"), + "cells", + ["user_filler_id"], + unique=False, + ) + + op.drop_index(op.f("ix_cells_ai_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_ai_filler_id"), + "cells", + ["ai_filler_id"], + unique=False, + ) diff --git a/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py b/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py new file mode 100644 index 0000000..51b1779 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py @@ -0,0 +1,91 @@ +""" +merge `matchmaking_user_waitings` and `users`. + +Revision ID: ba0f7132baef +Revises: 051831b12f05 +Create Date: 2025-09-17 17:37:58.210228 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "ba0f7132baef" +down_revision: str | None = "051831b12f05" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_matchmaking_user_waitings_user_id"), + table_name="matchmaking_user_waitings", + ) + op.drop_table("matchmaking_user_waitings") + op.add_column( + "users", + sa.Column( + "has_matchmaking_waiting", + sa.Boolean(), + server_default="false", + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "matchmaking_waiting_start_datetime", sa.DateTime(), nullable=True, + ), + ) + op.create_index( + "ix_users_has_matchmaking_waiting", + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where=sa.text("has_matchmaking_waiting IS true"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_users_has_matchmaking_waiting", + table_name="users", + postgresql_where=sa.text("has_matchmaking_waiting IS true"), + ) + op.drop_column("users", "matchmaking_waiting_start_datetime") + op.drop_column("users", "has_matchmaking_waiting") + op.create_table( + "matchmaking_user_waitings", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column( + "start_datetime", + postgresql.TIMESTAMP(), + autoincrement=False, + nullable=False, + ), + sa.Column("user_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("matchmaking_user_waitings_user_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint( + "id", name=op.f("matchmaking_user_waitings_pkey"), + ), + ) + op.create_index( + op.f("ix_matchmaking_user_waitings_user_id"), + "matchmaking_user_waitings", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py b/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py new file mode 100644 index 0000000..b4eaa8d --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py @@ -0,0 +1,46 @@ +""" +rename `users.game_location_game_id` to `users.current_game_id`. + +Revision ID: c122fc5b8ec6 +Revises: 679c935495d1 +Create Date: 2025-09-16 11:10:56.703137 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "c122fc5b8ec6" +down_revision: str | None = "679c935495d1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column( + "users", "game_location_game_id", new_column_name="current_game_id", + ) + op.execute(""" + ALTER INDEX ix_users_game_location_game_id + RENAME TO ix_users_current_game_id + """) + op.execute(""" + ALTER TABLE users RENAME CONSTRAINT users_game_location_game_id_fkey + TO users_current_game_id_fkey + """) + + +def downgrade() -> None: + op.alter_column( + "users", "current_game_id", new_column_name="game_location_game_id", + ) + op.execute(""" + ALTER INDEX ix_users_current_game_id + RENAME TO ix_users_game_location_game_id + """) + op.execute(""" + ALTER TABLE users RENAME CONSTRAINT users_current_game_id_fkey + TO users_game_location_game_id_fkey + """) diff --git a/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py b/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py new file mode 100644 index 0000000..0bad67a --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py @@ -0,0 +1,39 @@ +""" +add `username` columns to `invitations_to_game`. + +Revision ID: dc1b9f720a1d +Revises: 2dcb2be9e277 +Create Date: 2025-10-05 09:44:49.062023 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "dc1b9f720a1d" +down_revision: str | None = "2dcb2be9e277" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "invitations_to_game", + sa.Column("inviting_user_username", sa.String(), nullable=True), + ) + op.add_column( + "invitations_to_game", + sa.Column("invited_user_username", sa.String(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("invitations_to_game", "invited_user_username") + op.drop_column("invitations_to_game", "inviting_user_username") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py b/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py new file mode 100644 index 0000000..402a5e7 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py @@ -0,0 +1,51 @@ +""" +remove `last_games`. + +Revision ID: dc59db88676c +Revises: 325e2d4a600b +Create Date: 2025-09-15 10:38:01.081651 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "dc59db88676c" +down_revision: str | None = "325e2d4a600b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("last_games") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "last_games", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column("game_id", sa.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["games.id"], + name=op.f("last_games_game_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("last_games_user_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint("id", name=op.f("last_games_pkey")), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/background_tasks.py b/src/ttt/infrastructure/background_tasks.py deleted file mode 100644 index 7b06e80..0000000 --- a/src/ttt/infrastructure/background_tasks.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncio -from collections.abc import Coroutine -from dataclasses import dataclass, field -from types import TracebackType -from typing import Any, Self - - -@dataclass(frozen=True, unsafe_hash=False) -class BackgroundTasks: - _loop: asyncio.AbstractEventLoop = field( - default_factory=asyncio.get_running_loop, - ) - _tasks: set[asyncio.Task[Any]] = field(init=False, default_factory=set) - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - error_type: type[BaseException] | None, - error: BaseException | None, - traceback: TracebackType | None, - ) -> None: - for task in self._tasks: - task.cancel() - - errors = await asyncio.gather(*self._tasks, return_exceptions=True) - errors = [error for error in errors if isinstance(error, Exception)] - - if errors: - raise ExceptionGroup("unhandled errors", errors) # noqa: TRY003 - - def create_task[T](self, coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]: - task = self._loop.create_task(coro) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - return task diff --git a/src/ttt/infrastructure/buffer.py b/src/ttt/infrastructure/buffer.py deleted file mode 100644 index b565324..0000000 --- a/src/ttt/infrastructure/buffer.py +++ /dev/null @@ -1,29 +0,0 @@ -from asyncio import Event -from collections import deque -from collections.abc import AsyncIterator -from dataclasses import dataclass, field - - -@dataclass(frozen=True, unsafe_hash=False) -class Buffer[ValueT]: - _values: deque[ValueT] = field(default_factory=deque) - _has_values: Event = field(default_factory=Event, init=False) - - def __post_init__(self) -> None: - if self._values: - self._has_values.set() - - def __len__(self) -> int: - return len(self._values) - - def add(self, value: ValueT) -> None: - self._values.append(value) - self._has_values.set() - - async def stream(self) -> AsyncIterator[ValueT]: - while True: - await self._has_values.wait() - yield self._values.popleft() - - if not self._values: - self._has_values.clear() diff --git a/src/ttt/entities/core/matchmaking_queue/__init__.py b/src/ttt/infrastructure/dishka/__init__.py similarity index 100% rename from src/ttt/entities/core/matchmaking_queue/__init__.py rename to src/ttt/infrastructure/dishka/__init__.py diff --git a/src/ttt/infrastructure/dishka/next_container.py b/src/ttt/infrastructure/dishka/next_container.py new file mode 100644 index 0000000..068c325 --- /dev/null +++ b/src/ttt/infrastructure/dishka/next_container.py @@ -0,0 +1,9 @@ +from collections.abc import Mapping +from typing import Any, Protocol + +from dishka.async_container import AsyncContextWrapper + + +class NextContainer(Protocol): + def __call__(self, context: Mapping[Any, Any] = {}) -> AsyncContextWrapper: + ... diff --git a/src/ttt/infrastructure/multi_asynccontextmanager.py b/src/ttt/infrastructure/multi_asynccontextmanager.py new file mode 100644 index 0000000..086b98d --- /dev/null +++ b/src/ttt/infrastructure/multi_asynccontextmanager.py @@ -0,0 +1,23 @@ +from asyncio import gather +from collections.abc import AsyncIterator +from contextlib import AbstractAsyncContextManager, asynccontextmanager + + +@asynccontextmanager +async def multi_asynccontextmanager[T]( + *managers: AbstractAsyncContextManager[T], +) -> AsyncIterator[list[T]]: + result = await gather(*(manager.__aenter__() for manager in managers)) # noqa: PLC2801 + + try: + yield result + except BaseException as error: # noqa: BLE001 + await gather(*( + manager.__aexit__(type(error), error, error.__traceback__) + for manager in managers + )) + else: + await gather(*( + manager.__aexit__(None, None, None) + for manager in managers + )) diff --git a/src/ttt/infrastructure/nats/messages.py b/src/ttt/infrastructure/nats/messages.py deleted file mode 100644 index 410b4ec..0000000 --- a/src/ttt/infrastructure/nats/messages.py +++ /dev/null @@ -1,27 +0,0 @@ -from collections.abc import AsyncIterator - -from nats.aio.msg import Msg -from nats.errors import TimeoutError as NatsTimeoutError -from nats.js import JetStreamContext - - -async def at_least_once_messages( - subscription: JetStreamContext.PullSubscription, - batch: int = 1, - timeout: float | None = 5, # noqa: ASYNC109 - heartbeat: float | None = None, -) -> AsyncIterator[Msg]: - while True: - try: - messages = await subscription.fetch(batch, timeout, heartbeat) - except NatsTimeoutError: - continue - - for message in messages: - try: - yield message - except BaseException as error: - await message.nak() - raise error from error - else: - await message.ack() diff --git a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index 00ae60c..0000000 --- a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,45 +0,0 @@ -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from types import TracebackType -from typing import ClassVar, Self, cast - -from nats.js import JetStreamContext -from pydantic import TypeAdapter - -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.infrastructure.nats.messages import at_least_once_messages - - -@dataclass -class InNatsPaidStarsPurchasePaymentInbox: - _js: JetStreamContext - - _subscription: JetStreamContext.PullSubscription = field(init=False) - _adapter: ClassVar = TypeAdapter(PaidStarsPurchasePayment) - _subject: ClassVar = "user.stars_purchase.paid_payment_inbox" - - async def __aenter__(self) -> Self: - self._subscription = await self._js.pull_subscribe( - self._subject, - "ttt-user-stars_purchase-paid_payment_inbox", - "USER", - ) - return self - - async def __aexit__( - self, - _: type[BaseException] | None, - __: BaseException | None, - ___: TracebackType | None, - ) -> None: ... - - async def push(self, payment: PaidStarsPurchasePayment) -> None: - json = self._adapter.dump_json(payment) - await self._js.publish(self._subject, json) - - async def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: - async for message in at_least_once_messages(self._subscription): - yield cast( - PaidStarsPurchasePayment, - self._adapter.validate_json(message.data), - ) diff --git a/src/ttt/infrastructure/nats/__init__.py b/src/ttt/infrastructure/processors/__init__.py similarity index 100% rename from src/ttt/infrastructure/nats/__init__.py rename to src/ttt/infrastructure/processors/__init__.py diff --git a/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py b/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py new file mode 100644 index 0000000..1319a07 --- /dev/null +++ b/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py @@ -0,0 +1,29 @@ +from asyncio import sleep +from dataclasses import dataclass + +from structlog.types import FilteringBoundLogger + +from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 + AutoCancelInvitationsToGame, +) +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.processors.processor import Processor +from ttt.infrastructure.retrier import Retrier +from ttt.infrastructure.structlog.logger import unexpected_error_logging + + +@dataclass(frozen=True) +class AutoCancelInvitationsToGameProcessor(Processor): + _interval_seconds: float + _logger: FilteringBoundLogger + + async def __call__(self, container: NextContainer) -> None: + while True: + await sleep(self._interval_seconds) + async with unexpected_error_logging(self._logger): # noqa: SIM117 + async with container() as request: + retrier = await request.get(Retrier) + cancel_invitations = await request.get( + AutoCancelInvitationsToGame, + ) + await retrier(cancel_invitations) diff --git a/src/ttt/infrastructure/processors/matchmake_processor.py b/src/ttt/infrastructure/processors/matchmake_processor.py new file mode 100644 index 0000000..c1a3d50 --- /dev/null +++ b/src/ttt/infrastructure/processors/matchmake_processor.py @@ -0,0 +1,39 @@ +from asyncio import Semaphore, TaskGroup, sleep +from dataclasses import dataclass, field + +from structlog.types import FilteringBoundLogger + +from ttt.application.user.game.matchmake import Matchmake +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.processors.processor import Processor +from ttt.infrastructure.retrier import Retrier +from ttt.infrastructure.structlog.logger import unexpected_error_log + + +@dataclass +class MatchmakeProcessor(Processor): + _max_workers: int + _worker_creation_interval_seconds: float + _logger: FilteringBoundLogger + + _semaphore: Semaphore = field(init=False) + _task_group: TaskGroup = field(init=False, default_factory=TaskGroup) + + def __post_init__(self) -> None: + self._semaphore = Semaphore(self._max_workers) + + async def __call__(self, container: NextContainer) -> None: + async with self._task_group: + while True: + await sleep(self._worker_creation_interval_seconds) + if not self._semaphore.locked(): + self._task_group.create_task(self._matchmake(container)) + + async def _matchmake(self, container: NextContainer) -> None: + try: + async with self._semaphore, container() as request: + matchmake = await request.get(Matchmake) + retrier = await request.get(Retrier) + await retrier(matchmake) + except Exception as error: # noqa: BLE001 + await unexpected_error_log(self._logger, error) diff --git a/src/ttt/infrastructure/processors/processor.py b/src/ttt/infrastructure/processors/processor.py new file mode 100644 index 0000000..895de82 --- /dev/null +++ b/src/ttt/infrastructure/processors/processor.py @@ -0,0 +1,7 @@ +from typing import Any, Protocol + +from ttt.infrastructure.dishka.next_container import NextContainer + + +class Processor(Protocol): + async def __call__(self, container: NextContainer, /) -> Any: ... # noqa: ANN401 diff --git a/src/ttt/infrastructure/pydantic_settings/envs.py b/src/ttt/infrastructure/pydantic_settings/envs.py index a0fc325..3f489aa 100644 --- a/src/ttt/infrastructure/pydantic_settings/envs.py +++ b/src/ttt/infrastructure/pydantic_settings/envs.py @@ -23,6 +23,16 @@ class Envs(BaseSettings): gemini_url: str + dialog_ttl: int + + matchmaking_max_workers: int + matchmaking_worker_max_users: int + matchmaking_worker_creation_interval_seconds: float + + auto_cancel_invitations_to_game_interval_seconds: float + + serialization_error_max_retries: int + @classmethod def settings_customise_sources( cls, diff --git a/src/ttt/presentation/tasks/__init__.py b/src/ttt/infrastructure/remote_funcs/__init__.py similarity index 100% rename from src/ttt/presentation/tasks/__init__.py rename to src/ttt/infrastructure/remote_funcs/__init__.py diff --git a/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py b/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py new file mode 100644 index 0000000..3533bfc --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py @@ -0,0 +1,40 @@ +from uuid import UUID + +from dishka import AsyncContainer + +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.remote_funcs.nats_remote_func import nats_remote +from ttt.infrastructure.retrier import Retrier + + +@nats_remote( + subject="stars_purchase.stars_purchase.complete_stars_purchase_payment", + pull_subscribe=lambda js, subject: js.pull_subscribe( + subject, + "ttt-stars_purchase-stars_purchase-complete_stars_purchase_payment", + stream="STARS_PURCHASE", + ), +) +async def complete_stars_purchase_payment_remotely( + container: AsyncContainer, + *, + purchase_id: str, + payment_success_id: str, + payment_success_gateway_id: str, +) -> None: + payment_success = PaymentSuccess( + payment_success_id, payment_success_gateway_id, + ) + + retrier = await container.get(Retrier) + complete_stars_purchase_payment = await container.get( + CompleteStarsPurchasePayment, + ) + await retrier( + complete_stars_purchase_payment, + UUID(hex=purchase_id), + payment_success, + ) diff --git a/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py b/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py new file mode 100644 index 0000000..a4184da --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py @@ -0,0 +1,30 @@ +from uuid import UUID + +from dishka import AsyncContainer + +from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame +from ttt.infrastructure.remote_funcs.nats_remote_func import nats_remote +from ttt.infrastructure.retrier import Retrier + + +@nats_remote( + subject="game.game.make_ai_move_in_game", + pull_subscribe=lambda js, subject: js.pull_subscribe( + subject, + durable="ttt-game-game-make_ai_move_in_game", + stream="GAME", + ), +) +async def make_ai_move_in_game_remotely( + container: AsyncContainer, + *, + user_id: int, + game_id: str, + ai_id: str, +) -> None: + retrier = await container.get(Retrier) + make_ai_move_in_game = await container.get(MakeAiMoveInGame) + + await retrier( + make_ai_move_in_game, user_id, UUID(hex=game_id), UUID(hex=ai_id), + ) diff --git a/src/ttt/infrastructure/remote_funcs/nats_remote_func.py b/src/ttt/infrastructure/remote_funcs/nats_remote_func.py new file mode 100644 index 0000000..5e4faeb --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/nats_remote_func.py @@ -0,0 +1,161 @@ +import json +from asyncio import ( + AbstractEventLoop, + Semaphore, + Task, + gather, + get_event_loop, +) +from collections.abc import ( + AsyncIterator, + Callable, +) +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any, Protocol, Self + +from dishka import AsyncContainer +from nats.aio.msg import Msg as NatsMessage +from nats.errors import TimeoutError as NatsTimeoutError +from nats.js import JetStreamContext +from structlog.types import FilteringBoundLogger + +from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.structlog.logger import ( + unexpected_error_log, +) + + +class PullSubscribe(Protocol): + async def __call__( + self, js: JetStreamContext, subject: str, + ) -> JetStreamContext.PullSubscription: ... + + +class NatsRemoteFuncBody[**PmT](Protocol): + async def __call__( + self, + container: AsyncContainer, + *args: PmT.args, + **kwargs: PmT.kwargs, + ) -> Any: ... # noqa: ANN401 + + +@dataclass +class NatsRemoteFunc[**PmT = ...]: + body: NatsRemoteFuncBody[PmT] + _pull_subscribe: PullSubscribe + _subject: str + + _max_workers: int | None = None + _pull_consume_batch: int | None = None + _pull_consume_timeout: float | None = None + + _js: JetStreamContext = field(init=False) + _pull_subscription: JetStreamContext.PullSubscription = field(init=False) + _workers: set[Task[None]] = field(init=False, default_factory=set) + _semaphore: Semaphore = field(init=False, default_factory=Semaphore) + _container: AsyncContainer = field(init=False) + _loop: AbstractEventLoop = field(init=False) + + @asynccontextmanager + async def startup( + self, + js: JetStreamContext, + max_workers: int | None = None, + pull_consume_batch: int | None = None, + pull_consume_timeout: float | None = None, + ) -> AsyncIterator[Self]: + self._loop = get_event_loop() + self._max_workers = max_workers or self._max_workers or 1000 + self._pull_consume_batch = ( + pull_consume_batch or self._pull_consume_batch or 1 + ) + self._pull_consume_timeout = ( + pull_consume_timeout or self._pull_consume_timeout or 5 + ) + + self._js = js + self._pull_subscription = await self._pull_subscribe( + self._js, + self._subject, + ) + self._semaphore = Semaphore(self._max_workers) + + try: + yield self + finally: + await gather(*self._workers) + + async def __call__(self, *args: PmT.args, **kwargs: PmT.kwargs) -> None: + payload = {"args": args, "kwargs": kwargs} + + await not_none(self._js).publish( + self._subject, + payload=json.dumps(payload).encode(), + ) + + async def processor(self, next_container: NextContainer) -> None: + while True: + try: + messages = await self._pull_subscription.fetch( + batch=not_none(self._pull_consume_batch), + timeout=self._pull_consume_timeout, + ) + except NatsTimeoutError: + continue + + for message in messages: + await self._create_worker(message, next_container) + + async def _create_worker( + self, message: NatsMessage, next_container: NextContainer, + ) -> None: + await self._semaphore.acquire() + task = self._loop.create_task(self._worker(message, next_container)) + self._workers.add(task) + task.add_done_callback(self._workers.discard) + + async def _worker( + self, message: NatsMessage, next_container: NextContainer, + ) -> None: + async with next_container() as container: + try: + json_str = message.data.decode() + json_ = json.loads(json_str) + await self.body(container, *json_["args"], **json_["kwargs"]) + except Exception as error: # noqa: BLE001 + await message.nak() + + logger = await container.get(FilteringBoundLogger) + await unexpected_error_log(logger, error) + + self._semaphore.release() + except BaseException as error: + await message.nak() + self._semaphore.release() + raise error from error + else: + await message.ack() + self._semaphore.release() + + +def nats_remote[**PmT]( + subject: str, + pull_subscribe: PullSubscribe, + max_workers: int | None = None, + pull_consume_batch: int | None = None, + pull_consume_timeout: float | None = None, +) -> Callable[[NatsRemoteFuncBody[PmT]], NatsRemoteFunc[PmT]]: + def decorator(body: NatsRemoteFuncBody[PmT]) -> NatsRemoteFunc[PmT]: + return NatsRemoteFunc( + body, + pull_subscribe, + subject, + max_workers, + pull_consume_batch, + pull_consume_timeout, + ) + + return decorator diff --git a/src/ttt/infrastructure/retrier.py b/src/ttt/infrastructure/retrier.py new file mode 100644 index 0000000..4301467 --- /dev/null +++ b/src/ttt/infrastructure/retrier.py @@ -0,0 +1,43 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + + +type MaxRetries = int +type Retries = int + + +@dataclass +class Retrier: + _max_retries_map: dict[type[BaseException], MaxRetries] + + _retries_map: dict[type[BaseException], Retries] = field( + init=False, default_factory=dict, + ) + + def retry(self) -> bool: + return bool(self._retries_map) + + async def __call__[**PmT, RT]( + self, + action: Callable[PmT, Awaitable[RT]], + *args: PmT.args, + **kwargs: PmT.kwargs, + ) -> RT: + while True: + try: + return await action(*args, **kwargs) + except BaseException as error: + if type(error) not in self._max_retries_map: + raise error from error + + for error_type, max_retries in self._max_retries_map.items(): + if not isinstance(error, error_type): + continue + + if error_type not in self._retries_map: + self._retries_map[error_type] = 0 + + self._retries_map[error_type] += 1 + + if self._retries_map[error_type] > max_retries: + raise error from error diff --git a/src/ttt/infrastructure/sqlalchemy/serialization.py b/src/ttt/infrastructure/sqlalchemy/serialization.py new file mode 100644 index 0000000..3fb7bd9 --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/serialization.py @@ -0,0 +1,18 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from psycopg.errors import SerializationFailure +from sqlalchemy.exc import OperationalError + +from ttt.application.common.errors.serialization_error import SerializationError + + +@contextmanager +def reraise_serialization_error() -> Iterator[None]: + try: + yield + except OperationalError as error: + if isinstance(error.orig, SerializationFailure): + raise SerializationError from error + + raise error from error diff --git a/src/ttt/infrastructure/sqlalchemy/stmts.py b/src/ttt/infrastructure/sqlalchemy/stmts.py index a98ae9a..681e799 100644 --- a/src/ttt/infrastructure/sqlalchemy/stmts.py +++ b/src/ttt/infrastructure/sqlalchemy/stmts.py @@ -1,9 +1,12 @@ from collections.abc import Sequence from typing import cast -from sqlalchemy import exists, select +from sqlalchemy import exists, func, select from sqlalchemy.ext.asyncio import AsyncSession +from ttt.entities.core.user.rank import UsersWithMaxRating +from ttt.entities.elo.rating import EloRating +from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.tables.user import TableUser, TableUserEmoji @@ -39,3 +42,28 @@ async def selected_user_emoji_str_from_postgres( async def user_exists_in_postgres(session: AsyncSession, user_id: int) -> bool: stmt = select(exists(1).where(TableUser.id == user_id)) return bool(await session.scalar(stmt)) + + +async def max_rating_and_users_with_max_rating_from_postgres( + session: AsyncSession, +) -> tuple[EloRating, UsersWithMaxRating]: + max_rating_stmt = select(func.max(TableUser.rating)) + max_rating = not_none(await session.scalar(max_rating_stmt)) + + raw_users_with_max_rating_stmt = ( + select(func.count(1)) + .select_from( + select(1) + .where(TableUser.rating == max_rating) + .limit(2) + .subquery(), + ) + ) + raw_users_with_max_rating = not_none( + await session.scalar(raw_users_with_max_rating_stmt), + ) + users_with_max_rating: UsersWithMaxRating = ( + "1" if raw_users_with_max_rating == 1 else ">1" + ) + + return max_rating, users_with_max_rating diff --git a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py index 440c596..c27260e 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py @@ -3,8 +3,8 @@ from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( TableInvitationToGame, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableMatchmakingQueue, -) from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchase, +) from ttt.infrastructure.sqlalchemy.tables.user import TableUser diff --git a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py index 590e2a7..d84a747 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py @@ -7,9 +7,7 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueueAtomic, -) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import ( PaymentAtomic, @@ -22,14 +20,14 @@ TableInvitationToGameAtomic, table_invitation_to_game_atomic, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableMatchmakingQueueAtomic, - table_matchmaking_queue_atomic, -) from ttt.infrastructure.sqlalchemy.tables.payment import ( TablePaymentAtomic, table_payment_atomic, ) +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchaseAtomic, + table_stars_purchase_atomic, +) from ttt.infrastructure.sqlalchemy.tables.user import ( TableUserAtomic, table_user_atomic, @@ -38,10 +36,11 @@ type TableAtomic = ( TableUserAtomic + | TableStarsPurchaseAtomic | TableGameAtomic - | TableMatchmakingQueueAtomic | TableInvitationToGameAtomic | TablePaymentAtomic + | None ) @@ -55,12 +54,12 @@ def mapped_table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 if isinstance(entity, PaymentAtomic): return table_payment_atomic(entity) - if isinstance(entity, MatchmakingQueueAtomic): - return table_matchmaking_queue_atomic(entity) - if isinstance(entity, InvitationToGameAtomic): return table_invitation_to_game_atomic(entity) + if isinstance(entity, StarsPurchaseAtomic): + return table_stars_purchase_atomic(entity) + def linked_table_atomic(entity: Atomic) -> TableAtomic: if hasattr(entity, "_table_entity"): diff --git a/src/ttt/infrastructure/sqlalchemy/tables/game.py b/src/ttt/infrastructure/sqlalchemy/tables/game.py index 8f720bd..8d40286 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/game.py @@ -15,7 +15,6 @@ Game, GameAtomic, GameState, - number_of_unfilled_cells, ) from ttt.entities.core.game.game_result import ( CancelledGameResult, @@ -87,11 +86,22 @@ class TableCell(Base[Cell]): user_filler_id: Mapped[int | None] = mapped_column( BigInteger(), ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - index=True, ) ai_filler_id: Mapped[UUID | None] = mapped_column( ForeignKey("ais.id", deferrable=True, initially="DEFERRED"), - index=True, + ) + + __table_args__ = ( + Index( + "ix_cells_user_filler_id", + user_filler_id, + postgresql_where=(user_filler_id.is_not(None)), + ), + Index( + "ix_cells_ai_filler_id", + ai_filler_id, + postgresql_where=(ai_filler_id.is_not(None)), + ), ) def __entity__(self) -> Cell: @@ -284,7 +294,6 @@ def __entity__(self) -> Game: self._player2(), Emoji(self.player2_emoji_str), board, - number_of_unfilled_cells(board), self._result(), self.state.entity(), ) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py index ab30e4f..034e318 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py @@ -75,6 +75,7 @@ class TableInvitationToGame(Base[InvitationToGame]): ), index=True, ) + inviting_user_username: Mapped[str | None] invited_user_id: Mapped[int] = mapped_column( ForeignKey( "users.id", @@ -83,6 +84,7 @@ class TableInvitationToGame(Base[InvitationToGame]): ), index=True, ) + invited_user_username: Mapped[str | None] invitation_datetime: Mapped[datetime] = mapped_column() state: Mapped[TableInvitationToGameState] = mapped_column( invitation_to_game_state, @@ -117,6 +119,8 @@ def __entity__(self) -> InvitationToGame: invited_user=self.invited_user.entity(), invitation_datetime=self.invitation_datetime, state=self.state.entity(), + inviting_user_username=self.inviting_user_username, + invited_user_username=self.invited_user_username, ) @classmethod @@ -127,6 +131,8 @@ def of(cls, it: InvitationToGame) -> "TableInvitationToGame": invited_user_id=it.invited_user.id, invitation_datetime=it.invitation_datetime, state=TableInvitationToGameState.of(it.state), + inviting_user_username=it.inviting_user_username, + invited_user_username=it.invited_user_username, ) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py deleted file mode 100644 index 598eb6c..0000000 --- a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime -from uuid import UUID - -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, - MatchmakingQueueAtomic, -) -from ttt.entities.core.matchmaking_queue.user_waiting import UserWaiting -from ttt.infrastructure.sqlalchemy.tables.common import Base -from ttt.infrastructure.sqlalchemy.tables.user import TableUser - - -class TableUserWaiting(Base[UserWaiting]): - __tablename__ = "matchmaking_queue_user_waitings" - - id: Mapped[UUID] = mapped_column(primary_key=True) - start_datetime: Mapped[datetime] - user_id: Mapped[int] = mapped_column( - ForeignKey( - "users.id", - deferrable=True, - initially="DEFERRED", - ), - index=True, - ) - - user: Mapped[TableUser] = relationship(lazy="selectin") - - def __entity__(self) -> UserWaiting: - return UserWaiting( - id_=self.id, - user=self.user.entity(), - start_datetime=self.start_datetime, - ) - - @classmethod - def of(cls, it: UserWaiting) -> "TableUserWaiting": - return TableUserWaiting( - id=it.id_, - user_id=it.user.id, - start_datetime=it.start_datetime, - ) - - -type TableMatchmakingQueue = None -type TableMatchmakingQueueAtomic = TableMatchmakingQueue | TableUserWaiting - - -def table_matchmaking_queue_atomic( - entity: MatchmakingQueueAtomic, -) -> TableMatchmakingQueueAtomic: - match entity: - case MatchmakingQueue(): - return None - - case UserWaiting(): - return TableUserWaiting.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py b/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py new file mode 100644 index 0000000..5d91b7f --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py @@ -0,0 +1,67 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ttt.entities.core.stars_purchase.stars_purchase import ( + StarsPurchase, + StarsPurchaseAtomic, +) +from ttt.infrastructure.sqlalchemy.tables.common import Base +from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +class TableStarsPurchase(Base[StarsPurchase]): + __tablename__ = "stars_purchases" + + id: Mapped[UUID] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", deferrable=True, initially="DEFERRED"), + index=True, + ) + stars: Mapped[int] + payment_id: Mapped[UUID | None] = mapped_column( + ForeignKey("payments.id", deferrable=True, initially="DEFERRED"), + ) + + payment: Mapped[TablePayment | None] = relationship( + TablePayment, lazy="selectin", + ) + user: Mapped[TableUser] = relationship(TableUser, lazy="selectin") + + __table_args__ = ( + Index( + "ix_stars_purchases_payment_id", + payment_id, + postgresql_where=(payment_id.is_not(None)), + ), + ) + + def __entity__(self) -> StarsPurchase: + return StarsPurchase( + id_=self.id, + user=self.user.entity(), + stars=self.stars, + payment=None if self.payment is None else self.payment.entity(), + ) + + @classmethod + def of(cls, it: StarsPurchase) -> "TableStarsPurchase": + return TableStarsPurchase( + id=it.id_, + user_id=it.user.id, + stars=it.stars, + payment_id=None if it.payment is None else it.payment.id_, + ) + + +type TableStarsPurchaseAtomic = TableStarsPurchase + + +def table_stars_purchase_atomic( + entity: StarsPurchaseAtomic, +) -> TableStarsPurchaseAtomic: + match entity: + case StarsPurchase(): + return TableStarsPurchase.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index f1b4d6e..0e84139 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -13,14 +13,11 @@ AdminRightViaOtherAdmin, ) from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.last_game import LastGame -from ttt.entities.core.user.location import UserGameLocation -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting from ttt.entities.core.user.user import User, UserAtomic from ttt.entities.text.emoji import Emoji from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.tables.common import Base -from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment class TableUserEmoji(Base[UserEmoji]): @@ -52,77 +49,6 @@ def of(cls, it: UserEmoji) -> "TableUserEmoji": ) -class TableStarsPurchase(Base[StarsPurchase]): - __tablename__ = "stars_purchases" - - id: Mapped[UUID] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - index=True, - ) - stars: Mapped[int] - payment_id: Mapped[UUID | None] = mapped_column( - ForeignKey("payments.id", deferrable=True, initially="DEFERRED"), - ) - - payment: Mapped[TablePayment | None] = relationship( - TablePayment, - lazy="joined", - ) - - __table_args__ = ( - Index( - "ix_stars_purchases_payment_id", - payment_id, - postgresql_where=(payment_id.is_not(None)), - ), - ) - - def __entity__(self) -> StarsPurchase: - return StarsPurchase( - id_=self.id, - user_id=self.user_id, - stars=self.stars, - payment=None if self.payment is None else self.payment.entity(), - ) - - @classmethod - def of(cls, it: StarsPurchase) -> "TableStarsPurchase": - return TableStarsPurchase( - id=it.id_, - user_id=it.user_id, - stars=it.stars, - payment_id=None if it.payment is None else it.payment.id_, - ) - - -class TableLastGame(Base[LastGame]): - __tablename__ = "last_games" - - id: Mapped[UUID] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - ) - game_id: Mapped[UUID] = mapped_column( - ForeignKey("games.id", deferrable=True, initially="DEFERRED"), - ) - - def __entity__(self) -> LastGame: - return LastGame( - id=self.id, - user_id=self.user_id, - game_id=self.game_id, - ) - - @classmethod - def of(cls, it: LastGame) -> "TableLastGame": - return TableLastGame( - id=it.id, - user_id=it.user_id, - game_id=it.game_id, - ) - - class TableAdminRight(StrEnum): via_admin_token = "via_admin_token" # noqa: S105 via_other_admin = "via_other_admin" @@ -153,35 +79,36 @@ class TableUser(Base[User]): account_stars: Mapped[int] = mapped_column(server_default="0") selected_emoji_id: Mapped[UUID | None] = mapped_column( ForeignKey("user_emojis.id", deferrable=True, initially="DEFERRED"), - index=True, ) - rating: Mapped[float] - number_of_wins: Mapped[int] - number_of_draws: Mapped[int] - number_of_defeats: Mapped[int] - game_location_game_id: Mapped[UUID | None] = mapped_column( + rating: Mapped[float] = mapped_column(index=True) + current_game_id: Mapped[UUID | None] = mapped_column( ForeignKey("games.id", deferrable=True, initially="DEFERRED"), - index=True, ) admin_right: Mapped[TableAdminRight | None] = mapped_column(admin_right) admin_right_via_other_admin_admin_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", deferrable=True, initially="DEFERRED"), ) + has_matchmaking_waiting: Mapped[bool] = mapped_column( + server_default="false", + ) + matchmaking_waiting_start_datetime: Mapped[datetime | None] emojis: Mapped[list[TableUserEmoji]] = relationship( lazy="selectin", foreign_keys=[TableUserEmoji.user_id], ) - stars_purchases: Mapped[list[TableStarsPurchase]] = relationship( - lazy="selectin", - foreign_keys=[TableStarsPurchase.user_id], - ) - last_games: Mapped[list[TableLastGame]] = relationship( - lazy="selectin", - foreign_keys=[TableLastGame.user_id], - ) __table_args__ = ( + Index( + "ix_users_selected_emoji_id", + selected_emoji_id, + postgresql_where=(selected_emoji_id.is_not(None)), + ), + Index( + "ix_users_current_game_id", + current_game_id, + postgresql_where=(current_game_id.is_not(None)), + ), Index( "ix_users_admin_right_via_other_admin_admin_id", admin_right_via_other_admin_admin_id, @@ -192,17 +119,14 @@ class TableUser(Base[User]): admin_right, postgresql_where=(admin_right.is_not(None)), ), + Index( + "ix_users_has_matchmaking_waiting", + has_matchmaking_waiting, + postgresql_where="has_matchmaking_waiting", + ), ) def __entity__(self) -> User: - if self.game_location_game_id is not None: - location = UserGameLocation( - self.id, - self.game_location_game_id, - ) - else: - location = None - if self.admin_right is not None: admin_right = self.admin_right.entity( self.admin_right_via_other_admin_admin_id, @@ -210,28 +134,26 @@ def __entity__(self) -> User: else: admin_right = None + if self.has_matchmaking_waiting: + matchmaking_waiting = MatchmakingWaiting( + start_datetime=not_none(self.matchmaking_waiting_start_datetime), + ) + else: + matchmaking_waiting = None + return User( id=self.id, account=Account(self.account_stars), emojis=[it.entity() for it in self.emojis], - stars_purchases=[it.entity() for it in self.stars_purchases], - last_games=[it.entity() for it in self.last_games], selected_emoji_id=self.selected_emoji_id, rating=self.rating, - number_of_wins=self.number_of_wins, - number_of_draws=self.number_of_draws, - number_of_defeats=self.number_of_defeats, - game_location=location, + current_game_id=self.current_game_id, admin_right=admin_right, + matchmaking_waiting=matchmaking_waiting, ) @classmethod def of(cls, it: User) -> "TableUser": - if it.game_location is None: - game_location_game_id = None - else: - game_location_game_id = it.game_location.game_id - match it.admin_right: case None: admin_right = None @@ -243,25 +165,31 @@ def of(cls, it: User) -> "TableUser": admin_right = TableAdminRight.via_other_admin admin_right_via_other_admin_admin_id = admin_id + if it.matchmaking_waiting is not None: + has_matchmaking_waiting = True + matchmaking_waiting_start_datetime = ( + it.matchmaking_waiting.start_datetime + ) + else: + has_matchmaking_waiting = False + matchmaking_waiting_start_datetime = None + return TableUser( id=it.id, account_stars=it.account.stars, selected_emoji_id=it.selected_emoji_id, rating=it.rating, - number_of_wins=it.number_of_wins, - number_of_draws=it.number_of_draws, - number_of_defeats=it.number_of_defeats, - game_location_game_id=game_location_game_id, + current_game_id=it.current_game_id, admin_right=admin_right, admin_right_via_other_admin_admin_id=( admin_right_via_other_admin_admin_id ), + matchmaking_waiting_start_datetime=matchmaking_waiting_start_datetime, + has_matchmaking_waiting=has_matchmaking_waiting, ) -type TableUserAtomic = ( - TableUser | TableUserEmoji | TableStarsPurchase | TableLastGame -) +type TableUserAtomic = TableUser | TableUserEmoji def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: @@ -270,7 +198,3 @@ def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: return TableUser.of(entity) case UserEmoji(): return TableUserEmoji.of(entity) - case StarsPurchase(): - return TableStarsPurchase.of(entity) - case LastGame(): - return TableLastGame.of(entity) diff --git a/src/ttt/infrastructure/structlog/logger.py b/src/ttt/infrastructure/structlog/logger.py index 21a6474..2601bf5 100644 --- a/src/ttt/infrastructure/structlog/logger.py +++ b/src/ttt/infrastructure/structlog/logger.py @@ -1,6 +1,6 @@ import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import cast import structlog @@ -10,48 +10,40 @@ from ttt.infrastructure.structlog.processors import AddRequestId -class LoggerFactory(ABC): - @abstractmethod - def __call__(self) -> FilteringBoundLogger: ... - - -@dataclass(frozen=True) -class DevLoggerFactory(LoggerFactory): - adds_request_id: bool = field(kw_only=True) - - def __call__(self) -> FilteringBoundLogger: - return cast( - FilteringBoundLogger, - structlog.wrap_logger( - structlog.PrintLogger(), - processors=[ - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso"), - *([AddRequestId()] if self.adds_request_id else []), - structlog.dev.ConsoleRenderer(), - ], - ), - ) - - -@dataclass(frozen=True) -class ProdLoggerFactory(LoggerFactory): - adds_request_id: bool = field(kw_only=True) - - def __call__(self) -> FilteringBoundLogger: - return cast( - FilteringBoundLogger, - structlog.wrap_logger( - structlog.PrintLogger(), - processors=[ - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso", utc=True), - *([AddRequestId()] if self.adds_request_id else []), - SentryProcessor(event_level=logging.WARNING), - structlog.processors.KeyValueRenderer(), - ], - ), - ) +def dev_logger(*, with_request_id: bool) -> FilteringBoundLogger: + renderer = structlog.dev.ConsoleRenderer( + exception_formatter=( + structlog.dev.RichTracebackFormatter(show_locals=False) + ), + ) + return cast( + FilteringBoundLogger, + structlog.wrap_logger( + structlog.PrintLogger(), + processors=[ + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + *([AddRequestId()] if with_request_id else []), + renderer, + ], + ), + ) + + +def prod_logger(*, with_request_id: bool) -> FilteringBoundLogger: + return cast( + FilteringBoundLogger, + structlog.wrap_logger( + structlog.PrintLogger(), + processors=[ + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + *([AddRequestId()] if with_request_id else []), + SentryProcessor(event_level=logging.WARNING), + structlog.processors.KeyValueRenderer(), + ], + ), + ) async def unexpected_error_log( @@ -59,3 +51,13 @@ async def unexpected_error_log( error: Exception, ) -> None: await logger.aexception("unexpected_error", exc_info=error) + + +@asynccontextmanager +async def unexpected_error_logging( + logger: FilteringBoundLogger, +) -> AsyncIterator[None]: + try: + yield + except Exception as error: # noqa: BLE001 + await unexpected_error_log(logger, error) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 8b00d42..4032f47 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator +from typing import Annotated -from dishka import Provider, Scope, from_context, provide +from dishka import FromComponent, Provider, Scope, provide from nats import connect as connect_to_nats from nats.aio.client import Client as Nats from nats.js import JetStreamContext @@ -12,13 +13,21 @@ ) from structlog.types import FilteringBoundLogger +from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.retry import Retry +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, + ReadonlyTransaction, + SerializableTransaction, +) from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.games import Games from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 InvitationToGameDao, @@ -29,18 +38,20 @@ from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( InvitationsToGame, ) -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, ) +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) from ttt.application.user.common.ports.original_admin_token import ( OriginalAdminToken, ) +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.users import Users from ttt.application.user.emoji_purchase.ports.user_log import ( @@ -49,15 +60,12 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) +from ttt.application.user.game.ports.user_log import GameUserLog from ttt.infrastructure.adapters.clock import NotMonotonicUtcClock from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway +from ttt.infrastructure.adapters.game_dao import PostgresGameDao from ttt.infrastructure.adapters.game_log import StructlogGameLog +from ttt.infrastructure.adapters.game_tasks import NatsRemoteFuncGameTasks from ttt.infrastructure.adapters.games import InPostgresGames from ttt.infrastructure.adapters.invitation_to_game_dao import ( PostgresInvitationToGameDao, @@ -69,45 +77,54 @@ InPostgresInvitationsToGame, ) from ttt.infrastructure.adapters.map import MapToPostgres -from ttt.infrastructure.adapters.matchmaking_queue_log import ( - StructlogCommonMatchmakingQueueLog, -) from ttt.infrastructure.adapters.original_admin_token import ( TokenAsOriginalAdminToken, ) -from ttt.infrastructure.adapters.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox, -) from ttt.infrastructure.adapters.randoms import MersenneTwisterRandoms -from ttt.infrastructure.adapters.shared_matchmaking_queue import ( - InPostgresSharedMatchmakingQueue, +from ttt.infrastructure.adapters.retry import RetrierRetry +from ttt.infrastructure.adapters.stars_purchase_log import ( + StructlogStarsPurchaseLog, ) -from ttt.infrastructure.adapters.transaction import InPostgresTransaction +from ttt.infrastructure.adapters.stars_purchase_tasks import ( + NatsRemoteFuncStarsPurchaseTasks, +) +from ttt.infrastructure.adapters.stars_purchases import PostgresStarsPurchases +from ttt.infrastructure.adapters.transaction import ( + InPostgresNotSerializableTransaction, + InPostgresReadonlyTransaction, + InPostgresSerializableTransaction, +) +from ttt.infrastructure.adapters.user_locks import InPostgresUserLocks from ttt.infrastructure.adapters.user_log import ( StructlogChangeOtherUserAccountLog, StructlogCommonUserLog, StructlogEmojiPurchaseUserLog, StructlogEmojiSelectionUserLog, - StructlogStarsPurchaseUserLog, + StructlogGameUserLog, ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s -from ttt.infrastructure.background_tasks import BackgroundTasks -from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox as OriginalInNatsPaidStarsPurchasePaymentInbox, # noqa: E501 +from ttt.infrastructure.multi_asynccontextmanager import ( + multi_asynccontextmanager, ) from ttt.infrastructure.openai.gemini import Gemini, gemini +from ttt.infrastructure.processors.auto_cancel_invitations_to_game_processor import ( # noqa: E501 + AutoCancelInvitationsToGameProcessor, +) +from ttt.infrastructure.processors.matchmake_processor import MatchmakeProcessor +from ttt.infrastructure.processors.processor import Processor from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets -from ttt.infrastructure.structlog.logger import LoggerFactory +from ttt.infrastructure.remote_funcs.complete_stars_purchase_payment import ( + complete_stars_purchase_payment_remotely, +) +from ttt.infrastructure.remote_funcs.make_ai_move_in_game import ( + make_ai_move_in_game_remotely, +) +from ttt.infrastructure.retrier import Retrier class InfrastructureProvider(Provider): - provide_logger_factory = from_context( - provides=LoggerFactory, - scope=Scope.APP, - ) - provide_envs = provide(source=Envs.load, scope=Scope.APP) provide_secrets = provide(source=Secrets.load, scope=Scope.APP) @@ -117,11 +134,6 @@ def provide_original_admin_token( ) -> OriginalAdminToken: return TokenAsOriginalAdminToken(secrets.admin_token) - @provide(scope=Scope.APP) - async def provide_background_tasks(self) -> AsyncIterator[BackgroundTasks]: - async with BackgroundTasks() as tasks: - yield tasks - @provide(scope=Scope.APP) async def provide_postgres_engine(self, envs: Envs) -> AsyncEngine: return create_async_engine( @@ -142,7 +154,6 @@ async def provide_postgres_session( session = AsyncSession( engine, autoflush=False, - autobegin=False, expire_on_commit=False, ) @@ -163,16 +174,8 @@ async def provide_redis_pool( finally: await pool.aclose() - @provide(scope=Scope.REQUEST) - async def provide_request_redis( - self, - pool: ConnectionPool, - ) -> AsyncIterator[Redis]: - async with Redis(connection_pool=pool) as redis: - yield redis - @provide(scope=Scope.APP) - async def provide_app_redis(self, envs: Envs) -> AsyncIterator[Redis]: + async def provide_redis(self, envs: Envs) -> AsyncIterator[Redis]: async with Redis.from_url(str(envs.redis_url)) as redis: yield redis @@ -190,35 +193,23 @@ async def provide_nats( async def provide_jetstream(self, nats: Nats) -> JetStreamContext: return nats.jetstream() - @provide(scope=Scope.APP) - async def provide_original_in_nats_paid_stars_purchase_payment_inbox( - self, - jetstream: JetStreamContext, - ) -> AsyncIterator[OriginalInNatsPaidStarsPurchasePaymentInbox]: - inbox = OriginalInNatsPaidStarsPurchasePaymentInbox(jetstream) - async with inbox: - yield inbox - @provide(scope=Scope.APP) def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: return gemini(secrets.gemini_api_key, envs.gemini_url) - @provide(scope=Scope.REQUEST) - def provide_logger( - self, - logger_factory: LoggerFactory, - ) -> FilteringBoundLogger: - return logger_factory() - - provide_in_nats_paid_stars_purchase_payment_inbox = provide( - InNatsPaidStarsPurchasePaymentInbox, - provides=PaidStarsPurchasePaymentInbox, - scope=Scope.APP, + provide_serializable_transaction = provide( + InPostgresSerializableTransaction, + provides=SerializableTransaction, + scope=Scope.REQUEST, ) - - provide_transaction = provide( - InPostgresTransaction, - provides=Transaction, + provide_not_serializable_transaction = provide( + InPostgresNotSerializableTransaction, + provides=NotSerializableTransaction, + scope=Scope.REQUEST, + ) + provide_readonly_transaction = provide( + InPostgresReadonlyTransaction, + provides=ReadonlyTransaction, scope=Scope.REQUEST, ) @@ -228,15 +219,20 @@ def provide_logger( scope=Scope.REQUEST, ) - provide_users = provide( - InPostgresUsers, - provides=Users, - scope=Scope.REQUEST, - ) + @provide(scope=Scope.REQUEST) + def provide_users( + self, + session: AsyncSession, + envs: Envs, + ) -> Users: + return InPostgresUsers( + session, + _users_to_matchmake_limit=envs.matchmaking_worker_max_users, + ) - provide_shared_matchmaking_queue = provide( - InPostgresSharedMatchmakingQueue, - provides=SharedMatchmakingQueue, + provide_stars_purchases = provide( + PostgresStarsPurchases, + provides=StarsPurchases, scope=Scope.REQUEST, ) @@ -252,6 +248,12 @@ def provide_logger( scope=Scope.REQUEST, ) + provide_game_dao = provide( + PostgresGameDao, + provides=GameDao, + scope=Scope.REQUEST, + ) + provide_map = provide( MapToPostgres, provides=Map, @@ -292,6 +294,12 @@ def provide_randoms(self) -> Randoms: scope=Scope.REQUEST, ) + provide_game_user_log = provide( + StructlogGameUserLog, + provides=GameUserLog, + scope=Scope.REQUEST, + ) + provide_emoji_purchase_user_log = provide( StructlogEmojiPurchaseUserLog, provides=EmojiPurchaseUserLog, @@ -305,14 +313,8 @@ def provide_randoms(self) -> Randoms: ) provide_stars_purchase_user_log = provide( - StructlogStarsPurchaseUserLog, - provides=StarsPurchaseUserLog, - scope=Scope.REQUEST, - ) - - provide_common_matchmaking_queue_log = provide( - StructlogCommonMatchmakingQueueLog, - provides=CommonMatchmakingQueueLog, + StructlogStarsPurchaseLog, + provides=StarsPurchaseLog, scope=Scope.REQUEST, ) @@ -327,3 +329,81 @@ def provide_randoms(self) -> Randoms: provides=InvitationToGameLog, scope=Scope.REQUEST, ) + provide_user_locks = provide( + InPostgresUserLocks, + provides=UserLocks, + scope=Scope.REQUEST, + ) + + provide_game_tasks = provide( + NatsRemoteFuncGameTasks, + provides=GameTasks, + scope=Scope.APP, + ) + provide_stars_purchase_tasks = provide( + NatsRemoteFuncStarsPurchaseTasks, + provides=StarsPurchaseTasks, + scope=Scope.APP, + ) + + @provide(scope=Scope.REQUEST) + def provide_retrier(self, envs: Envs) -> Retrier: + return Retrier(_max_retries_map={ + SerializationError: envs.serialization_error_max_retries, + }) + + provide_retry = provide(RetrierRetry, provides=Retry, scope=Scope.REQUEST) + + @provide(scope=Scope.APP) + def provide_auto_cancel_invitations_to_game_task( + self, + envs: Envs, + logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> AutoCancelInvitationsToGameProcessor: + return AutoCancelInvitationsToGameProcessor( + _interval_seconds=( + envs.auto_cancel_invitations_to_game_interval_seconds + ), + _logger=logger, + ) + + @provide(scope=Scope.APP) + def provide_matchmake_processor( + self, + envs: Envs, + logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> MatchmakeProcessor: + return MatchmakeProcessor( + _max_workers=envs.matchmaking_max_workers, + _worker_creation_interval_seconds=( + envs.matchmaking_worker_creation_interval_seconds + ), + _logger=logger, + ) + + @provide(scope=Scope.APP) + async def processors( + self, + js: JetStreamContext, + auto_cancel_invitations_to_game_processor: ( + AutoCancelInvitationsToGameProcessor + ), + matchmake_processor: MatchmakeProcessor, + ) -> AsyncIterator[tuple[Processor, ...]]: + nats_remote_funcs = ( + make_ai_move_in_game_remotely, + complete_stars_purchase_payment_remotely, + ) + multi_startup = multi_asynccontextmanager(*( + func.startup(js) for func in nats_remote_funcs + )) + async with multi_startup: + nats_remote_func_processors = ( + func.processor + for func in nats_remote_funcs + ) + yield ( + auto_cancel_invitations_to_game_processor, + matchmake_processor, + *nats_remote_func_processors, + ) diff --git a/src/ttt/main/common/next_container.py b/src/ttt/main/common/next_container.py new file mode 100644 index 0000000..3e50175 --- /dev/null +++ b/src/ttt/main/common/next_container.py @@ -0,0 +1,25 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from dishka import AsyncContainer +from dishka.async_container import AsyncContextWrapper + +from ttt.infrastructure.dishka.next_container import NextContainer + + +@dataclass +class NextContainerWithFilledContext(NextContainer): + _root_container: AsyncContainer + _context_type_hints: tuple[Any, ...] + + def __call__( + self, context: Mapping[Any, Any] = {}, + ) -> AsyncContextWrapper: + return self._root_container(self._filled_context(context)) + + def _filled_context(self, context: Mapping[Any, Any]) -> dict[Any, Any]: + return { + hint | None: context.get(hint) + for hint in self._context_type_hints + } diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 0960fbc..75663cd 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -3,7 +3,6 @@ from typing import cast from aiogram import Bot, Dispatcher -from aiogram.fsm.context import FSMContext from aiogram.fsm.storage.base import BaseStorage, DefaultKeyBuilder from aiogram.fsm.storage.redis import RedisStorage from aiogram.types import ( @@ -18,14 +17,15 @@ from dishka import ( Provider, Scope, + from_context, provide, ) from dishka.integrations.aiogram import AiogramMiddlewareData from redis.asyncio import Redis -from structlog.types import FilteringBoundLogger from ttt.application.common.ports.emojis import Emojis from ttt.application.game.game.cancel_game import CancelGame +from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame from ttt.application.game.game.make_move_in_game import MakeMoveInGame from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.start_game_with_ai import StartGameWithAi @@ -58,10 +58,24 @@ from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 ViewOutcomingInvitationsToGame, ) -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, +) +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.application.stars_purchase.start_stars_purchase import ( + StartStarsPurchase, +) +from ttt.application.stars_purchase.start_stars_purchase_payment import ( + StartStarsPurchasePayment, +) +from ttt.application.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 + StartStarsPurchasePaymentCompletion, ) -from ttt.application.matchmaking_queue.game.wait_game import WaitGame from ttt.application.user.authorize_as_admin import AuthorizeAsAdmin from ttt.application.user.authorize_other_user_as_admin import ( AuthorizeOtherUserAsAdmin, @@ -78,7 +92,6 @@ from ttt.application.user.change_other_user_account.view_user_account_to_change import ( # noqa: E501 ViewUserAccountToChange, ) -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.deauthorize_other_user_as_admin import ( DeauthorizeOtherUserAsAdmin, @@ -91,32 +104,21 @@ EmojiSelectionUserViews, ) from ttt.application.user.emoji_selection.select_emoji import SelectEmoji +from ttt.application.user.game.dont_wait_for_matchmaking import ( + DontWaitForMatchmaking, +) +from ttt.application.user.game.matchmake import Matchmake +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.application.user.game.view_matchmaking import ViewMatchmaking +from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking from ttt.application.user.register_user import RegisterUser from ttt.application.user.relinquish_admin_right import RelinquishAdminRight -from ttt.application.user.stars_purchase.complete_stars_purchase_payment import ( # noqa: E501 - CompleteStarsPurchasePayment, -) -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, -) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) -from ttt.application.user.stars_purchase.start_stars_purchase import ( - StartStarsPurchase, -) -from ttt.application.user.stars_purchase.start_stars_purchase_payment import ( - StartStarsPurchasePayment, -) -from ttt.application.user.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 - StartStarsPurchasePaymentCompletion, -) from ttt.application.user.view_admin_menu import ViewAdminMenu from ttt.application.user.view_main_menu import ViewMainMenu from ttt.application.user.view_other_user import ViewOtherUser from ttt.application.user.view_user import ViewUser from ttt.application.user.view_user_emojis import ViewUserEmojis -from ttt.infrastructure.buffer import Buffer +from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.presentation.adapters.emojis import PictographsAsEmojis from ttt.presentation.adapters.game_views import ( @@ -125,18 +127,18 @@ from ttt.presentation.adapters.invitation_to_game_views import ( AiogramInvitationToGameViews, ) -from ttt.presentation.adapters.matchmaking_queue_views import ( - AiogramCommonMatchmakingQueueViews, -) from ttt.presentation.adapters.stars_purchase_payment_gateway import ( AiogramPaymentGateway, ) +from ttt.presentation.adapters.stars_purchase_views import ( + AiogramStarsPurchaseViews, +) from ttt.presentation.adapters.user_views import ( AiogramChangeOtherUserAccountViews, AiogramCommonUserViews, AiogramEmojiPurchaseUserViews, AiogramEmojiSelectionUserViews, - AiogramStarsPurchaseUserViews, + AiogramGameUserViews, ) from ttt.presentation.aiogram.common.bots import ttt_bot from ttt.presentation.aiogram.common.routes.all import common_routers @@ -147,7 +149,6 @@ ) from ttt.presentation.aiogram_dialog.main_dialog import main_dialog from ttt.presentation.result_buffer import ResultBuffer -from ttt.presentation.unkillable_tasks import UnkillableTasks @dataclass @@ -156,19 +157,20 @@ class NoMessageInEventError(Exception): class PresentationProvider(Provider): - @provide(scope=Scope.REQUEST) - def provide_event(self, event: TelegramObject) -> TelegramObject | None: - return event - - @provide(scope=Scope.REQUEST) - def provide_aiogram_middleware_data( - self, data: AiogramMiddlewareData, - ) -> AiogramMiddlewareData | None: - return data + provide_aiogram_middleware_data = from_context( + AiogramMiddlewareData | None, + scope=Scope.REQUEST, + ) + provide_event = from_context(TelegramObject | None, scope=Scope.REQUEST) @provide(scope=Scope.APP) - def provide_strage(self, redis: Redis) -> BaseStorage: - return RedisStorage(redis, DefaultKeyBuilder(with_destiny=True)) + def provide_strage(self, redis: Redis, envs: Envs) -> BaseStorage: + return RedisStorage( + redis, + DefaultKeyBuilder(with_destiny=True), + state_ttl=envs.dialog_ttl, + data_ttl=envs.dialog_ttl, + ) @provide(scope=Scope.APP) def provide_bg_manager_factory(self, dp: Dispatcher) -> BgManagerFactory: @@ -181,7 +183,7 @@ def provide_manager_impl( if middleware_data is None: return None - return cast(ManagerImpl, middleware_data["dialog_manager"]) + return cast(ManagerImpl, middleware_data.get("dialog_manager")) @provide(scope=Scope.APP) async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: @@ -212,9 +214,14 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: provides=CommonUserViews, scope=Scope.REQUEST, ) + provide_game_user_views = provide( + AiogramGameUserViews, + provides=GameUserViews, + scope=Scope.REQUEST, + ) provide_stars_purchase_user_views = provide( - AiogramStarsPurchaseUserViews, - provides=StarsPurchaseUserViews, + AiogramStarsPurchaseViews, + provides=StarsPurchaseViews, scope=Scope.REQUEST, ) provide_emoji_selection_user_views = provide( @@ -227,11 +234,6 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: provides=EmojiPurchaseUserViews, scope=Scope.REQUEST, ) - provide_common_matchmaking_queue_views = provide( - AiogramCommonMatchmakingQueueViews, - provides=CommonMatchmakingQueueViews, - scope=Scope.REQUEST, - ) provide_change_other_user_account_views = provide( AiogramChangeOtherUserAccountViews, provides=ChangeOtherUserAccountViews, @@ -248,27 +250,6 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: def provide_result_buffer(self) -> ResultBuffer: return ResultBuffer() - @provide(scope=Scope.REQUEST) - async def unkillable_tasks( - self, - logger: FilteringBoundLogger, - start_stars_purchase_payment_completion: ( - StartStarsPurchasePaymentCompletion - ), - complete_stars_purchase_payment: CompleteStarsPurchasePayment, - ) -> UnkillableTasks: - tasks = UnkillableTasks(logger) - tasks.add(start_stars_purchase_payment_completion) - tasks.add(complete_stars_purchase_payment) - - return tasks - - @provide(scope=Scope.APP) - def provide_paid_stars_purchase_payment_buffer( - self, - ) -> Buffer[PaidStarsPurchasePayment]: - return Buffer() - @provide(scope=Scope.APP) def provide_dp(self, storage: BaseStorage) -> Dispatcher: dp = Dispatcher(name="main", storage=storage) @@ -315,25 +296,16 @@ def provide_callback_query( case _: return None - @provide(scope=Scope.REQUEST) - def provide_fsm_context( - self, - middleware_data: AiogramMiddlewareData, - ) -> FSMContext: - return cast(FSMContext, middleware_data["state"]) - @provide(scope=Scope.REQUEST) def provide_stars_purchase_payment_gateway( self, pre_checkout_query: PreCheckoutQuery | None, secrets: Secrets, bot: Bot, - buffer: Buffer[PaidStarsPurchasePayment], dialog_manager_for_user: DialogManagerForUser, ) -> StarsPurchasePaymentGateway: return AiogramPaymentGateway( pre_checkout_query, - buffer, bot, secrets.payments_token, dialog_manager_for_user, @@ -343,14 +315,6 @@ def provide_stars_purchase_payment_gateway( class ApplicationProvider(Provider): provide_buy_emoji = provide(BuyEmoji, scope=Scope.REQUEST) provide_select_emoji = provide(SelectEmoji, scope=Scope.REQUEST) - provide_start_stars_purchase = provide( - StartStarsPurchase, - scope=Scope.REQUEST, - ) - provide_start_stars_purchase_payment = provide( - StartStarsPurchasePayment, - scope=Scope.REQUEST, - ) provide_view_user_emojis = provide( ViewUserEmojis, scope=Scope.REQUEST, @@ -361,14 +325,6 @@ class ApplicationProvider(Provider): ) provide_view_user = provide(ViewUser, scope=Scope.REQUEST) provide_register_user = provide(RegisterUser, scope=Scope.REQUEST) - probide_complete_stars_purchase_payment = provide( - CompleteStarsPurchasePayment, - scope=Scope.REQUEST, - ) - probide_start_stars_purchase_payment_completion = provide( - StartStarsPurchasePaymentCompletion, - scope=Scope.REQUEST, - ) provide_authorize_as_admin = provide(AuthorizeAsAdmin, scope=Scope.REQUEST) provide_relinquish_admin_right = provide( RelinquishAdminRight, @@ -391,6 +347,31 @@ class ApplicationProvider(Provider): provide_view_user_account_to_change = provide( ViewUserAccountToChange, scope=Scope.REQUEST, ) + provide_matchmake = provide(Matchmake, scope=Scope.REQUEST) + provide_wait_for_matchmaking = provide( + WaitForMatchmaking, scope=Scope.REQUEST, + ) + provide_dont_wait_for_matchmaking = provide( + DontWaitForMatchmaking, scope=Scope.REQUEST, + ) + provide_view_matchmaking = provide(ViewMatchmaking, scope=Scope.REQUEST) + + provide_start_stars_purchase = provide( + StartStarsPurchase, + scope=Scope.REQUEST, + ) + provide_start_stars_purchase_payment = provide( + StartStarsPurchasePayment, + scope=Scope.REQUEST, + ) + probide_complete_stars_purchase_payment = provide( + CompleteStarsPurchasePayment, + scope=Scope.REQUEST, + ) + probide_start_stars_purchase_payment_completion = provide( + StartStarsPurchasePaymentCompletion, + scope=Scope.REQUEST, + ) provide_start_game_with_ai = provide( StartGameWithAi, @@ -398,10 +379,11 @@ class ApplicationProvider(Provider): ) provide_cancel_game = provide(CancelGame, scope=Scope.REQUEST) provide_make_move_in_game = provide(MakeMoveInGame, scope=Scope.REQUEST) + provide_make_ai_move_in_game = provide( + MakeAiMoveInGame, scope=Scope.REQUEST, + ) provide_view_game = provide(ViewGame, scope=Scope.REQUEST) - provide_wait_game = provide(WaitGame, scope=Scope.REQUEST) - provide_accept_invitation_to_game = provide( AcceptInvitationToGame, scope=Scope.REQUEST, ) diff --git a/src/ttt/main/tg_bot/start_aiogram.py b/src/ttt/main/tg_bot/start_aiogram.py deleted file mode 100644 index 7dead24..0000000 --- a/src/ttt/main/tg_bot/start_aiogram.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -from functools import partial - -from aiogram import Bot, Dispatcher -from aiogram.types import TelegramObject -from dishka import AsyncContainer -from dishka.integrations.aiogram import ( - AiogramMiddlewareData, - ContainerMiddleware, -) - -from ttt.presentation.tasks.auto_cancel_invitation_to_game_task import ( - auto_cancel_invitation_to_game_task, -) -from ttt.presentation.unkillable_tasks import UnkillableTasks - - -async def start_aiogram(container: AsyncContainer) -> None: - dp = await container.get(Dispatcher) - - middleware = ContainerMiddleware(container) - - for observer in dp.observers.values(): - observer.middleware(middleware) - - context = {TelegramObject: None, AiogramMiddlewareData: None} - async with container(context) as request: - tasks = await request.get(UnkillableTasks) - tasks.add(partial(auto_cancel_invitation_to_game_task, container)) - - logging.basicConfig(level=logging.INFO) - - bot = await container.get(Bot) - - try: - async with tasks: - await dp.start_polling(bot) - finally: - await container.close() diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py new file mode 100644 index 0000000..72a76f1 --- /dev/null +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -0,0 +1,41 @@ +import logging +from asyncio import CancelledError, TaskGroup +from contextlib import suppress + +from aiogram import Bot, Dispatcher +from aiogram.types import TelegramObject +from dishka import AsyncContainer +from dishka.integrations.aiogram import AiogramMiddlewareData + +from ttt.infrastructure.processors.processor import Processor +from ttt.main.common.next_container import NextContainerWithFilledContext +from ttt.presentation.aiogram.common.middlewares import ( + AiogramNextContainerMiddleware, +) + + +async def start_tg_bot(container: AsyncContainer) -> None: + next_container = NextContainerWithFilledContext( + container, + (TelegramObject, AiogramMiddlewareData), + ) + + dp = await container.get(Dispatcher) + middleware = AiogramNextContainerMiddleware(next_container) + for observer in dp.observers.values(): + observer.middleware(middleware) + + processors = await container.get(tuple[Processor, ...]) + bot = await container.get(Bot) + + logging.basicConfig(level=logging.INFO) + + try: + with suppress(CancelledError): + async with TaskGroup() as tasks: + for processor in processors: + tasks.create_task(processor(next_container)) + await dp.start_polling(bot) + raise CancelledError + finally: + await container.close() diff --git a/src/ttt/main/tg_bot_dev/__main__.py b/src/ttt/main/tg_bot_dev/__main__.py index 223015f..29940f6 100644 --- a/src/ttt/main/tg_bot_dev/__main__.py +++ b/src/ttt/main/tg_bot_dev/__main__.py @@ -1,32 +1,29 @@ import asyncio from dishka import make_async_container -from dishka.integrations.aiogram import AiogramProvider -from ttt.infrastructure.structlog.logger import ( - DevLoggerFactory, - LoggerFactory, -) from ttt.main.common.di import InfrastructureProvider from ttt.main.tg_bot.di import ( ApplicationProvider, PresentationProvider, ) -from ttt.main.tg_bot.start_aiogram import start_aiogram +from ttt.main.tg_bot.start_tg_bot import start_tg_bot +from ttt.main.tg_bot_dev.di import ( + DevTgBotAppLoggerProvider, + DevTgBotRequestLoggerProvider, +) async def amain() -> None: container = make_async_container( - AiogramProvider(), ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), - context={ - LoggerFactory: DevLoggerFactory(adds_request_id=True), - }, + DevTgBotRequestLoggerProvider(), + DevTgBotAppLoggerProvider(), ) - await start_aiogram(container) + await start_tg_bot(container) def main() -> None: diff --git a/src/ttt/main/tg_bot_dev/di.py b/src/ttt/main/tg_bot_dev/di.py new file mode 100644 index 0000000..42a951b --- /dev/null +++ b/src/ttt/main/tg_bot_dev/di.py @@ -0,0 +1,18 @@ +from dishka import Provider, Scope, provide +from structlog.types import FilteringBoundLogger + +from ttt.infrastructure.structlog.logger import dev_logger + + +class DevTgBotRequestLoggerProvider(Provider): + @provide(scope=Scope.REQUEST) + def provide_request_logger(self) -> FilteringBoundLogger: + return dev_logger(with_request_id=True) + + +class DevTgBotAppLoggerProvider(Provider): + component = "app" + + @provide(scope=Scope.APP) + def provide_app_logger(self) -> FilteringBoundLogger: + return dev_logger(with_request_id=False) diff --git a/src/ttt/main/tg_bot_prod/__main__.py b/src/ttt/main/tg_bot_prod/__main__.py index 76a46bf..2e4cce2 100644 --- a/src/ttt/main/tg_bot_prod/__main__.py +++ b/src/ttt/main/tg_bot_prod/__main__.py @@ -2,34 +2,34 @@ import sentry_sdk from dishka import make_async_container -from dishka.integrations.aiogram import AiogramProvider from ttt import __version__ from ttt.infrastructure.pydantic_settings.secrets import Secrets -from ttt.infrastructure.structlog.logger import LoggerFactory, ProdLoggerFactory from ttt.main.common.di import InfrastructureProvider from ttt.main.tg_bot.di import ( ApplicationProvider, PresentationProvider, ) -from ttt.main.tg_bot.start_aiogram import start_aiogram +from ttt.main.tg_bot.start_tg_bot import start_tg_bot +from ttt.main.tg_bot_prod.di import ( + ProdTgBotAppLoggerProvider, + ProdTgBotRequestLoggerProvider, +) async def amain() -> None: container = make_async_container( - AiogramProvider(), ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), - context={ - LoggerFactory: ProdLoggerFactory(adds_request_id=True), - }, + ProdTgBotAppLoggerProvider(), + ProdTgBotRequestLoggerProvider(), ) secrets = await container.get(Secrets) sentry_sdk.init(dsn=secrets.sentry_dsn, release=__version__) - await start_aiogram(container) + await start_tg_bot(container) def main() -> None: diff --git a/src/ttt/main/tg_bot_prod/di.py b/src/ttt/main/tg_bot_prod/di.py new file mode 100644 index 0000000..540c54b --- /dev/null +++ b/src/ttt/main/tg_bot_prod/di.py @@ -0,0 +1,18 @@ +from dishka import Provider, Scope, provide +from structlog.types import FilteringBoundLogger + +from ttt.infrastructure.structlog.logger import prod_logger + + +class ProdTgBotRequestLoggerProvider(Provider): + @provide(scope=Scope.REQUEST) + def provide_request_logger(self) -> FilteringBoundLogger: + return prod_logger(with_request_id=True) + + +class ProdTgBotAppLoggerProvider(Provider): + component = "app" + + @provide(scope=Scope.APP) + def provide_app_logger(self) -> FilteringBoundLogger: + return prod_logger(with_request_id=False) diff --git a/src/ttt/presentation/adapters/game_views.py b/src/ttt/presentation/adapters/game_views.py index 89bb8cd..f69429d 100644 --- a/src/ttt/presentation/adapters/game_views.py +++ b/src/ttt/presentation/adapters/game_views.py @@ -1,5 +1,4 @@ from asyncio import gather -from collections.abc import Sequence from dataclasses import dataclass from aiogram import Bot @@ -11,7 +10,6 @@ from ttt.entities.core.game.game import ( Game, ) -from ttt.entities.core.user.location import UserGameLocation from ttt.infrastructure.sqlalchemy.tables.game import TableGame from ttt.infrastructure.sqlalchemy.tables.user import TableUser from ttt.presentation.aiogram.game.messages import completed_game_sticker @@ -36,7 +34,7 @@ class AiogramGameViews(GameViews): async def current_game_view_with_user_id(self, user_id: int, /) -> None: join_condition = ( (TableUser.id == user_id) - & (TableUser.game_location_game_id == TableGame.id) + & (TableUser.current_game_id == TableGame.id) ) stmt = select(TableGame).join(TableUser, join_condition) table_game = await self._session.scalar(stmt) @@ -47,36 +45,26 @@ async def current_game_view_with_user_id(self, user_id: int, /) -> None: game = table_game.entity() self._result_buffer.result = ActiveGameView.of(game, user_id) - async def game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: + async def game_view(self, game: Game, /) -> None: match game.result: case None: await gather(*( - self._active_game_view(location, game) - for location in user_locations + self._active_game_view(user.id, game) + for user in game.users() )) case _: await gather(*( - self._completed_game_view(location, game) - for location in user_locations + self._completed_game_view(user.id, game) + for user in game.users() )) - async def started_game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: + async def started_game_view(self, game: Game, /) -> None: await gather(*( - self._started_game_view(location, game) - for location in user_locations + self._started_game_view(user.id, game) + for user in game.users() )) - async def no_game_view(self, user_id: int, /) -> None: + async def no_current_game_view(self, user_id: int, /) -> None: dialog_manager = self._dialog_manager_for_user(user_id) data = {"hint": "❌ Игра уже закончилась"} @@ -145,50 +133,34 @@ async def already_filled_cell_error( MainDialogState.game, data, StartMode.RESET_STACK, ) - async def _started_game_view( - self, - user_location: UserGameLocation, - game: Game, - /, - ) -> None: - dialog_manager = self._dialog_manager_for_user(user_location.user_id) - + async def _started_game_view(self, user_id: int, game: Game, /) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) await dialog_manager.start( MainDialogState.game, - ActiveGameView.of(game, user_location.user_id).window_data(), + ActiveGameView.of(game, user_id).window_data(), StartMode.RESET_STACK, ) - async def _active_game_view( - self, location: UserGameLocation, game: Game, - ) -> None: - dialog_manager = self._dialog_manager_for_user(location.user_id) + async def _active_game_view(self, user_id: int, game: Game) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) - view = ActiveGameView.of(game, location.user_id) + view = ActiveGameView.of(game, user_id) data = view.window_data() await dialog_manager.start( MainDialogState.game, data, StartMode.RESET_STACK, ) - async def _completed_game_view( - self, - location: UserGameLocation, - game: Game, - ) -> None: - dialog_manager = self._dialog_manager_for_user(location.user_id) + async def _completed_game_view(self, user_id: int, game: Game) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) - view = CompletedGameView.of(game, location.user_id) + view = CompletedGameView.of(game, user_id) data = view.window_data() - await gather( - completed_game_sticker( - self._bot, location.user_id, game, location.user_id, - ), - dialog_manager.start( - MainDialogState.game, - data, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ), + await completed_game_sticker(self._bot, user_id, game, user_id) + await dialog_manager.start( + MainDialogState.game, + data, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, ) diff --git a/src/ttt/presentation/adapters/invitation_to_game_views.py b/src/ttt/presentation/adapters/invitation_to_game_views.py index 7c3cb6d..b7e096b 100644 --- a/src/ttt/presentation/adapters/invitation_to_game_views.py +++ b/src/ttt/presentation/adapters/invitation_to_game_views.py @@ -5,7 +5,7 @@ from uuid import UUID from aiogram.types import CallbackQuery -from aiogram.utils.formatting import Code, Text +from aiogram.utils.formatting import Code from aiogram_dialog import DialogManager, ShowMode, StartMode from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -61,6 +61,7 @@ async def incoming_user_invitations_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, ) .where( (TableInvitationToGame.invited_user_id == user_id) @@ -74,7 +75,11 @@ async def incoming_user_invitations_to_game_view( rows = result.all() invitations = [ - IncomingInvitationToGameData(row.id, row.inviting_user_id) + IncomingInvitationToGameData( + row.id, + row.inviting_user_id, + row.inviting_user_username, + ) for row in rows ] self._result_buffer.result = IncomingInvitationsToGameView.of( @@ -90,6 +95,7 @@ async def outcoming_user_invitations_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.invited_user_id, + TableInvitationToGame.invited_user_username, ) .where( (TableInvitationToGame.inviting_user_id == user_id) @@ -103,7 +109,11 @@ async def outcoming_user_invitations_to_game_view( rows = result.all() invitations = [ - OutcomingInvitationToGameData(row.id.hex, row.invited_user_id) + OutcomingInvitationToGameData( + row.id.hex, + row.invited_user_id, + row.invited_user_username, + ) for row in rows ] self._result_buffer.result = OutcomingInvitationsToGameView.of( @@ -117,6 +127,7 @@ async def one_incoming_invitation_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, ) .where( (TableInvitationToGame.invited_user_id == user_id) @@ -137,13 +148,17 @@ async def one_incoming_invitation_to_game_view( self._result_buffer.result = IncomingInvitationToGameView( id_hex=row.id.hex, inviting_user_id=row.inviting_user_id, + inviting_user_username=row.inviting_user_username, ) async def incoming_invitation_to_game_view( self, user_id: int, invitation_to_game_id: UUID, /, ) -> None: stmt = ( - select(TableInvitationToGame.inviting_user_id) + select( + TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, + ) .where( (TableInvitationToGame.id == invitation_to_game_id) & ( @@ -152,15 +167,17 @@ async def incoming_invitation_to_game_view( ), ) ) - inviting_user_id = await self._session.scalar(stmt) + result = await self._session.execute(stmt) + row = result.first() - if inviting_user_id is None: + if row is None: self._result_buffer.result = None return self._result_buffer.result = IncomingInvitationToGameView( id_hex=invitation_to_game_id.hex, - inviting_user_id=inviting_user_id, + inviting_user_id=row.inviting_user_id, + inviting_user_username=row.inviting_user_username, ) async def invitation_to_game_view( @@ -195,6 +212,7 @@ async def _invited_user_to_game_view( view = IncomingInvitationToGameView( id_hex=invitation_to_game.id_.hex, inviting_user_id=invitation_to_game.inviting_user.id, + inviting_user_username=invitation_to_game.inviting_user_username, ) start_data = view.window_data() await manager.start( @@ -230,17 +248,20 @@ async def rejected_invitation_to_game_view( invitation_to_game.inviting_user.id, ) - inviting_user_hint = Text( - "👤 Пользователь ", - Code(invitation_to_game.invited_user.id), - " отклонил ваше приглашение к игре", - ).as_html() + if invitation_to_game.invited_user_username is None: + public_user_id = Code(invitation_to_game.invited_user.id).as_html() + else: + public_user_id = f"@{invitation_to_game.invited_user_username}" + + hint = ( + f"👤 Пользователь {public_user_id} отклонил ваше приглашение к игре" + ) await gather( invited_user_manager.done(), inviting_user_manager.start( MainDialogState.notification, - {"hint": inviting_user_hint}, + {"hint": hint}, ), ) diff --git a/src/ttt/presentation/adapters/matchmaking_queue_views.py b/src/ttt/presentation/adapters/matchmaking_queue_views.py deleted file mode 100644 index f211250..0000000 --- a/src/ttt/presentation/adapters/matchmaking_queue_views.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass - -from aiogram_dialog import StartMode - -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, -) -from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( - DialogManagerForUser, -) -from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState - - -@dataclass(frozen=True, unsafe_hash=False) -class AiogramCommonMatchmakingQueueViews(CommonMatchmakingQueueViews): - _dialog_manager_for_user: DialogManagerForUser - - async def waiting_for_game_view(self, user_id: int, /) -> None: - dialog_manager = self._dialog_manager_for_user(user_id) - - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Поиск игры начат"}, - StartMode.RESET_STACK, - ) - - async def double_waiting_for_game_view(self, user_id: int, /) -> None: - dialog_manager = self._dialog_manager_for_user(user_id) - - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Поиск игры начат"}, - StartMode.RESET_STACK, - ) diff --git a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index bb8257d..3b2b331 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -1,4 +1,3 @@ -from collections.abc import AsyncIterable from dataclasses import dataclass, field from uuid import UUID @@ -6,13 +5,11 @@ from aiogram.types import PreCheckoutQuery from aiogram_dialog import ShowMode, StartMode -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 StarsPurchasePaymentGateway, ) -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.tools.assertion import not_none -from ttt.infrastructure.buffer import Buffer from ttt.presentation.aiogram.user.invoices import stars_invoce from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( DialogManagerForUser, @@ -23,7 +20,6 @@ @dataclass class AiogramPaymentGateway(StarsPurchasePaymentGateway): _pre_checkout_query: PreCheckoutQuery | None - _buffer: Buffer[PaidStarsPurchasePayment] _bot: Bot _payments_token: str = field(repr=False) _dialog_manager_for_user: DialogManagerForUser @@ -32,7 +28,7 @@ async def send_invoice( self, purchase: StarsPurchase, ) -> None: - manager = self._dialog_manager_for_user(purchase.user_id) + manager = self._dialog_manager_for_user(purchase.user.id) await stars_invoce(self._bot, purchase, self._payments_token) await manager.start( @@ -60,9 +56,3 @@ async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ok=False, error_message=message, ) - - async def paid_payment_stream( - self, - ) -> AsyncIterable[PaidStarsPurchasePayment]: - async for payment in self._buffer.stream(): - yield payment diff --git a/src/ttt/presentation/adapters/stars_purchase_views.py b/src/ttt/presentation/adapters/stars_purchase_views.py new file mode 100644 index 0000000..a28ad66 --- /dev/null +++ b/src/ttt/presentation/adapters/stars_purchase_views.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass + +from aiogram_dialog import ShowMode, StartMode + +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramStarsPurchaseViews(StarsPurchaseViews): + _dialog_manager_for_user: DialogManagerForUser + + async def invalid_stars_for_stars_purchase_view( + self, + user_id: int, + /, + ) -> None: + raise NotImplementedError + + async def stars_purchase_will_be_completed_view( + self, + user_id: int, + /, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + await manager.start( + MainDialogState.stars_shop, + {"hint": "🌟 Звёзды скоро начислятся!"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def completed_stars_purchase_view( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + manager = self._dialog_manager_for_user(stars_purchase.user.id) + await manager.start( + MainDialogState.stars_shop, + {"hint": "🌟 Звезды начислились!"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 3a11f1b..90f669b 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -1,7 +1,7 @@ +from asyncio import gather from collections import OrderedDict from dataclasses import dataclass from typing import cast -from uuid import UUID from aiogram import Bot from aiogram_dialog import ShowMode, StartMode @@ -18,16 +18,17 @@ from ttt.application.user.emoji_selection.ports.user_views import ( EmojiSelectionUserViews, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.game.game import Game from ttt.entities.core.stars import Stars -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User, is_user_in_game, user_stars +from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.stmts import ( + max_rating_and_users_with_max_rating_from_postgres, selected_user_emoji_str_from_postgres, user_emojis_from_postgres, ) +from ttt.infrastructure.sqlalchemy.tables.game import TableGame from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( TableInvitationToGame, TableInvitationToGameState, @@ -61,6 +62,12 @@ from ttt.presentation.aiogram_dialog.main_dialog.emojis_window import ( EmojiMenuView, ) +from ttt.presentation.aiogram_dialog.main_dialog.game_start_window import ( + GameStartView, +) +from ttt.presentation.aiogram_dialog.main_dialog.game_window import ( + ActiveGameView, +) from ttt.presentation.aiogram_dialog.main_dialog.main_window import ( AmoutOfIncomingInvitationsToGame, MainMenuView, @@ -84,13 +91,7 @@ async def view_of_user_with_id( /, ) -> None: user_stmt = ( - select( - TableUser.number_of_wins, - TableUser.number_of_draws, - TableUser.number_of_defeats, - TableUser.account_stars, - TableUser.rating, - ) + select(TableUser.account_stars, TableUser.rating) .where(TableUser.id == user_id) ) result = await self._session.execute(user_stmt) @@ -100,12 +101,41 @@ async def view_of_user_with_id( await need_to_start_message(self._bot, user_id) return + wins_stmt = ( + select(func.count(1)) + .where(TableGame.result_decided_game_user_win_user_id == user_id) + ) + wins = not_none(await self._session.scalar(wins_stmt)) + + draws_stmt = ( + select(func.count(1)) + .where( + (TableGame.result_draw_game_user_draw1_user_id == user_id) + | (TableGame.result_draw_game_user_draw2_user_id == user_id), + ) + ) + draws = not_none(await self._session.scalar(draws_stmt)) + + defeats_stmt = ( + select(func.count(1)) + .where(TableGame.result_decided_game_user_loss_user_id == user_id) + ) + defeats = not_none(await self._session.scalar(defeats_stmt)) + + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) + view = UserProfileView.of( - user_row.number_of_wins, - user_row.number_of_draws, - user_row.number_of_defeats, + wins, + draws, + defeats, user_row.account_stars, user_row.rating, + max_rating, + users_with_max_rating, ) self._result_buffer.result = view @@ -116,7 +146,7 @@ async def user_menu_view(self, user_id: int, /) -> None: ) stmt = ( select( - TableUser.game_location_game_id, + TableUser.current_game_id, TableUser.account_stars, TableUser.rating, has_user_emojis_stmt, @@ -129,13 +159,6 @@ async def user_menu_view(self, user_id: int, /) -> None: if row is None: raise ValueError - game_location_game_id = row.game_location_game_id - - if game_location_game_id is None: - game_location = None - else: - game_location = UserGameLocation(user_id, game_location_game_id) - incoming_invitations_to_game_stmt = ( select(func.count(1)) .where( @@ -163,14 +186,21 @@ async def user_menu_view(self, user_id: int, /) -> None: else: amout_of_incoming_invitations_to_game = "many" + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) view = MainMenuView( - is_user_in_game=is_user_in_game(game_location), + is_user_in_game=is_user_in_game(row.current_game_id), has_user_emojis=row.has_user_emojis, stars=row.account_stars, rating=row.rating, amout_of_incoming_invitations_to_game=( amout_of_incoming_invitations_to_game ), + max_rating=max_rating, + users_with_max_rating=users_with_max_rating, ) self._result_buffer.result = view @@ -330,9 +360,6 @@ async def user_is_not_admin_view(self, user: User, /) -> None: async def other_user_view(self, user: User, other_user_id: int, /) -> None: stmt = ( select( - TableUser.number_of_wins, - TableUser.number_of_draws, - TableUser.number_of_defeats, TableUser.account_stars, TableUser.rating, TableUser.admin_right, @@ -353,6 +380,22 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: ) return + wins_stmt = select(func.count(1)).where( + TableGame.result_decided_game_user_win_user_id == other_user_id, + ) + wins = not_none(await self._session.scalar(wins_stmt)) + + draws_stmt = select(func.count(1)).where( + (TableGame.result_draw_game_user_draw1_user_id == other_user_id) + | (TableGame.result_draw_game_user_draw2_user_id == other_user_id), + ) + draws = not_none(await self._session.scalar(draws_stmt)) + + defeats_stmt = select(func.count(1)).where( + TableGame.result_decided_game_user_loss_user_id == other_user_id, + ) + defeats = not_none(await self._session.scalar(defeats_stmt)) + if row.admin_right is None: admin_right = None else: @@ -360,14 +403,22 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: row.admin_right_via_other_admin_admin_id, ) + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) + view = OtherUserProfileView.of( other_user_id, admin_right, - row.number_of_wins, - row.number_of_draws, - row.number_of_defeats, + wins, + draws, + defeats, row.account_stars, row.rating, + max_rating, + users_with_max_rating, ) manager = self._dialog_manager_for_user(user.id) @@ -449,45 +500,6 @@ async def user_deauthorized_other_user_as_admin_view( ) -@dataclass(frozen=True, unsafe_hash=False) -class AiogramStarsPurchaseUserViews(StarsPurchaseUserViews): - _dialog_manager_for_user: DialogManagerForUser - - async def invalid_stars_for_stars_purchase_view( - self, - user_id: int, - /, - ) -> None: - raise NotImplementedError - - async def stars_purchase_will_be_completed_view( - self, - user_id: int, - /, - ) -> None: - manager = self._dialog_manager_for_user(user_id) - await manager.start( - MainDialogState.stars_shop, - {"hint": "🌟 Звёзды скоро начислятся!"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - - async def completed_stars_purchase_view( - self, - user: User, - purchase_id: UUID, - /, - ) -> None: - manager = self._dialog_manager_for_user(user.id) - await manager.start( - MainDialogState.stars_shop, - {"hint": "🌟 Звезды начислились!"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - - @dataclass(frozen=True, unsafe_hash=False) class AiogramEmojiSelectionUserViews(EmojiSelectionUserViews): async def invalid_emoji_to_select_view( @@ -639,3 +651,77 @@ async def _account_view( StartMode.RESET_STACK, ShowMode.DELETE_AND_SEND, ) + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramGameUserViews(GameUserViews): + _dialog_manager_for_user: DialogManagerForUser + _result_buffer: ResultBuffer + _session: AsyncSession + + async def user_is_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: + ... + + async def user_is_already_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: + ... + + async def user_is_in_game_to_wait_for_matchmaking_view( + self, user: User, /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user.id) + await dialog_manager.start( + MainDialogState.game_mode_to_start_game, + {"hint": "⚔️ Вы уже в игре"}, + StartMode.RESET_STACK, + ) + + async def matched_games_view(self, games: list[Game], /) -> None: + await gather(*map(self._started_game_view, games)) + + async def _started_game_view(self, game: Game, /) -> None: + await gather(*( + self._started_game_view_for_user(user.id, game) + for user in game.users() + )) + + async def _started_game_view_for_user( + self, user_id: int, game: Game, /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + await dialog_manager.start( + MainDialogState.game, + ActiveGameView.of(game, user_id).window_data(), + StartMode.RESET_STACK, + ) + + async def user_is_not_waiting_for_matchmaking_to_dont_wait_view( + self, user: User, /, + ) -> None: + ... + + async def user_is_not_waiting_for_matchmaking_view( + self, user: User, /, + ) -> None: + ... + + async def matchmaking_view(self, user_id: int, /) -> None: + stmt = ( + select(TableUser.has_matchmaking_waiting) + .where(TableUser.id == user_id) + ) + has_matchmaking_waiting = await self._session.scalar(stmt) + + if has_matchmaking_waiting is None: + self._result_buffer.result = None + else: + self._result_buffer.result = GameStartView( + is_user_waiting_for_matchmaking=has_matchmaking_waiting, + ) diff --git a/src/ttt/presentation/aiogram/common/middlewares.py b/src/ttt/presentation/aiogram/common/middlewares.py new file mode 100644 index 0000000..3d67846 --- /dev/null +++ b/src/ttt/presentation/aiogram/common/middlewares.py @@ -0,0 +1,27 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from dishka.integrations.aiogram import CONTAINER_NAME, AiogramMiddlewareData + +from ttt.infrastructure.dishka.next_container import NextContainer + + +class AiogramNextContainerMiddleware(BaseMiddleware): + def __init__(self, next_container: NextContainer) -> None: + self.next_container = next_container + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: # noqa: ANN401 + context = { + TelegramObject: event, + AiogramMiddlewareData: data, + } + async with self.next_container(context) as sub_container: + data[CONTAINER_NAME] = sub_container + return await handler(event, data) diff --git a/src/ttt/presentation/aiogram/common/routes/error_handling.py b/src/ttt/presentation/aiogram/common/routes/error_handling.py index 7790edd..76c365c 100644 --- a/src/ttt/presentation/aiogram/common/routes/error_handling.py +++ b/src/ttt/presentation/aiogram/common/routes/error_handling.py @@ -1,14 +1,47 @@ +from typing import cast + from aiogram import Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import ExceptionTypeFilter from aiogram.types import ErrorEvent +from aiogram_dialog import DialogManager, StartMode +from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent from dishka.integrations.aiogram import FromDishka, inject from structlog.types import FilteringBoundLogger from ttt.infrastructure.structlog.logger import unexpected_error_log +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState error_handling_router = Router(name=__name__) +@error_handling_router.error( + ExceptionTypeFilter(UnknownIntent, OutdatedIntent), +) +async def _(_: ErrorEvent, dialog_manager: DialogManager) -> None: + await dialog_manager.start( + MainDialogState.main, + {"hint": "Сессия устарела"}, + StartMode.RESET_STACK, + ) + + +@error_handling_router.error(ExceptionTypeFilter(TelegramBadRequest)) +@inject +async def _( + event: ErrorEvent, + logger: FromDishka[FilteringBoundLogger], +) -> None: + error = cast(TelegramBadRequest, event.exception) + + if error.message not in { + "Bad Request: chat not found", + "Forbidden: bot was blocked by the user", + }: + await unexpected_error_log(logger, error) + + @error_handling_router.error() @inject async def _( diff --git a/src/ttt/presentation/aiogram/user/invoices.py b/src/ttt/presentation/aiogram/user/invoices.py index 65965e3..63c15ef 100644 --- a/src/ttt/presentation/aiogram/user/invoices.py +++ b/src/ttt/presentation/aiogram/user/invoices.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, TypeAdapter from ttt.entities.core.stars import price_of_stars -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase class StarsPurchaseInvoicePayload(BaseModel): @@ -42,7 +42,7 @@ async def stars_invoce( ) payload_model = StarsPurchaseInvoicePayload.of( - purchase.id_, purchase.user_id, + purchase.id_, purchase.user.id, ) payload = payload_model.model_dump_json(by_alias=True) @@ -63,7 +63,7 @@ async def stars_invoce( }) await bot.send_invoice( - purchase.user_id, + purchase.user.id, title="Звёзды", description="Покупка звёзд", payload=payload, diff --git a/src/ttt/presentation/aiogram/user/routes/handle_payment.py b/src/ttt/presentation/aiogram/user/routes/handle_payment.py index a4dae4f..86b4271 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_payment.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_payment.py @@ -3,10 +3,12 @@ from dishka import AsyncContainer from dishka.integrations.aiogram import inject -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 + StartStarsPurchasePaymentCompletion, +) from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import not_none -from ttt.infrastructure.buffer import Buffer +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.invoices import ( StarsPurchaseInvoicePayload, invoce_payload_adapter, @@ -22,26 +24,25 @@ async def _( message: Message, dishka_container: AsyncContainer, ) -> None: - payment = not_none(message.successful_payment) + aiogram_payment = not_none(message.successful_payment) success = PaymentSuccess( - payment.telegram_payment_charge_id, - payment.provider_payment_charge_id, + aiogram_payment.telegram_payment_charge_id, + aiogram_payment.provider_payment_charge_id, ) invoce_payload = invoce_payload_adapter.validate_json( - payment.invoice_payload, + aiogram_payment.invoice_payload, ) - parent_container = not_none(dishka_container.parent_container) match invoce_payload: case StarsPurchaseInvoicePayload(): - buffer = await parent_container.get( - Buffer[PaidStarsPurchasePayment], + retrier = await dishka_container.get(Retrier) + start_stars_purchase_payment_completion = ( + await dishka_container.get(StartStarsPurchasePaymentCompletion) ) - buffer.add( - PaidStarsPurchasePayment( - invoce_payload.purchase_id, - invoce_payload.user_id, - success, - ), + await retrier( + start_stars_purchase_payment_completion, + invoce_payload.user_id, + invoce_payload.purchase_id, + success, ) diff --git a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py index 4c4ca3e..b1f473d 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py @@ -1,10 +1,12 @@ from aiogram import Router from aiogram.types import PreCheckoutQuery from dishka import AsyncContainer +from dishka.integrations.aiogram import inject -from ttt.application.user.stars_purchase.start_stars_purchase_payment import ( +from ttt.application.stars_purchase.start_stars_purchase_payment import ( StartStarsPurchasePayment, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.invoices import ( StarsPurchaseInvoicePayload, invoce_payload_adapter, @@ -15,6 +17,7 @@ @handle_pre_checkout_query_router.pre_checkout_query() +@inject async def _( pre_checkout_query: PreCheckoutQuery, dishka_container: AsyncContainer, @@ -25,8 +28,6 @@ async def _( match invoce_payload: case StarsPurchaseInvoicePayload(): + retrier = await dishka_container.get(Retrier) action = await dishka_container.get(StartStarsPurchasePayment) - await action( - invoce_payload.user_id, - invoce_payload.purchase_id, - ) + await retrier(action, invoce_payload.purchase_id) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py index b2ca006..a76f2d9 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py @@ -14,6 +14,7 @@ AuthorizeOtherUserAsAdmin, ) from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -26,6 +27,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, authorize_other_user_as_admin: FromDishka[AuthorizeOtherUserAsAdmin], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -37,8 +39,10 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await authorize_other_user_as_admin( - not_none(message.from_user).id, other_user_id, + await retrier( + authorize_other_user_as_admin, + not_none(message.from_user).id, + other_user_id, ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py index b366004..fe43a05 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py @@ -26,6 +26,7 @@ ) from ttt.entities.core.stars import Stars from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.hint import hint @@ -46,6 +47,7 @@ async def getter( event_from_user: User, view_user_account_to_change: FromDishka[ViewUserAccountToChange], result_buffer: FromDishka[ResultBuffer], + retrier: FromDishka[Retrier], dialog_manager: DialogManager, **_: Any, # noqa: ANN401 ) -> dict[str, Any]: @@ -57,7 +59,8 @@ async def getter( view = ChangeOtherUserAccount2View(stars) return view.window_data() - await view_user_account_to_change( + await retrier( + view_user_account_to_change, event_from_user.id, dialog_manager.start_data["other_user_id"], ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py index 5283b8b..64aaaad 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py @@ -15,6 +15,7 @@ DeauthorizeOtherUserAsAdmin, ) from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -27,6 +28,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, deauthorize_other_user_as_admin: FromDishka[DeauthorizeOtherUserAsAdmin], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -38,8 +40,10 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await deauthorize_other_user_as_admin( - not_none(message.from_user).id, other_user_id, + await retrier( + deauthorize_other_user_as_admin, + not_none(message.from_user).id, + other_user_id, ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py index f995ffe..e76bd1b 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py @@ -15,6 +15,7 @@ from ttt.application.user.relinquish_admin_right import RelinquishAdminRight from ttt.application.user.view_admin_menu import ViewAdminMenu from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import ( AdminDialogState, AdminRightName, @@ -84,10 +85,11 @@ async def main_getter( *, event_from_user: User, view_admin_menu: FromDishka[ViewAdminMenu], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_admin_menu(event_from_user.id) + await retrier(view_admin_menu, event_from_user.id) view: AdminMainMenuView = result_buffer(AdminMainMenuView) # type: ignore[arg-type] return view.window_data() @@ -99,6 +101,7 @@ async def input_admin_token( _: MessageInput, manager: DialogManager, authorize_as_admin: FromDishka[AuthorizeAsAdmin], + retrier: FromDishka[Retrier], ) -> None: admin_token = message.text @@ -111,7 +114,9 @@ async def input_admin_token( ) return - await authorize_as_admin(not_none(message.from_user).id, admin_token) + await retrier( + authorize_as_admin, not_none(message.from_user).id, admin_token, + ) @inject @@ -120,8 +125,9 @@ async def on_relinquish_admin_right_clicked( _: Button, __: DialogManager, relinquish_admin_right: FromDishka[RelinquishAdminRight], + retrier: FromDishka[Retrier], ) -> None: - await relinquish_admin_right(callback.from_user.id) + await retrier(relinquish_admin_right, callback.from_user.id) @FuncText diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py index c525233..2d4eda2 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py @@ -15,8 +15,9 @@ from ttt.application.user.view_other_user import ViewOtherUser from ttt.entities.core.user.admin_right import AdminRight -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import ( AdminDialogState, AdminRightName, @@ -57,6 +58,8 @@ def of( # noqa: PLR0913, PLR0917 number_of_defeats: int, account_stars: int, rating: float, + max_rating: float, + users_with_max_rating: UsersWithMaxRating, ) -> "OtherUserProfileView": return OtherUserProfileView( id_=id_, @@ -67,7 +70,9 @@ def of( # noqa: PLR0913, PLR0917 number_of_defeats=number_of_defeats, account_stars=account_stars, rating_text=short_float_text(rating), - rank_text=rank_title(rank_for_rating(rating)), + rank_text=rank_title( + rank(rating, max_rating, users_with_max_rating), + ), ) @@ -77,6 +82,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, view_other_user: FromDishka[ViewOtherUser], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -88,7 +94,9 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await view_other_user(not_none(message.from_user).id, other_user_id) + await retrier( + view_other_user, not_none(message.from_user).id, other_user_id, + ) other_user_profile_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py index 20e9afd..8b7a570 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py @@ -9,6 +9,7 @@ from magic_filter import F from ttt.application.user.relinquish_admin_right import RelinquishAdminRight +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -22,8 +23,9 @@ async def on_yes_clicked( _: Button, __: DialogManager, relinquish_admin_right: FromDishka[RelinquishAdminRight], + retrier: FromDishka[Retrier], ) -> None: - await relinquish_admin_right(callback.from_user.id) + await retrier(relinquish_admin_right, callback.from_user.id) relinquish_admin_right2_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py new file mode 100644 index 0000000..cd6b415 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py @@ -0,0 +1,53 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram.types import ( + CallbackQuery, + KeyboardButton, + KeyboardButtonRequestUsers, +) +from aiogram_dialog.api.internal import RawKeyboard +from aiogram_dialog.api.protocols import DialogManager, DialogProtocol +from aiogram_dialog.widgets.common import WhenCondition +from aiogram_dialog.widgets.kbd import Keyboard +from aiogram_dialog.widgets.text import Text + + +UsersRequestOnClick = Callable[ + [CallbackQuery, "UsersRequest", DialogManager], Awaitable[Any], +] + + +class UsersRequest(Keyboard): + def __init__( + self, + text: Text, + id: str, # noqa: A002 + criteria: KeyboardButtonRequestUsers, + when: WhenCondition = None, + ) -> None: + super().__init__(id=id, when=when) + self.text = text + self.criteria = criteria + + async def _process_own_callback( + self, + callback: CallbackQuery, # noqa: ARG002 + dialog: DialogProtocol, # noqa: ARG002 + manager: DialogManager, # noqa: ARG002 + ) -> bool: + return True + + async def _render_keyboard( + self, + data: dict[Any, Any], + manager: DialogManager, + ) -> RawKeyboard: + return [ + [ + KeyboardButton( + text=await self.text.render_text(data, manager), + request_users=self.criteria, + ), + ], + ] diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py index a5547bc..eea3e62 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py @@ -12,6 +12,7 @@ from ttt.application.game.game.start_game_with_ai import StartGameWithAi from ttt.entities.core.game.ai import AiType +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @@ -21,8 +22,11 @@ async def on_game_against_gemini_2_0_flash_clicked( _: Button, __: DialogManager, start_game_with_ai: FromDishka[StartGameWithAi], + retrier: FromDishka[Retrier], ) -> None: - await start_game_with_ai(callback.from_user.id, AiType.gemini_2_0_flash) + await retrier( + start_game_with_ai, callback.from_user.id, AiType.gemini_2_0_flash, + ) ai_type_to_start_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py index 11c225c..23e8d6c 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py @@ -13,6 +13,7 @@ from ttt.application.user.emoji_purchase.buy_emoji import BuyEmoji from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.parsing import parsed_emoji_str from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -27,10 +28,11 @@ async def handler( _: MessageInput, __: DialogManager, buy_emoji: FromDishka[BuyEmoji], + retrier: FromDishka[Retrier], ) -> None: emoji_str = parsed_emoji_str(message) - await buy_emoji(not_none(message.from_user).id, emoji_str) + await retrier(buy_emoji, not_none(message.from_user).id, emoji_str) emoji_shop_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py index cdaf6dd..3c2552b 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py @@ -17,6 +17,7 @@ from ttt.application.user.emoji_selection.select_emoji import SelectEmoji from ttt.application.user.view_user_emojis import ViewUserEmojis +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -65,9 +66,10 @@ async def emoji_getter( event_from_user: User, view_user_emojis: FromDishka[ViewUserEmojis], result_buffer: FromDishka[ResultBuffer], + retrier: FromDishka[Retrier], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_user_emojis(event_from_user.id) + await retrier(view_user_emojis, event_from_user.id) view = result_buffer(EmojiMenuView) return view.window_data() @@ -80,8 +82,9 @@ async def on_emoji_selected( __: DialogManager, emoji_str: str, select_emoji: FromDishka[SelectEmoji], + retrier: FromDishka[Retrier], ) -> None: - await select_emoji(callback.from_user.id, emoji_str) + await retrier(select_emoji, callback.from_user.id, emoji_str) emoji_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py index 405b4b6..f905cd6 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py @@ -1,4 +1,8 @@ +from dataclasses import dataclass +from typing import Any + from aiogram.types import CallbackQuery +from aiogram.types.user import User from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.kbd import ( Button, @@ -10,22 +14,61 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F -from ttt.application.matchmaking_queue.game.wait_game import WaitGame +from ttt.application.user.game.dont_wait_for_matchmaking import ( + DontWaitForMatchmaking, +) +from ttt.application.user.game.view_matchmaking import ViewMatchmaking +from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking +from ttt.infrastructure.retrier import Retrier +from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, ) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True) +class GameStartView(EncodableToWindowData): + is_user_waiting_for_matchmaking: bool @inject -async def on_matchmaking_clicked( +async def on_wait_for_matchmaking_clicked( callback: CallbackQuery, _: Button, __: DialogManager, - wait_game: FromDishka[WaitGame], + wait_for_matchmaking: FromDishka[WaitForMatchmaking], + retrier: FromDishka[Retrier], ) -> None: - await wait_game(callback.from_user.id) + await retrier(wait_for_matchmaking, callback.from_user.id) + + +@inject +async def on_dont_wait_for_matchmaking_clicked( + callback: CallbackQuery, + _: Button, + __: DialogManager, + dont_wait_for_matchmaking: FromDishka[DontWaitForMatchmaking], + retrier: FromDishka[Retrier], +) -> None: + await retrier(dont_wait_for_matchmaking, callback.from_user.id) + + +@inject +async def getter( + *, + event_from_user: User, + view_matchmaking: FromDishka[ViewMatchmaking], + retrier: FromDishka[Retrier], + result_buffer: FromDishka[ResultBuffer], + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + await retrier(view_matchmaking, event_from_user.id) + view = result_buffer(GameStartView) + + return view.window_data() game_start_window = Window( @@ -34,8 +77,15 @@ async def on_matchmaking_clicked( Row( Button( Const("🗡 Подбор игр"), - id="matchmaking", - on_click=on_matchmaking_clicked, + id="wait_for_matchmaking", + on_click=on_wait_for_matchmaking_clicked, + when=~F["main"]["is_user_waiting_for_matchmaking"], + ), + Button( + Const("🗡❌ Отменить"), + id="dont_wait_for_matchmaking", + on_click=on_dont_wait_for_matchmaking_clicked, + when=F["main"]["is_user_waiting_for_matchmaking"], ), SwitchTo( Const("🤖 Играть с ИИ"), @@ -54,4 +104,5 @@ async def on_matchmaking_clicked( OneTimekey("hint"), state=MainDialogState.game_mode_to_start_game, + getter=getter, ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py index ce5a98c..b9490df 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py @@ -26,6 +26,7 @@ from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.win import UserWin from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -43,9 +44,10 @@ async def on_cell_clicked( button: Button, _: DialogManager, make_move_in_game: FromDishka[MakeMoveInGame], + retrier: FromDishka[Retrier], ) -> None: cell_number_int = int(not_none(button.widget_id)[-1]) - await make_move_in_game(callback.from_user.id, cell_number_int) + await retrier(make_move_in_game, callback.from_user.id, cell_number_int) def cell_button(cell_number_int: int) -> Button: diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py index 16923bc..623e983 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py @@ -22,6 +22,7 @@ from ttt.application.invitation_to_game.game.reject_invitation_to_game import ( RejectInvitationToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.common.wigets.hint import hint @@ -35,6 +36,7 @@ class IncomingInvitationToGameView(EncodableToWindowData): id_hex: str inviting_user_id: int + inviting_user_username: str | None @inject @@ -43,12 +45,15 @@ async def on_accept_clicked( _: Button, manager: DialogManager, accept_invitation_to_game: FromDishka[AcceptInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: if not isinstance(manager.start_data, dict): raise TypeError invitation_id = UUID(hex=manager.start_data["main"]["id_hex"]) - await accept_invitation_to_game(callback.from_user.id, invitation_id) + await retrier( + accept_invitation_to_game, callback.from_user.id, invitation_id, + ) @inject @@ -57,12 +62,15 @@ async def on_reject_clicked( _: Button, manager: DialogManager, reject_invitation_to_game: FromDishka[RejectInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: if not isinstance(manager.start_data, dict): raise TypeError invitation_id = UUID(hex=manager.start_data["main"]["id_hex"]) - await reject_invitation_to_game(callback.from_user.id, invitation_id) + await retrier( + reject_invitation_to_game, callback.from_user.id, invitation_id, + ) async def incoming_invitation_to_game_html( # noqa: RUF029 @@ -73,10 +81,14 @@ async def incoming_invitation_to_game_html( # noqa: RUF029 raise TypeError invitation = manager.start_data["main"] - text = Text( - "👤 Приглашение к игре от ", Code(invitation["inviting_user_id"]), - ) - return text.as_html() + + if invitation.get("inviting_user_username") is None: + text = Text( + "👤 Приглашение к игре от ", Code(invitation["inviting_user_id"]), + ) + return text.as_html() + + return f"👤 Приглашение к игре от @{invitation["inviting_user_username"]}" incoming_invitation_to_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py index 49f0170..8cb3eb0 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -9,7 +9,7 @@ Select, SwitchTo, ) -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from magic_filter import F @@ -20,6 +20,7 @@ from ttt.application.invitation_to_game.game.view_incoming_invitations_to_game import ( # noqa: E501 ViewIncomingInvitationsToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @@ -33,21 +34,18 @@ class IncomingInvitationToGameData: id_hex: str inviting_user_id: int + inviting_user_username: str | None @dataclass(frozen=True) class IncomingInvitationsToGameView(EncodableToWindowData): invitations: list[IncomingInvitationToGameData] - need_to_paginate: bool @classmethod def of( cls, invitations: list[IncomingInvitationToGameData], ) -> "IncomingInvitationsToGameView": - return IncomingInvitationsToGameView( - invitations=invitations, - need_to_paginate=len(invitations) > 7, # noqa: PLR2004 - ) + return IncomingInvitationsToGameView(invitations=invitations) @inject @@ -55,26 +53,30 @@ async def getter( *, event_from_user: User, view_invitations: FromDishka[ViewIncomingInvitationsToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_invitations(event_from_user.id) + await retrier(view_invitations, event_from_user.id) view = result_buffer(IncomingInvitationsToGameView) return view.window_data() @inject -async def on_invitation_selected( +async def on_invitation_selected( # noqa: PLR0913, PLR0917 callback_query: CallbackQuery, _: Select[Any], manager: DialogManager, invitation_id_hex: str, view_invitation_to_game: FromDishka[ViewIncomingInvitationToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await view_invitation_to_game(callback_query.from_user.id, invitation_id) + await retrier( + view_invitation_to_game, callback_query.from_user.id, invitation_id, + ) view = result_buffer.result if not isinstance(view, IncomingInvitationToGameView | None): @@ -92,35 +94,39 @@ async def on_invitation_selected( await manager.start(MainDialogState.incoming_invitation_to_game, start_data) -async def incoming_invitations_to_game_html( # noqa: RUF029 +async def incoming_invitations_to_game_text( # noqa: RUF029 data: dict[str, Any], _: DialogManager, ) -> str: return f"👥 У вас {len(data["main"]["invitations"])} приглашений к игре" +async def incoming_invitation_to_game_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + invitation = data["item"] + + if invitation.get("inviting_user_username") is None: + return f"От {invitation["inviting_user_id"]}" + + return f"От @{invitation["inviting_user_username"]}" + + incoming_invitations_to_game_window = Window( - FuncText(incoming_invitations_to_game_html), - Select( - Format("От {item[inviting_user_id]}"), - id="n", - items=F["main"]["invitations"], - item_id_getter=lambda it: it["id_hex"], - on_click=on_invitation_selected, - when=~F["main"]["need_to_paginate"], - ), + FuncText(incoming_invitations_to_game_text), ScrollingGroup( Select( - Format("От {item[inviting_user_id]}"), + FuncText(incoming_invitation_to_game_text), id="n", items=F["main"]["invitations"], item_id_getter=lambda it: it["id_hex"], on_click=on_invitation_selected, ), - width=4, - height=4, + width=2, + height=2, + hide_on_single_page=True, id="y", - when=F["main"]["need_to_paginate"], ), SwitchTo( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py index 322ff3e..c87ccf8 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py @@ -19,8 +19,9 @@ ) from ttt.application.user.view_main_menu import ViewMainMenu from ttt.entities.core.stars import Stars -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.elo.rating import EloRating +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -50,6 +51,8 @@ class MainMenuView(EncodableToWindowData): is_user_in_game: bool has_user_emojis: bool rating: EloRating + max_rating: EloRating + users_with_max_rating: UsersWithMaxRating stars: Stars amout_of_incoming_invitations_to_game: AmoutOfIncomingInvitationsToGame @@ -59,9 +62,11 @@ async def rank_text( # noqa: RUF029 _: DialogManager, ) -> str: rating = data["main"]["rating"] - rank = rank_for_rating(rating) + max_rating = data["main"]["max_rating"] + users_with_max_rating = data["main"]["users_with_max_rating"] + rank_ = rank(rating, max_rating, users_with_max_rating) - return f"Вы — {rank_title(rank)} {rank_progres_text(rating)}" + return f"Вы — {rank_title(rank_)} {rank_progres_text(rank_, rating)}" @inject @@ -69,10 +74,11 @@ async def main_getter( *, event_from_user: User, view_main_menu: FromDishka[ViewMainMenu], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_main_menu(event_from_user.id) + await retrier(view_main_menu, event_from_user.id) view = result_buffer(MainMenuView) return view.window_data() @@ -84,8 +90,9 @@ async def on_cancel_game_clicked( _: Button, __: DialogManager, cancel_game: FromDishka[CancelGame], + retrier: FromDishka[Retrier], ) -> None: - await cancel_game(callback.from_user.id) + await retrier(cancel_game, callback.from_user.id) @inject @@ -94,9 +101,10 @@ async def on_back_to_game_clicked( _: Button, manager: DialogManager, view_game: FromDishka[ViewGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: - await view_game(callback.from_user.id) + await retrier(view_game, callback.from_user.id) view = result_buffer(ActiveGameView) data = view.window_data() @@ -109,9 +117,10 @@ async def on_incoming_invitation_to_game_clicked( _: Button, manager: DialogManager, view_invitation_to_game: FromDishka[ViewOneIncomingInvitationToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: - await view_invitation_to_game(callback_query.from_user.id) + await retrier(view_invitation_to_game, callback_query.from_user.id) view = result_buffer.result if not isinstance(view, IncomingInvitationToGameView | None): diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py index 1e2edf4..438cd75 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -3,15 +3,21 @@ from uuid import UUID from aiogram.enums import ContentType -from aiogram.types import CallbackQuery, Message, User -from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram.types import ( + CallbackQuery, + KeyboardButtonRequestUsers, + Message, + User, +) +from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import ( - ScrollingGroup, + Group, Select, SwitchTo, ) -from aiogram_dialog.widgets.text import Const, Format, Multi +from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory +from aiogram_dialog.widgets.text import Const from alembic.util import not_none from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -24,10 +30,16 @@ from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 ViewOutcomingInvitationsToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData +from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText +from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, ) +from ttt.presentation.aiogram_dialog.common.wigets.users_request import ( + UsersRequest, +) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -36,21 +48,18 @@ class OutcomingInvitationToGameData: id_hex: str invited_user_id: int + invited_user_username: str | None @dataclass(frozen=True) class OutcomingInvitationsToGameView(EncodableToWindowData): invitations: list[OutcomingInvitationToGameData] - need_to_paginate: bool @classmethod def of( cls, invitations: list[OutcomingInvitationToGameData], ) -> "OutcomingInvitationsToGameView": - return OutcomingInvitationsToGameView( - invitations=invitations, - need_to_paginate=len(invitations) > 7, # noqa: PLR2004 - ) + return OutcomingInvitationsToGameView(invitations=invitations) @inject @@ -58,10 +67,11 @@ async def getter( *, event_from_user: User, view_invitations: FromDishka[ViewOutcomingInvitationsToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_invitations(event_from_user.id) + await retrier(view_invitations, event_from_user.id) view = result_buffer(OutcomingInvitationsToGameView) return view.window_data() @@ -74,61 +84,87 @@ async def on_invitation_selected( __: DialogManager, invitation_id_hex: str, cancel_invitation_to_game: FromDishka[CancelInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await cancel_invitation_to_game(callback_query.from_user.id, invitation_id) + await retrier( + cancel_invitation_to_game, callback_query.from_user.id, invitation_id, + ) @inject -async def input_user_id( +async def input_user( message: Message, _: MessageInput, - manager: DialogManager, + __: DialogManager, invite_to_game: FromDishka[InviteToGame], + retrier: FromDishka[Retrier], ) -> None: - try: - invited_user_id = int(message.text) # type: ignore[arg-type] - except (ValueError, TypeError): - await manager.start( - MainDialogState.outcoming_invitations_to_game, - {"hint": "👎 ID должен быть целочисленым числом"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - else: - await invite_to_game(not_none(message.from_user).id, invited_user_id) + if message.users_shared is None: + return + + user = not_none(message.from_user) + shared_user = message.users_shared.users[0] + + await retrier( + invite_to_game, + user.id, + user.username, + shared_user.user_id, + shared_user.username, + ) + + +async def title_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + if data["main"]["invitations"]: + return f"👤 Приглашено {len(data["main"]["invitations"])}" + + return "👤 Никто не приглашён" + + +async def invitation_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + invited_user_id = data["item"]["invited_user_id"] + invited_user_username = data["item"].get("invited_user_username") + + if invited_user_username is None: + return f"➖ {invited_user_id}" + + return f"➖ @{invited_user_username}" outcoming_invitations_to_game_window = Window( - Multi( - Format("{start_data[hint]}"), - Const(" "), - when=F["start_data"]["hint"], - ), - Const("👤 Введите ID пользователя:"), - MessageInput(input_user_id, content_types=[ContentType.ANY]), - - Select( - Format("❌ {item[invited_user_id]}"), - id="n", - items=F["main"]["invitations"], - item_id_getter=lambda it: it["id_hex"], - on_click=on_invitation_selected, - when=~F["main"]["need_to_paginate"], - ), - ScrollingGroup( + hint(key="hint"), + FuncText(title_text, when=~F["start_data"]["hint"]), + + Group( Select( - Format("❌ {item[invited_user_id]}"), + FuncText(invitation_text), id="n", items=F["main"]["invitations"], item_id_getter=lambda it: it["id_hex"], on_click=on_invitation_selected, ), - width=4, - height=4, - id="y", - when=F["main"]["need_to_paginate"], + id="l", + width=1, + ), + UsersRequest( + Const("➕ Пригласить"), + id="invite_to_game", + criteria=KeyboardButtonRequestUsers( + request_id=4, + user_is_bot=False, + request_name=False, + request_username=True, + request_photo=False, + ), ), + MessageInput(input_user, content_types=[ContentType.ANY]), SwitchTo( Const("Назад"), @@ -136,6 +172,7 @@ async def input_user_id( state=MainDialogState.game_mode_to_start_game, ), OneTimekey("hint"), + markup_factory=ReplyKeyboardFactory(resize_keyboard=True), state=MainDialogState.outcoming_invitations_to_game, getter=getter, ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py index d8d50f6..70527cb 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py @@ -11,7 +11,8 @@ from dishka.integrations.aiogram_dialog import inject from ttt.application.user.view_user import ViewUser -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -31,13 +32,15 @@ class UserProfileView(EncodableToWindowData): rank_text: str @classmethod - def of( + def of( # noqa: PLR0913, PLR0917 cls, number_of_wins: int, number_of_draws: int, number_of_defeats: int, account_stars: int, rating: float, + max_rating: float, + users_with_max_rating: UsersWithMaxRating, ) -> "UserProfileView": return UserProfileView( number_of_wins=number_of_wins, @@ -45,7 +48,9 @@ def of( number_of_defeats=number_of_defeats, account_stars=account_stars, rating_text=short_float_text(rating), - rank_text=rank_title(rank_for_rating(rating)), + rank_text=rank_title( + rank(rating, max_rating, users_with_max_rating), + ), ) @@ -54,10 +59,11 @@ async def profile_getter( *, event_from_user: User, view_user: FromDishka[ViewUser], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_user(event_from_user.id) + await retrier(view_user, event_from_user.id) view = result_buffer(UserProfileView) return view.window_data() diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py index 2a39e89..8a7f349 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py @@ -12,9 +12,10 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F -from ttt.application.user.stars_purchase.start_stars_purchase import ( +from ttt.application.stars_purchase.start_stars_purchase import ( StartStarsPurchase, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -28,6 +29,7 @@ async def on_stars_purchase_clicked( button: Button, _: DialogManager, start_stars_purchase: FromDishka[StartStarsPurchase], + retrier: FromDishka[Retrier], ) -> None: match button.widget_id: case "8192_stars_purchase": @@ -41,7 +43,7 @@ async def on_stars_purchase_clicked( case _: raise ValueError(button.widget_id) - await start_stars_purchase(callback.from_user.id, stars) + await retrier(start_stars_purchase, callback.from_user.id, stars) @inject diff --git a/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py deleted file mode 100644 index e0de920..0000000 --- a/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py +++ /dev/null @@ -1,17 +0,0 @@ -from asyncio import sleep - -from dishka import AsyncContainer - -from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 - AutoCancelInvitationsToGame, -) - - -async def auto_cancel_invitation_to_game_task( - diska_container: AsyncContainer, -) -> None: - while True: - await sleep(1) - async with diska_container() as request: - cancel_invitations = await request.get(AutoCancelInvitationsToGame) - await cancel_invitations() diff --git a/src/ttt/presentation/texts.py b/src/ttt/presentation/texts.py index fc4d0c6..fa6bf86 100644 --- a/src/ttt/presentation/texts.py +++ b/src/ttt/presentation/texts.py @@ -1,4 +1,4 @@ -from ttt.entities.core.user.rank import Rank, rank_for_rating, rank_with_tier +from ttt.entities.core.user.rank import IntervalRank, Rank, rank_with_tier from ttt.entities.elo.rating import EloRating @@ -10,8 +10,8 @@ def copy_signed_text(text: str, original_signed: float) -> str: return f"+{text}" if original_signed >= 0 else f"{text}" -def rank_sign(rank: Rank) -> str: - match rank.tier: +def rank_sign(rank_: Rank) -> str: # noqa: PLR0911 + match rank_.tier: case -1: return "🪨" case 0: @@ -24,10 +24,12 @@ def rank_sign(rank: Rank) -> str: return "👹" case 4: return "🪬" + case 5: + return "⚪️" -def rank_name(rank: Rank) -> str: - match rank.tier: +def rank_name(rank_: Rank) -> str: # noqa: PLR0911 + match rank_.tier: case -1: return "Камень" case 0: @@ -40,18 +42,20 @@ def rank_name(rank: Rank) -> str: return "Демон" case 4: return "Око" + case 5: + return "Сильнейший" -def rank_title(rank: Rank) -> str: - return f"{rank_sign(rank)} {rank_name(rank)}" +def rank_title(rank_: Rank) -> str: + return f"{rank_sign(rank_)} {rank_name(rank_)}" -def rank_progres_text(raiting: EloRating) -> str: - rank = rank_for_rating(raiting) - - if rank.tier == 4: # noqa: PLR2004 +def rank_progres_text(rank_: Rank, raiting: EloRating) -> str: + if rank_.tier == 4 or rank_.tier == 5: # noqa: PLR1714, PLR2004 return f"({short_float_text(raiting)})" - next_rank = rank_with_tier(rank.tier + 1) # type: ignore[arg-type] + next_rank = rank_with_tier(rank_.tier + 1) # type: ignore[arg-type] + if not isinstance(next_rank, IntervalRank): + raise TypeError return f"({short_float_text(raiting)} / {next_rank.min_rating})" diff --git a/src/ttt/presentation/unkillable_tasks.py b/src/ttt/presentation/unkillable_tasks.py deleted file mode 100644 index 1dfae95..0000000 --- a/src/ttt/presentation/unkillable_tasks.py +++ /dev/null @@ -1,64 +0,0 @@ -import asyncio -from collections.abc import Callable, Coroutine -from dataclasses import dataclass, field -from types import TracebackType -from typing import Any, Self - -from structlog.types import FilteringBoundLogger - -from ttt.infrastructure.structlog.logger import unexpected_error_log - - -@dataclass(frozen=True, unsafe_hash=False) -class UnkillableTasks: - _logger: FilteringBoundLogger - _loop: asyncio.AbstractEventLoop = field( - init=False, - default_factory=asyncio.get_running_loop, - ) - _tasks: set[asyncio.Task[Any]] = field(init=False, default_factory=set) - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - error_type: type[BaseException] | None, - error: BaseException | None, - traceback: TracebackType | None, - ) -> None: - for task in self._tasks: - task.cancel() - - errors = await asyncio.gather(*self._tasks, return_exceptions=True) - errors.append(error) - - errors = [error for error in errors if isinstance(error, Exception)] - - if errors: - raise ExceptionGroup("unhandled errors", errors) # noqa: TRY003 - - def add( - self, - func: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - decorated_func = self._decorator(func) - self._create_task(decorated_func()) - - def _decorator( - self, - func: Callable[[], Coroutine[Any, Any, Any]], - ) -> Callable[[], Coroutine[Any, Any, Any]]: - async def decorated_func() -> None: - try: - await func() - except Exception as error: # noqa: BLE001 - self._create_task(decorated_func()) - await unexpected_error_log(self._logger, error) - - return decorated_func - - def _create_task(self, coro: Coroutine[Any, Any, Any]) -> None: - task = self._loop.create_task(coro) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index 1e2f831..0d1f5a3 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -3,7 +3,6 @@ from pytest import fixture from ttt.entities.core.user.account import Account -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User from ttt.entities.math.random import Random from ttt.entities.text.emoji import Emoji @@ -26,15 +25,11 @@ def user1() -> User: id=1, account=Account(0), emojis=[], - stars_purchases=[], - last_games=[], selected_emoji_id=None, rating=1000., - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, - game_location=UserGameLocation(1, UUID(int=0)), + current_game_id=UUID(int=0), admin_right=None, + matchmaking_waiting=None, ) @@ -44,15 +39,11 @@ def user2() -> User: id=2, account=Account(0), emojis=[], - stars_purchases=[], - last_games=[], rating=1000., selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, - game_location=UserGameLocation(2, UUID(int=0)), + current_game_id=UUID(int=0), admin_right=None, + matchmaking_waiting=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index 869b689..3a30d1c 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -19,7 +19,6 @@ from ttt.entities.core.game.game_result import DecidedGameResult, DrawGameResult from ttt.entities.core.user.account import Account from ttt.entities.core.user.draw import UserDraw -from ttt.entities.core.user.last_game import LastGame from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.user import User from ttt.entities.core.user.win import UserWin @@ -121,7 +120,6 @@ def game( user2, emoji2, standard_board, - 9, None, GameState.wait_player1, ) @@ -163,7 +161,6 @@ def test_not_standard_board( user2, emoji2, not_standard_board, - 9, None, GameState.wait_player1, ) @@ -183,7 +180,6 @@ def test_one_user( user1, emoji2, standard_board, - 9, None, GameState.wait_player1, ) @@ -203,7 +199,6 @@ def test_one_emoji( user2, emoji1, standard_board, - 9, None, GameState.wait_player1, ) @@ -224,7 +219,6 @@ def test_game_with_invalid_cell_order( user2, emoji2, board_with_invalid_cell_order, - 9, None, GameState.wait_player1, ) @@ -281,7 +275,6 @@ def test_make_move_with_completed_game( # noqa: PLR0913, PLR0917 user2, emoji2, standard_board, - 9, DecidedGameResult( win=UserWin(user_id=1, new_stars=20, rating_vector=20.), loss=UserLoss(user_id=2, rating_vector=-20.), @@ -293,8 +286,7 @@ def test_make_move_with_completed_game( # noqa: PLR0913, PLR0917 game.make_user_move( 1, 1, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -309,8 +301,7 @@ def test_make_move_with_not_user( game.make_user_move( 100, 9, - UUID(int=8), - UUID(int=9), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -325,8 +316,7 @@ def test_make_move_with_not_current_user( game.make_user_move( 2, 9, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -341,8 +331,7 @@ def test_make_move_with_no_cell( game.make_user_move( 1, 10, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -354,15 +343,14 @@ def test_make_move_with_already_filled_cell( tracking: Tracking, ) -> None: game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) with raises(AlreadyFilledCellError): game.make_user_move( 2, 1, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -374,15 +362,14 @@ def test_make_move_with_double_move( tracking: Tracking, ) -> None: game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) with raises(NotCurrentPlayerError): game.make_user_move( 1, 2, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -404,21 +391,21 @@ def test_winning_game( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -433,17 +420,11 @@ def test_winning_game( # noqa: PLR0913, PLR0917 id=1, account=Account(50), emojis=[], - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], rating=1020.0, - stars_purchases=[], selected_emoji_id=None, - number_of_wins=1, - number_of_draws=0, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -451,17 +432,11 @@ def test_winning_game( # noqa: PLR0913, PLR0917 id=2, account=Account(0), emojis=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], rating=981.1500225556907, - stars_purchases=[], selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=1, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": @@ -469,9 +444,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 6, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -493,35 +466,35 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 7, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 7, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 6, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 6, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 9, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 9, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 8, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 8, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -537,16 +510,10 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=1020.0, - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], - stars_purchases=[], selected_emoji_id=None, - number_of_wins=0, - number_of_draws=1, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -554,17 +521,11 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 id=2, account=Account(0), emojis=[], - stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], rating=1020.0, selected_emoji_id=None, - number_of_wins=0, - number_of_draws=1, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": @@ -572,9 +533,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 5, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -596,35 +555,35 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 6, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 6, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 8, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 8, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 9, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 9, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 7, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 7, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -640,16 +599,10 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 account=Account(50), emojis=[], rating=1020.0, - stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], selected_emoji_id=None, - number_of_wins=1, - number_of_draws=0, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -658,16 +611,10 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=981.1500225556907, - stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=1, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": @@ -675,9 +622,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 5, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index b452789..51099d1 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -15,14 +15,10 @@ def test_create_user(tracking: Tracking, object_: str) -> None: account=Account(0), emojis=[], rating=1000., - stars_purchases=[], - last_games=[], selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, - game_location=None, + current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "tracking": diff --git a/tests/test_ttt/test_entities/test_tools/__init__.py b/tests/test_ttt/test_entities/test_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ttt/test_entities/test_tools/test_combinations.py b/tests/test_ttt/test_entities/test_tools/test_combinations.py new file mode 100644 index 0000000..1e39331 --- /dev/null +++ b/tests/test_ttt/test_entities/test_tools/test_combinations.py @@ -0,0 +1,149 @@ +from pytest import mark + +from ttt.entities.tools.combinations import Combinations + + +@mark.parametrize( + ("combinations", "expected_result"), + [ + (Combinations([]), []), + (Combinations([1]), []), + (Combinations([1, 2]), [(1, 2)]), + (Combinations([1, 2, 3]), [(1, 2), (1, 3), (2, 3)]), + ( + Combinations([1, 2, 3, 4]), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + (5, 6), + ], + ), + ], +) +def test_without_cut( + combinations: Combinations[int], expected_result: list[tuple[int, int]], +) -> None: + assert list(combinations) == expected_result + + +@mark.parametrize( + ("combinations", "value_to_cut", "expected_result"), + [ + (Combinations([]), (0, 0), []), + (Combinations([1]), (0, 0), []), + (Combinations([1, 2]), (1, 2), [(1, 2)]), + (Combinations([1, 2, 3]), (1, 3), [(1, 2), (1, 3)]), + (Combinations([1, 2, 3, 4]), (1, 2), [(1, 2), (3, 4)]), + ( + Combinations([1, 2, 3, 4]), + (1, 3), + [(1, 2), (1, 3), (2, 4)], + ), + ( + Combinations([1, 2, 3, 4]), + (2, 3), + [(1, 2), (1, 3), (1, 4), (2, 3)], + ), + ( + Combinations([1, 2, 3, 4]), + (2, 4), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4)], + ), + ( + Combinations([1, 2, 3, 4]), + (3, 4), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + ), + ( + Combinations([1, 2, 3, 4, 5]), + (2, 4), + [(1, 2), (1, 3), (1, 4), (1, 5), (2, 3), (2, 4), (3, 5)], + ), + ( + Combinations([1, 2, 3, 4, 5]), + (2, 3), + [(1, 2), (1, 3), (1, 4), (1, 5), (2, 3), (4, 5)], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (1, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), + (3, 4), (3, 5), + (4, 5), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (5, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + (5, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (4, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (4, 5), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (2, 5), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), + (3, 4), (3, 6), + (4, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (3, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), + ], + ), + ], +) +def test_with_cut( + combinations: Combinations[int], + value_to_cut: tuple[int, int], + expected_result: list[tuple[int, int]], +) -> None: + result = [] + + for value in combinations: + result.append(value) + + if value == value_to_cut: + combinations.cut() + + assert result == expected_result diff --git a/tests/test_ttt/test_infrastructure/conftest.py b/tests/test_ttt/test_infrastructure/conftest.py index f3373a0..0a97cfb 100755 --- a/tests/test_ttt/test_infrastructure/conftest.py +++ b/tests/test_ttt/test_infrastructure/conftest.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import AsyncIterable from pytest import fixture @@ -18,7 +19,7 @@ def engine(envs: Envs) -> AsyncEngine: @fixture(scope="session") -async def _session_session( +async def _session( engine: AsyncEngine, ) -> AsyncIterable[AsyncSession]: session = AsyncSession( @@ -34,12 +35,16 @@ async def _session_session( @fixture async def session( - _session_session: AsyncSession, + _session: AsyncSession, ) -> AsyncSession: - await _clear_db(_session_session) - return _session_session + async with _session.begin(): + await _clear_db(_session) + return _session async def _clear_db(session: AsyncSession) -> None: - for table in reversed(Base.metadata.sorted_tables): - await session.execute(delete(table)) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "Cannot correctly sort tables") + + for table in reversed(Base.metadata.sorted_tables): + await session.execute(delete(table)) diff --git a/tests/test_ttt/test_infrastructure/test_adapters/__init__.py b/tests/test_ttt/test_infrastructure/test_adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py new file mode 100644 index 0000000..1cd729d --- /dev/null +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -0,0 +1,112 @@ +from uuid import UUID, uuid4 + +from pytest import fixture, mark +from sqlalchemy import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.entities.core.game.ai import Ai, AiType +from ttt.entities.core.game.cell import Cell +from ttt.entities.core.game.game import Game, GameState +from ttt.entities.core.user.account import Account +from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import GamesPlayed +from ttt.entities.math.matrix import Matrix +from ttt.entities.text.emoji import Emoji +from ttt.infrastructure.adapters.game_dao import PostgresGameDao +from ttt.infrastructure.sqlalchemy.tables.game import TableGame, TableGameState +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +@fixture +def dao(session: AsyncSession) -> PostgresGameDao: + return PostgresGameDao(session) + + +@fixture +def player1() -> User: + return User( + id=1, + account=Account(0), + emojis=[], + selected_emoji_id=None, + rating=1000., + current_game_id=UUID(int=0), + admin_right=None, + matchmaking_waiting=None, + ) + + +@fixture +def player2() -> Ai: + return Ai( + id=UUID(int=2), + type=AiType.gemini_2_0_flash, + ) + + +@fixture +def game(player1: User, player2: Ai) -> Game: + return Game( + id=UUID(int=0), + player1=player1, + player1_emoji=Emoji("1"), + player2=player2, + player2_emoji=Emoji("2"), + board=Matrix([ + [ + Cell(UUID(int=0), UUID(int=0), (0, 0), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 0), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 0), None, None), + ], + [ + Cell(UUID(int=0), UUID(int=0), (0, 1), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 1), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 1), None, None), + ], + [ + Cell(UUID(int=0), UUID(int=0), (0, 2), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 2), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 2), None, None), + ], + ]), + result=None, + state=GameState.wait_player1, + ) + + +@mark.parametrize( + ("games", "result"), + [ + (0, "<=30"), + (5, "<=30"), + (30, "<=30"), + (31, ">30"), + (40, ">30"), + ], +) +async def test_games_played_by_player_id( + dao: PostgresGameDao, + session: AsyncSession, + game: Game, + games: int, + result: GamesPlayed, +) -> None: + async with session.begin(): + await session.execute( + insert(TableUser).values({"id": 1, "rating": 1000}), + ) + if games: + await session.execute( + insert(TableGame).values([ + { + "id": uuid4(), + "user1_id": 1, + "state": TableGameState.completed.value, + } + for _ in range(games) + ]), + ) + + async with session.begin(): + games_played_by_player_id = await dao.games_played_by_player_id(game) + assert games_played_by_player_id == {1: result} diff --git a/uv.lock b/uv.lock index 4d0f8e6..e59404d 100644 --- a/uv.lock +++ b/uv.lock @@ -154,18 +154,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] -[[package]] -name = "better-exceptions" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/d8/30b745b965765c08ee132fd590fca46c31296e8f1a606de0c53cc6b5a68f/better_exceptions-0.3.3.tar.gz", hash = "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b", size = 30156, upload-time = "2021-01-29T16:48:54.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/50/abf6850135f1e95d321a525d0a36e05255a039b3fc118b7d88413e8a8207/better_exceptions-0.3.3-py3-none-any.whl", hash = "sha256:9c70b1c61d5a179b84cd2c9d62c3324b667d74286207343645ed4306fdaad976", size = 11857, upload-time = "2021-01-29T16:48:53.642Z" }, -] - [[package]] name = "cachetools" version = "5.5.2" @@ -481,6 +469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/83/de0a49e7de540513f53ab5d2e105321dedeb08a8f5850f0208decf4390ec/Mako-1.3.9-py3-none-any.whl", hash = "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1", size = 78456, upload-time = "2025-02-04T15:05:51.115Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -509,6 +509,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "multidict" version = "6.4.4" @@ -554,22 +563,22 @@ wheels = [ [[package]] name = "mypy" -version = "1.16.0" +version = "1.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447, upload-time = "2025-09-11T23:00:47.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, - { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, - { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, - { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ec/ef4a7260e1460a3071628a9277a7579e7da1b071bc134ebe909323f2fbc7/mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f", size = 12918671, upload-time = "2025-09-11T22:58:29.814Z" }, + { url = "https://files.pythonhosted.org/packages/a1/82/0ea6c3953f16223f0b8eda40c1aeac6bd266d15f4902556ae6e91f6fca4c/mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce", size = 11913023, upload-time = "2025-09-11T23:00:29.049Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/5e2057e692c2690fc27b3ed0a4dbde4388330c32e2576a23f0302bc8358d/mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e", size = 12473355, upload-time = "2025-09-11T23:00:04.544Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/b7e429fc4be10e390a167b0cd1810d41cb4e4add4ae50bab96faff695a3b/mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71", size = 13346944, upload-time = "2025-09-11T22:58:23.024Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/899dba0bfe36bbd5b7c52e597de4cf47b5053d337b6d201a30e3798e77a6/mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746", size = 13512574, upload-time = "2025-09-11T22:59:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684, upload-time = "2025-09-11T22:58:44.454Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212, upload-time = "2025-09-11T22:59:26.576Z" }, ] [package.optional-dependencies] @@ -926,29 +935,43 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + [[package]] name = "ruff" -version = "0.11.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, - { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, - { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, - { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, - { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, + { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, ] [[package]] @@ -1030,7 +1053,7 @@ wheels = [ [[package]] name = "ttt" -version = "0.3.0" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "aiogram" }, @@ -1052,12 +1075,12 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "better-exceptions" }, { name = "dirty-equals" }, { name = "mypy", extra = ["faster-cache"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "rich" }, { name = "ruff" }, ] @@ -1082,13 +1105,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "better-exceptions", specifier = "==0.3.3" }, { name = "dirty-equals", specifier = "==0.9.0" }, - { name = "mypy", extras = ["faster-cache"], specifier = "==1.16.0" }, + { name = "mypy", extras = ["faster-cache"], specifier = "==1.18.1" }, { name = "pytest", specifier = "==8.4.0" }, { name = "pytest-asyncio", specifier = "==1.0.0" }, { name = "pytest-cov", specifier = "==6.1.1" }, - { name = "ruff", specifier = "==0.11.13" }, + { name = "rich", specifier = "==14.1.0" }, + { name = "ruff", specifier = "==0.13.0" }, ] [[package]]