Skip to content

Commit 4220d2f

Browse files
committed
feat: add untested stars purchases (#4)
1 parent 4f6ff7b commit 4220d2f

38 files changed

+1211
-89
lines changed

deploy/dev/docker-compose.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ services:
1616
condition: service_healthy
1717
redis:
1818
condition: service_healthy
19+
nats:
20+
condition: service_healthy
21+
nats_streams:
22+
condition: service_completed_successfully
1923
environment:
2024
TTT_POSTGRES_URL: postgresql+psycopg://root:root@postgres/root
2125
TTT_POSTGRES_POOL_SIZE: 8
@@ -27,6 +31,8 @@ services:
2731
TTT_REDIS_URL: redis://redis:6379/0
2832
TTT_REDIS_POOL_SIZE: 16
2933

34+
TTT_NATS_URL: nats://nats:4222
35+
3036
TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_MIN_MS: 100
3137
TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_SALT_MS: 100
3238
secrets:
@@ -64,10 +70,36 @@ services:
6470
interval: 3s
6571
command: redis-server /mnt/dev/redis.conf
6672

73+
nats:
74+
image: nats:2.11.5-alpine3.22
75+
container_name: ttt-nats
76+
volumes:
77+
- nats-data:/data
78+
ports:
79+
- 4222:4222
80+
command: nats-server -js
81+
healthcheck:
82+
test: wget http://nats:8222/healthz -q -O /dev/null
83+
start_period: 2m
84+
start_interval: 0.5s
85+
interval: 3s
86+
87+
nats_streams:
88+
image: bitnami/natscli:0.2.3-debian-12-r4
89+
container_name: ttt-nats-streams
90+
depends_on:
91+
nats:
92+
condition: service_healthy
93+
volumes:
94+
- ./nats:/mnt
95+
entrypoint: null
96+
command: ["bash", "/mnt/add_streams.sh"]
97+
6798
volumes:
6899
backend-data: null
69100
postgres-data: null
70101
redis-data: null
102+
nats-data: null
71103

72104
secrets:
73105
secrets:

deploy/dev/nats/add_streams.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/bash
2+
3+
nats stream add --config /mnt/streams/player.json
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "PLAYER",
3+
"subjects": ["player.>"],
4+
"retention": "limits",
5+
"max_consumers": -1,
6+
"max_msgs": -1,
7+
"max_bytes": -1,
8+
"max_age": 31536000000000000,
9+
"max_msg_size": -1,
10+
"storage": "file",
11+
"discard": "old",
12+
"num_replicas": 1,
13+
"duplicate_window": 0
14+
}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"pydantic==2.10.6",
2121
"pydantic-settings[yaml]==2.9.1",
2222
"aiogram==3.20.0.post0",
23+
"nats-py[nkeys]==2.10.0",
2324
]
2425

2526
[dependency-groups]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from dataclasses import dataclass
2+
3+
from ttt.application.common.ports.clock import Clock
4+
from ttt.application.common.ports.map import Map
5+
from ttt.application.common.ports.transaction import Transaction
6+
from ttt.application.player.ports.player_views import PlayerViews
7+
from ttt.application.player.ports.players import Players
8+
from ttt.application.player.ports.stars_purchase_payment_gateway import (
9+
StarsPurchasePaymentGateway,
10+
)
11+
from ttt.entities.core.player.player import NoPurchaseError
12+
from ttt.entities.finance.payment.payment import PaymentAlreadyCompletedError
13+
from ttt.entities.tools.tracking import Tracking
14+
15+
16+
@dataclass(frozen=True, unsafe_hash=False)
17+
class CompleteStarsPurshase:
18+
clock: Clock
19+
payment_gateway: StarsPurchasePaymentGateway
20+
players: Players
21+
transaction: Transaction
22+
map_: Map
23+
player_views: PlayerViews
24+
25+
async def __call__(self) -> None:
26+
current_datetime = await self.clock.current_datetime()
27+
28+
async for paid_payment in self.payment_gateway.paid_payment_stream():
29+
async with self.transaction:
30+
player = await self.players.player_with_id(
31+
paid_payment.location.player_id,
32+
)
33+
34+
tracking = Tracking()
35+
try:
36+
player.complete_stars_purchase(
37+
paid_payment.purshase_id,
38+
paid_payment.success,
39+
current_datetime,
40+
tracking,
41+
)
42+
except (NoPurchaseError, PaymentAlreadyCompletedError):
43+
...
44+
else:
45+
await self.map_(tracking)
46+
await (
47+
self.player_views
48+
.render_completed_stars_purshase_view(
49+
player,
50+
paid_payment.purshase_id,
51+
paid_payment.location,
52+
)
53+
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from asyncio import gather
2+
from dataclasses import dataclass
3+
4+
from ttt.application.common.ports.clock import Clock
5+
from ttt.application.common.ports.transaction import Transaction
6+
from ttt.application.common.ports.uuids import UUIDs
7+
from ttt.application.player.ports.player_fsm import PlayerFsm
8+
from ttt.application.player.ports.player_views import PlayerViews
9+
from ttt.application.player.ports.players import Players
10+
from ttt.application.player.ports.stars_purchase_payment_gateway import (
11+
StarsPurchasePaymentGateway,
12+
)
13+
from ttt.entities.core.player.location import PlayerLocation
14+
from ttt.entities.core.player.stars_purchase import StarsPurchase
15+
from ttt.entities.core.stars import NonExchangeableRublesForStarsError
16+
from ttt.entities.finance.rubles import Rubles
17+
from ttt.entities.tools.tracking import Tracking
18+
19+
20+
@dataclass(frozen=True, unsafe_hash=False)
21+
class InitiateStarsPurchasePayment:
22+
fsm: PlayerFsm
23+
transaction: Transaction
24+
players: Players
25+
uuids: UUIDs
26+
clock: Clock
27+
player_views: PlayerViews
28+
payment_gateway: StarsPurchasePaymentGateway
29+
30+
async def __call__(
31+
self, location: PlayerLocation, rubles: Rubles,
32+
) -> None:
33+
async with self.transaction:
34+
player, purchase_id, payment_id, current_datetime = await gather(
35+
self.players.player_with_id(location.player_id),
36+
self.uuids.random_uuid(),
37+
self.uuids.random_uuid(),
38+
self.clock.current_datetime(),
39+
)
40+
41+
tracking = Tracking()
42+
try:
43+
player.initiate_stars_purchase_payment(
44+
purchase_id,
45+
location.chat_id,
46+
payment_id,
47+
rubles,
48+
current_datetime,
49+
tracking,
50+
)
51+
except NonExchangeableRublesForStarsError:
52+
await self.fsm.set(None)
53+
await (
54+
self.player_views
55+
.render_non_exchangeable_rubles_for_stars_view(location)
56+
)
57+
return
58+
59+
await self.fsm.set(None)
60+
await gather(*[
61+
self.payment_gateway.process_payment(it, location)
62+
for it in tracking.new
63+
if isinstance(it, StarsPurchase)
64+
])

src/ttt/application/player/ports/player_views.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from abc import ABC, abstractmethod
2+
from uuid import UUID
23

34
from ttt.entities.core.player.location import PlayerLocation
5+
from ttt.entities.core.player.player import Player
46
from ttt.entities.core.stars import Stars
57

68

@@ -68,3 +70,18 @@ async def render_emoji_selected_view(
6870
async def render_selected_emoji_removed_view(
6971
self, location: PlayerLocation, /,
7072
) -> None: ...
73+
74+
@abstractmethod
75+
async def render_wait_rubles_to_start_stars_purshase_view(
76+
self, location: PlayerLocation, /,
77+
) -> None: ...
78+
79+
@abstractmethod
80+
async def render_non_exchangeable_rubles_for_stars_view(
81+
self, location: PlayerLocation, /,
82+
) -> None: ...
83+
84+
@abstractmethod
85+
async def render_completed_stars_purshase_view(
86+
self, player: Player, purshase_id: UUID, location: PlayerLocation, /,
87+
) -> None: ...
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from abc import ABC, abstractmethod
2+
from collections.abc import AsyncIterable
3+
from dataclasses import dataclass
4+
from uuid import UUID
5+
6+
from ttt.entities.core.player.location import PlayerLocation
7+
from ttt.entities.core.player.stars_purchase import StarsPurchase
8+
from ttt.entities.finance.payment.success import PaymentSuccess
9+
10+
11+
@dataclass(frozen=True)
12+
class PaidStarsPurchasePayment:
13+
purshase_id: UUID
14+
location: PlayerLocation
15+
success: PaymentSuccess
16+
17+
18+
class StarsPurchasePaymentGateway(ABC):
19+
@abstractmethod
20+
async def process_payment(
21+
self,
22+
purshase: StarsPurchase,
23+
location: PlayerLocation,
24+
) -> None:
25+
...
26+
27+
@abstractmethod
28+
def paid_payment_stream(self) -> AsyncIterable[PaidStarsPurchasePayment]:
29+
...
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from dataclasses import dataclass
2+
3+
from ttt.application.player.ports.player_fsm import PlayerFsm
4+
from ttt.application.player.ports.player_views import PlayerViews
5+
from ttt.entities.core.player.location import PlayerLocation
6+
7+
8+
@dataclass(frozen=True, unsafe_hash=False)
9+
class WaitRublesToStartStarsPurshase:
10+
fsm: PlayerFsm
11+
player_views: PlayerViews
12+
13+
async def __call__(self, location: PlayerLocation) -> None:
14+
await (
15+
self.player_views
16+
.render_wait_rubles_to_start_stars_purshase_view(location)
17+
)

src/ttt/entities/aggregate.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ttt.entities.core.game.game import GameAggregate
22
from ttt.entities.core.player.player import PlayerAggregate
3+
from ttt.entities.finance.payment.payment import PaymentAggregate
34

45

5-
type Aggregate = GameAggregate | PlayerAggregate
6+
type Aggregate = GameAggregate | PlayerAggregate | PaymentAggregate

0 commit comments

Comments
 (0)