noita leaderboard + view + management commands

This commit is contained in:
Loïc Gremaud 2026-05-10 02:11:30 +02:00
parent 52a6a4adb2
commit fa53d74295
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
6 changed files with 147 additions and 7 deletions

View File

@ -5,6 +5,7 @@ from django.db.models import (
Case, Case,
When, When,
Sum, Sum,
Count,
IntegerField, IntegerField,
Subquery, Subquery,
OuterRef, OuterRef,
@ -154,7 +155,6 @@ def get_leaderboard(request: HttpRequest):
# Get unique users and their scores, then apply ranking # Get unique users and their scores, then apply ranking
leaderboard = ( leaderboard = (
# User.objects.filter(objectiv_set__isnull=False)
User.objects.filter(objectiv__isnull=False) User.objects.filter(objectiv__isnull=False)
.distinct() .distinct()
.annotate( .annotate(
@ -163,13 +163,14 @@ def get_leaderboard(request: HttpRequest):
output_field=IntegerField(), output_field=IntegerField(),
) )
) )
.annotate(objectives_count=Count("objectiv", distinct=True))
.annotate( .annotate(
rank=Window( rank=Window(
expression=Rank(), expression=Rank(),
order_by=F("total_score").desc(), order_by=F("total_score").desc(),
) )
) )
.values("rank", "username", "total_score") .values("rank", "username", "total_score", "objectives_count")
.order_by("rank") .order_by("rank")
) )
@ -179,13 +180,14 @@ def get_leaderboard(request: HttpRequest):
"rank": entry["rank"], "rank": entry["rank"],
"username": entry["username"], "username": entry["username"],
"total_score": entry["total_score"] or 0, "total_score": entry["total_score"] or 0,
"objectives_count": entry["objectives_count"],
} }
for entry in leaderboard for entry in leaderboard
] ]
} }
@router.post("submit", response=NoitaSubmissionOut) @router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
""" """
Submit a Noita run file (log file, screenshot, or video). Submit a Noita run file (log file, screenshot, or video).
@ -200,6 +202,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
# Validate file type # Validate file type
allowed_types = [ allowed_types = [
"text/plain", "text/plain",
"text/x-log",
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",

View File

@ -0,0 +1,62 @@
from django.core.management.base import BaseCommand
from noita.models import ObjectivPoint
from noita.services.decode import POINTS
class Command(BaseCommand):
help = "Load ObjectivPoints from the POINTS dictionary in services.decode"
def add_arguments(self, parser):
parser.add_argument(
"--clear",
action="store_true",
help="Clear all existing ObjectivPoints before loading",
)
def handle(self, *args, **options):
if options["clear"]:
ObjectivPoint.objects.all().delete()
self.stdout.write(self.style.SUCCESS("Cleared existing ObjectivPoints"))
created_count = 0
updated_count = 0
for objectiv_id, point_value in POINTS.items():
# Skip special entries
if objectiv_id in {"-", "DEBUG"}:
continue
# Get display string from objectiv_id (convert to title case)
display_string = objectiv_id.replace("_", " ").title()
# Create or update ObjectivPoint
obj, created = ObjectivPoint.objects.get_or_create(
objectiv_id=objectiv_id,
defaults={
"display_string": display_string,
"point": point_value,
"max_count": 1, # Default max_count is 1
},
)
if created:
created_count += 1
self.stdout.write(f"✓ Created: {objectiv_id} - {point_value} points")
else:
# Update if points changed
if obj.point != point_value or obj.display_string != display_string:
obj.point = point_value
obj.display_string = display_string
obj.save()
updated_count += 1
self.stdout.write(
self.style.WARNING(
f"↻ Updated: {objectiv_id} - {point_value} points"
)
)
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
self.stdout.write(
self.style.SUCCESS(f"Total ObjectivPoints: {ObjectivPoint.objects.count()}")
)

View File

@ -37,6 +37,7 @@ class LeaderboardEntryOut(Schema):
rank: int rank: int
username: str username: str
total_score: int total_score: int
objectives_count: int
class LeaderboardOut(Schema): class LeaderboardOut(Schema):

View File

@ -19,6 +19,8 @@ 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 isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const handleFileUpload = (event: Event) => { const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@ -127,10 +129,11 @@ const fetchLeaderboard = async () => {
const response = await fetch("/api/noita/leaderboard"); const response = await fetch("/api/noita/leaderboard");
if (!response.ok) throw new Error("Failed to fetch leaderboard"); if (!response.ok) throw new Error("Failed to fetch leaderboard");
const leaderboard = await response.json(); const data = await response.json();
leaderboard.value = data.leaderboard;
// Find current user's rank // Find current user's rank
const userRank = leaderboard.leaderboard.find( const userRank = leaderboard.value.find(
(entry: any) => entry.username === userInfo.value.username (entry: any) => entry.username === userInfo.value.username
); );
@ -230,8 +233,8 @@ onMounted(() => {
</div> </div>
</div> </div>
<button class="btn btn-outline btn-sm w-full mt-6"> <button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
<i class="mdi mdi-refresh mr-1"></i> <i class="mdi mdi-trophy mr-1"></i>
View Full Leaderboard View Full Leaderboard
</button> </button>
</div> </div>
@ -341,5 +344,76 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- Leaderboard Modal -->
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
Global Leaderboard
</h3>
<button
@click="isLeaderboardModalOpen = false"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Leaderboard Table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Rank</th>
<th>Username</th>
<th class="text-right">Objectives</th>
<th class="text-right">Score</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in leaderboard"
:key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }"
>
<td class="font-bold">
<span
v-if="entry.rank === 1"
class="badge badge-warning badge-lg"
>
🏆 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 2" class="badge badge-lg">
🥈 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 3" class="badge badge-lg">
🥉 #{{ entry.rank }}
</span>
<span v-else>#{{ entry.rank }}</span>
</td>
<td class="font-medium">
{{ entry.username }}
<span
v-if="entry.username === userInfo.username"
class="badge badge-primary badge-sm ml-2"
>
You
</span>
</td>
<td class="text-right">{{ entry.objectives_count }}</td>
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="leaderboard.length === 0" class="text-center py-8">
<p class="text-base-content/70">No entries yet</p>
</div>
</div>
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
</div>
</div> </div>
</template> </template>