Skip to content

Commit c85492e

Browse files
committed
Fix ranked match result synchronization
1 parent 9c8a8cd commit c85492e

6 files changed

Lines changed: 283 additions & 59 deletions

File tree

src/api/modules/gameplay/repository.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,15 @@ async def get_user_by_id(self, user_id: UUID) -> User | None:
208208
return await self.session.get(User, user_id)
209209

210210
async def create_move(self, move: GameMove) -> GameMove:
211-
self.session.add(move)
211+
await self.create_move_uncommitted(move)
212212
await self.session.commit()
213213
await self.session.refresh(move)
214214
return move
215215

216+
async def create_move_uncommitted(self, move: GameMove) -> GameMove:
217+
self.session.add(move)
218+
return move
219+
216220
async def rollback(self) -> None:
217221
await self.session.rollback()
218222

@@ -237,11 +241,18 @@ async def list_moves(self, game_id: UUID, limit: int = 200) -> list[GameMove]:
237241
return list(result.scalars().all())
238242

239243
async def save_game(self, game: Game) -> Game:
240-
self.session.add(game)
244+
await self.save_game_uncommitted(game)
241245
await self.session.commit()
242246
await self.session.refresh(game)
243247
return game
244248

249+
async def save_game_uncommitted(self, game: Game) -> Game:
250+
self.session.add(game)
251+
return game
252+
253+
async def commit(self) -> None:
254+
await self.session.commit()
255+
245256
async def delete_game(self, game_id: UUID) -> bool:
246257
exists_stmt = (
247258
select(func.count())

src/api/modules/gameplay/service.py

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,15 @@ async def record_inference_move(
307307
action_idx=inference.action_idx,
308308
value=inference.value,
309309
)
310-
stored_move = await self.game_repository.create_move(move)
311-
await self._update_game_state(game=game, board_after=scratch)
310+
stored_move = await self.game_repository.create_move_uncommitted(move)
311+
should_apply_rated_result = await self._update_game_state(
312+
game=game,
313+
board_after=scratch,
314+
commit=False,
315+
)
316+
await self.game_repository.commit()
317+
if should_apply_rated_result:
318+
await self._apply_rated_result(game)
312319
return stored_move, game
313320

314321
async def list_game_moves(self, game_id: UUID, limit: int = 200) -> list[GameMove]:
@@ -349,7 +356,7 @@ async def record_manual_move(
349356

350357
r1, c1, r2, c2 = move
351358
try:
352-
stored_move = await self.game_repository.create_move(
359+
stored_move = await self.game_repository.create_move_uncommitted(
353360
GameMove(
354361
game_id=game_id,
355362
ply=ply,
@@ -365,26 +372,42 @@ async def record_manual_move(
365372
value=0.0,
366373
)
367374
)
375+
should_apply_rated_result = await self._update_game_state(
376+
game=game,
377+
board_after=scratch,
378+
commit=False,
379+
)
380+
await self.game_repository.commit()
368381
except DBAPIError as exc:
369382
await self.game_repository.rollback()
370383
if _is_concurrent_move_db_error(exc):
371384
raise MoveConflictError(
372385
"Concurrent move conflict; refresh board and retry."
373386
) from exc
374387
raise
375-
await self._update_game_state(game=game, board_after=scratch)
388+
if should_apply_rated_result:
389+
await self._apply_rated_result(game)
376390
return stored_move, game
377391

378-
async def _update_game_state(self, game: Game, board_after: AtaxxBoard) -> None:
392+
async def _update_game_state(
393+
self,
394+
game: Game,
395+
board_after: AtaxxBoard,
396+
*,
397+
commit: bool = True,
398+
) -> bool:
379399
changed = False
380400
if game.started_at is None:
381401
game.started_at = datetime.now(timezone.utc).replace(tzinfo=None)
382402
changed = True
383403

384404
if not board_after.is_game_over():
385405
if changed:
386-
await self.game_repository.save_game(game)
387-
return
406+
if commit:
407+
await self.game_repository.save_game(game)
408+
else:
409+
await self.game_repository.save_game_uncommitted(game)
410+
return False
388411

389412
result = board_after.get_result()
390413
if result == 1:
@@ -400,28 +423,38 @@ async def _update_game_state(self, game: Game, board_after: AtaxxBoard) -> None:
400423
game.status = GameStatus.FINISHED
401424
game.termination_reason = TerminationReason.NORMAL
402425
game.ended_at = datetime.now(timezone.utc).replace(tzinfo=None)
403-
await self.game_repository.save_game(game)
426+
if commit:
427+
await self.game_repository.save_game(game)
428+
else:
429+
await self.game_repository.save_game_uncommitted(game)
430+
return self._should_apply_rated_result(game)
404431

405-
if (
432+
def _should_apply_rated_result(self, game: Game) -> bool:
433+
return (
406434
game.rated
407435
and game.season_id is not None
408436
and game.player1_id is not None
409437
and game.player2_id is not None
410438
and game.winner_side is not None
411439
and self.ranking_service is not None
412-
):
413-
await self.ranking_service.apply_rated_result(
414-
game_id=game.id,
415-
season_id=game.season_id,
416-
player1_id=game.player1_id,
417-
player2_id=game.player2_id,
418-
winner_side=game.winner_side,
419-
)
420-
# Keep leaderboard_entry synchronized after each rated game result.
421-
await self.ranking_service.recompute_leaderboard(
422-
season_id=game.season_id,
423-
limit=500,
424-
)
440+
)
425441

442+
async def _apply_rated_result(self, game: Game) -> None:
443+
if self.ranking_service is None:
444+
raise RuntimeError("Ranking service is required for rated result finalization.")
445+
if game.season_id is None or game.player1_id is None or game.player2_id is None or game.winner_side is None:
446+
raise RuntimeError("Rated game is missing required identifiers for rating application.")
447+
await self.ranking_service.apply_rated_result(
448+
game_id=game.id,
449+
season_id=game.season_id,
450+
player1_id=game.player1_id,
451+
player2_id=game.player2_id,
452+
winner_side=game.winner_side,
453+
)
454+
# Keep leaderboard_entry synchronized after each rated game result.
455+
await self.ranking_service.recompute_leaderboard(
456+
season_id=game.season_id,
457+
limit=500,
458+
)
426459

427460

src/api/modules/matches/repository.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,32 @@ async def get_bot_profile(self, user_id: UUID) -> BotProfile | None:
3535
return result.scalars().first()
3636

3737
async def save_game(self, game: Game) -> Game:
38-
self.session.add(game)
38+
await self.save_game_uncommitted(game)
3939
await self.session.commit()
4040
await self.session.refresh(game)
4141
return game
4242

43+
async def save_game_uncommitted(self, game: Game) -> Game:
44+
self.session.add(game)
45+
return game
46+
4347
async def create_move(self, move: GameMove) -> GameMove:
44-
self.session.add(move)
48+
await self.create_move_uncommitted(move)
4549
await self.session.commit()
4650
await self.session.refresh(move)
4751
return move
4852

53+
async def create_move_uncommitted(self, move: GameMove) -> GameMove:
54+
self.session.add(move)
55+
return move
56+
4957
async def next_ply(self, game_id: UUID) -> int:
50-
stmt = select(GameMove).where(GameMove.game_id == game_id)
58+
stmt = select(func.count()).select_from(GameMove).where(GameMove.game_id == game_id)
5159
result = await self.session.execute(stmt)
52-
return len(list(result.scalars().all()))
60+
return int(result.scalar_one())
61+
62+
async def commit(self) -> None:
63+
await self.session.commit()
5364

5465
async def get_last_move(self, game_id: UUID) -> GameMove | None:
5566
stmt = (

src/api/modules/matches/service.py

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ async def submit_move(
273273
board_after = board_to_state(board)
274274

275275
ply = await self.repository.next_ply(game_id)
276-
stored = await self.repository.create_move(
276+
stored = await self.repository.create_move_uncommitted(
277277
GameMove(
278278
game_id=game_id,
279279
ply=ply,
@@ -289,8 +289,14 @@ async def submit_move(
289289
value=0.0,
290290
)
291291
)
292-
293-
await self._update_game_terminal_state(game=game, board=board)
292+
should_apply_rated_result = await self._update_game_terminal_state(
293+
game=game,
294+
board=board,
295+
commit=False,
296+
)
297+
await self.repository.commit()
298+
if should_apply_rated_result:
299+
await self._apply_rated_result(game)
294300
return stored
295301

296302
async def advance_bot_turn(
@@ -368,7 +374,7 @@ async def advance_bot_turn(
368374
board_after = board_to_state(board)
369375
ply = await self.repository.next_ply(game_id)
370376

371-
stored = await self.repository.create_move(
377+
stored = await self.repository.create_move_uncommitted(
372378
GameMove(
373379
game_id=game_id,
374380
ply=ply,
@@ -384,7 +390,14 @@ async def advance_bot_turn(
384390
value=value,
385391
)
386392
)
387-
await self._update_game_terminal_state(game=game, board=board)
393+
should_apply_rated_result = await self._update_game_terminal_state(
394+
game=game,
395+
board=board,
396+
commit=False,
397+
)
398+
await self.repository.commit()
399+
if should_apply_rated_result:
400+
await self._apply_rated_result(game)
388401
return stored
389402

390403
@staticmethod
@@ -399,12 +412,21 @@ def _resolve_actor_side(game: Game, actor_user_id: UUID) -> PlayerSide:
399412
return PlayerSide.P2
400413
raise PermissionError("Authenticated user is not a participant in this match.")
401414

402-
async def _update_game_terminal_state(self, game: Game, board: AtaxxBoard) -> None:
415+
async def _update_game_terminal_state(
416+
self,
417+
game: Game,
418+
board: AtaxxBoard,
419+
*,
420+
commit: bool = True,
421+
) -> bool:
403422
if not board.is_game_over():
404423
if game.status != GameStatus.IN_PROGRESS:
405424
game.status = GameStatus.IN_PROGRESS
406-
await self.repository.save_game(game)
407-
return
425+
if commit:
426+
await self.repository.save_game(game)
427+
else:
428+
await self.repository.save_game_uncommitted(game)
429+
return False
408430

409431
result = board.get_result()
410432
if result == 1:
@@ -420,28 +442,39 @@ async def _update_game_terminal_state(self, game: Game, board: AtaxxBoard) -> No
420442
game.status = GameStatus.FINISHED
421443
game.termination_reason = TerminationReason.NORMAL
422444
game.ended_at = datetime.now(timezone.utc).replace(tzinfo=None)
423-
await self.repository.save_game(game)
445+
if commit:
446+
await self.repository.save_game(game)
447+
else:
448+
await self.repository.save_game_uncommitted(game)
449+
return self._should_apply_rated_result(game)
424450

425-
if (
451+
def _should_apply_rated_result(self, game: Game) -> bool:
452+
return (
426453
game.rated
427454
and game.season_id is not None
428455
and game.player1_id is not None
429456
and game.player2_id is not None
430457
and game.winner_side is not None
431458
and self.ranking_service is not None
432-
):
433-
await self.ranking_service.apply_rated_result(
434-
game_id=game.id,
435-
season_id=game.season_id,
436-
player1_id=game.player1_id,
437-
player2_id=game.player2_id,
438-
winner_side=game.winner_side,
439-
)
440-
# Keep public leaderboard in sync with the latest rated result.
441-
await self.ranking_service.recompute_leaderboard(
442-
season_id=game.season_id,
443-
limit=500,
444-
)
459+
)
460+
461+
async def _apply_rated_result(self, game: Game) -> None:
462+
if self.ranking_service is None:
463+
raise RuntimeError("Ranking service is required for rated result finalization.")
464+
if game.season_id is None or game.player1_id is None or game.player2_id is None or game.winner_side is None:
465+
raise RuntimeError("Rated game is missing required identifiers for rating application.")
466+
await self.ranking_service.apply_rated_result(
467+
game_id=game.id,
468+
season_id=game.season_id,
469+
player1_id=game.player1_id,
470+
player2_id=game.player2_id,
471+
winner_side=game.winner_side,
472+
)
473+
# Keep public leaderboard in sync with the latest rated result.
474+
await self.ranking_service.recompute_leaderboard(
475+
season_id=game.season_id,
476+
limit=500,
477+
)
445478

446479
@staticmethod
447480
def _to_side(player: int) -> PlayerSide:

0 commit comments

Comments
 (0)