Skip to content

Commit f0ab80a

Browse files
committed
feat: add strongest user rank (#57)
1 parent bd6a9e4 commit f0ab80a

File tree

14 files changed

+206
-41
lines changed

14 files changed

+206
-41
lines changed

src/ttt/application/user/common/ports/users.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from collections.abc import Sequence
33
from typing import overload
44

5+
from ttt.entities.core.user.rank import UsersWithMaxRating
56
from ttt.entities.core.user.user import User
7+
from ttt.entities.elo.rating import EloRating
68

79

810
class Users(ABC):
@@ -43,3 +45,8 @@ async def users_with_ids(
4345
async def some_users_waiting_for_matchmaking_to_matchmake(
4446
self,
4547
) -> list[User]: ...
48+
49+
@abstractmethod
50+
async def max_rating_and_users_with_max_rating(
51+
self,
52+
) -> tuple[EloRating, UsersWithMaxRating]: ...

src/ttt/application/user/game/matchmake.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ async def _result(self, tracking: Tracking) -> list[Game]:
4040
self.users.some_users_waiting_for_matchmaking_to_matchmake(),
4141
self._matchmaking_input(),
4242
)
43+
max_rating, users_with_max_rating = (
44+
await self.users.max_rating_and_users_with_max_rating()
45+
)
4346

4447
games = list[Game]()
45-
matchmaking_ = matchmaking(users, input_, tracking)
48+
matchmaking_ = matchmaking(
49+
users, input_, max_rating, users_with_max_rating, tracking,
50+
)
4651

4752
with suppress(StopIteration):
4853
games.append(next(matchmaking_))

src/ttt/entities/core/user/matchmaking.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from uuid import UUID
44

55
from ttt.entities.core.game.game import Game, start_game
6-
from ttt.entities.core.user.rank import are_ranks_adjacent
6+
from ttt.entities.core.user.rank import UsersWithMaxRating, are_ranks_adjacent
77
from ttt.entities.core.user.user import User
8+
from ttt.entities.elo.rating import EloRating
89
from ttt.entities.math.matrix import Matrix
910
from ttt.entities.text.emoji import Emoji
1011
from ttt.entities.tools.combinations import Combinations
@@ -27,6 +28,8 @@ class UsersAreNotWaitingForMatchmakingError(Exception):
2728
def matchmaking(
2829
users: list[User],
2930
input_: MatchmakingInput,
31+
max_rating: EloRating,
32+
users_with_max_rating: UsersWithMaxRating,
3033
tracking: Tracking,
3134
) -> Generator[Game, MatchmakingInput]:
3235
"""
@@ -52,7 +55,7 @@ def matchmaking(
5255

5356
combinations = Combinations(users)
5457
for user1, user2 in combinations:
55-
if _is_game_allowed(user1, user2):
58+
if _is_game_allowed(user1, user2, max_rating, users_with_max_rating):
5659
user1.dont_wait_for_matchmaking(tracking)
5760
user2.dont_wait_for_matchmaking(tracking)
5861
combinations.cut()
@@ -69,8 +72,13 @@ def matchmaking(
6972
input_ = yield game
7073

7174

72-
def _is_game_allowed(user1: User, user2: User) -> bool:
73-
rank1 = user1.rank()
74-
rank2 = user2.rank()
75+
def _is_game_allowed(
76+
user1: User,
77+
user2: User,
78+
max_rating: EloRating,
79+
users_with_max_rating: UsersWithMaxRating,
80+
) -> bool:
81+
rank1 = user1.rank(max_rating, users_with_max_rating)
82+
rank2 = user2.rank(max_rating, users_with_max_rating)
7583

7684
return rank1 == rank2 or are_ranks_adjacent(rank1, rank2)

src/ttt/entities/core/user/rank.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,46 @@
55
from ttt.entities.elo.rating import EloRating
66

77

8-
type RankTier = Literal[-1, 0, 1, 2, 3, 4]
8+
type RankTier = Literal[-1, 0, 1, 2, 3, 4, 5]
99

1010

1111
@dataclass(frozen=True)
12-
class Rank:
12+
class IntervalRank:
1313
tier: RankTier
1414
min_rating: EloRating
1515
max_rating: EloRating
1616

1717

18+
@dataclass(frozen=True)
19+
class SpecialRank[TierT: RankTier, NameT: str]:
20+
tier: TierT
21+
name: NameT
22+
23+
24+
StrongestRank = SpecialRank[Literal[5], Literal["strongest"]]
25+
strongest_rank = StrongestRank(tier=5, name="strongest")
26+
27+
special_ranks = (
28+
strongest_rank,
29+
)
30+
31+
32+
type Rank = IntervalRank | StrongestRank
33+
34+
35+
interval_ranks = (
36+
IntervalRank(tier=-1, min_rating=-math.inf, max_rating=871),
37+
IntervalRank(tier=0, min_rating=872, max_rating=1085),
38+
IntervalRank(tier=1, min_rating=1086, max_rating=1336),
39+
IntervalRank(tier=2, min_rating=1337, max_rating=1679),
40+
IntervalRank(tier=3, min_rating=1680, max_rating=1999),
41+
IntervalRank(tier=4, min_rating=2000, max_rating=math.inf),
42+
)
43+
44+
1845
ranks = (
19-
Rank(tier=-1, min_rating=-math.inf, max_rating=871),
20-
Rank(tier=0, min_rating=872, max_rating=1085),
21-
Rank(tier=1, min_rating=1086, max_rating=1336),
22-
Rank(tier=2, min_rating=1337, max_rating=1679),
23-
Rank(tier=3, min_rating=1680, max_rating=1999),
24-
Rank(tier=4, min_rating=2000, max_rating=math.inf),
46+
*interval_ranks,
47+
*special_ranks,
2548
)
2649

2750

@@ -33,8 +56,18 @@ def rank_with_tier(tier: RankTier) -> Rank:
3356
raise ValueError
3457

3558

36-
def rank_for_rating(rating: EloRating) -> Rank:
37-
for rank in ranks:
59+
type UsersWithMaxRating = Literal["1", ">1"]
60+
61+
62+
def rank(
63+
rating: EloRating,
64+
max_rating: EloRating,
65+
users_with_max_rating: UsersWithMaxRating,
66+
) -> Rank:
67+
if rating == max_rating and users_with_max_rating == "1":
68+
return strongest_rank
69+
70+
for rank in interval_ranks:
3871
if rank.min_rating <= rating <= rank.max_rating:
3972
return rank
4073

src/ttt/entities/core/user/user.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from ttt.entities.core.user.emoji import UserEmoji
1515
from ttt.entities.core.user.loss import UserLoss
1616
from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting
17-
from ttt.entities.core.user.rank import Rank, rank_for_rating
17+
from ttt.entities.core.user.rank import Rank, UsersWithMaxRating, rank
1818
from ttt.entities.core.user.win import UserWin
1919
from ttt.entities.elo.rating import (
2020
EloRating,
@@ -97,8 +97,10 @@ class User:
9797

9898
emoji_cost: ClassVar[Stars] = 1000
9999

100-
def rank(self) -> Rank:
101-
return rank_for_rating(self.rating)
100+
def rank(
101+
self, max_rating: EloRating, users_with_max_rating: UsersWithMaxRating,
102+
) -> Rank:
103+
return rank(self.rating, max_rating, users_with_max_rating)
102104

103105
def is_admin(self) -> bool:
104106
return is_user_admin(self.admin_right)

src/ttt/infrastructure/adapters/users.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from ttt.application.user.common.ports.users import Users
9+
from ttt.entities.core.user.rank import UsersWithMaxRating
910
from ttt.entities.core.user.user import User
11+
from ttt.entities.elo.rating import EloRating
12+
from ttt.infrastructure.sqlalchemy.stmts import (
13+
max_rating_and_users_with_max_rating_from_postgres,
14+
)
1015
from ttt.infrastructure.sqlalchemy.tables.user import TableUser
1116

1217

@@ -69,3 +74,10 @@ async def some_users_waiting_for_matchmaking_to_matchmake(
6974
table_users = result.all()
7075

7176
return [table_user.entity() for table_user in table_users]
77+
78+
async def max_rating_and_users_with_max_rating(
79+
self,
80+
) -> tuple[EloRating, UsersWithMaxRating]:
81+
return await max_rating_and_users_with_max_rating_from_postgres(
82+
self._session,
83+
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
index `users.rating`.
3+
4+
Revision ID: 6642791b3bf8
5+
Revises: 7ce97d6efd2c
6+
Create Date: 2025-09-20 08:16:00.242481
7+
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
from alembic import op
13+
14+
15+
revision: str = "6642791b3bf8"
16+
down_revision: str | None = "7ce97d6efd2c"
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_index(op.f("ix_users_rating"), "users", ["rating"], unique=False)
24+
# ### end Alembic commands ###
25+
26+
27+
def downgrade() -> None:
28+
# ### commands auto generated by Alembic - please adjust! ###
29+
op.drop_index(op.f("ix_users_rating"), table_name="users")
30+
# ### end Alembic commands ###

src/ttt/infrastructure/sqlalchemy/stmts.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from collections.abc import Sequence
22
from typing import cast
33

4-
from sqlalchemy import exists, select
4+
from sqlalchemy import exists, func, select
55
from sqlalchemy.ext.asyncio import AsyncSession
66

7+
from ttt.entities.core.user.rank import UsersWithMaxRating
8+
from ttt.entities.elo.rating import EloRating
9+
from ttt.entities.tools.assertion import not_none
710
from ttt.infrastructure.sqlalchemy.tables.user import TableUser, TableUserEmoji
811

912

@@ -39,3 +42,28 @@ async def selected_user_emoji_str_from_postgres(
3942
async def user_exists_in_postgres(session: AsyncSession, user_id: int) -> bool:
4043
stmt = select(exists(1).where(TableUser.id == user_id))
4144
return bool(await session.scalar(stmt))
45+
46+
47+
async def max_rating_and_users_with_max_rating_from_postgres(
48+
session: AsyncSession,
49+
) -> tuple[EloRating, UsersWithMaxRating]:
50+
max_rating_stmt = select(func.max(TableUser.rating))
51+
max_rating = not_none(await session.scalar(max_rating_stmt))
52+
53+
raw_users_with_max_rating_stmt = (
54+
select(func.count(1))
55+
.select_from(
56+
select(1)
57+
.where(TableUser.rating == max_rating)
58+
.limit(2)
59+
.subquery(),
60+
)
61+
)
62+
raw_users_with_max_rating = not_none(
63+
await session.scalar(raw_users_with_max_rating_stmt),
64+
)
65+
users_with_max_rating: UsersWithMaxRating = (
66+
"1" if raw_users_with_max_rating == 1 else ">1"
67+
)
68+
69+
return max_rating, users_with_max_rating

src/ttt/infrastructure/sqlalchemy/tables/user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class TableUser(Base[User]):
8080
selected_emoji_id: Mapped[UUID | None] = mapped_column(
8181
ForeignKey("user_emojis.id", deferrable=True, initially="DEFERRED"),
8282
)
83-
rating: Mapped[float]
83+
rating: Mapped[float] = mapped_column(index=True)
8484
current_game_id: Mapped[UUID | None] = mapped_column(
8585
ForeignKey("games.id", deferrable=True, initially="DEFERRED"),
8686
)

src/ttt/presentation/adapters/user_views.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ttt.entities.core.user.user import User, is_user_in_game, user_stars
2525
from ttt.entities.tools.assertion import not_none
2626
from ttt.infrastructure.sqlalchemy.stmts import (
27+
max_rating_and_users_with_max_rating_from_postgres,
2728
selected_user_emoji_str_from_postgres,
2829
user_emojis_from_postgres,
2930
)
@@ -121,12 +122,20 @@ async def view_of_user_with_id(
121122
)
122123
defeats = not_none(await self._session.scalar(defeats_stmt))
123124

125+
max_rating, users_with_max_rating = (
126+
await max_rating_and_users_with_max_rating_from_postgres(
127+
self._session,
128+
)
129+
)
130+
124131
view = UserProfileView.of(
125132
wins,
126133
draws,
127134
defeats,
128135
user_row.account_stars,
129136
user_row.rating,
137+
max_rating,
138+
users_with_max_rating,
130139
)
131140
self._result_buffer.result = view
132141

@@ -177,6 +186,11 @@ async def user_menu_view(self, user_id: int, /) -> None:
177186
else:
178187
amout_of_incoming_invitations_to_game = "many"
179188

189+
max_rating, users_with_max_rating = (
190+
await max_rating_and_users_with_max_rating_from_postgres(
191+
self._session,
192+
)
193+
)
180194
view = MainMenuView(
181195
is_user_in_game=is_user_in_game(row.current_game_id),
182196
has_user_emojis=row.has_user_emojis,
@@ -185,6 +199,8 @@ async def user_menu_view(self, user_id: int, /) -> None:
185199
amout_of_incoming_invitations_to_game=(
186200
amout_of_incoming_invitations_to_game
187201
),
202+
max_rating=max_rating,
203+
users_with_max_rating=users_with_max_rating,
188204
)
189205
self._result_buffer.result = view
190206

@@ -387,6 +403,12 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None:
387403
row.admin_right_via_other_admin_admin_id,
388404
)
389405

406+
max_rating, users_with_max_rating = (
407+
await max_rating_and_users_with_max_rating_from_postgres(
408+
self._session,
409+
)
410+
)
411+
390412
view = OtherUserProfileView.of(
391413
other_user_id,
392414
admin_right,
@@ -395,6 +417,8 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None:
395417
defeats,
396418
row.account_stars,
397419
row.rating,
420+
max_rating,
421+
users_with_max_rating,
398422
)
399423

400424
manager = self._dialog_manager_for_user(user.id)

0 commit comments

Comments
 (0)