From 08d1b7b7316adb4a06a11bd7034742f4843ef634 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Tue, 22 Sep 2026 21:59:31 -0400 Subject: [PATCH] Add paginated player scoreboard --- app/controllers/index.py | 35 +++++++- app/models/solve.py | 62 ++++++++++++++ templates/partial/menu.html | 4 + templates/scoreboard/index.html | 46 +++++++++++ tests/test_routes.py | 2 +- tests/test_scoreboard.py | 142 ++++++++++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 templates/scoreboard/index.html create mode 100644 tests/test_scoreboard.py diff --git a/app/controllers/index.py b/app/controllers/index.py index 8b20e1d..a6d5bfd 100644 --- a/app/controllers/index.py +++ b/app/controllers/index.py @@ -2,10 +2,13 @@ Index controller - Home page. """ -from flask import Blueprint, render_template +from math import ceil + +from flask import Blueprint, abort, redirect, render_template from app.models.user import count_users from app.models.crackme import count_crackmes from app.models.solution import count_solutions +from app.models.solve import scoreboard index_bp = Blueprint('index', __name__) @@ -25,3 +28,33 @@ def index(): nbusers=nbusers, nbcrackmes=nbcrackmes, nbsolutions=nbsolutions) + + +@index_bp.route('/scoreboard') +def scoreboard_index(): + return _scoreboard_page(1) + + +def _scoreboard_page(page): + try: + rows, has_more, total = scoreboard(page) + except Exception as e: + print(f"Error getting scoreboard: {e}") + abort(500) + + if page > 1 and not rows: + last_page = max(1, ceil(total / 25)) + return redirect('/scoreboard' if last_page == 1 + else f'/scoreboard/{last_page}') + + return render_template('scoreboard/index.html', + scoreboard=rows, + page=page, + has_more=has_more) + + +@index_bp.route('/scoreboard/') +def scoreboard_page(page): + if page < 2: + return redirect('/scoreboard') + return _scoreboard_page(page) diff --git a/app/models/solve.py b/app/models/solve.py index 903cae3..c0bb42f 100644 --- a/app/models/solve.py +++ b/app/models/solve.py @@ -117,6 +117,68 @@ def user_score(user_hexid): return sum(solve.get('points', 0) for solve in solves) +def scoreboard(page=1, per_page=25): + """Rank visible players by score, then by when they reached that score. + + Scores remain derived from solve records. User documents are resolved + after aggregation so deleted, hidden, or otherwise missing accounts never + appear on the public scoreboard. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + from app.models.user import users_by_hexids + + totals = list(get_collection('solve').aggregate([ + {'$group': { + '_id': '$user_hexid', + 'score': {'$sum': {'$ifNull': ['$points', 0]}}, + 'last_solve_at': {'$max': { + '$cond': [{'$gt': ['$points', 0]}, '$created_at', None] + }}, + 'last_undated_solve_id': {'$max': { + '$cond': [{'$and': [ + {'$gt': ['$points', 0]}, + {'$eq': [{'$ifNull': ['$created_at', None]}, None]}, + ]}, '$_id', None] + }}, + }}, + {'$match': {'score': {'$gt': 0}}}, + ])) + users = users_by_hexids(total['_id'] for total in totals) + + def reached_at(total): + candidates = [] + if total.get('last_solve_at'): + candidates.append(total['last_solve_at']) + if total.get('last_undated_solve_id'): + candidates.append(total['last_undated_solve_id'].generation_time) + return max(timestamp.replace(tzinfo=timezone.utc) + if timestamp.tzinfo is None else timestamp + for timestamp in candidates) + + rows = [ + { + 'user_hexid': total['_id'], + 'username': users[total['_id']]['name'], + 'score': total['score'], + 'reached_at': reached_at(total), + } + for total in totals + if total['_id'] in users and not users[total['_id']].get('deleted', False) + ] + rows.sort(key=lambda row: (-row['score'], row['reached_at'], + row['user_hexid'])) + + for position, row in enumerate(rows, start=1): + row['rank'] = position + + page = max(page, 1) + start = (page - 1) * per_page + paginated_rows = rows[start:start + per_page] + return paginated_rows, start + per_page < len(rows), len(rows) + + def count_solves_by_crackme(crackme_hexid): """Count how many users have solved a crackme.""" if not check_connection(): diff --git a/templates/partial/menu.html b/templates/partial/menu.html index 2a1ee03..e34a5fa 100644 --- a/templates/partial/menu.html +++ b/templates/partial/menu.html @@ -12,6 +12,7 @@

crackmes.one

Search Upload crackme Latest + Scoreboard Faq Discord Blog @@ -32,6 +33,7 @@

crackmes.one

+ @@ -48,6 +50,7 @@

crackmes.one