From 52a6a4adb20e50e4475718dca44c0b6105a00c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Sun, 10 May 2026 02:01:10 +0200 Subject: [PATCH] noita objectives + leaderboard --- polylan_submitter/noita/admin.py | 9 +- polylan_submitter/noita/api.py | 179 ++++++++++++++++++++++++++++- polylan_submitter/noita/schemas.py | 22 ++++ polylan_submitter/src/Noita.vue | 88 ++++++++++++-- 4 files changed, 288 insertions(+), 10 deletions(-) diff --git a/polylan_submitter/noita/admin.py b/polylan_submitter/noita/admin.py index e1d7f89..6884a55 100644 --- a/polylan_submitter/noita/admin.py +++ b/polylan_submitter/noita/admin.py @@ -4,7 +4,14 @@ from .models import LogfileSubmission, Objectiv, ObjectivPoint @admin.register(LogfileSubmission) class LogfileSubmissionAdmin(admin.ModelAdmin): - list_display = ("id", "user", "content_type", "file_size", "created_at", "processed") + list_display = ( + "id", + "user", + "content_type", + "file_size", + "created_at", + "processed", + ) list_filter = ("content_type", "processed", "created_at") search_fields = ("id", "user__username") readonly_fields = ("id", "created_at", "updated_at") diff --git a/polylan_submitter/noita/api.py b/polylan_submitter/noita/api.py index 14087fd..854c242 100644 --- a/polylan_submitter/noita/api.py +++ b/polylan_submitter/noita/api.py @@ -1,11 +1,23 @@ from django.http import HttpRequest from django.core.files.base import ContentFile +from django.db.models import ( + F, + Case, + When, + Sum, + IntegerField, + Subquery, + OuterRef, + Window, +) +from django.db.models.functions import Rank from ninja import Router, File from ninja.files import UploadedFile -from noita.schemas import ObjectivOut +from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut +from noita.services.objectives import parse_objectives_and_store -from .models import LogfileSubmission, Objectiv +from .models import LogfileSubmission, Objectiv, ObjectivPoint from .schemas import NoitaSubmissionOut @@ -17,6 +29,162 @@ def get_my_objectives(request: HttpRequest): return Objectiv.objects.order_by("-count").filter(user=request.user) +@router.get("results", response=ResultsOut) +def get_results(request: HttpRequest): + """ + Get the user's score based on their objectives. + + Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective + Uses Django ORM annotate for efficient queryset computation. + """ + # Fetch points from ObjectivPoint using Subquery + user_objectives = Objectiv.objects.filter(user=request.user).annotate( + # Get points per objective from ObjectivPoint + points_per_objectiv=Subquery( + ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values( + "point" + )[:1], + output_field=IntegerField(), + ), + # Get max_count from ObjectivPoint + max_objectives=Subquery( + ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values( + "max_count" + )[:1], + output_field=IntegerField(), + ), + ) + + # Handle negative max_count (means unlimited) + user_objectives = user_objectives.annotate( + effective_max=Case( + When(max_objectives__lt=0, then=F("count")), + default=F("max_objectives"), + output_field=IntegerField(), + ) + ) + + # Calculate capped count and total points + user_objectives = user_objectives.annotate( + capped_count=Case( + When(effective_max__lt=F("count"), then=F("effective_max")), + default=F("count"), + output_field=IntegerField(), + ), + total_points=F("points_per_objectiv") * F("capped_count"), + ) + + # Get total score + total_score_result = user_objectives.aggregate(Sum("total_points"))[ + "total_points__sum" + ] + total_score = total_score_result or 0 + + # Build response with all objectives + objectives_with_points = [ + { + "objectiv_id": obj.objectiv_id, + "count": obj.count, + "points_per_objectiv": obj.points_per_objectiv or 0, + "total_points": obj.total_points or 0, + } + for obj in user_objectives.order_by("-total_points") + ] + + return { + "total_score": total_score, + "objectives": objectives_with_points, + } + + +@router.get("leaderboard", response=LeaderboardOut) +def get_leaderboard(request: HttpRequest): + """ + Get the global leaderboard for all users ranked by total score. + + Uses Window functions to rank users by their total score in descending order. + """ + from django.contrib.auth import get_user_model + + User = get_user_model() + + # Get all objectives with calculated points + all_objectives = ( + Objectiv.objects.annotate( + # Fetch points from ObjectivPoint using Subquery + points_per_objectiv=Subquery( + ObjectivPoint.objects.filter( + objectiv_id=OuterRef("objectiv_id") + ).values("point")[:1], + output_field=IntegerField(), + ), + # Get max_count from ObjectivPoint + max_objectives=Subquery( + ObjectivPoint.objects.filter( + objectiv_id=OuterRef("objectiv_id") + ).values("max_count")[:1], + output_field=IntegerField(), + ), + ) + .annotate( + # Handle negative max_count (means unlimited) + effective_max=Case( + When(max_objectives__lt=0, then=F("count")), + default=F("max_objectives"), + output_field=IntegerField(), + ) + ) + .annotate( + # Calculate capped count and total points + capped_count=Case( + When(effective_max__lt=F("count"), then=F("effective_max")), + default=F("count"), + output_field=IntegerField(), + ), + total_points=F("points_per_objectiv") * F("capped_count"), + ) + ) + + # Get user totals using subquery + user_totals = ( + all_objectives.values("user") + .annotate(total_score=Sum("total_points")) + .values("user", "total_score") + ) + + # Get unique users and their scores, then apply ranking + leaderboard = ( + # User.objects.filter(objectiv_set__isnull=False) + User.objects.filter(objectiv__isnull=False) + .distinct() + .annotate( + total_score=Subquery( + user_totals.filter(user=OuterRef("id")).values("total_score")[:1], + output_field=IntegerField(), + ) + ) + .annotate( + rank=Window( + expression=Rank(), + order_by=F("total_score").desc(), + ) + ) + .values("rank", "username", "total_score") + .order_by("rank") + ) + + return { + "leaderboard": [ + { + "rank": entry["rank"], + "username": entry["username"], + "total_score": entry["total_score"] or 0, + } + for entry in leaderboard + ] + } + + @router.post("submit", response=NoitaSubmissionOut) def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): """ @@ -60,6 +228,13 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): # Save the file submission.file.save(file.name, ContentFile(file.read()), save=True) + try: + parse_objectives_and_store(submission) + submission.processed = True + submission.save(update_fields=["processed"]) + except Exception: + pass + return { "id": str(submission.id), "user_id": submission.user_id, diff --git a/polylan_submitter/noita/schemas.py b/polylan_submitter/noita/schemas.py index f5a274f..f00bb6c 100644 --- a/polylan_submitter/noita/schemas.py +++ b/polylan_submitter/noita/schemas.py @@ -19,3 +19,25 @@ class NoitaSubmissionOut(Schema): content_type: str created_at: datetime processed: bool + + +class ObjectivResultOut(Schema): + objectiv_id: str + count: int + points_per_objectiv: int + total_points: int + + +class ResultsOut(Schema): + total_score: int + objectives: list[ObjectivResultOut] + + +class LeaderboardEntryOut(Schema): + rank: int + username: str + total_score: int + + +class LeaderboardOut(Schema): + leaderboard: list[LeaderboardEntryOut] diff --git a/polylan_submitter/src/Noita.vue b/polylan_submitter/src/Noita.vue index 4f498c5..6e5142a 100644 --- a/polylan_submitter/src/Noita.vue +++ b/polylan_submitter/src/Noita.vue @@ -8,9 +8,9 @@ interface Objective { const userInfo = ref({ username: "Player", - rank: 42, - score: 15420, - runsSubmitted: 8, + rank: null as number | null, + score: 0, + runsSubmitted: 0, }); const uploadedFiles = ref([]); @@ -18,6 +18,7 @@ const isUploading = ref(false); const isDragover = ref(false); const objectives = ref([]); const isLoadingObjectives = ref(false); +const isLoadingLeaderboard = ref(false); const handleFileUpload = (event: Event) => { const input = event.target as HTMLInputElement; @@ -74,6 +75,13 @@ const submitRun = async () => { uploadedFiles.value = []; alert("Run submitted successfully!"); + + // Refresh objectives, score, and rank after successful submission + await Promise.all([ + fetchObjectives(), + fetchUserResults(), + fetchLeaderboard(), + ]); } catch (error) { console.error("Error submitting run:", error); alert("Error submitting run. Please try again."); @@ -100,8 +108,67 @@ const fetchObjectives = async () => { } }; +const fetchUserResults = async () => { + try { + const response = await fetch("/api/noita/results"); + if (!response.ok) throw new Error("Failed to fetch results"); + + const results = await response.json(); + userInfo.value.score = results.total_score; + userInfo.value.runsSubmitted = results.objectives.length; + } catch (error) { + console.error("Error fetching results:", error); + } +}; + +const fetchLeaderboard = async () => { + isLoadingLeaderboard.value = true; + try { + const response = await fetch("/api/noita/leaderboard"); + if (!response.ok) throw new Error("Failed to fetch leaderboard"); + + const leaderboard = await response.json(); + + // Find current user's rank + const userRank = leaderboard.leaderboard.find( + (entry: any) => entry.username === userInfo.value.username + ); + + if (userRank) { + userInfo.value.rank = userRank.rank; + userInfo.value.score = userRank.total_score; + } + } catch (error) { + console.error("Error fetching leaderboard:", error); + } finally { + isLoadingLeaderboard.value = false; + } +}; + +const loadUserData = async () => { + // Get user info first + try { + const response = await fetch("/api/user"); + if (response.ok) { + const user = await response.json(); + if (user.is_authenticated) { + userInfo.value.username = user.username; + } + } + } catch (error) { + console.error("Error fetching user info:", error); + } + + // Fetch objectives, results, and leaderboard + await Promise.all([ + fetchObjectives(), + fetchUserResults(), + fetchLeaderboard(), + ]); +}; + onMounted(() => { - fetchObjectives(); + loadUserData(); }); @@ -139,10 +206,17 @@ onMounted(() => {
-
+
+ +
+ +

Current Rank

-

#{{ userInfo.rank }}

+

+ #{{ userInfo.rank }} +

+

No rank yet

@@ -151,7 +225,7 @@ onMounted(() => {
-

Runs Submitted

+

Objectives Completed

{{ userInfo.runsSubmitted }}