noita objectives + leaderboard

This commit is contained in:
Loïc Gremaud 2026-05-10 02:01:10 +02:00
parent 69b6b46ee2
commit 52a6a4adb2
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
4 changed files with 288 additions and 10 deletions

View File

@ -4,7 +4,14 @@ from .models import LogfileSubmission, Objectiv, ObjectivPoint
@admin.register(LogfileSubmission) @admin.register(LogfileSubmission)
class LogfileSubmissionAdmin(admin.ModelAdmin): 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") list_filter = ("content_type", "processed", "created_at")
search_fields = ("id", "user__username") search_fields = ("id", "user__username")
readonly_fields = ("id", "created_at", "updated_at") readonly_fields = ("id", "created_at", "updated_at")

View File

@ -1,11 +1,23 @@
from django.http import HttpRequest from django.http import HttpRequest
from django.core.files.base import ContentFile 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 import Router, File
from ninja.files import UploadedFile 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 from .schemas import NoitaSubmissionOut
@ -17,6 +29,162 @@ def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user) 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) @router.post("submit", response=NoitaSubmissionOut)
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): 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 # Save the file
submission.file.save(file.name, ContentFile(file.read()), save=True) 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 { return {
"id": str(submission.id), "id": str(submission.id),
"user_id": submission.user_id, "user_id": submission.user_id,

View File

@ -19,3 +19,25 @@ class NoitaSubmissionOut(Schema):
content_type: str content_type: str
created_at: datetime created_at: datetime
processed: bool 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]

View File

@ -8,9 +8,9 @@ interface Objective {
const userInfo = ref({ const userInfo = ref({
username: "Player", username: "Player",
rank: 42, rank: null as number | null,
score: 15420, score: 0,
runsSubmitted: 8, runsSubmitted: 0,
}); });
const uploadedFiles = ref<File[]>([]); const uploadedFiles = ref<File[]>([]);
@ -18,6 +18,7 @@ const isUploading = ref(false);
const isDragover = ref(false); const isDragover = ref(false);
const objectives = ref<Objective[]>([]); const objectives = ref<Objective[]>([]);
const isLoadingObjectives = ref(false); const isLoadingObjectives = ref(false);
const isLoadingLeaderboard = ref(false);
const handleFileUpload = (event: Event) => { const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@ -74,6 +75,13 @@ const submitRun = async () => {
uploadedFiles.value = []; uploadedFiles.value = [];
alert("Run submitted successfully!"); alert("Run submitted successfully!");
// Refresh objectives, score, and rank after successful submission
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
} catch (error) { } catch (error) {
console.error("Error submitting run:", error); console.error("Error submitting run:", error);
alert("Error submitting run. Please try again."); 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(() => { onMounted(() => {
fetchObjectives(); loadUserData();
}); });
</script> </script>
@ -139,10 +206,17 @@ onMounted(() => {
<div class="divider"></div> <div class="divider"></div>
<div class="space-y-4"> <div v-if="isLoadingLeaderboard" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="space-y-4">
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p> <p class="text-sm text-base-content/70 mb-1">Current Rank</p>
<p class="text-4xl font-bold text-primary">#{{ userInfo.rank }}</p> <p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
#{{ userInfo.rank }}
</p>
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
</div> </div>
<div class="text-center"> <div class="text-center">
@ -151,7 +225,7 @@ onMounted(() => {
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Runs Submitted</p> <p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p> <p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
</div> </div>
</div> </div>