feat(noita): death counter + ranking display
This commit is contained in:
parent
7cfab20826
commit
3e04f8312a
@ -16,7 +16,7 @@ from ninja.files import UploadedFile
|
||||
from noita.schemas import ResultsOut, LeaderboardOut
|
||||
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
|
||||
|
||||
|
||||
@ -103,8 +103,12 @@ def get_results(request: HttpRequest):
|
||||
)
|
||||
total_score += points
|
||||
|
||||
# Count deaths for the user
|
||||
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||
|
||||
data = {
|
||||
"total_score": total_score,
|
||||
"deaths_count": deaths_count,
|
||||
"objectives": objectives_with_points,
|
||||
}
|
||||
|
||||
@ -187,12 +191,14 @@ def get_leaderboard(request: HttpRequest):
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||
users_with_scores.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"total_score": total_score,
|
||||
"objectives_count": objectives_count,
|
||||
"deaths_count": deaths_count,
|
||||
}
|
||||
)
|
||||
|
||||
@ -204,6 +210,7 @@ def get_leaderboard(request: HttpRequest):
|
||||
"username": entry["username"],
|
||||
"total_score": entry["total_score"],
|
||||
"objectives_count": entry["objectives_count"],
|
||||
"deaths_count": entry["deaths_count"],
|
||||
}
|
||||
for idx, entry in enumerate(users_with_scores)
|
||||
]
|
||||
|
||||
@ -23,6 +23,7 @@ class ObjectivResultOut(Schema):
|
||||
|
||||
class ResultsOut(Schema):
|
||||
total_score: int
|
||||
deaths_count: int
|
||||
objectives: list[ObjectivResultOut]
|
||||
|
||||
|
||||
@ -31,6 +32,7 @@ class LeaderboardEntryOut(Schema):
|
||||
username: str
|
||||
total_score: int
|
||||
objectives_count: int
|
||||
deaths_count: int
|
||||
|
||||
|
||||
class LeaderboardOut(Schema):
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import RankBadge from "@/components/RankBadge.vue";
|
||||
import {
|
||||
createColumnHelper,
|
||||
useVueTable,
|
||||
@ -24,6 +25,7 @@ const userInfo = ref({
|
||||
rank: null as number | null,
|
||||
score: 0,
|
||||
runsSubmitted: 0,
|
||||
deathsCount: 0,
|
||||
isStaff: false,
|
||||
});
|
||||
|
||||
@ -31,10 +33,8 @@ const uploadedFiles = ref<File[]>([]);
|
||||
const isUploading = ref(false);
|
||||
const isDragover = ref(false);
|
||||
const objectives = ref<Objective[]>([]);
|
||||
const objectiveSearchQuery = ref("");
|
||||
const isLoadingLeaderboard = ref(false);
|
||||
const leaderboard = ref<any[]>([]);
|
||||
const isLeaderboardModalOpen = ref(false);
|
||||
|
||||
const columnHelper = createColumnHelper<Objective>();
|
||||
const sorting = ref<SortingState>([]);
|
||||
@ -111,7 +111,6 @@ const table = computed(() =>
|
||||
return String(itemData).toLowerCase().includes(searchValue);
|
||||
},
|
||||
},
|
||||
globalFilterFn: "fuzzy",
|
||||
})
|
||||
);
|
||||
|
||||
@ -197,6 +196,7 @@ const fetchUserResults = async () => {
|
||||
|
||||
const results = await response.json();
|
||||
userInfo.value.score = results.total_score;
|
||||
userInfo.value.deathsCount = results.deaths_count;
|
||||
userInfo.value.runsSubmitted = results.objectives.length;
|
||||
objectives.value = results.objectives;
|
||||
} catch (error) {
|
||||
@ -221,6 +221,7 @@ const fetchLeaderboard = async () => {
|
||||
if (userRank) {
|
||||
userInfo.value.rank = userRank.rank;
|
||||
userInfo.value.score = userRank.total_score;
|
||||
userInfo.value.deathsCount = userRank.deaths_count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching leaderboard:", error);
|
||||
@ -297,18 +298,18 @@ onMounted(() => {
|
||||
|
||||
<!-- Main Content -->
|
||||
<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 -->
|
||||
<div class="lg:col-span-1">
|
||||
<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">
|
||||
<i class="mdi mdi-trophy text-4xl"></i>
|
||||
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
||||
<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-5xl"></i>
|
||||
<h2 class="text-3xl font-bold mt-3">Your Ranking</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-sm text-base-content/70">Player</p>
|
||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
||||
<div class="card-body p-8">
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-base text-base-content/70">Player</p>
|
||||
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
@ -317,30 +318,63 @@ onMounted(() => {
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70 mb-1">Current 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>
|
||||
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
||||
<RankBadge :rank="userInfo.rank" />
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
||||
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
||||
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<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-base text-base-content/70 mb-2">Objectives Completed</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>
|
||||
|
||||
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
|
||||
<i class="mdi mdi-trophy mr-1"></i>
|
||||
View Full Leaderboard
|
||||
</button>
|
||||
<!-- Leaderboard Table -->
|
||||
<div class="mt-6">
|
||||
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
|
||||
<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">
|
||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||
@ -351,7 +385,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 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-body">
|
||||
<h2 class="card-title text-2xl mb-6">
|
||||
@ -428,18 +462,12 @@ onMounted(() => {
|
||||
|
||||
<div v-if="objectives.length > 0" class="space-y-4">
|
||||
<!-- Search Input -->
|
||||
<input
|
||||
:value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''"
|
||||
@input="
|
||||
<input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
|
||||
(e) => {
|
||||
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 -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
@ -451,15 +479,10 @@ onMounted(() => {
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="header in table.getHeaderGroups()[0]?.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
|
||||
'cursor-pointer hover:bg-base-300',
|
||||
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||
]"
|
||||
@click="header.column.toggleSorting()"
|
||||
>
|
||||
]" @click="header.column.toggleSorting()">
|
||||
<div class="flex items-center justify-between">
|
||||
<span v-if="header.column.columnDef.id === 'objectiv_id'">
|
||||
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||
@ -467,36 +490,26 @@ onMounted(() => {
|
||||
<span v-else class="ml-auto">
|
||||
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||
</span>
|
||||
<i
|
||||
v-if="header.column.getIsSorted()"
|
||||
:class="[
|
||||
<i v-if="header.column.getIsSorted()" :class="[
|
||||
'mdi ml-2',
|
||||
header.column.getIsSorted() === 'desc'
|
||||
? 'mdi-arrow-down'
|
||||
: 'mdi-arrow-up',
|
||||
]"
|
||||
></i>
|
||||
]"></i>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
|
||||
cell.column.id === 'objectiv_id'
|
||||
? 'font-medium'
|
||||
: 'text-right',
|
||||
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<template v-if="cell.column.id === 'objectiv_id'">
|
||||
<a
|
||||
:href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`"
|
||||
target="_blank"
|
||||
>
|
||||
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
|
||||
{{ row.original.objectiv_id }}
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</a>
|
||||
@ -526,63 +539,5 @@ onMounted(() => {
|
||||
</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>
|
||||
</template>
|
||||
|
||||
25
polylan_submitter/src/components/RankBadge.vue
Normal file
25
polylan_submitter/src/components/RankBadge.vue
Normal 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>
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import RankBadge from "./RankBadge.vue";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
@ -264,16 +265,7 @@ onMounted(() => {
|
||||
<tbody>
|
||||
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
|
||||
<td class="font-bold">
|
||||
<span v-if="index === 0" class="badge badge-warning badge-lg">
|
||||
🏆 #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>
|
||||
<RankBadge :rank="index + 1" />
|
||||
</td>
|
||||
<td class="font-medium">{{ user.username }}</td>
|
||||
<td class="text-right">{{ user.puzzlesSolved }}</td>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user