rework noita objectives + deathcounter
This commit is contained in:
parent
754b0b0803
commit
7cfab20826
@ -1,5 +1,7 @@
|
|||||||
from django.contrib import admin
|
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)
|
@admin.register(LogfileSubmission)
|
||||||
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
("Processing", {"fields": ("processed",)}),
|
("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)
|
@admin.register(Objectiv)
|
||||||
class ObjectivAdmin(admin.ModelAdmin):
|
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")
|
list_filter = ("objectiv_id", "user")
|
||||||
search_fields = ("objectiv_id", "user__username")
|
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)
|
@admin.register(ObjectivPoint)
|
||||||
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
|||||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
("Scoring", {"fields": ("max_count", "point")}),
|
("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,
|
F,
|
||||||
Case,
|
Case,
|
||||||
When,
|
When,
|
||||||
Sum,
|
|
||||||
Count,
|
Count,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
Subquery,
|
Subquery,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Window,
|
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Rank
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
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 noita.services.objectives import parse_objectives_and_store
|
||||||
|
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
||||||
@ -26,11 +23,6 @@ from .schemas import NoitaSubmissionOut
|
|||||||
router = Router()
|
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)
|
@router.get("results", response=ResultsOut)
|
||||||
def get_results(request: HttpRequest):
|
def get_results(request: HttpRequest):
|
||||||
cache_key = f"api:noita:results:{request.user.id}"
|
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
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
Uses Django ORM annotate for efficient queryset computation.
|
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
|
# 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
|
# Get points per objective from ObjectivPoint
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
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"),
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total score
|
# Annotate seed + first-seen-at
|
||||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
user_objectives = user_objectives.annotate(
|
||||||
"total_points__sum"
|
seed=F("seed"),
|
||||||
]
|
first_seen_at=F("first_seen_at"),
|
||||||
total_score = total_score_result or 0
|
)
|
||||||
|
|
||||||
# Build response with all objectives
|
# Build response with all objectives and compute total score
|
||||||
objectives_with_points = [
|
objectives_with_points = []
|
||||||
{
|
total_score = 0
|
||||||
"objectiv_id": obj.objectiv_id,
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
"count": obj.count,
|
points = obj["total_points"] or 0
|
||||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
objectives_with_points.append(
|
||||||
"total_points": obj.total_points or 0,
|
{
|
||||||
}
|
"objectiv_id": obj["objectiv_id"],
|
||||||
for obj in user_objectives.order_by("-total_points")
|
"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 = {
|
data = {
|
||||||
"total_score": total_score,
|
"total_score": total_score,
|
||||||
@ -124,9 +129,11 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
# Get all objectives with calculated points
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
all_objectives = (
|
all_objectives = (
|
||||||
Objectiv.objects.annotate(
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(
|
ObjectivPoint.objects.filter(
|
||||||
@ -161,46 +168,47 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user totals using subquery
|
# Build user totals by iterating through objectives
|
||||||
user_totals = (
|
user_totals_dict = {}
|
||||||
all_objectives.values("user")
|
for obj in all_objectives:
|
||||||
.annotate(total_score=Sum("total_points"))
|
user_id = obj["user"]
|
||||||
.values("user", "total_score")
|
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
|
# Get unique users and their scores, then apply ranking
|
||||||
leaderboard = (
|
users_with_scores = []
|
||||||
User.objects.filter(objectiv__isnull=False)
|
for user_id, total_score in user_totals_dict.items():
|
||||||
.distinct()
|
user = User.objects.get(id=user_id)
|
||||||
.annotate(
|
objectives_count = (
|
||||||
total_score=Subquery(
|
Objectiv.objects.filter(user_id=user_id)
|
||||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
.values("objectiv_id")
|
||||||
output_field=IntegerField(),
|
.distinct()
|
||||||
)
|
.count()
|
||||||
)
|
)
|
||||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
users_with_scores.append(
|
||||||
.annotate(
|
|
||||||
rank=Window(
|
|
||||||
expression=Rank(),
|
|
||||||
order_by=F("total_score").desc(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values("rank", "username", "total_score", "objectives_count")
|
|
||||||
.order_by("rank")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"leaderboard": [
|
|
||||||
{
|
{
|
||||||
"rank": entry["rank"],
|
"user_id": user_id,
|
||||||
"username": entry["username"],
|
"username": user.username,
|
||||||
"total_score": entry["total_score"] or 0,
|
"total_score": total_score,
|
||||||
"objectives_count": entry["objectives_count"],
|
"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)
|
cache.set("api:noita:leaderboard", data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal file
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal file
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal 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),
|
||||||
|
]
|
||||||
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal file
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
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):
|
class ObjectivPoint(models.Model):
|
||||||
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
|||||||
display_string = models.CharField(max_length=255)
|
display_string = models.CharField(max_length=255)
|
||||||
max_count = models.IntegerField(default=1)
|
max_count = models.IntegerField(default=1)
|
||||||
point = models.IntegerField(default=0)
|
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 typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ninja import Schema, ModelSchema
|
from ninja import Schema
|
||||||
|
|
||||||
from noita.models import Objectiv
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectivOut(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Objectiv
|
|
||||||
fields = ["objectiv_id", "count"]
|
|
||||||
|
|
||||||
|
|
||||||
class NoitaSubmissionOut(Schema):
|
class NoitaSubmissionOut(Schema):
|
||||||
@ -23,7 +15,8 @@ class NoitaSubmissionOut(Schema):
|
|||||||
|
|
||||||
class ObjectivResultOut(Schema):
|
class ObjectivResultOut(Schema):
|
||||||
objectiv_id: str
|
objectiv_id: str
|
||||||
count: int
|
first_seen_at: datetime
|
||||||
|
seed: str
|
||||||
points_per_objectiv: int
|
points_per_objectiv: int
|
||||||
total_points: 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 noita.services.decode import parse_log, resolve
|
||||||
|
|
||||||
|
|
||||||
from collections import Counter
|
def parse_objectives_from_logfile(
|
||||||
|
logfile: LogfileSubmission,
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
|
||||||
"""Parse a log file, and output a count for each ID."""
|
"""Parse a log file, and output a count for each ID."""
|
||||||
file_data = logfile.file.read().decode()
|
file_data = logfile.file.read().decode()
|
||||||
|
|
||||||
ids = []
|
entries: list[tuple[str, str, str]] = []
|
||||||
for entry in parse_log(file_data):
|
for entry in parse_log(file_data):
|
||||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||||
|
|
||||||
if idx:
|
if idx and seed:
|
||||||
ids.append(idx)
|
entries.append((idx, str(seed), entry["ts"]))
|
||||||
|
|
||||||
return Counter(ids)
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||||
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
|||||||
if not logfile.user:
|
if not logfile.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
counter = parse_objectives_from_logfile(logfile)
|
objectives = []
|
||||||
|
deaths = []
|
||||||
for idx, count in counter.items():
|
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||||
|
print(idx, seed, ts)
|
||||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
obj, created = Objectiv.objects.get_or_create(
|
if idx == "DEATH":
|
||||||
objectiv_id=idx,
|
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||||
user=logfile.user,
|
continue
|
||||||
|
|
||||||
|
objectives.append(
|
||||||
|
Objectiv(
|
||||||
|
objectiv_id=idx,
|
||||||
|
user=logfile.user,
|
||||||
|
first_seen_at=ts,
|
||||||
|
seed=seed,
|
||||||
|
submission=logfile,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.count += count
|
Objectiv.objects.bulk_create(
|
||||||
obj.save(update_fields=["count"])
|
objectives,
|
||||||
|
update_conflicts=True,
|
||||||
|
update_fields=["seed", "submission"],
|
||||||
|
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
|||||||
@ -17,6 +17,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.20
|
||||||
|
version: 1.11.20
|
||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
@ -579,6 +582,9 @@ packages:
|
|||||||
daisyui@5.3.10:
|
daisyui@5.3.10:
|
||||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
||||||
|
|
||||||
|
dayjs@1.11.20:
|
||||||
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1287,6 +1293,8 @@ snapshots:
|
|||||||
|
|
||||||
daisyui@5.3.10: {}
|
daisyui@5.3.10: {}
|
||||||
|
|
||||||
|
dayjs@1.11.20: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
<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 {
|
||||||
|
createColumnHelper,
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/vue-table";
|
||||||
|
|
||||||
interface Objective {
|
interface Objective {
|
||||||
objectiv_id: string;
|
objectiv_id: string;
|
||||||
count: number;
|
first_seen_at: string;
|
||||||
points_per_objectiv?: number;
|
seed: string;
|
||||||
total_points?: number;
|
points_per_objectiv: number;
|
||||||
|
total_points: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = ref({
|
const userInfo = ref({
|
||||||
@ -21,61 +32,91 @@ const isUploading = ref(false);
|
|||||||
const isDragover = ref(false);
|
const isDragover = ref(false);
|
||||||
const objectives = ref<Objective[]>([]);
|
const objectives = ref<Objective[]>([]);
|
||||||
const objectiveSearchQuery = ref("");
|
const objectiveSearchQuery = ref("");
|
||||||
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
|
|
||||||
const objectiveSortDesc = ref(false);
|
|
||||||
const isLoadingLeaderboard = ref(false);
|
const isLoadingLeaderboard = ref(false);
|
||||||
const leaderboard = ref<any[]>([]);
|
const leaderboard = ref<any[]>([]);
|
||||||
const isLeaderboardModalOpen = ref(false);
|
const isLeaderboardModalOpen = ref(false);
|
||||||
|
|
||||||
const filteredObjectives = computed(() => {
|
const columnHelper = createColumnHelper<Objective>();
|
||||||
const query = objectiveSearchQuery.value.toLowerCase();
|
const sorting = ref<SortingState>([]);
|
||||||
|
const columnFilters = ref<ColumnFiltersState>([]);
|
||||||
|
|
||||||
let filtered = objectives.value;
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = dayjs(dateString);
|
||||||
if (query) {
|
return date.format("MMM DD, YYYY HH:mm");
|
||||||
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 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 handleFileUpload = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
@ -387,8 +428,18 @@ 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 v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
|
<input
|
||||||
class="input input-bordered w-full" />
|
: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 -->
|
<!-- Results Summary -->
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
@ -400,31 +451,64 @@ onMounted(() => {
|
|||||||
<table class="table table-zebra w-full">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
|
<th
|
||||||
Objective ID
|
v-for="header in table.getHeaderGroups()[0]?.headers"
|
||||||
<i v-if="objectiveSortBy === 'id'"
|
:key="header.id"
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
:class="[
|
||||||
</th>
|
'cursor-pointer hover:bg-base-300',
|
||||||
<th class="text-right cursor-pointer hover:bg-base-300"
|
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||||
@click="toggleObjectiveSort('total_points')">
|
]"
|
||||||
Total Points
|
@click="header.column.toggleSorting()"
|
||||||
<i v-if="objectiveSortBy === 'total_points'"
|
>
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
|
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||||
<td class="font-medium">
|
<td
|
||||||
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
|
v-for="cell in row.getVisibleCells()"
|
||||||
{{ obj.objectiv_id }}
|
:key="cell.id"
|
||||||
<i class="mdi mdi-open-in-new"></i>
|
:class="[
|
||||||
</a>
|
cell.column.id === 'objectiv_id'
|
||||||
</td>
|
? 'font-medium'
|
||||||
<td class="text-right font-bold text-success">
|
: 'text-right',
|
||||||
{{ obj.total_points || 0 }}
|
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
|
||||||
</td>
|
]"
|
||||||
<td class="text-right">
|
>
|
||||||
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user