feat(noita): death counter + ranking display

This commit is contained in:
Loïc Gremaud 2026-05-15 02:15:00 +02:00
parent 7cfab20826
commit 3e04f8312a
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
5 changed files with 120 additions and 139 deletions

View File

@ -16,7 +16,7 @@ from ninja.files import UploadedFile
from noita.schemas import ResultsOut, LeaderboardOut from noita.schemas import ResultsOut, LeaderboardOut
from noita.services.objectives import parse_objectives_and_store from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
from .schemas import NoitaSubmissionOut from .schemas import NoitaSubmissionOut
@ -103,8 +103,12 @@ def get_results(request: HttpRequest):
) )
total_score += points total_score += points
# Count deaths for the user
deaths_count = DeathCounter.objects.filter(user=request.user).count()
data = { data = {
"total_score": total_score, "total_score": total_score,
"deaths_count": deaths_count,
"objectives": objectives_with_points, "objectives": objectives_with_points,
} }
@ -187,12 +191,14 @@ def get_leaderboard(request: HttpRequest):
.distinct() .distinct()
.count() .count()
) )
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
users_with_scores.append( users_with_scores.append(
{ {
"user_id": user_id, "user_id": user_id,
"username": user.username, "username": user.username,
"total_score": total_score, "total_score": total_score,
"objectives_count": objectives_count, "objectives_count": objectives_count,
"deaths_count": deaths_count,
} }
) )
@ -204,6 +210,7 @@ def get_leaderboard(request: HttpRequest):
"username": entry["username"], "username": entry["username"],
"total_score": entry["total_score"], "total_score": entry["total_score"],
"objectives_count": entry["objectives_count"], "objectives_count": entry["objectives_count"],
"deaths_count": entry["deaths_count"],
} }
for idx, entry in enumerate(users_with_scores) for idx, entry in enumerate(users_with_scores)
] ]

View File

@ -23,6 +23,7 @@ class ObjectivResultOut(Schema):
class ResultsOut(Schema): class ResultsOut(Schema):
total_score: int total_score: int
deaths_count: int
objectives: list[ObjectivResultOut] objectives: list[ObjectivResultOut]
@ -31,6 +32,7 @@ class LeaderboardEntryOut(Schema):
username: str username: str
total_score: int total_score: int
objectives_count: int objectives_count: int
deaths_count: int
class LeaderboardOut(Schema): class LeaderboardOut(Schema):

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import RankBadge from "@/components/RankBadge.vue";
import { import {
createColumnHelper, createColumnHelper,
useVueTable, useVueTable,
@ -24,6 +25,7 @@ const userInfo = ref({
rank: null as number | null, rank: null as number | null,
score: 0, score: 0,
runsSubmitted: 0, runsSubmitted: 0,
deathsCount: 0,
isStaff: false, isStaff: false,
}); });
@ -31,10 +33,8 @@ const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false); const isUploading = ref(false);
const isDragover = ref(false); const isDragover = ref(false);
const objectives = ref<Objective[]>([]); const objectives = ref<Objective[]>([]);
const objectiveSearchQuery = ref("");
const isLoadingLeaderboard = ref(false); const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]); const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const columnHelper = createColumnHelper<Objective>(); const columnHelper = createColumnHelper<Objective>();
const sorting = ref<SortingState>([]); const sorting = ref<SortingState>([]);
@ -111,7 +111,6 @@ const table = computed(() =>
return String(itemData).toLowerCase().includes(searchValue); return String(itemData).toLowerCase().includes(searchValue);
}, },
}, },
globalFilterFn: "fuzzy",
}) })
); );
@ -197,6 +196,7 @@ const fetchUserResults = async () => {
const results = await response.json(); const results = await response.json();
userInfo.value.score = results.total_score; userInfo.value.score = results.total_score;
userInfo.value.deathsCount = results.deaths_count;
userInfo.value.runsSubmitted = results.objectives.length; userInfo.value.runsSubmitted = results.objectives.length;
objectives.value = results.objectives; objectives.value = results.objectives;
} catch (error) { } catch (error) {
@ -221,6 +221,7 @@ const fetchLeaderboard = async () => {
if (userRank) { if (userRank) {
userInfo.value.rank = userRank.rank; userInfo.value.rank = userRank.rank;
userInfo.value.score = userRank.total_score; userInfo.value.score = userRank.total_score;
userInfo.value.deathsCount = userRank.deaths_count;
} }
} catch (error) { } catch (error) {
console.error("Error fetching leaderboard:", error); console.error("Error fetching leaderboard:", error);
@ -297,18 +298,18 @@ onMounted(() => {
<!-- Main Content --> <!-- Main Content -->
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: User Ranking --> <!-- Left Column: User Ranking -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg sticky top-8"> <div class="card bg-base-100 shadow-lg sticky top-8">
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl"> <div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
<i class="mdi mdi-trophy text-4xl"></i> <i class="mdi mdi-trophy text-5xl"></i>
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2> <h2 class="text-3xl font-bold mt-3">Your Ranking</h2>
</div> </div>
<div class="card-body"> <div class="card-body p-8">
<div class="text-center mb-6"> <div class="text-center mb-8">
<p class="text-sm text-base-content/70">Player</p> <p class="text-base text-base-content/70">Player</p>
<p class="text-3xl font-bold">{{ userInfo.username }}</p> <p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@ -317,30 +318,63 @@ onMounted(() => {
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-6">
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p> <p class="text-base text-base-content/70 mb-3">Current Rank</p>
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary"> <RankBadge :rank="userInfo.rank" />
#{{ 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">
<p class="text-sm text-base-content/70 mb-1">Total Score</p> <p class="text-base text-base-content/70 mb-2">Total Score</p>
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p> <p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p> <p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p> <p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
</div>
<div class="text-center">
<p class="text-base text-base-content/70 mb-2">Deaths</p>
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
</div> </div>
</div> </div>
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6"> <!-- Leaderboard Table -->
<i class="mdi mdi-trophy mr-1"></i> <div class="mt-6">
View Full Leaderboard <h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
</button> <div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th class="text-right">Score</th>
<th class="text-right">Objectives</th>
<th class="text-right">Deaths</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">
<RankBadge :rank="entry.rank" />
</td>
<td class="text-sm">
{{ entry.username }}
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
You
</span>
</td>
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
</td>
<td class="text-right text-sm">{{ entry.objectives_count }}</td>
<td class="text-right text-sm text-error">{{ entry.deaths_count }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3"> <button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
<i class="mdi mdi-cache-clear mr-1"></i> <i class="mdi mdi-cache-clear mr-1"></i>
@ -351,7 +385,7 @@ onMounted(() => {
</div> </div>
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-2"> <div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-6"> <h2 class="card-title text-2xl mb-6">
@ -428,18 +462,12 @@ onMounted(() => {
<div v-if="objectives.length > 0" class="space-y-4"> <div v-if="objectives.length > 0" class="space-y-4">
<!-- Search Input --> <!-- Search Input -->
<input <input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
:value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" (e) => {
@input=" const target = e.target as HTMLInputElement;
(e) => { table.getColumn('objectiv_id')?.setFilterValue(target.value);
const target = e.target as HTMLInputElement; }
table.getColumn('objectiv_id')?.setFilterValue(target.value); " type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
}
"
type="text"
placeholder="Search objectives..."
class="input input-bordered w-full"
/>
<!-- Results Summary --> <!-- Results Summary -->
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
@ -451,15 +479,10 @@ onMounted(() => {
<table class="table table-zebra w-full"> <table class="table table-zebra w-full">
<thead> <thead>
<tr> <tr>
<th <th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
v-for="header in table.getHeaderGroups()[0]?.headers" 'cursor-pointer hover:bg-base-300',
:key="header.id" header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
:class="[ ]" @click="header.column.toggleSorting()">
'cursor-pointer hover:bg-base-300',
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
]"
@click="header.column.toggleSorting()"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span v-if="header.column.columnDef.id === 'objectiv_id'"> <span v-if="header.column.columnDef.id === 'objectiv_id'">
{{ header.isPlaceholder ? null : header.column.columnDef.header }} {{ header.isPlaceholder ? null : header.column.columnDef.header }}
@ -467,36 +490,26 @@ onMounted(() => {
<span v-else class="ml-auto"> <span v-else class="ml-auto">
{{ header.isPlaceholder ? null : header.column.columnDef.header }} {{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span> </span>
<i <i v-if="header.column.getIsSorted()" :class="[
v-if="header.column.getIsSorted()" 'mdi ml-2',
:class="[ header.column.getIsSorted() === 'desc'
'mdi ml-2', ? 'mdi-arrow-down'
header.column.getIsSorted() === 'desc' : 'mdi-arrow-up',
? 'mdi-arrow-down' ]"></i>
: 'mdi-arrow-up',
]"
></i>
</div> </div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in filteredObjectives" :key="row.id"> <tr v-for="row in filteredObjectives" :key="row.id">
<td <td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
v-for="cell in row.getVisibleCells()" cell.column.id === 'objectiv_id'
:key="cell.id" ? 'font-medium'
:class="[ : 'text-right',
cell.column.id === 'objectiv_id' cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
? 'font-medium' ]">
: 'text-right',
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
]"
>
<template v-if="cell.column.id === 'objectiv_id'"> <template v-if="cell.column.id === 'objectiv_id'">
<a <a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
:href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`"
target="_blank"
>
{{ row.original.objectiv_id }} {{ row.original.objectiv_id }}
<i class="mdi mdi-open-in-new"></i> <i class="mdi mdi-open-in-new"></i>
</a> </a>
@ -526,63 +539,5 @@ onMounted(() => {
</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>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
rank: number | null;
}>();
</script>
<template>
<div v-if="rank !== null" class="flex justify-center">
<span v-if="rank === 1" class="badge badge-warning badge-lg">
🏆 #{{ rank }}
</span>
<span v-else-if="rank === 2" class="badge badge-lg">
🥈 #{{ rank }}
</span>
<span v-else-if="rank === 3" class="badge badge-lg">
🥉 #{{ rank }}
</span>
<span v-else class="badge badge-lg">
#{{ rank }}
</span>
</div>
<div v-else class="text-2xl text-base-content/50">
No rank yet
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import RankBadge from "./RankBadge.vue";
interface User { interface User {
id: number; id: number;
@ -264,16 +265,7 @@ onMounted(() => {
<tbody> <tbody>
<tr v-for="(user, index) in getOverallRanking()" :key="user.username"> <tr v-for="(user, index) in getOverallRanking()" :key="user.username">
<td class="font-bold"> <td class="font-bold">
<span v-if="index === 0" class="badge badge-warning badge-lg"> <RankBadge :rank="index + 1" />
🏆 #1
</span>
<span v-else-if="index === 1" class="badge badge-lg">
🥈 #2
</span>
<span v-else-if="index === 2" class="badge badge-lg">
🥉 #3
</span>
<span v-else>#{{ index + 1 }}</span>
</td> </td>
<td class="font-medium">{{ user.username }}</td> <td class="font-medium">{{ user.username }}</td>
<td class="text-right">{{ user.puzzlesSolved }}</td> <td class="text-right">{{ user.puzzlesSolved }}</td>