noita objectives + leaderboard
This commit is contained in:
parent
69b6b46ee2
commit
52a6a4adb2
@ -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")
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user