Skip to content

Commit 5485bb3

Browse files
committed
Improve queue UX, ranked sync latency, and match/lobby flows
1 parent 7139c22 commit 5485bb3

21 files changed

Lines changed: 905 additions & 232 deletions

.railwayignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ web/test-results/
2727
*.swp
2828
*.tmp
2929
node_modules/
30+
31+
# Local runtime/debug artifacts that can grow very large.
32+
.tmp*
33+
*.log
34+
SCI-FI_UI_SFX_PACK.zip

src/api/error_handling.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ async def attach_request_id(
7777
request_id = _resolve_request_id(request)
7878
status_code = 503 if isinstance(exc, (OperationalError, OSError)) else 500
7979
detail = "Database unavailable" if status_code == 503 else "Internal server error"
80+
raw_error_detail = str(getattr(exc, "orig", exc))
81+
error_detail = raw_error_detail[:240] if raw_error_detail else type(exc).__name__
8082
logger.error(
8183
"request_failed",
8284
extra={
@@ -85,6 +87,7 @@ async def attach_request_id(
8587
"path": request.url.path,
8688
"status_code": status_code,
8789
"error_type": type(exc).__name__,
90+
"error_detail": error_detail,
8891
},
8992
)
9093
body = _build_error_body(

src/api/modules/gameplay/repository.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,24 @@ async def create_move(self, move: GameMove) -> GameMove:
213213
await self.session.refresh(move)
214214
return move
215215

216+
async def rollback(self) -> None:
217+
await self.session.rollback()
218+
216219
async def next_ply(self, game_id: UUID) -> int:
217220
stmt = select(func.count()).select_from(GameMove).where(GameMove.game_id == game_id)
218221
result = await self.session.execute(stmt)
219222
return int(result.scalar_one())
220223

224+
async def get_last_move(self, game_id: UUID) -> GameMove | None:
225+
stmt = (
226+
select(GameMove)
227+
.where(GameMove.game_id == game_id)
228+
.order_by(col(GameMove.ply).desc())
229+
.limit(1)
230+
)
231+
result = await self.session.execute(stmt)
232+
return result.scalar_one_or_none()
233+
221234
async def list_moves(self, game_id: UUID, limit: int = 200) -> list[GameMove]:
222235
stmt = select(GameMove).where(GameMove.game_id == game_id).limit(limit)
223236
result = await self.session.execute(stmt)

src/api/modules/gameplay/router.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
MoveResponse,
3838
StoredMoveResponse,
3939
)
40-
from api.modules.gameplay.service import GameplayService
40+
from api.modules.gameplay.service import GameplayService, MoveConflictError
4141
from api.modules.gameplay.ws import gameplay_ws_hub
4242
from game.actions import ACTION_SPACE
4343
from game.serialization import board_from_state
@@ -541,6 +541,9 @@ async def post_game_move(
541541
status_code=status.HTTP_201_CREATED,
542542
summary="Apply Manual Move",
543543
description="Stores one explicit move payload for a game. Allowed for participants or admin.",
544+
responses={
545+
409: {"description": "Move conflict due to stale board or concurrent write."},
546+
},
544547
)
545548
async def post_game_manual_move(
546549
game_id: UUID,
@@ -575,6 +578,11 @@ async def post_game_manual_move(
575578
status_code=status.HTTP_403_FORBIDDEN,
576579
detail=str(exc),
577580
) from exc
581+
except MoveConflictError as exc:
582+
raise HTTPException(
583+
status_code=status.HTTP_409_CONFLICT,
584+
detail=str(exc),
585+
) from exc
578586
except ValueError as exc:
579587
raise HTTPException(
580588
status_code=status.HTTP_400_BAD_REQUEST,

src/api/modules/gameplay/service.py

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from datetime import datetime, timezone
55
from uuid import UUID
66

7+
from sqlalchemy.exc import DBAPIError
8+
79
from api.db.enums import (
810
AgentType,
911
GameStatus,
@@ -24,6 +26,27 @@
2426
logger = logging.getLogger(__name__)
2527

2628

29+
class MoveConflictError(ValueError):
30+
"""Raised when a move collides with a newer persisted board or concurrent write."""
31+
32+
33+
def _is_concurrent_move_db_error(exc: DBAPIError) -> bool:
34+
message = str(getattr(exc, "orig", exc)).lower()
35+
# Multiple DB backends/drivers report write races with different messages.
36+
# Normalize to one conflict path so the API returns 409 instead of 500.
37+
conflict_signals = (
38+
"duplicate key value",
39+
"unique constraint",
40+
"uq_game_moves_game_ply",
41+
"deadlock detected",
42+
"could not serialize access",
43+
"serialization failure",
44+
"database is locked",
45+
"lock timeout",
46+
)
47+
return any(signal in message for signal in conflict_signals)
48+
49+
2750
class GameplayService:
2851
def __init__(
2952
self,
@@ -303,35 +326,52 @@ async def record_manual_move(
303326
mode: str = "manual",
304327
) -> tuple[GameMove, Game]:
305328
game = await self.ensure_can_view_game(game_id=game_id, actor_user=actor_user)
329+
board_before = board_to_state(board)
330+
last_move = await self.game_repository.get_last_move(game_id)
331+
if (
332+
last_move is not None
333+
and isinstance(last_move.board_after, dict)
334+
and last_move.board_after != board_before
335+
):
336+
raise MoveConflictError(
337+
"Board state is stale; refresh game state and retry the move."
338+
)
306339

307340
legal_moves = board.get_valid_moves()
308341
if move not in legal_moves:
309342
raise ValueError("Illegal move for provided board state.")
310343

311344
ply = await self.game_repository.next_ply(game_id)
312345
side = PlayerSide.P1 if board.current_player == 1 else PlayerSide.P2
313-
board_before = board_to_state(board)
314346
scratch = board.copy()
315347
scratch.step(move)
316348
board_after = board_to_state(scratch)
317349

318350
r1, c1, r2, c2 = move
319-
stored_move = await self.game_repository.create_move(
320-
GameMove(
321-
game_id=game_id,
322-
ply=ply,
323-
player_side=side,
324-
r1=r1,
325-
c1=c1,
326-
r2=r2,
327-
c2=c2,
328-
board_before=board_before,
329-
board_after=board_after,
330-
mode=mode,
331-
action_idx=ACTION_SPACE.encode(move),
332-
value=0.0,
351+
try:
352+
stored_move = await self.game_repository.create_move(
353+
GameMove(
354+
game_id=game_id,
355+
ply=ply,
356+
player_side=side,
357+
r1=r1,
358+
c1=c1,
359+
r2=r2,
360+
c2=c2,
361+
board_before=board_before,
362+
board_after=board_after,
363+
mode=mode,
364+
action_idx=ACTION_SPACE.encode(move),
365+
value=0.0,
366+
)
333367
)
334-
)
368+
except DBAPIError as exc:
369+
await self.game_repository.rollback()
370+
if _is_concurrent_move_db_error(exc):
371+
raise MoveConflictError(
372+
"Concurrent move conflict; refresh board and retry."
373+
) from exc
374+
raise
335375
await self._update_game_state(game=game, board_after=scratch)
336376
return stored_move, game
337377

src/api/modules/matchmaking/repository.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ async def cancel_waiting_entry(
123123
async def get_entry_by_id(self, queue_id: UUID) -> QueueEntry | None:
124124
return await self.session.get(QueueEntry, queue_id)
125125

126+
async def get_game(self, game_id: UUID) -> Game | None:
127+
return await self.session.get(Game, game_id)
128+
129+
async def get_user(self, user_id: UUID) -> User | None:
130+
return await self.session.get(User, user_id)
131+
126132
async def list_matched_entries_for_game(self, *, season_id: UUID, game_id: UUID) -> list[QueueEntry]:
127133
stmt = (
128134
select(QueueEntry)

src/api/modules/matchmaking/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class QueueJoinResponse(BaseModel):
1616
"season_id": "4aaddf8e-ca81-4347-a278-f6f7be86c6d0",
1717
"game_id": "44efed45-d197-4416-bc45-d1cc804f3936",
1818
"matched_with": "bot",
19+
"opponent_username": "mm-bot-hard",
1920
"created_at": "2026-02-23T01:10:00",
2021
"updated_at": "2026-02-23T01:10:02",
2122
}
@@ -27,6 +28,7 @@ class QueueJoinResponse(BaseModel):
2728
season_id: UUID
2829
game_id: UUID | None
2930
matched_with: Literal["human", "bot"] | None = None
31+
opponent_username: str | None = None
3032
created_at: datetime
3133
updated_at: datetime
3234

@@ -40,6 +42,7 @@ class QueueStatusResponse(BaseModel):
4042
"season_id": "4aaddf8e-ca81-4347-a278-f6f7be86c6d0",
4143
"game_id": None,
4244
"matched_with": None,
45+
"opponent_username": None,
4346
"created_at": "2026-02-23T01:10:00",
4447
"updated_at": "2026-02-23T01:10:00",
4548
}
@@ -51,6 +54,7 @@ class QueueStatusResponse(BaseModel):
5154
season_id: UUID | None
5255
game_id: UUID | None
5356
matched_with: Literal["human", "bot"] | None = None
57+
opponent_username: str | None = None
5458
created_at: datetime | None = None
5559
updated_at: datetime | None = None
5660

src/api/modules/matchmaking/service.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ async def join_ranked_queue(self, *, actor_user: User) -> QueueJoinResponse:
8080
refreshed = await self.repository.get_entry_by_id(entry.id)
8181
if refreshed is None:
8282
raise LookupError("Queue entry not found after join.")
83+
opponent_username = await self._resolve_opponent_username(
84+
actor_user_id=actor_user.id,
85+
game_id=refreshed.matched_game_id,
86+
)
8387
return QueueJoinResponse(
8488
queue_id=refreshed.id,
8589
status=self._status_value(refreshed.status),
8690
season_id=refreshed.season_id,
8791
game_id=game_id,
8892
matched_with=matched_with,
93+
opponent_username=opponent_username,
8994
created_at=refreshed.created_at,
9095
updated_at=refreshed.updated_at,
9196
)
@@ -115,13 +120,24 @@ async def get_status(self, *, actor_user: User) -> QueueStatusResponse:
115120
refreshed = await self.repository.get_entry_by_id(entry.id)
116121
if refreshed is not None:
117122
entry = refreshed
123+
elif entry.status == QueueEntryStatus.MATCHED:
124+
matched_with = await self._resolve_matched_with(
125+
actor_user_id=actor_user.id,
126+
game_id=entry.matched_game_id,
127+
)
128+
129+
opponent_username = await self._resolve_opponent_username(
130+
actor_user_id=actor_user.id,
131+
game_id=entry.matched_game_id,
132+
)
118133

119134
return QueueStatusResponse(
120135
queue_id=entry.id,
121136
status=self._status_value(entry.status),
122137
season_id=entry.season_id,
123138
game_id=entry.matched_game_id,
124139
matched_with=matched_with,
140+
opponent_username=opponent_username,
125141
created_at=entry.created_at,
126142
updated_at=entry.updated_at,
127143
)
@@ -326,3 +342,53 @@ def _select_bot_candidate(
326342
if roll <= cumulative:
327343
return bot_user
328344
return candidates[-1][0]
345+
346+
async def _resolve_opponent_username(
347+
self,
348+
*,
349+
actor_user_id: UUID,
350+
game_id: UUID | None,
351+
) -> str | None:
352+
if game_id is None:
353+
return None
354+
game = await self.repository.get_game(game_id)
355+
if game is None:
356+
return None
357+
358+
opponent_user_id: UUID | None = None
359+
if game.player1_id == actor_user_id:
360+
opponent_user_id = game.player2_id
361+
elif game.player2_id == actor_user_id:
362+
opponent_user_id = game.player1_id
363+
if opponent_user_id is None:
364+
return None
365+
366+
opponent_user = await self.repository.get_user(opponent_user_id)
367+
if opponent_user is None:
368+
return None
369+
return opponent_user.username
370+
371+
async def _resolve_matched_with(
372+
self,
373+
*,
374+
actor_user_id: UUID,
375+
game_id: UUID | None,
376+
) -> MatchedWith | None:
377+
if game_id is None:
378+
return None
379+
game = await self.repository.get_game(game_id)
380+
if game is None:
381+
return None
382+
383+
opponent_user_id: UUID | None = None
384+
if game.player1_id == actor_user_id:
385+
opponent_user_id = game.player2_id
386+
elif game.player2_id == actor_user_id:
387+
opponent_user_id = game.player1_id
388+
if opponent_user_id is None:
389+
return None
390+
391+
opponent_user = await self.repository.get_user(opponent_user_id)
392+
if opponent_user is None:
393+
return None
394+
return "bot" if opponent_user.is_bot else "human"

src/api/modules/ranking/repository.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,18 @@ async def list_rating_events(
6767
limit: int = 50,
6868
offset: int = 0,
6969
) -> list[RatingEvent]:
70-
stmt = select(RatingEvent).where(
71-
RatingEvent.user_id == user_id,
72-
RatingEvent.season_id == season_id,
70+
stmt = (
71+
select(RatingEvent)
72+
.where(
73+
RatingEvent.user_id == user_id,
74+
RatingEvent.season_id == season_id,
75+
)
76+
.order_by(col(RatingEvent.created_at).desc(), col(RatingEvent.id).desc())
77+
.limit(limit)
78+
.offset(offset)
7379
)
7480
result = await self.session.execute(stmt)
75-
rows = list(result.scalars().all())
76-
rows.sort(key=lambda row: row.created_at, reverse=True)
77-
return rows[offset : offset + limit]
81+
return list(result.scalars().all())
7882

7983
async def get_latest_rating_events_for_user_ids(
8084
self,

tests/test_api_games_integration.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,40 @@ def test_creator_can_control_bot_vs_bot_game(self) -> None:
270270
)
271271
self.assertEqual(outsider_move.status_code, 403)
272272

273+
def test_manual_move_rejects_stale_board_snapshot(self) -> None:
274+
user = self._register_and_login("gp-stale-user", "gp-stale-user@example.com")
275+
create_resp = self.client.post(
276+
"/api/v1/gameplay/games",
277+
json={"queue_type": "casual"},
278+
headers={"Authorization": f"Bearer {user['access_token']}"},
279+
)
280+
self.assertEqual(create_resp.status_code, 201)
281+
game_id = create_resp.json()["id"]
282+
283+
board = AtaxxBoard()
284+
first_move = self.client.post(
285+
f"/api/v1/gameplay/games/{game_id}/move/manual",
286+
json={
287+
"board": board_to_state(board),
288+
"move": {"r1": 0, "c1": 0, "r2": 1, "c2": 1},
289+
"mode": "manual",
290+
},
291+
headers={"Authorization": f"Bearer {user['access_token']}"},
292+
)
293+
self.assertEqual(first_move.status_code, 201)
294+
295+
stale_move = self.client.post(
296+
f"/api/v1/gameplay/games/{game_id}/move/manual",
297+
json={
298+
"board": board_to_state(board),
299+
"move": {"r1": 0, "c1": 0, "r2": 1, "c2": 1},
300+
"mode": "manual",
301+
},
302+
headers={"Authorization": f"Bearer {user['access_token']}"},
303+
)
304+
self.assertEqual(stale_move.status_code, 409)
305+
self.assertIn("stale", str(stale_move.json()["detail"]).lower())
306+
273307
def test_create_game_rejects_same_player_on_both_sides(self) -> None:
274308
creator = self._register_and_login("gp-owner-same", "gp-owner-same@example.com")
275309
bot_id = self._create_bot_user_with_profile(

0 commit comments

Comments
 (0)