Skip to content

Commit ad1d550

Browse files
JacobCoffeeclaude
andcommitted
feat(auth): track game stats for authenticated users + routing fixes
Stats tracking: - Add auth_user_id to Player model for linking game players to auth users - Pass auth_user_id from frontend when joining game via WebSocket - Record game results (score, wins, guesses) when game ends - Wire auth service into game WebSocket handler via plugin Routing improvements: - Root / now redirects to /canvas-clash/ instead of /ui/ - Add /profile shortcut that redirects to /auth/profile - Update navbar to use /profile link 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 1d9cea3 commit ad1d550

File tree

8 files changed

+127
-9
lines changed

8 files changed

+127
-9
lines changed

src/scribbl_py/app.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,16 +207,22 @@ def create_app(
207207
# Root redirect handler
208208
@get("/", include_in_schema=False)
209209
async def root_redirect() -> Redirect:
210-
"""Redirect root to UI dashboard."""
211-
return Redirect(path="/ui/")
210+
"""Redirect root to Canvas Clash game."""
211+
return Redirect(path="/canvas-clash/")
212212

213213
# Favicon redirect
214214
@get("/favicon.ico", include_in_schema=False)
215215
async def favicon_redirect() -> Redirect:
216216
"""Redirect favicon.ico to SVG favicon."""
217217
return Redirect(path="/static/favicon.svg")
218218

219-
route_handlers = [root_redirect, favicon_redirect, HealthController] if enable_ui else [HealthController]
219+
# Profile shortcut (redirects to /auth/profile)
220+
@get("/profile", include_in_schema=False)
221+
async def profile_redirect() -> Redirect:
222+
"""Redirect /profile to auth profile page."""
223+
return Redirect(path="/auth/profile")
224+
225+
route_handlers = [root_redirect, favicon_redirect, profile_redirect, HealthController] if enable_ui else [HealthController]
220226

221227
# Configure templates if UI is enabled (must be before Litestar init for VitePlugin)
222228
template_config = None

src/scribbl_py/game/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class Player:
107107
user_id: str = ""
108108
user_name: str = "Anonymous"
109109
avatar_url: str | None = None
110+
auth_user_id: UUID | None = None # Linked auth user for stats tracking
110111
score: int = 0
111112
is_host: bool = False
112113
is_spectator: bool = False

src/scribbl_py/plugin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,13 @@ def provide_auth_service(request: Any) -> DatabaseAuthService:
345345
path=f"{self._config.ws_path}/canvas-clash",
346346
game_service=self._game_service,
347347
connection_manager=self._connection_manager,
348+
auth_service=self._auth_service, # Will be set lazily
348349
)
349350
app_config.route_handlers.append(game_ws_router)
350351

352+
# Store reference to plugin on handler for lazy auth service access
353+
self._game_ws_handler._plugin = self
354+
351355
# Register game WebSocket handler as a dependency
352356
def provide_game_ws_handler() -> Any:
353357
"""Dependency provider for GameWebSocketHandler."""

src/scribbl_py/realtime/game_handler.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
if TYPE_CHECKING:
2727
from litestar import Router, WebSocket
2828

29+
from scribbl_py.auth.db_service import DatabaseAuthService
2930
from scribbl_py.game.models import GameRoom
3031
from scribbl_py.services.game import GameService
3132

@@ -41,6 +42,7 @@ class GameConnection:
4142
player_id: UUID
4243
user_id: str
4344
user_name: str
45+
auth_user_id: UUID | None = None # Linked auth user for stats
4446
connected_at: datetime = field(default_factory=lambda: datetime.now(UTC))
4547

4648

@@ -100,20 +102,32 @@ def __init__(
100102
self,
101103
game_service: GameService,
102104
connection_manager: ConnectionManager | None = None,
105+
auth_service: DatabaseAuthService | None = None,
103106
) -> None:
104107
"""Initialize the game WebSocket handler.
105108
106109
Args:
107110
game_service: The game service instance.
108111
connection_manager: Optional connection manager for advanced tracking.
112+
auth_service: Optional auth service for user stats tracking.
109113
"""
110114
self._service = game_service
111115
self._manager = connection_manager or ConnectionManager()
116+
self._auth_service = auth_service
117+
self._plugin: Any = None # Set by plugin for lazy auth service access
112118
self._connections: dict[int, GameConnection] = {} # socket_id -> connection
113119
self._room_sockets: dict[UUID, set[int]] = {} # room_id -> socket_ids
114120
self._timer_tasks: dict[UUID, asyncio.Task] = {} # room_id -> timer task
115121
self._lobby_browsers: dict[int, WebSocket] = {} # socket_id -> socket for lobby browsers
116122

123+
def _get_auth_service(self) -> DatabaseAuthService | None:
124+
"""Get auth service, trying plugin reference if direct reference is None."""
125+
if self._auth_service is not None:
126+
return self._auth_service
127+
if self._plugin is not None and hasattr(self._plugin, "_auth_service"):
128+
return self._plugin._auth_service
129+
return None
130+
117131
async def handle_lobby_connection(self, socket: WebSocket, room_id: UUID) -> None:
118132
"""Handle WebSocket connection to game lobby.
119133
@@ -336,13 +350,18 @@ async def _handle_join(
336350
socket: The WebSocket connection.
337351
socket_id: Socket identifier.
338352
room_id: The room ID.
339-
data: Message data with user_id and user_name.
353+
data: Message data with user_id, user_name, and optional auth_user_id.
340354
"""
341355
user_id = data.get("user_id", "")
342356
user_name = data.get("user_name", "Anonymous")
357+
auth_user_id_str = data.get("auth_user_id")
358+
auth_user_id = UUID(auth_user_id_str) if auth_user_id_str else None
343359

344360
try:
345361
player = self._service.join_room(room_id, user_id, user_name)
362+
# Store auth user ID on player for stats tracking
363+
if auth_user_id:
364+
player.auth_user_id = auth_user_id
346365
except (GameNotFoundError, GameStateError) as e:
347366
await self._send_error(socket, "join_failed", str(e))
348367
return
@@ -354,6 +373,7 @@ async def _handle_join(
354373
player_id=player.id,
355374
user_id=user_id,
356375
user_name=user_name,
376+
auth_user_id=auth_user_id,
357377
)
358378
self._connections[socket_id] = connection
359379

@@ -1513,6 +1533,11 @@ async def _end_round(self, room_id: UUID) -> None:
15131533
],
15141534
},
15151535
)
1536+
1537+
# Record game stats for authenticated players
1538+
auth_svc = self._get_auth_service()
1539+
if auth_svc:
1540+
await self._record_game_stats(room, results, auth_svc)
15161541
else:
15171542
# Start next round after delay
15181543
await asyncio.sleep(5)
@@ -1523,6 +1548,59 @@ async def _end_round(self, room_id: UUID) -> None:
15231548
except Exception:
15241549
pass
15251550

1551+
async def _record_game_stats(
1552+
self,
1553+
room: GameRoom,
1554+
results: dict[str, Any],
1555+
auth_service: DatabaseAuthService,
1556+
) -> None:
1557+
"""Record game stats for authenticated players.
1558+
1559+
Args:
1560+
room: The game room.
1561+
results: Game results from end_round.
1562+
auth_service: Auth service for recording stats.
1563+
"""
1564+
1565+
leaderboard = results.get("leaderboard", [])
1566+
if not leaderboard:
1567+
return
1568+
1569+
# Winner is first in leaderboard
1570+
winner_player = leaderboard[0][0] if leaderboard else None
1571+
1572+
for player, score in leaderboard:
1573+
# Skip players without auth user ID
1574+
if not player.auth_user_id:
1575+
continue
1576+
1577+
won = player.id == winner_player.id if winner_player else False
1578+
1579+
try:
1580+
await auth_service.record_game_result(
1581+
user_id=player.auth_user_id,
1582+
score=score,
1583+
won=won,
1584+
correct_guesses=1 if player.has_guessed else 0, # Simplified
1585+
total_guesses=1, # Simplified
1586+
total_guess_time_ms=int((player.guess_time or 0) * 1000),
1587+
fastest_guess_ms=int((player.guess_time or 0) * 1000) if player.guess_time else None,
1588+
drawings_completed=1, # Simplified - each player drew once per round
1589+
drawings_guessed=1 if player.has_guessed else 0,
1590+
)
1591+
logger.info(
1592+
"Recorded game stats",
1593+
user_id=str(player.auth_user_id),
1594+
score=score,
1595+
won=won,
1596+
)
1597+
except Exception as e:
1598+
logger.error(
1599+
"Failed to record game stats",
1600+
user_id=str(player.auth_user_id),
1601+
error=str(e),
1602+
)
1603+
15261604
async def _broadcast_round_started(
15271605
self,
15281606
room_id: UUID,
@@ -1684,20 +1762,22 @@ def create_game_websocket_handler(
16841762
path: str,
16851763
game_service: GameService,
16861764
connection_manager: ConnectionManager | None = None,
1765+
auth_service: DatabaseAuthService | None = None,
16871766
) -> tuple[Router, GameWebSocketHandler]:
16881767
"""Create a WebSocket router for game real-time communication.
16891768
16901769
Args:
16911770
path: Base path for WebSocket routes.
16921771
game_service: The game service instance.
16931772
connection_manager: Optional connection manager.
1773+
auth_service: Optional auth service for stats tracking.
16941774
16951775
Returns:
16961776
A tuple of (Litestar Router, GameWebSocketHandler instance).
16971777
"""
16981778
from litestar import Router, websocket
16991779

1700-
handler = GameWebSocketHandler(game_service, connection_manager)
1780+
handler = GameWebSocketHandler(game_service, connection_manager, auth_service)
17011781

17021782
@websocket(path="/lobby/{room_id:uuid}")
17031783
async def lobby_websocket(socket: WebSocket, room_id: UUID) -> None:

src/scribbl_py/templates/auth/navbar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<li class="menu-title">
1919
<span>{{ user.username }}</span>
2020
</li>
21-
<li><a href="/auth/profile" hx-boost="false">
21+
<li><a href="/profile" hx-boost="false">
2222
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
2323
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
2424
</svg>

src/scribbl_py/templates/canvas_clash_game.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ <h3 class="font-bold flex items-center gap-2">
504504
document.addEventListener('DOMContentLoaded', function() {
505505
const roomId = gameRoomId;
506506
const userId = '{{ user_id }}';
507+
const authUserId = {{ auth_user_id | tojson | safe if auth_user_id else 'null' }};
507508
const isDrawing = {{ is_drawing|lower }};
508509
const canvas = document.getElementById('game-canvas');
509510
const ctx = canvas ? canvas.getContext('2d') : null;
@@ -532,7 +533,8 @@ <h3 class="font-bold flex items-center gap-2">
532533
hideCanvasLoading();
533534
ws.send(JSON.stringify({
534535
type: 'join',
535-
user_id: userId
536+
user_id: userId,
537+
auth_user_id: authUserId
536538
}));
537539
};
538540

src/scribbl_py/templates/canvas_clash_lobby.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ <h2 class="card-title text-xl mb-4">
368368
// Get user_id from localStorage (set on home page) or fall back to cookie
369369
const userId = localStorage.getItem('canvas_clash_user_id') || '{{ user_id }}';
370370
const userName = localStorage.getItem('canvas_clash_user_name') || 'Anonymous';
371+
// Auth user ID for stats tracking (null if not logged in)
372+
const authUserId = {{ auth_user_id | tojson | safe if auth_user_id else 'null' }};
371373
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/canvas-clash/lobby/${roomId}`;
372374

373375
// Ensure cookie is set for server-side access
@@ -384,11 +386,12 @@ <h2 class="card-title text-xl mb-4">
384386
reconnectAttempts = 0;
385387
updateConnectionStatus('connected');
386388

387-
// Send join message with user_name
389+
// Send join message with user_name and auth_user_id
388390
lobbyWs.send(JSON.stringify({
389391
type: 'join',
390392
user_id: userId,
391-
user_name: userName
393+
user_name: userName,
394+
auth_user_id: authUserId
392395
}));
393396
};
394397

src/scribbl_py/web/game_controllers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,13 +597,15 @@ async def game_lobby(
597597
self,
598598
room_id: UUID,
599599
game_service: GameService,
600+
auth_service: DatabaseAuthService,
600601
request: Request,
601602
) -> Template:
602603
"""Render the game lobby page.
603604
604605
Args:
605606
room_id: The room UUID.
606607
game_service: Game service instance.
608+
auth_service: Auth service instance.
607609
request: The request object.
608610
609611
Returns:
@@ -612,6 +614,14 @@ async def game_lobby(
612614
room = game_service.get_room(room_id)
613615
user_id = request.cookies.get("user_id", "")
614616

617+
# Get auth user ID if logged in
618+
auth_user_id = None
619+
session_id = request.cookies.get(auth_service._config.session_cookie_name)
620+
if session_id:
621+
session = await auth_service.get_session(session_id)
622+
if session and session.user_id:
623+
auth_user_id = str(session.user_id)
624+
615625
# Find current player
616626
current_player = next((p for p in room.players if p.user_id == user_id), None)
617627
is_host = current_player.is_host if current_player else False
@@ -653,6 +663,7 @@ async def game_lobby(
653663
for p in spectators
654664
],
655665
"user_id": user_id,
666+
"auth_user_id": auth_user_id,
656667
"is_host": is_host,
657668
"is_spectator": is_spectator,
658669
},
@@ -663,13 +674,15 @@ async def game_screen(
663674
self,
664675
room_id: UUID,
665676
game_service: GameService,
677+
auth_service: DatabaseAuthService,
666678
request: Request,
667679
) -> Template:
668680
"""Render the active game screen.
669681
670682
Args:
671683
room_id: The room UUID.
672684
game_service: Game service instance.
685+
auth_service: Auth service instance.
673686
request: The request object.
674687
675688
Returns:
@@ -678,6 +691,14 @@ async def game_screen(
678691
room = game_service.get_room(room_id)
679692
user_id = request.cookies.get("user_id", "")
680693

694+
# Get auth user ID if logged in
695+
auth_user_id = None
696+
session_id = request.cookies.get(auth_service._config.session_cookie_name)
697+
if session_id:
698+
session = await auth_service.get_session(session_id)
699+
if session and session.user_id:
700+
auth_user_id = str(session.user_id)
701+
681702
# Find current player
682703
current_player = next((p for p in room.players if p.user_id == user_id), None)
683704

@@ -731,6 +752,7 @@ async def game_screen(
731752
],
732753
},
733754
"user_id": user_id,
755+
"auth_user_id": auth_user_id,
734756
"is_drawing": is_drawing,
735757
"is_spectator": is_spectator,
736758
"current_word": current_word,

0 commit comments

Comments
 (0)