diff --git a/server/routers/stats.py b/server/routers/stats.py index 08468cd..d2dce68 100644 --- a/server/routers/stats.py +++ b/server/routers/stats.py @@ -159,6 +159,7 @@ async def get_leaderboard( metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), + include_test: bool = Query(False, description="Include soak-harness test accounts"), service: StatsService = Depends(get_stats_service_dep), ): """ @@ -172,8 +173,9 @@ async def get_leaderboard( - streak: Best win streak Players must have 5+ games to appear on leaderboards. + By default, soak-harness test accounts are hidden. """ - entries = await service.get_leaderboard(metric, limit, offset) + entries = await service.get_leaderboard(metric, limit, offset, include_test) return { "metric": metric, @@ -228,10 +230,11 @@ async def get_player_stats( async def get_player_rank( user_id: str, metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), + include_test: bool = Query(False, description="Include soak-harness test accounts"), service: StatsService = Depends(get_stats_service_dep), ): """Get player's rank on a leaderboard.""" - rank = await service.get_player_rank(user_id, metric) + rank = await service.get_player_rank(user_id, metric, include_test) return { "user_id": user_id, @@ -348,11 +351,12 @@ async def get_my_stats( @router.get("/me/rank", response_model=PlayerRankResponse) async def get_my_rank( metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"), + include_test: bool = Query(False, description="Include soak-harness test accounts"), user: User = Depends(require_user), service: StatsService = Depends(get_stats_service_dep), ): """Get current user's rank on a leaderboard.""" - rank = await service.get_player_rank(user.id, metric) + rank = await service.get_player_rank(user.id, metric, include_test) return { "user_id": user.id, diff --git a/server/services/stats_service.py b/server/services/stats_service.py index 92234d5..cedda22 100644 --- a/server/services/stats_service.py +++ b/server/services/stats_service.py @@ -171,6 +171,7 @@ class StatsService: metric: str = "wins", limit: int = 50, offset: int = 0, + include_test: bool = False, ) -> List[LeaderboardEntry]: """ Get leaderboard by metric. @@ -179,6 +180,8 @@ class StatsService: metric: Ranking metric - wins, win_rate, avg_score, knockouts, streak. limit: Maximum entries to return. offset: Pagination offset. + include_test: If True, include soak-harness test accounts. Default + False so real users never see synthetic load-test traffic. Returns: List of LeaderboardEntry sorted by metric. @@ -212,9 +215,10 @@ class StatsService: COALESCE(rating, 1500) as rating, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank FROM leaderboard_overall + WHERE ($3 OR NOT is_test_account) ORDER BY {column} {direction} LIMIT $1 OFFSET $2 - """, limit, offset) + """, limit, offset, include_test) else: # Fall back to direct query rows = await conn.fetch(f""" @@ -230,9 +234,10 @@ class StatsService: WHERE s.games_played >= 5 AND u.deleted_at IS NULL AND (u.is_banned = false OR u.is_banned IS NULL) + AND ($3 OR NOT COALESCE(u.is_test_account, FALSE)) ORDER BY {column} {direction} LIMIT $1 OFFSET $2 - """, limit, offset) + """, limit, offset, include_test) return [ LeaderboardEntry( @@ -246,16 +251,26 @@ class StatsService: for row in rows ] - async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]: + async def get_player_rank( + self, + user_id: str, + metric: str = "wins", + include_test: bool = False, + ) -> Optional[int]: """ Get a player's rank on a leaderboard. Args: user_id: User UUID. metric: Ranking metric. + include_test: If True, rank within a leaderboard that includes + soak-harness test accounts. Default False matches the public + leaderboard view. Returns: - Rank number or None if not ranked (< 5 games or not found). + Rank number or None if not ranked (< 5 games or not found, or + filtered out because they are a test account and include_test is + False). """ order_map = { "wins": ("games_won", "DESC"), @@ -288,9 +303,10 @@ class StatsService: SELECT rank FROM ( SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank FROM leaderboard_overall + WHERE ($2 OR NOT is_test_account) ) ranked WHERE user_id = $1 - """, user_id) + """, user_id, include_test) else: row = await conn.fetchrow(f""" SELECT rank FROM ( @@ -300,9 +316,10 @@ class StatsService: WHERE s.games_played >= 5 AND u.deleted_at IS NULL AND (u.is_banned = false OR u.is_banned IS NULL) + AND ($2 OR NOT COALESCE(u.is_test_account, FALSE)) ) ranked WHERE user_id = $1 - """, user_id) + """, user_id, include_test) return row["rank"] if row else None