rework noita objectives + deathcounter
This commit is contained in:
@@ -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",)}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+31
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user