rework noita objectives + deathcounter

This commit is contained in:
Loïc Gremaud 2026-05-15 01:50:34 +02:00
parent 754b0b0803
commit 7cfab20826
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
14 changed files with 481 additions and 167 deletions

View File

@ -1,5 +1,7 @@
from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
from noita.services.objectives import parse_objectives_from_logfile
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
@admin.register(LogfileSubmission)
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
("Processing", {"fields": ("processed",)}),
)
actions = ["validate_submission"]
def validate_submission(self, request, queryset):
for logfile in queryset:
parse_objectives_from_logfile(logfile)
self.message_user(request, f"{queryset.count()} submissions validated.")
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count")
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",)
readonly_fields = ("user", "first_seen_at")
def get_user_objectiv_count(self, obj):
return Objectiv.objects.filter(
objectiv_id=obj.objectiv_id, user=obj.user
).count()
get_user_objectiv_count.short_description = "Count"
@admin.register(ObjectivPoint)
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}),
)
@admin.register(DeathCounter)
class DeathCounterAdmin(admin.ModelAdmin):
list_display = ("user_id", "seed", "seen_at")
list_filter = ("user_id", "seed")
search_fields = ("user_id", "seed")
fieldsets = (
("User Information", {"fields": ("user_id",)}),
("Scoring", {"fields": ("seed",)}),
)

View File

@ -5,18 +5,15 @@ from django.db.models import (
F,
Case,
When,
Sum,
Count,
IntegerField,
Subquery,
OuterRef,
Window,
)
from django.db.models.functions import Rank
from ninja import Router, File
from ninja.files import UploadedFile
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut
from noita.schemas import ResultsOut, LeaderboardOut
from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint
@ -26,11 +23,6 @@ from .schemas import NoitaSubmissionOut
router = Router()
@router.get("objectives", response=list[ObjectivOut])
def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user)
@router.get("results", response=ResultsOut)
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
@ -44,8 +36,15 @@ def get_results(request: HttpRequest):
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
Uses Django ORM annotate for efficient queryset computation.
"""
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
.values("objectiv_id")
.annotate(count=Count("id"))
)
# Fetch points from ObjectivPoint using Subquery
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
user_objectives = user_objectives.annotate(
# Get points per objective from ObjectivPoint
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
@ -81,22 +80,28 @@ def get_results(request: HttpRequest):
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
# Annotate seed + first-seen-at
user_objectives = user_objectives.annotate(
seed=F("seed"),
first_seen_at=F("first_seen_at"),
)
# 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")
]
# Build response with all objectives and compute total score
objectives_with_points = []
total_score = 0
for obj in user_objectives.order_by("-total_points"):
points = obj["total_points"] or 0
objectives_with_points.append(
{
"objectiv_id": obj["objectiv_id"],
"count": obj["count"],
"points_per_objectiv": obj["points_per_objectiv"] or 0,
"total_points": points,
"first_seen_at": obj["first_seen_at"],
"seed": obj["seed"],
}
)
total_score += points
data = {
"total_score": total_score,
@ -124,9 +129,11 @@ def get_leaderboard(request: HttpRequest):
User = get_user_model()
# Get all objectives with calculated points
# Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = (
Objectiv.objects.annotate(
Objectiv.objects.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(
@ -161,46 +168,47 @@ def get_leaderboard(request: HttpRequest):
)
)
# Get user totals using subquery
user_totals = (
all_objectives.values("user")
.annotate(total_score=Sum("total_points"))
.values("user", "total_score")
)
# Build user totals by iterating through objectives
user_totals_dict = {}
for obj in all_objectives:
user_id = obj["user"]
points = obj["total_points"] or 0
if user_id not in user_totals_dict:
user_totals_dict[user_id] = 0
user_totals_dict[user_id] += points
# Get unique users and their scores, then apply ranking
leaderboard = (
User.objects.filter(objectiv__isnull=False)
.distinct()
.annotate(
total_score=Subquery(
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
output_field=IntegerField(),
)
users_with_scores = []
for user_id, total_score in user_totals_dict.items():
user = User.objects.get(id=user_id)
objectives_count = (
Objectiv.objects.filter(user_id=user_id)
.values("objectiv_id")
.distinct()
.count()
)
.annotate(objectives_count=Count("objectiv", distinct=True))
.annotate(
rank=Window(
expression=Rank(),
order_by=F("total_score").desc(),
)
)
.values("rank", "username", "total_score", "objectives_count")
.order_by("rank")
)
data = {
"leaderboard": [
users_with_scores.append(
{
"rank": entry["rank"],
"username": entry["username"],
"total_score": entry["total_score"] or 0,
"objectives_count": entry["objectives_count"],
"user_id": user_id,
"username": user.username,
"total_score": total_score,
"objectives_count": objectives_count,
}
for entry in leaderboard
]
}
)
# Sort by score and add rank
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
leaderboard = [
{
"rank": idx + 1,
"username": entry["username"],
"total_score": entry["total_score"],
"objectives_count": entry["objectives_count"],
}
for idx, entry in enumerate(users_with_scores)
]
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300)
return data

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-05-11 08:20
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0006_objectivpoint"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="count",
),
migrations.AddField(
model_name="objectiv",
name="first_seen_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddConstraint(
model_name="objectiv",
constraint=models.UniqueConstraint(
fields=("objectiv_id", "user", "first_seen_at"),
name="unique_objectiv_per_user_timestamp",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-11 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="seed",
field=models.CharField(default="", max_length=32),
preserve_default=False,
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 08:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0008_objectiv_seed"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="submission",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="noita.logfilesubmission",
),
preserve_default=False,
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-05-14 23:36
from django.db import migrations, models
def fw_func(apps, _schema_editor):
Objectiv = apps.get_model("noita", "Objectiv")
Objectiv.objects.filter(objectiv_id="DEATH").delete()
class Migration(migrations.Migration):
dependencies = [
("noita", "0009_objectiv_submission"),
]
operations = [
migrations.CreateModel(
name="DeathCounter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("seed", models.CharField(max_length=32)),
("seen_at", models.DateTimeField()),
],
),
migrations.RunPython(fw_func, migrations.RunPython.noop),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-05-14 23:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0010_deathcounter"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="deathcounter",
name="user",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
preserve_default=False,
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-05-14 23:45
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0011_deathcounter_user"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="deathcounter",
constraint=models.UniqueConstraint(
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
),
),
]

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
import uuid
@ -49,7 +50,17 @@ class Objectiv(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1)
first_seen_at = models.DateTimeField(default=timezone.now)
seed = models.CharField(max_length=32)
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["objectiv_id", "user", "first_seen_at"],
name="unique_objectiv_per_user_timestamp",
)
]
class ObjectivPoint(models.Model):
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0)
class DeathCounter(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user_id", "seen_at"],
name="unique_death_per_seen_at",
)
]

View File

@ -1,14 +1,6 @@
from typing import Optional
from datetime import datetime
from ninja import Schema, ModelSchema
from noita.models import Objectiv
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
from ninja import Schema
class NoitaSubmissionOut(Schema):
@ -23,7 +15,8 @@ class NoitaSubmissionOut(Schema):
class ObjectivResultOut(Schema):
objectiv_id: str
count: int
first_seen_at: datetime
seed: str
points_per_objectiv: int
total_points: int

View File

@ -1,22 +1,21 @@
from noita.models import LogfileSubmission, Objectiv
from noita.models import LogfileSubmission, Objectiv, DeathCounter
from noita.services.decode import parse_log, resolve
from collections import Counter
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
def parse_objectives_from_logfile(
logfile: LogfileSubmission,
) -> list[tuple[str, str, str]]:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
ids = []
entries: list[tuple[str, str, str]] = []
for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"])
idx, seed = resolve(entry["hash"], entry["ts"])
if idx:
ids.append(idx)
if idx and seed:
entries.append((idx, str(seed), entry["ts"]))
return Counter(ids)
return entries
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
if not logfile.user:
return
counter = parse_objectives_from_logfile(logfile)
for idx, count in counter.items():
objectives = []
deaths = []
for idx, seed, ts in parse_objectives_from_logfile(logfile):
print(idx, seed, ts)
if idx in {"-", "DEBUG", "polylan-mod"}:
continue
obj, created = Objectiv.objects.get_or_create(
objectiv_id=idx,
user=logfile.user,
if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
continue
objectives.append(
Objectiv(
objectiv_id=idx,
user=logfile.user,
first_seen_at=ts,
seed=seed,
submission=logfile,
)
)
obj.count += count
obj.save(update_fields=["count"])
Objectiv.objects.bulk_create(
objectives,
update_conflicts=True,
update_fields=["seed", "submission"],
unique_fields=["objectiv_id", "user", "first_seen_at"],
)
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)

View File

@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0",
"dayjs": "^1.11.20",
"install": "^0.13.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.16",

View File

@ -17,6 +17,9 @@ importers:
'@vueuse/core':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
dayjs:
specifier: ^1.11.20
version: 1.11.20
install:
specifier: ^0.13.0
version: 0.13.0
@ -579,6 +582,9 @@ packages:
daisyui@5.3.10:
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -1287,6 +1293,8 @@ snapshots:
daisyui@5.3.10: {}
dayjs@1.11.20: {}
detect-libc@2.1.2: {}
enhanced-resolve@5.18.3:

View File

@ -1,11 +1,22 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import dayjs from "dayjs";
import {
createColumnHelper,
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/vue-table";
interface Objective {
objectiv_id: string;
count: number;
points_per_objectiv?: number;
total_points?: number;
first_seen_at: string;
seed: string;
points_per_objectiv: number;
total_points: number;
}
const userInfo = ref({
@ -21,61 +32,91 @@ const isUploading = ref(false);
const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const objectiveSearchQuery = ref("");
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
const objectiveSortDesc = ref(false);
const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const filteredObjectives = computed(() => {
const query = objectiveSearchQuery.value.toLowerCase();
const columnHelper = createColumnHelper<Objective>();
const sorting = ref<SortingState>([]);
const columnFilters = ref<ColumnFiltersState>([]);
let filtered = objectives.value;
if (query) {
filtered = filtered.filter(
(obj) =>
obj.objectiv_id.toLowerCase().includes(query) ||
obj.count.toString().includes(query)
);
}
const sorted = [...filtered].sort((a, b) => {
let aValue: number | string;
let bValue: number | string;
switch (objectiveSortBy.value) {
case "points_per":
aValue = a.points_per_objectiv || 0;
bValue = b.points_per_objectiv || 0;
break;
case "total_points":
aValue = a.total_points || 0;
bValue = b.total_points || 0;
break;
case "id":
default:
aValue = a.objectiv_id.toLowerCase();
bValue = b.objectiv_id.toLowerCase();
}
if (aValue < bValue) return objectiveSortDesc.value ? 1 : -1;
if (aValue > bValue) return objectiveSortDesc.value ? -1 : 1;
return 0;
});
return sorted;
});
const toggleObjectiveSort = (column: "id" | "points_per" | "total_points") => {
if (objectiveSortBy.value === column) {
objectiveSortDesc.value = !objectiveSortDesc.value;
} else {
objectiveSortBy.value = column;
objectiveSortDesc.value = false;
}
const formatDate = (dateString: string) => {
const date = dayjs(dateString);
return date.format("MMM DD, YYYY HH:mm");
};
const getDateTooltip = (dateString: string) => {
const date = dayjs(dateString);
return date.format("dddd, MMMM D, YYYY [at] h:mm A");
};
const columns = [
columnHelper.accessor("objectiv_id", {
header: "Objective ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("total_points", {
header: "Total Points",
cell: (info) => info.getValue() || 0,
}),
columnHelper.accessor("first_seen_at", {
header: "First seen",
cell: (info) => formatDate(info.getValue()),
sortingFn: (rowA, rowB) => {
const dateA = dayjs(rowA.original.first_seen_at);
const dateB = dayjs(rowB.original.first_seen_at);
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
},
}),
columnHelper.accessor("seed", {
header: "Seed",
cell: (info) => info.getValue(),
}),
];
const table = computed(() =>
useVueTable({
get data() {
return objectives.value;
},
columns,
state: {
get sorting() {
return sorting.value;
},
get columnFilters() {
return columnFilters.value;
},
},
onSortingChange: (updater) => {
sorting.value =
typeof updater === "function" ? updater(sorting.value) : updater;
},
onColumnFiltersChange: (updater) => {
columnFilters.value =
typeof updater === "function" ? updater(columnFilters.value) : updater;
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
filterFns: {
fuzzy: (row, columnId, value) => {
const itemData = row.getValue(columnId);
const searchValue = value.toLowerCase();
if (columnId === "first_seen_at") {
const dateStr = itemData as string;
const formatted = dayjs(dateStr).format("MMM DD, YYYY HH:mm");
return formatted.toLowerCase().includes(searchValue);
}
return String(itemData).toLowerCase().includes(searchValue);
},
},
globalFilterFn: "fuzzy",
})
);
const filteredObjectives = computed(() => table.value.getRowModel().rows);
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
@ -387,8 +428,18 @@ onMounted(() => {
<div v-if="objectives.length > 0" class="space-y-4">
<!-- Search Input -->
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
class="input input-bordered w-full" />
<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"
/>
<!-- Results Summary -->
<div class="text-sm text-base-content/70">
@ -400,31 +451,64 @@ onMounted(() => {
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
Objective ID
<i v-if="objectiveSortBy === 'id'"
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
</th>
<th class="text-right cursor-pointer hover:bg-base-300"
@click="toggleObjectiveSort('total_points')">
Total Points
<i v-if="objectiveSortBy === 'total_points'"
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
<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()"
>
<div class="flex items-center justify-between">
<span v-if="header.column.columnDef.id === 'objectiv_id'">
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span>
<span v-else class="ml-auto">
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span>
<i
v-if="header.column.getIsSorted()"
:class="[
'mdi ml-2',
header.column.getIsSorted() === 'desc'
? 'mdi-arrow-down'
: 'mdi-arrow-up',
]"
></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
<td class="font-medium">
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
{{ obj.objectiv_id }}
<i class="mdi mdi-open-in-new"></i>
</a>
</td>
<td class="text-right font-bold text-success">
{{ obj.total_points || 0 }}
</td>
<td class="text-right">
<tr v-for="row in filteredObjectives" :key="row.id">
<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"
>
{{ row.original.objectiv_id }}
<i class="mdi mdi-open-in-new"></i>
</a>
</template>
<template v-else-if="cell.column.id === 'first_seen_at'">
<span :title="getDateTooltip(row.original.first_seen_at)">
{{ formatDate(row.original.first_seen_at) }}
</span>
</template>
<template v-else>
{{ cell.renderValue() }}
</template>
</td>
</tr>
</tbody>