Skip to content

Commit 640bcac

Browse files
committed
feat: add game cancellation (#1)
1 parent 5609b3f commit 640bcac

File tree

19 files changed

+366
-62
lines changed

19 files changed

+366
-62
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from asyncio import gather
2+
from dataclasses import dataclass
3+
4+
from ttt.application.common.ports.map import Map
5+
from ttt.application.common.ports.transaction import Transaction
6+
from ttt.application.common.ports.uuids import UUIDs
7+
from ttt.application.game.ports.game_views import GameViews
8+
from ttt.application.game.ports.games import Games
9+
from ttt.entities.core.game.game import AlreadyCompletedGameError
10+
from ttt.entities.core.player.location import PlayerLocation
11+
from ttt.entities.tools.assertion import not_none
12+
from ttt.entities.tools.tracking import Tracking
13+
14+
15+
@dataclass(frozen=True, unsafe_hash=False)
16+
class CancelGame:
17+
map_: Map
18+
games: Games
19+
game_views: GameViews
20+
uuids: UUIDs
21+
transaction: Transaction
22+
23+
async def __call__(self, location: PlayerLocation) -> None:
24+
async with self.transaction:
25+
game, game_result_id = await gather(
26+
self.games.game_with_game_location(location.player_id),
27+
self.uuids.random_uuid(),
28+
)
29+
if game is None:
30+
await self.game_views.render_no_game_view(location)
31+
return
32+
33+
locations = tuple(
34+
not_none(player.game_location)
35+
for player in (game.player1, game.player2)
36+
)
37+
38+
try:
39+
tracking = Tracking()
40+
game.cancel(location.player_id, game_result_id, tracking)
41+
except AlreadyCompletedGameError:
42+
await self.game_views.render_game_already_complteted_view(
43+
location, game,
44+
)
45+
return
46+
47+
await self.map_(tracking)
48+
await self.game_views.render_game_view_with_locations(
49+
locations, game,
50+
)

src/ttt/application/game/make_move_in_game.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async def __call__(
3232
"""
3333
:raises ttt.application.common.ports.players.NoPlayerWithIDError:
3434
:raises ttt.application.game.ports.games.NoGameError:
35-
:raises ttt.entities.core.game.game.CompletedGameError:
35+
:raises ttt.entities.core.game.game.AlreadyCompletedGameError:
3636
:raises ttt.entities.core.game.game.NotCurrentPlayerError:
3737
:raises ttt.entities.core.game.game.NoCellError:
3838
:raises ttt.entities.core.game.cell.AlreadyFilledCellError:

src/ttt/application/game/ports/game_views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from collections.abc import Sequence
33

44
from ttt.entities.core.game.game import Game
5-
from ttt.entities.core.player.location import PlayerGameLocation
5+
from ttt.entities.core.player.location import PlayerGameLocation, PlayerLocation
66

77

88
class GameViews(ABC):
@@ -21,3 +21,13 @@ async def render_started_game_view_with_locations(
2121
game: Game,
2222
/,
2323
) -> None: ...
24+
25+
@abstractmethod
26+
async def render_no_game_view(
27+
self, player_location: PlayerLocation, /,
28+
) -> None: ...
29+
30+
@abstractmethod
31+
async def render_game_already_complteted_view(
32+
self, player_location: PlayerLocation, game: Game, /,
33+
) -> None: ...

src/ttt/application/game/ports/games.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ async def game_with_id(self, id_: UUID | None, /) -> Game:
1313
"""
1414
:raises ttt.application.game.ports.games.NoGameError:
1515
"""
16+
17+
@abstractmethod
18+
async def game_with_game_location(
19+
self, game_location_player_id: int, /,
20+
) -> Game | None: ...

src/ttt/entities/core/game/game.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from dataclasses import dataclass
33
from enum import Enum, auto
44
from itertools import chain
5+
from typing import cast
56
from uuid import UUID
67

78
from ttt.entities.core.game.board import (
@@ -30,12 +31,22 @@ class GameState(Enum):
3031

3132

3233
@dataclass(frozen=True)
33-
class GameResult:
34+
class GameCompletionResult:
3435
id: UUID
3536
game_id: UUID
3637
win: Win | None
3738

3839

40+
@dataclass(frozen=True)
41+
class GameCancellationResult:
42+
id: UUID
43+
game_id: UUID
44+
canceler_id: int
45+
46+
47+
type GameResult = GameCompletionResult | GameCancellationResult
48+
49+
3950
class OnePlayerError(Exception): ...
4051

4152

@@ -110,6 +121,29 @@ def __post_init__(self) -> None:
110121
else_=InvalidNumberOfUnfilledCellsError,
111122
)
112123

124+
def cancel(
125+
self, player_id: int, game_result_id: UUID, tracking: Tracking,
126+
) -> None:
127+
"""
128+
:raises ttt.entities.core.game.game.AlreadyCompletedGameError:
129+
:raises ttt.entities.core.game.game.NotPlayerError:
130+
"""
131+
132+
if self.result is not None:
133+
raise AlreadyCompletedGameError(self.result)
134+
135+
canceler = not_none(self._player(player_id), else_=NotPlayerError)
136+
137+
self.player1.leave_game(tracking)
138+
self.player2.leave_game(tracking)
139+
140+
self.result = GameCancellationResult(
141+
game_result_id, self.id, canceler.id,
142+
)
143+
tracking.register_new(self.result)
144+
self.state = GameState.completed
145+
tracking.register_mutated(self)
146+
113147
def make_move(
114148
self,
115149
player_id: int,
@@ -119,7 +153,7 @@ def make_move(
119153
tracking: Tracking,
120154
) -> GameResult | None:
121155
"""
122-
:raises ttt.entities.core.game.game.CompletedGameError:
156+
:raises ttt.entities.core.game.game.AlreadyCompletedGameError:
123157
:raises ttt.entities.core.game.game.NotPlayerError:
124158
:raises ttt.entities.core.game.game.NotCurrentPlayerError:
125159
:raises ttt.entities.core.game.game.NoCellError:
@@ -129,7 +163,9 @@ def make_move(
129163
current_player = self._current_player()
130164

131165
if current_player is None:
132-
raise AlreadyCompletedGameError(not_none(self.result))
166+
raise AlreadyCompletedGameError(
167+
cast(GameResult, not_none(self.result)),
168+
)
133169

134170
assert_(
135171
player_id in {self.player1.id, self.player2.id},
@@ -220,6 +256,15 @@ def _current_player(self) -> Player | None:
220256
case GameState.completed:
221257
return None
222258

259+
def _player(self, player_id: int) -> Player | None:
260+
match player_id:
261+
case self.player1.id:
262+
return self.player1
263+
case self.player2.id:
264+
return self.player2
265+
case _:
266+
return None
267+
223268
def _not_current_player(self) -> Player | None:
224269
match self.state:
225270
case GameState.wait_player1:
@@ -243,7 +288,8 @@ def _wait_next_move(self, tracking: Tracking) -> None:
243288
def _complete(
244289
self, win: Win | None, game_result_id: UUID, tracking: Tracking,
245290
) -> None:
246-
self.result = GameResult(game_result_id, self.id, win)
291+
self.result = GameCompletionResult(game_result_id, self.id, win)
292+
tracking.register_new(self.result)
247293
self.state = GameState.completed
248294
tracking.register_mutated(self)
249295

src/ttt/entities/core/player/player.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def lose(self, tracking: Tracking) -> None:
5151
:raises ttt.entities.core.player.player.PlayerNotInGameError:
5252
"""
5353

54-
self._leave_game(tracking)
54+
self.leave_game(tracking)
5555

5656
self.number_of_defeats += 1
5757
tracking.register_mutated(self)
@@ -61,7 +61,7 @@ def win(self, random: Random, tracking: Tracking) -> Win:
6161
:raises ttt.entities.core.player.player.PlayerNotInGameError:
6262
"""
6363

64-
self._leave_game(tracking)
64+
self.leave_game(tracking)
6565

6666
self.number_of_wins += 1
6767

@@ -76,12 +76,12 @@ def be_draw(self, tracking: Tracking) -> None:
7676
:raises ttt.entities.core.player.player.PlayerNotInGameError:
7777
"""
7878

79-
self._leave_game(tracking)
79+
self.leave_game(tracking)
8080

8181
self.number_of_draws += 1
8282
tracking.register_mutated(self)
8383

84-
def _leave_game(self, tracking: Tracking) -> None:
84+
def leave_game(self, tracking: Tracking) -> None:
8585
"""
8686
:raises ttt.entities.core.player.player.PlayerNotInGameError:
8787
"""

src/ttt/infrastructure/adapters/games.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from dataclasses import dataclass
22
from uuid import UUID
33

4+
from sqlalchemy import select
45
from sqlalchemy.ext.asyncio import AsyncSession
56

67
from ttt.application.game.ports.games import Games, NoGameError
78
from ttt.entities.core.game.game import Game
8-
from ttt.infrastructure.sqlalchemy.tables import TableGame
9+
from ttt.infrastructure.sqlalchemy.tables import TableGame, TablePlayer
910

1011

1112
@dataclass(frozen=True, unsafe_hash=False)
@@ -16,6 +17,22 @@ async def game_with_id(self, id_: UUID | None, /) -> Game:
1617
table_game = await self._session.get(TableGame, id_)
1718

1819
if table_game is None:
19-
raise NoGameError(id_)
20+
raise NoGameError
21+
22+
return table_game.entity()
23+
24+
async def game_with_game_location(
25+
self, game_location_player_id: int, /,
26+
) -> Game | None:
27+
join_condition = (
28+
(TablePlayer.id == game_location_player_id)
29+
& (TablePlayer.game_location_game_id == TableGame.id)
30+
)
31+
stmt = select(TableGame).join(TablePlayer, join_condition)
32+
33+
table_game = await self._session.scalar(stmt)
34+
35+
if table_game is None:
36+
return None
2037

2138
return table_game.entity()

src/ttt/infrastructure/alembic/versions/54be98063760_add_stars.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def upgrade() -> None:
2626
new_column_name="win_winner_id",
2727
)
2828
op.add_column(
29-
"game_results", sa.Column("win_new_stars", sa.Integer(), nullable=True),
29+
"game_results",
30+
sa.Column("win_new_stars", sa.Integer(), nullable=True),
3031
)
3132

3233

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
add game cancelation.
3+
4+
Revision ID: 59f959c8e3cd
5+
Revises: 54be98063760
6+
Create Date: 2025-06-25 10:55:35.024804
7+
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy.dialects import postgresql
15+
16+
17+
revision: str = "59f959c8e3cd"
18+
down_revision: str | None = "54be98063760"
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
23+
def upgrade() -> None:
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
sa.Enum("completed", "cancelled", name="game_result_type").create(
26+
op.get_bind(),
27+
)
28+
op.add_column(
29+
"game_results",
30+
sa.Column(
31+
"type",
32+
postgresql.ENUM(
33+
"completed",
34+
"cancelled",
35+
name="game_result_type",
36+
create_type=False,
37+
),
38+
nullable=False,
39+
),
40+
)
41+
op.add_column(
42+
"game_results",
43+
sa.Column("canceler_id", sa.BigInteger(), nullable=True),
44+
)
45+
op.create_foreign_key(
46+
"game_results_canceler_id_fkey",
47+
"game_results",
48+
"players",
49+
["canceler_id"],
50+
["id"],
51+
)
52+
# ### end Alembic commands ###
53+
54+
55+
def downgrade() -> None:
56+
# ### commands auto generated by Alembic - please adjust! ###
57+
op.drop_constraint(
58+
"game_results_canceler_id_fkey",
59+
"game_results",
60+
type_="foreignkey",
61+
)
62+
op.drop_column("game_results", "canceler_id")
63+
op.drop_column("game_results", "type")
64+
sa.Enum("completed", "cancelled", name="game_result_type").drop(
65+
op.get_bind(),
66+
)
67+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)