From 7cfab208265ecf7d22f208cbed75ca3265d7c04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Fri, 15 May 2026 01:50:34 +0200 Subject: [PATCH] rework noita objectives + deathcounter --- polylan_submitter/noita/admin.py | 35 ++- polylan_submitter/noita/api.py | 130 +++++----- ...v_count_objectiv_first_seen_at_and_more.py | 31 +++ .../noita/migrations/0008_objectiv_seed.py | 18 ++ .../migrations/0009_objectiv_submission.py | 23 ++ .../noita/migrations/0010_deathcounter.py | 34 +++ .../migrations/0011_deathcounter_user.py | 25 ++ ...2_deathcounter_unique_death_per_seen_at.py | 20 ++ polylan_submitter/noita/models.py | 27 +- polylan_submitter/noita/schemas.py | 13 +- .../noita/services/objectives.py | 51 ++-- polylan_submitter/package.json | 1 + polylan_submitter/pnpm-lock.yaml | 8 + polylan_submitter/src/Noita.vue | 232 ++++++++++++------ 14 files changed, 481 insertions(+), 167 deletions(-) create mode 100644 polylan_submitter/noita/migrations/0007_remove_objectiv_count_objectiv_first_seen_at_and_more.py create mode 100644 polylan_submitter/noita/migrations/0008_objectiv_seed.py create mode 100644 polylan_submitter/noita/migrations/0009_objectiv_submission.py create mode 100644 polylan_submitter/noita/migrations/0010_deathcounter.py create mode 100644 polylan_submitter/noita/migrations/0011_deathcounter_user.py create mode 100644 polylan_submitter/noita/migrations/0012_deathcounter_unique_death_per_seen_at.py diff --git a/polylan_submitter/noita/admin.py b/polylan_submitter/noita/admin.py index 6884a55..e3b646d 100644 --- a/polylan_submitter/noita/admin.py +++ b/polylan_submitter/noita/admin.py @@ -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",)}), + ) diff --git a/polylan_submitter/noita/api.py b/polylan_submitter/noita/api.py index 839693c..ccfac8f 100644 --- a/polylan_submitter/noita/api.py +++ b/polylan_submitter/noita/api.py @@ -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 diff --git a/polylan_submitter/noita/migrations/0007_remove_objectiv_count_objectiv_first_seen_at_and_more.py b/polylan_submitter/noita/migrations/0007_remove_objectiv_count_objectiv_first_seen_at_and_more.py new file mode 100644 index 0000000..7420f95 --- /dev/null +++ b/polylan_submitter/noita/migrations/0007_remove_objectiv_count_objectiv_first_seen_at_and_more.py @@ -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", + ), + ), + ] diff --git a/polylan_submitter/noita/migrations/0008_objectiv_seed.py b/polylan_submitter/noita/migrations/0008_objectiv_seed.py new file mode 100644 index 0000000..a40d71d --- /dev/null +++ b/polylan_submitter/noita/migrations/0008_objectiv_seed.py @@ -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, + ), + ] diff --git a/polylan_submitter/noita/migrations/0009_objectiv_submission.py b/polylan_submitter/noita/migrations/0009_objectiv_submission.py new file mode 100644 index 0000000..35a7a03 --- /dev/null +++ b/polylan_submitter/noita/migrations/0009_objectiv_submission.py @@ -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, + ), + ] diff --git a/polylan_submitter/noita/migrations/0010_deathcounter.py b/polylan_submitter/noita/migrations/0010_deathcounter.py new file mode 100644 index 0000000..b69a6ab --- /dev/null +++ b/polylan_submitter/noita/migrations/0010_deathcounter.py @@ -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), + ] diff --git a/polylan_submitter/noita/migrations/0011_deathcounter_user.py b/polylan_submitter/noita/migrations/0011_deathcounter_user.py new file mode 100644 index 0000000..b4f5b2e --- /dev/null +++ b/polylan_submitter/noita/migrations/0011_deathcounter_user.py @@ -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, + ), + ] diff --git a/polylan_submitter/noita/migrations/0012_deathcounter_unique_death_per_seen_at.py b/polylan_submitter/noita/migrations/0012_deathcounter_unique_death_per_seen_at.py new file mode 100644 index 0000000..0ff30e6 --- /dev/null +++ b/polylan_submitter/noita/migrations/0012_deathcounter_unique_death_per_seen_at.py @@ -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" + ), + ), + ] diff --git a/polylan_submitter/noita/models.py b/polylan_submitter/noita/models.py index c7210f6..5ec82f3 100644 --- a/polylan_submitter/noita/models.py +++ b/polylan_submitter/noita/models.py @@ -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", + ) + ] diff --git a/polylan_submitter/noita/schemas.py b/polylan_submitter/noita/schemas.py index 1b1411c..2c363b9 100644 --- a/polylan_submitter/noita/schemas.py +++ b/polylan_submitter/noita/schemas.py @@ -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 diff --git a/polylan_submitter/noita/services/objectives.py b/polylan_submitter/noita/services/objectives.py index 129a7da..a5f0984 100644 --- a/polylan_submitter/noita/services/objectives.py +++ b/polylan_submitter/noita/services/objectives.py @@ -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) diff --git a/polylan_submitter/package.json b/polylan_submitter/package.json index 9a61d02..ae56e66 100644 --- a/polylan_submitter/package.json +++ b/polylan_submitter/package.json @@ -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", diff --git a/polylan_submitter/pnpm-lock.yaml b/polylan_submitter/pnpm-lock.yaml index f3e5175..41ab180 100644 --- a/polylan_submitter/pnpm-lock.yaml +++ b/polylan_submitter/pnpm-lock.yaml @@ -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: diff --git a/polylan_submitter/src/Noita.vue b/polylan_submitter/src/Noita.vue index a0ebdae..6452fa8 100644 --- a/polylan_submitter/src/Noita.vue +++ b/polylan_submitter/src/Noita.vue @@ -1,11 +1,22 @@