Compare commits

...

10 Commits

131 changed files with 2439 additions and 114 deletions

View File

@ -1,12 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">General Results</h2>
<div class="flex flex-wrap gap-4 mt-4">TODO :)</div>
</div>
</div>
</div>
</template>

View File

@ -1,33 +0,0 @@
{
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-CNlI4PW6.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-HDjkw-xK.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,12 +1,12 @@
from django.http.request import HttpRequest
from ninja import Router
from collections import defaultdict
from django.http import HttpRequest
from ninja import Router
from accounts.models import CustomUser
from animations.schemas import RankingSchema
from submissions.models import PuzzleResponse, SteamCollectionItem
router = Router()
@ -26,7 +26,9 @@ def results(request: HttpRequest) -> dict:
ranking = {}
for puzzle_id, responses in responses_by_puzzleid.items():
ranking[puzzle_id] = sorted(responses, key=lambda x: x.rank_points)
ranking[puzzle_id] = sorted(
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
)
return {
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),

View File

@ -14,8 +14,8 @@ class PuzzleResponseRankingOut(ModelSchema):
"updated_at",
]
points: int
rank_points: int
points: int | None = None
rank_points: int | None = None
puzzle_user_rank: int
user_response_rank: int
@ -30,8 +30,13 @@ class PuzzleResponseRankingOut(ModelSchema):
return obj.submission.user.id
class UserDisplayOut(Schema):
id: int
username: str
class RankingSchema(Schema):
users: list[UserInfoOut]
users: list[UserDisplayOut]
puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]

View File

@ -7,7 +7,7 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View File

@ -0,0 +1,43 @@
from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
@admin.register(LogfileSubmission)
class LogfileSubmissionAdmin(admin.ModelAdmin):
list_display = (
"id",
"user",
"content_type",
"file_size",
"created_at",
"processed",
)
list_filter = ("content_type", "processed", "created_at")
search_fields = ("id", "user__username")
readonly_fields = ("id", "created_at", "updated_at")
fieldsets = (
("Identification", {"fields": ("id",)}),
("File Information", {"fields": ("file", "content_type", "file_size")}),
("User", {"fields": ("user",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}),
("Processing", {"fields": ("processed",)}),
)
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",)
@admin.register(ObjectivPoint)
class ObjectivPointAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "display_string", "max_count", "point")
list_filter = ("objectiv_id",)
search_fields = ("objectiv_id", "display_string")
fieldsets = (
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}),
)

View File

@ -0,0 +1,252 @@
from django.http import HttpRequest
from django.core.files.base import ContentFile
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.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint
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):
"""
Get the user's score based on their objectives.
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
Uses Django ORM annotate for efficient queryset computation.
"""
# Fetch points from ObjectivPoint using Subquery
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
# Get points per objective from ObjectivPoint
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
"point"
)[:1],
output_field=IntegerField(),
),
# Get max_count from ObjectivPoint
max_objectives=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
"max_count"
)[:1],
output_field=IntegerField(),
),
)
# Handle negative max_count (means unlimited)
user_objectives = user_objectives.annotate(
effective_max=Case(
When(max_objectives__lt=0, then=F("count")),
default=F("max_objectives"),
output_field=IntegerField(),
)
)
# Calculate capped count and total points
user_objectives = user_objectives.annotate(
capped_count=Case(
When(effective_max__lt=F("count"), then=F("effective_max")),
default=F("count"),
output_field=IntegerField(),
),
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
# 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")
]
return {
"total_score": total_score,
"objectives": objectives_with_points,
}
@router.get("leaderboard", response=LeaderboardOut)
def get_leaderboard(request: HttpRequest):
"""
Get the global leaderboard for all users ranked by total score.
Uses Window functions to rank users by their total score in descending order.
"""
from django.contrib.auth import get_user_model
User = get_user_model()
# Get all objectives with calculated points
all_objectives = (
Objectiv.objects.annotate(
# Fetch points from ObjectivPoint using Subquery
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(
objectiv_id=OuterRef("objectiv_id")
).values("point")[:1],
output_field=IntegerField(),
),
# Get max_count from ObjectivPoint
max_objectives=Subquery(
ObjectivPoint.objects.filter(
objectiv_id=OuterRef("objectiv_id")
).values("max_count")[:1],
output_field=IntegerField(),
),
)
.annotate(
# Handle negative max_count (means unlimited)
effective_max=Case(
When(max_objectives__lt=0, then=F("count")),
default=F("max_objectives"),
output_field=IntegerField(),
)
)
.annotate(
# Calculate capped count and total points
capped_count=Case(
When(effective_max__lt=F("count"), then=F("effective_max")),
default=F("count"),
output_field=IntegerField(),
),
total_points=F("points_per_objectiv") * F("capped_count"),
)
)
# Get user totals using subquery
user_totals = (
all_objectives.values("user")
.annotate(total_score=Sum("total_points"))
.values("user", "total_score")
)
# 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(),
)
)
.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")
)
return {
"leaderboard": [
{
"rank": entry["rank"],
"username": entry["username"],
"total_score": entry["total_score"] or 0,
"objectives_count": entry["objectives_count"],
}
for entry in leaderboard
]
}
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
"""
Submit a Noita run file (log file, screenshot, or video).
Accepts:
- Text files (.txt) for polylan_mod_log.txt
- Images (.png, .jpg, .gif)
- Videos (.mp4, .webm)
Max file size: 256 MB
"""
# Validate file type
allowed_types = [
"text/plain",
"text/x-log",
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"video/mp4",
"video/webm",
]
if file.content_type not in allowed_types:
return 400, {
"detail": f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_types)}"
}
# Validate file size (256MB limit)
if file.size > 256 * 1024 * 1024:
return 400, {"detail": "File too large (max 256MB)"}
try:
# Create submission
submission = LogfileSubmission.objects.create(
user=request.user if request.user.is_authenticated else None,
content_type=file.content_type,
file_size=file.size,
)
# Save the file
submission.file.save(file.name, ContentFile(file.read()), save=True)
try:
parse_objectives_and_store(submission)
submission.processed = True
submission.save(update_fields=["processed"])
except Exception:
pass
return {
"id": str(submission.id),
"user_id": submission.user_id,
"username": submission.user.username if submission.user else None,
"file_size": submission.file_size,
"content_type": submission.content_type,
"created_at": submission.created_at,
"processed": submission.processed,
}
except Exception as e:
return 500, {"detail": f"Error creating submission: {str(e)}"}

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NoitaConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "noita"

View File

@ -0,0 +1,62 @@
from django.core.management.base import BaseCommand
from noita.models import ObjectivPoint
from noita.services.decode import POINTS
class Command(BaseCommand):
help = "Load ObjectivPoints from the POINTS dictionary in services.decode"
def add_arguments(self, parser):
parser.add_argument(
"--clear",
action="store_true",
help="Clear all existing ObjectivPoints before loading",
)
def handle(self, *args, **options):
if options["clear"]:
ObjectivPoint.objects.all().delete()
self.stdout.write(self.style.SUCCESS("Cleared existing ObjectivPoints"))
created_count = 0
updated_count = 0
for objectiv_id, point_value in POINTS.items():
# Skip special entries
if objectiv_id in {"-", "DEBUG"}:
continue
# Get display string from objectiv_id (convert to title case)
display_string = objectiv_id.replace("_", " ").title()
# Create or update ObjectivPoint
obj, created = ObjectivPoint.objects.get_or_create(
objectiv_id=objectiv_id,
defaults={
"display_string": display_string,
"point": point_value,
"max_count": 1, # Default max_count is 1
},
)
if created:
created_count += 1
self.stdout.write(f"✓ Created: {objectiv_id} - {point_value} points")
else:
# Update if points changed
if obj.point != point_value or obj.display_string != display_string:
obj.point = point_value
obj.display_string = display_string
obj.save()
updated_count += 1
self.stdout.write(
self.style.WARNING(
f"↻ Updated: {objectiv_id} - {point_value} points"
)
)
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
self.stdout.write(
self.style.SUCCESS(f"Total ObjectivPoints: {ObjectivPoint.objects.count()}")
)

View File

@ -0,0 +1,61 @@
# Generated by Django 5.2.7 on 2026-05-09 22:53
import django.db.models.deletion
import noita.models
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Submission",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"content_type",
models.CharField(help_text="MIME type of the file", max_length=100),
),
(
"file_size",
models.PositiveIntegerField(help_text="File size in bytes"),
),
(
"file",
models.FileField(
help_text="Uploaded file (image/gif)",
upload_to=noita.models.submission_file_upload_path,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("processed", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
blank=True,
help_text="User who made the submission (null for anonymous)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="noita_submissions",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-09 22:55
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("noita", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name="Submission",
new_name="LogfileSubmission",
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2026-05-09 23:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0002_rename_submission_logfilesubmission"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Objectiv",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_at", models.DateTimeField()),
("objectiv_id", models.CharField(max_length=64)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-09 23:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0003_objectiv"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="count",
field=models.IntegerField(default=1),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-05-09 23:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("noita", "0004_objectiv_count"),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="updated_at",
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2026-05-09 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0005_remove_objectiv_updated_at"),
]
operations = [
migrations.CreateModel(
name="ObjectivPoint",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("objectiv_id", models.CharField(max_length=64, unique=True)),
("display_string", models.CharField(max_length=255)),
("max_count", models.IntegerField(default=1)),
("point", models.IntegerField(default=0)),
],
),
]

View File

@ -0,0 +1,59 @@
from django.contrib.auth import get_user_model
from django.db import models
import uuid
User = get_user_model()
def submission_file_upload_path(instance, filename):
"""Generate upload path for submission files"""
# Create path: submissions/{submission_id}/{uuid}_{filename}
ext = filename.split(".")[-1] if "." in filename else ""
new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
return f"noita-submissions/{instance.id}/{new_filename}"
class LogfileSubmission(models.Model):
"""Model representing a submission containing multiple puzzle responses"""
# Identification
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
file_size = models.PositiveIntegerField(help_text="File size in bytes")
file = models.FileField(
upload_to=submission_file_upload_path,
help_text="Uploaded file (image/gif)",
)
# User information (optional for anonymous submissions)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="User who made the submission (null for anonymous)",
related_name="noita_submissions",
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
processed = models.BooleanField(default=False)
class Objectiv(models.Model):
objectiv_id = models.CharField(max_length=64)
user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1)
class ObjectivPoint(models.Model):
objectiv_id = models.CharField(max_length=64, unique=True)
display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0)

View File

@ -0,0 +1,44 @@
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"]
class NoitaSubmissionOut(Schema):
id: str
user_id: Optional[int]
username: Optional[str]
file_size: int
content_type: str
created_at: datetime
processed: bool
class ObjectivResultOut(Schema):
objectiv_id: str
count: int
points_per_objectiv: int
total_points: int
class ResultsOut(Schema):
total_score: int
objectives: list[ObjectivResultOut]
class LeaderboardEntryOut(Schema):
rank: int
username: str
total_score: int
objectives_count: int
class LeaderboardOut(Schema):
leaderboard: list[LeaderboardEntryOut]

View File

@ -0,0 +1,291 @@
"""
Decode a polylan_mod_log.txt.
Hash format: sha1(seed|timestamp|id) timestamp is bound into the hash so
it cannot be altered without breaking the signature.
Seed-less entries (INIT, DEBUG, mod checks) use sha1(id) with no seed or
timestamp those are resolved via a static lookup.
Usage:
python decode_log.py [path/to/polylan_mod_log.txt]
Default path: ~/.local/share/Steam/steamapps/common/Noita/polylan_mod_log.txt
"""
import hashlib
import re
from functools import cache
from noita.services.spells import ALL_SPELLS, ALL_PERKS
SEED_POOL = [
# General good seeds
3154823,
3718311,
10064758,
123156801,
1024089369,
1026967166,
# Pacifist seeds
177795258,
520542929,
10600249,
25300740,
21875589,
24085389,
59775105,
44190726,
1039649471,
1072607354,
# Perk combo seeds
839747651,
839844768,
840909713,
839959129,
840016192,
840039886,
840398606,
840439045,
840457463,
840492754,
840507802,
840513742,
840542079,
840574169,
840610974,
840626894,
840872436,
840894605,
841221188,
]
DEATH_PENALTY = 1
# All scoreable events: base 1 pt for every spell and perk, overrides below.
POINTS = {
# ── Spells ───────────────────────────────────────────────────────────────
**{sid: 1 for sid in ALL_SPELLS},
"ADD_TRIGGER": 10,
"NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5,
"OMEGA": 10,
"HEALING_BOLT": 5,
# ── Perks ─────────────────────────────────────────────────────────────────
**{pid: 1 for pid in ALL_PERKS},
"EDIT_WANDS_EVERYWHERE": 10,
# ── Spell combos ─────────────────────────────────────────────────────────
"PING_PONG_DRILL": 10,
"HEAVY_SHOT_DISC": 10,
"TOUCH_OF_ANY": 5,
"TWO_ARC_MODIFIERS": 10,
"TWO_TRAIL_MODIFIERS": 10,
# ── Perk combos ──────────────────────────────────────────────────────────
"CRIMSON_ALCHEMIST": 15,
"GREEDY_GOBLIN_KING": 15,
"STORM_TOUCHED_ASCENDANT": 15,
"ARCHMAGE_OF_CONTROL": 15,
"HAUNTED_MAGE": 15,
"GLASS_CANNON_MESSIAH": 15,
"INFINITE_ENGINE": 15,
"PERFECT_ACCURACY_LOOP": 15,
"HOMING_DEATH_SWARM": 15,
"UNTOUCHABLE_FIELD": 15,
"ELECTRIC_SUSTAIN_LOOP": 15,
"IMMORTAL_LEECH_CORE": 15,
"PROJECTILE_OVERLOAD": 15,
"CLOSE_RANGE_DEATH_MACHINE": 15,
"CRITICAL_MASS": 15,
"HOLY_MOUNTAIN_ABUSER": 15,
"DEFLECTOR_MATRIX": 15,
"STORMBORNE_LEVITATOR": 15,
# ── Objectives ───────────────────────────────────────────────────────────
"HP_200": 5,
"HP_500": 5,
"HP_2000": 5,
"HP_5000": 5,
"GOLD_1000": 5,
"GOLD_10000": 5,
"GOLD_100000": 5,
"GOLD_1000000": 5,
**{f"ORB_{i}": 5 for i in range(34)},
"FIND_AMBROSIA": 10,
"WAND_MANA_500": 10,
"WAND_MANA_1000": 10,
"WAND_MANA_1500": 10,
"WAND_CAPACITY_10": 10,
"WAND_CAPACITY_20": 10,
"BOSS_KILL": 100,
"CRIMSON_ALCHEMIST-BOSS_KILL": 100,
"GREEDY_GOBLIN_KING-BOSS_KILL": 100,
"STORM_TOUCHED_ASCENDANT-BOSS_KILL": 100,
"ARCHMAGE_OF_CONTROL-BOSS_KILL": 100,
"HAUNTED_MAGE-BOSS_KILL": 100,
"GLASS_CANNON_MESSIAH-BOSS_KILL": 100,
"INFINITE_ENGINE-BOSS_KILL": 100,
"PERFECT_ACCURACY_LOOP-BOSS_KILL": 100,
"HOMING_DEATH_SWARM-BOSS_KILL": 100,
"UNTOUCHABLE_FIELD-BOSS_KILL": 100,
"ELECTRIC_SUSTAIN_LOOP-BOSS_KILL": 100,
"IMMORTAL_LEECH_CORE-BOSS_KILL": 100,
"PROJECTILE_OVERLOAD-BOSS_KILL": 100,
"CLOSE_RANGE_DEATH_MACHINE-BOSS_KILL": 100,
"CRITICAL_MASS-BOSS_KILL": 100,
"HOLY_MOUNTAIN_ABUSER-BOSS_KILL": 100,
"DEFLECTOR_MATRIX-BOSS_KILL": 100,
"STORMBORNE_LEVITATOR-BOSS_KILL": 100,
}
# Perk-combo IDs — used to award a per-combo boss-kill bonus.
PERK_COMBO_IDS = {
"CRIMSON_ALCHEMIST",
"GREEDY_GOBLIN_KING",
"STORM_TOUCHED_ASCENDANT",
"ARCHMAGE_OF_CONTROL",
"HAUNTED_MAGE",
"GLASS_CANNON_MESSIAH",
"INFINITE_ENGINE",
"PERFECT_ACCURACY_LOOP",
"HOMING_DEATH_SWARM",
"UNTOUCHABLE_FIELD",
"ELECTRIC_SUSTAIN_LOOP",
"IMMORTAL_LEECH_CORE",
"PROJECTILE_OVERLOAD",
"CLOSE_RANGE_DEATH_MACHINE",
"CRITICAL_MASS",
"HOLY_MOUNTAIN_ABUSER",
"DEFLECTOR_MATRIX",
"STORMBORNE_LEVITATOR",
}
BOSS_KILL_COMBO_BONUS = 100 # default bonus per active perk combo on boss kill
# IDs tried with the seed+timestamp scheme.
_ALL_IDS = list(POINTS.keys()) + ["DEATH"]
# Seed-less hashes (sha1(id) only, no seed, no timestamp).
_STATIC_LOOKUP = {
hashlib.sha1(k.encode()).hexdigest(): k
for k in ["-", "DEBUG", "polylan-mod", "cheatgui", "alchemy_recipes_display"]
}
def _sha1(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()
@cache
def resolve(
hash_val: str, ts: str, preferred_seed: int | None = None
) -> tuple[str | None, int | None]:
"""Return (name, seed) or (None, None). preferred_seed is tried first."""
if hash_val in _STATIC_LOOKUP:
return _STATIC_LOOKUP[hash_val], None
seeds = (
[preferred_seed] + [s for s in SEED_POOL if s != preferred_seed]
if preferred_seed is not None
else SEED_POOL
)
for seed in seeds:
prefix = f"{seed}|{ts}|"
for name in _ALL_IDS:
if _sha1(prefix + name) == hash_val:
return name, seed
return None, None
def parse_log(file) -> list:
entries = []
for il, line in enumerate(file.split("\n")):
m = re.match(r"\[(.+?)\] ([0-9a-f]{40})", line.rstrip())
if m:
entries.append({"ts": m.group(1), "hash": m.group(2)})
continue
print(f"Unable to parse line number {il:>4}: {line.strip()}??")
return entries
def decode(file) -> None:
entries = parse_log(file)
if not entries:
print("No entries found in log.")
return
seen = set()
total = 0
deaths = 0
have_init = False
have_debug = False
known_seed = None # cached once first seeded entry is resolved
current_run_perk_combos = set() # perk combos seen since the last "-" entry
for entry in entries:
name, seed = resolve(entry["hash"], entry["ts"], known_seed)
if name is None:
print(f" ???? (unresolved) {entry['hash']} ts: {entry['ts']}")
continue
if seed is not None and known_seed is None:
known_seed = seed
if name == "-":
have_init = True
current_run_perk_combos = set()
continue
if name == "DEBUG":
have_debug = True
continue
if name == "DEATH":
deaths += 1
continue
# skip mod-presence entries
if name not in POINTS:
continue
if name in seen:
continue
if name not in {"BOSS_KILL"}:
seen.add(name)
if name in PERK_COMBO_IDS:
current_run_perk_combos.add(name)
pts = POINTS.get(name, 0)
if pts:
print(f" +{pts:>4} {name:<40} first seen: {entry['ts']}")
total += pts
if name == "BOSS_KILL":
for combo_id in sorted(current_run_perk_combos):
bonus_key = f"{combo_id}-BOSS_KILL"
if bonus_key not in seen:
seen.add(bonus_key)
bonus_pts = POINTS.get(bonus_key, BOSS_KILL_COMBO_BONUS)
print(f" +{bonus_pts:>4} {bonus_key:<40} combo bonus")
total += bonus_pts
death_deduction = deaths * DEATH_PENALTY
if deaths:
print(
f" -{death_deduction:>4} ({deaths} death{'s' if deaths > 1 else ''} × {DEATH_PENALTY} pts)"
)
if have_init:
print(
f"\nTotal: {total - death_deduction} pts ({total} earned {death_deduction} penalty)"
)
if have_debug:
print("Note: DEBUG mode was active during at least one session.")

View File

@ -0,0 +1,40 @@
from noita.models import LogfileSubmission, Objectiv
from noita.services.decode import parse_log, resolve
from collections import Counter
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
ids = []
for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"])
if idx:
ids.append(idx)
return Counter(ids)
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
"""Parse a logfile and store output."""
if not logfile.user:
return
counter = parse_objectives_from_logfile(logfile)
for idx, count in counter.items():
if idx in {"-", "DEBUG"}:
continue
obj, created = Objectiv.objects.get_or_create(
objectiv_id=idx,
user=logfile.user,
)
obj.count += count
obj.save(update_fields=["count"])

View File

@ -0,0 +1,535 @@
ALL_SPELLS = [
"FUNKY_SPELL",
"ACIDSHOT",
"BLACK_HOLE",
"BLACK_HOLE_DEATH_TRIGGER",
"BOMB",
"BOMB_CART",
"BUBBLESHOT",
"BUBBLESHOT_TRIGGER",
"AIR_BULLET",
"CHAIN_BOLT",
"CHAINSAW",
"CURSED_ORB",
"ANTIHEAL",
"DEATH_CROSS",
"DEATH_CROSS_BIG",
"LASER_EMITTER_FOUR",
"POWERDIGGER",
"DIGGER",
"PIPE_BOMB",
"PIPE_BOMB_DEATH_TRIGGER",
"GRENADE_LARGE",
"DYNAMITE",
"CRUMBLING_EARTH",
"TENTACLE_PORTAL",
"SLOW_BULLET",
"SLOW_BULLET_TRIGGER",
"SLOW_BULLET_TIMER",
"EXPANDING_ORB",
"FIREBALL",
"GRENADE",
"GRENADE_TRIGGER",
"GRENADE_TIER_2",
"GRENADE_TIER_3",
"GRENADE_ANTI",
"FIREBOMB",
"FIREWORK",
"FLAMETHROWER",
"GLITTER_BOMB",
"LANCE",
"GLUE_SHOT",
"HEAL_BULLET",
"LANCE_HOLY",
"BOMB_HOLY",
"BOMB_HOLY_GIGA",
"HOOK",
"ICEBALL",
"LASER",
"LIGHTNING",
"THUNDERBALL",
"BALL_LIGHTNING",
"LUMINOUS_DRILL",
"LASER_LUMINOUS_DRILL",
"BULLET",
"BULLET_TRIGGER",
"BULLET_TIMER",
"HEAVY_BULLET",
"HEAVY_BULLET_TRIGGER",
"HEAVY_BULLET_TIMER",
"MAGIC_SHIELD",
"BIG_MAGIC_SHIELD",
"ROCKET",
"ROCKET_TIER_2",
"ROCKET_TIER_3",
"METEOR",
"MIST_BLOOD",
"MIST_ALCOHOL",
"MIST_SLIME",
"MIST_RADIOACTIVE",
"BUCKSHOT",
"MEGALASER",
"EXPLODING_DUCKS",
"FREEZING_GAZE",
"INFESTATION",
"NUKE",
"NUKE_GIGA",
"DARKFLAME",
"GLOWING_BOLT",
"LASER_EMITTER",
"LASER_EMITTER_CUTTER",
"POLLEN",
"SPORE_POD",
"PROPANE_TANK",
"RANDOM_PROJECTILE",
"SUMMON_ROCK",
"DISC_BULLET",
"DISC_BULLET_BIG",
"DISC_BULLET_BIGGER",
"SLIMEBALL",
"LIGHT_BULLET",
"LIGHT_BULLET_TRIGGER",
"LIGHT_BULLET_TRIGGER_2",
"LIGHT_BULLET_TIMER",
"RUBBER_BALL",
"ARROW",
"BOUNCY_ORB",
"BOUNCY_ORB_TIMER",
"SPIRAL_SHOT",
"SPITTER",
"SPITTER_TIMER",
"SPITTER_TIER_2",
"SPITTER_TIER_2_TIMER",
"SPITTER_TIER_3",
"SPITTER_TIER_3_TIMER",
"EXPLODING_DEER",
"SUMMON_EGG",
"TNTBOX",
"TNTBOX_BIG",
"FISH",
"SUMMON_HOLLOW_EGG",
"MISSILE",
"PEBBLE",
"TENTACLE",
"TENTACLE_TIMER",
"TELEPORT_PROJECTILE_CLOSER",
"TELEPORT_PROJECTILE_STATIC",
"SWAPPER_PROJECTILE",
"TELEPORT_PROJECTILE",
"TELEPORT_PROJECTILE_SHORT",
"MINE",
"MINE_DEATH_TRIGGER",
"WHITE_HOLE",
"WORM_SHOT",
"WALL_HORIZONTAL",
"WALL_VERTICAL",
"WALL_SQUARE",
"REGENERATION_FIELD",
"FREEZE_FIELD",
"LEVITATION_FIELD",
"TELEPORTATION_FIELD",
"BERSERK_FIELD",
"SHIELD_FIELD",
"ELECTROCUTION_FIELD",
"POLYMORPH_FIELD",
"CHAOS_POLYMORPH_FIELD",
"CLOUD_WATER",
"CLOUD_OIL",
"CLOUD_BLOOD",
"CLOUD_ACID",
"CLOUD_THUNDER",
"DESTRUCTION",
"BOMB_DETONATOR",
"PURPLE_EXPLOSION_FIELD",
"WORM_RAIN",
"METEOR_RAIN",
"SWARM_FLY",
"SWARM_FIREBUG",
"SWARM_WASP",
"DELAYED_SPELL",
"MASS_POLYMORPH",
"PROJECTILE_THUNDER_FIELD",
"PROJECTILE_GRAVITY_FIELD",
"PROJECTILE_TRANSMUTATION_FIELD",
"RANDOM_STATIC_PROJECTILE",
"WHITE_HOLE_BIG",
"BLACK_HOLE_BIG",
"BLACK_HOLE_GIGA",
"THUNDER_BLAST",
"FIRE_BLAST",
"EXPLOSION_LIGHT",
"POISON_BLAST",
"EXPLOSION",
"ALCOHOL_BLAST",
"WHITE_HOLE_GIGA",
"FRIEND_FLY",
"VACUUM_POWDER",
"VACUUM_LIQUID",
"VACUUM_ENTITIES",
"CLUSTERMOD",
"MANA_REDUCE",
"ARC_POISON",
"ARC_FIRE",
"ARC_GUNPOWDER",
"ARC_ELECTRIC",
"ROCKET_DOWNWARDS",
"ROCKET_OCTAGON",
"BOUNCE",
"BOUNCE_SPARK",
"BOUNCE_LASER",
"REMOVE_BOUNCE",
"BOUNCE_SMALL_EXPLOSION",
"BOUNCE_HOLE",
"BOUNCE_EXPLOSION",
"BOUNCE_LARPA",
"BOUNCE_LIGHTNING",
"BOUNCE_LASER_EMITTER",
"EXPLOSION_TINY",
"HITFX_CRITICAL_BLOOD",
"HITFX_CRITICAL_OIL",
"HITFX_CRITICAL_WATER",
"HITFX_BURNING_CRITICAL_HIT",
"CRITICAL_HIT",
"AREA_DAMAGE",
"BLOODLUST",
"DAMAGE",
"DAMAGE_RANDOM",
"DAMAGE_FOREVER",
"ZERO_DAMAGE",
"HEAVY_SHOT",
"LIGHT_SHOT",
"CRUMBLING_EARTH_PROJECTILE",
"FREEZE",
"ELECTRIC_CHARGE",
"HITFX_EXPLOSION_ALCOHOL",
"HITFX_EXPLOSION_ALCOHOL_GIGA",
"HITFX_EXPLOSION_SLIME",
"HITFX_EXPLOSION_SLIME_GIGA",
"HITFX_TOXIC_CHARM",
"COLOUR_RED",
"COLOUR_ORANGE",
"COLOUR_YELLOW",
"COLOUR_GREEN",
"COLOUR_BLUE",
"COLOUR_PURPLE",
"COLOUR_RAINBOW",
"COLOUR_INVIS",
"HEAVY_SPREAD",
"KNOCKBACK",
"LARPA_CHAOS_2",
"LARPA_CHAOS",
"LARPA_DOWNWARDS",
"LARPA_DEATH",
"LARPA_UPWARDS",
"LIFETIME_DOWN",
"LIFETIME",
"CHAIN_SHOT",
"NOLLA",
"LIGHT",
"NECROMANCY",
"ORBIT_DISCS",
"ORBIT_NUKES",
"ORBIT_FIREBALLS",
"ORBIT_LARPA",
"ORBIT_LASERS",
"LINE_ARC",
"HORIZONTAL_ARC",
"GRAVITY",
"GRAVITY_ANTI",
"FLY_UPWARDS",
"FLY_DOWNWARDS",
"ORBIT_SHOT",
"TRUE_ORBIT",
"SPIRALING_SHOT",
"PINGPONG_PATH",
"PHASING_ARC",
"CHAOTIC_ARC",
"SINEWAVE",
"HOMING_WAND",
"HOMING_CURSOR",
"HOMING_SHORT",
"AUTOAIM",
"HOMING",
"HOMING_ROTATE",
"HOMING_SHOOTER",
"ANTI_HOMING",
"HOMING_ACCELERATING",
"HOMING_AREA",
"FIREBALL_RAY_ENEMY",
"GRAVITY_FIELD_ENEMY",
"TENTACLE_RAY_ENEMY",
"LIGHTNING_RAY_ENEMY",
"HITFX_PETRIFY",
"PIERCING_SHOT",
"LASER_EMITTER_WIDER",
"QUANTUM_SPLIT",
"RANDOM_EXPLOSION",
"RANDOM_MODIFIER",
"RECOIL",
"RECOIL_DAMPER",
"RECHARGE",
"SPREAD_REDUCE",
"EXPLOSION_REMOVE",
"SLOW_BUT_STEADY",
"ENERGY_SHIELD_SHOT",
"SPEED",
"DECELERATING_SHOT",
"ACCELERATING_SHOT",
"FIZZLE",
"FLOATING_ARC",
"AVOIDING_ARC",
"CLIPPING_SHOT",
"UNSTABLE_GUNPOWDER",
"MATTER_EATER",
"EXPLOSIVE_PROJECTILE",
"FIREBALL_RAY_LINE",
"FIREBALL_RAY",
"LIGHTNING_RAY",
"TENTACLE_RAY",
"LASER_EMITTER_RAY",
"SPELLS_TO_POWER",
"ESSENCE_TO_POWER",
"ACID_TRAIL",
"FIRE_TRAIL",
"GUNPOWDER_TRAIL",
"OIL_TRAIL",
"POISON_TRAIL",
"RAINBOW_TRAIL",
"WATER_TRAIL",
"BURN_TRAIL",
"WATER_TO_POISON",
"BLOOD_TO_ACID",
"LAVA_TO_BLOOD",
"LIQUID_TO_EXPLOSION",
"TOXIC_TO_ACID",
"STATIC_TO_SAND",
"TRANSMUTATION",
"CURSE_WITHER_ELECTRICITY",
"CURSE_WITHER_EXPLOSION",
"CURSE_WITHER_MELEE",
"CURSE_WITHER_PROJECTILE",
"CURSE",
"BURST_2",
"BURST_3",
"BURST_4",
"BURST_8",
"BURST_X",
"SCATTER_2",
"SCATTER_3",
"SCATTER_4",
"I_SHAPE",
"T_SHAPE",
"PENTAGRAM_SHAPE",
"CIRCLE_SHAPE",
"Y_SHAPE",
"W_SHAPE",
"TOUCH_PISS",
"TOUCH_GRASS",
"SOILBALL",
"CIRCLE_FIRE",
"CIRCLE_ACID",
"CIRCLE_OIL",
"CIRCLE_WATER",
"MATERIAL_BLOOD",
"MATERIAL_CEMENT",
"MATERIAL_OIL",
"MATERIAL_ACID",
"MATERIAL_WATER",
"SEA_LAVA",
"SEA_ALCOHOL",
"SEA_OIL",
"SEA_WATER",
"SEA_ACID",
"SEA_ACID_GAS",
"SEA_SWAMP",
"SEA_MIMIC",
"TOUCH_BLOOD",
"TOUCH_GOLD",
"TOUCH_OIL",
"TOUCH_SMOKE",
"TOUCH_ALCOHOL",
"TOUCH_WATER",
"X_RAY",
"BLOOD_MAGIC",
"CASTER_CAST",
"I_SHOT",
"Y_SHOT",
"T_SHOT",
"W_SHOT",
"QUAD_SHOT",
"PENTA_SHOT",
"HEXA_SHOT",
"SUPER_TELEPORT_CAST",
"TELEPORT_CAST",
"LONG_DISTANCE_CAST",
"ALL_ACID",
"ALL_BLACKHOLES",
"ALL_DEATHCROSSES",
"ALL_ROCKETS",
"ALL_NUKES",
"ALL_DISCS",
"SUMMON_WANDGHOST",
"TEMPORARY_PLATFORM",
"TEMPORARY_WALL",
"MONEY_MAGIC",
"BLOOD_TO_POWER",
"RESET",
"ENERGY_SHIELD",
"ENERGY_SHIELD_SECTOR",
"TINY_GHOST",
"TORCH",
"TORCH_ELECTRIC",
"ADD_TRIGGER",
"ADD_TIMER",
"ADD_DEATH_TRIGGER",
"CESSATION",
"DIVIDE_2",
"DIVIDE_3",
"DIVIDE_4",
"DIVIDE_10",
"OMEGA",
"ZETA",
"TAU",
"SIGMA",
"PHI",
"MU",
"ALPHA",
"GAMMA",
"KANTELE_A",
"KANTELE_D",
"KANTELE_DIS",
"KANTELE_E",
"KANTELE_G",
"OCARINA_A",
"OCARINA_B",
"OCARINA_C",
"OCARINA_D",
"OCARINA_E",
"OCARINA_F",
"OCARINA_GSHARP",
"OCARINA_A2",
"RANDOM_SPELL",
"DRAW_RANDOM",
"DRAW_RANDOM_X3",
"DRAW_3_RANDOM",
"IF_PROJECTILE",
"IF_HP",
"IF_ENEMY",
"IF_HALF",
"IF_ELSE",
"IF_END",
"DUPLICATE",
"SUMMON_PORTAL",
"ALL_SPELLS",
]
ALL_PERKS = [
"CRITICAL_HIT",
"BREATH_UNDERWATER",
"EXTRA_MONEY",
"EXTRA_MONEY_TRICK_KILL",
"GOLD_IS_FOREVER",
"TRICK_BLOOD_MONEY",
"EXPLODING_GOLD",
"HOVER_BOOST",
"FASTER_LEVITATION",
"MOVEMENT_FASTER",
"LOW_GRAVITY",
"HIGH_GRAVITY",
"SPEED_DIVER",
"STRONG_KICK",
"TELEKINESIS",
"REPELLING_CAPE",
"EXPLODING_CORPSES",
"SAVING_GRACE",
"INVISIBILITY",
"GLOBAL_GORE",
"REMOVE_FOG_OF_WAR",
"LEVITATION_TRAIL",
"VAMPIRISM",
"EXTRA_HP",
"HEARTS_MORE_EXTRA_HP",
"GLASS_CANNON",
"LOW_HP_DAMAGE_BOOST",
"RESPAWN",
"WORM_ATTRACTOR",
"WORM_DETRACTOR",
"RADAR_ENEMY",
"FOOD_CLOCK",
"WAND_RADAR",
"ITEM_RADAR",
"MOON_RADAR",
"PROTECTION_FIRE",
"PROTECTION_RADIOACTIVITY",
"PROTECTION_EXPLOSION",
"PROTECTION_MELEE",
"PROTECTION_ELECTRICITY",
"TELEPORTITIS",
"TELEPORTITIS_DODGE",
"STAINLESS_ARMOUR",
"EDIT_WANDS_EVERYWHERE",
"NO_WAND_EDITING",
"WAND_EXPERIMENTER",
"ADVENTURER",
"ABILITY_ACTIONS_MATERIALIZED",
"PROJECTILE_HOMING",
"PROJECTILE_HOMING_SHOOTER",
"UNLIMITED_SPELLS",
"FREEZE_FIELD",
"FIRE_GAS",
"DISSOLVE_POWDERS",
"BLEED_SLIME",
"BLEED_OIL",
"BLEED_GAS",
"SHIELD",
"REVENGE_EXPLOSION",
"REVENGE_TENTACLE",
"REVENGE_RATS",
"REVENGE_BULLET",
"ATTACK_FOOT",
"LEGGY_FEET",
"PLAGUE_RATS",
"VOMIT_RATS",
"CORDYCEPS",
"MOLD",
"WORM_SMALLER_HOLES",
"PROJECTILE_REPULSION",
"RISKY_CRITICAL",
"FUNGAL_DISEASE",
"PROJECTILE_SLOW_FIELD",
"PROJECTILE_REPULSION_SECTOR",
"PROJECTILE_EATER_SECTOR",
"ORBIT",
"ANGRY_GHOST",
"HUNGRY_GHOST",
"DEATH_GHOST",
"HOMUNCULUS",
"ELECTRICITY",
"ATTRACT_ITEMS",
"EXTRA_KNOCKBACK",
"LOWER_SPREAD",
"LOW_RECOIL",
"BOUNCE",
"FAST_PROJECTILES",
"ALWAYS_CAST",
"EXTRA_MANA",
"NO_MORE_SHUFFLE",
"NO_MORE_KNOCKBACK",
"DUPLICATE_PROJECTILE",
"FASTER_WANDS",
"EXTRA_SLOTS",
"CONTACT_DAMAGE",
"EXTRA_PERK",
"PERKS_LOTTERY",
"GAMBLE",
"EXTRA_SHOP_ITEM",
"GENOME_MORE_HATRED",
"GENOME_MORE_LOVE",
"PEACE_WITH_GODS",
"MANA_FROM_KILLS",
"ANGRY_LEVITATION",
"LASER_AIM",
"PERSONAL_LASER",
"MEGA_BEAM_STONE",
"IRON_STOMACH",
]

View File

@ -1,5 +1,5 @@
{
"name": "opus_submitter",
"name": "polylan_submitter",
"private": true,
"version": "0.0.0",
"type": "module",

View File

@ -10,7 +10,7 @@ importers:
dependencies:
'@tailwindcss/vite':
specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
@ -41,7 +41,7 @@ importers:
version: 24.9.2
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.22(typescript@5.9.3))
version: 6.0.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))(vue@3.5.22(typescript@5.9.3))
'@vue/tsconfig':
specifier: ^0.8.1
version: 0.8.1(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
@ -53,7 +53,7 @@ importers:
version: 5.9.3
vite:
specifier: ^7.1.7
version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
vue-tsc:
specifier: ^3.1.0
version: 3.1.2(typescript@5.9.3)
@ -880,6 +880,11 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
@ -1126,12 +1131,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.16
'@tailwindcss/oxide-win32-x64-msvc': 4.1.16
'@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))':
'@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))':
dependencies:
'@tailwindcss/node': 4.1.16
'@tailwindcss/oxide': 4.1.16
tailwindcss: 4.1.16
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
'@tanstack/table-core@8.21.3': {}
@ -1148,10 +1153,10 @@ snapshots:
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.22(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
vue: 3.5.22(typescript@5.9.3)
'@volar/language-core@2.4.23':
@ -1503,7 +1508,7 @@ snapshots:
undici-types@7.16.0: {}
vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2):
vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4):
dependencies:
esbuild: 0.25.11
fdir: 6.5.0(picomatch@4.0.3)
@ -1516,6 +1521,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
yaml: 2.8.4
vscode-uri@3.1.0: {}
@ -1544,4 +1550,7 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
yaml@2.8.4:
optional: true
zlibjs@0.3.1: {}

View File

@ -2,15 +2,18 @@ from ninja import NinjaAPI
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from animations.api import router as results_router
from noita.api import router as noita_router
# Create the main API instance
api = NinjaAPI(
title="Opus Magnum Submission API",
title="PolyLAN Submission API",
version="1.0.0",
description="""API for managing Opus Magnum puzzle submissions.
description="""API for managing Opus Magnum puzzle submissions, and Noita runs.
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
The Noita Submission API allows clients to upload the result of the log file of the PolyLAN noita mod. It parses the output, and store each objectiv made by the user.
""",
openapi_extra={
"info": {
@ -28,30 +31,14 @@ It provides features for user authentication, puzzle listing, submission uploads
# Include the submissions router
api.add_router("/submissions/", submissions_router, tags=["submissions"])
api.add_router("/results/", results_router, tags=["results"])
api.add_router("/noita/", noita_router, tags=["noita"])
# Health check endpoint
@api.get("/health")
def health_check(request):
"""Health check endpoint"""
return {"status": "healthy", "service": "opus-magnum-api"}
# API info endpoint
@api.get("/info")
def api_info(request):
"""Get API information"""
return {
"name": "Opus Magnum Submission API",
"version": "1.0.0",
"description": "API for managing puzzle submissions with OCR validation",
"features": [
"Multi-puzzle submissions",
"OCR validation",
"Manual validation workflow",
"Admin validation tools",
],
}
return {"status": "healthy", "service": "polylan-submitter-api"}
# User info endpoint

View File

@ -1,5 +1,5 @@
"""
ASGI config for opus_submitter project.
ASGI config for polylan_submitter project.
It exposes the ASGI callable as a module-level variable named ``application``.
@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
application = get_asgi_application()

View File

@ -1,5 +1,5 @@
"""
Django settings for opus_submitter project.
Django settings for polylan_submitter project.
Generated by 'django-admin startproject' using Django 5.2.7.
@ -42,6 +42,7 @@ INSTALLED_APPS = [
"accounts",
"animations",
"submissions",
"noita",
]
MIDDLEWARE = [
@ -55,7 +56,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "opus_submitter.urls"
ROOT_URLCONF = "polylan_submitter.urls"
TEMPLATES = [
{
@ -72,7 +73,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "opus_submitter.wsgi.application"
WSGI_APPLICATION = "polylan_submitter.wsgi.application"
# Database
@ -177,4 +178,4 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static_source/vite"),
]
from opus_submitter.settingsLocal import * # noqa
from polylan_submitter.settingsLocal import * # noqa

View File

@ -1,5 +1,5 @@
"""
URL configuration for opus_submitter project.
URL configuration for polylan_submitter project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
@ -28,22 +28,34 @@ from .api import api
@login_required
def home(request: HttpRequest):
return render(request, "home.html", {})
@login_required
def opus_magnum_home(request: HttpRequest):
from submissions.models import SteamCollection
return render(
request,
"index.html",
"opus-magnum.html",
{
"collection": SteamCollection.objects.filter(is_active=True).last(),
},
)
@login_required
def noita_home(request: HttpRequest):
return render(request, "noita.html", {})
urlpatterns = [
path("admin/", admin.site.urls),
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls),
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("noita", noita_home, name="noita.home"),
path("", home, name="home"),
]

View File

@ -1,5 +1,5 @@
"""
WSGI config for opus_submitter project.
WSGI config for polylan_submitter project.
It exposes the WSGI callable as a module-level variable named ``application``.
@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
application = get_wsgi_application()

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const games = computed(() => [
{
id: "opus-magnum",
title: "Opus Magnum",
description: "Submit your best Opus Magnum puzzle solutions",
appId: 558990,
path: "/opus-magnum",
},
{
id: "noita",
title: "Noita",
description: "Submit your greatest Noita achievements",
appId: 881100,
path: "/noita",
},
]);
const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (appId: number) => {
return `https://cdn.akamai.steamstatic.com/steam/apps/${appId}/header.jpg`;
};
const onImageError = (appId: number) => {
imageErrors.value.add(appId);
};
const navigate = (path: string) => {
window.location.href = path;
};
</script>
<template>
<div class="min-h-screen bg-base-300 flex items-center justify-center px-4">
<div class="w-full max-w-6xl">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">PolyLAN Submitter</h1>
<p class="text-xl text-base-content/70">
Choose a game and submit your best solutions
</p>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
<i class="mdi mdi-gamepad-variant text-5xl"></i>
</div>
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ game.title }}</h2>
<p class="text-base-content/70">{{ game.description }}</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
Submit results
</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-12 text-base-content/50">
<p>Select a game above to begin submitting</p>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,419 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface Objective {
objectiv_id: string;
count: number;
}
const userInfo = ref({
username: "Player",
rank: null as number | null,
score: 0,
runsSubmitted: 0,
});
const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false);
const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const isLoadingObjectives = ref(false);
const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
uploadedFiles.value = Array.from(input.files);
}
};
const handleDragover = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = true;
};
const handleDragleave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = false;
if (event.dataTransfer?.files) {
uploadedFiles.value = Array.from(event.dataTransfer.files);
}
};
const submitRun = async () => {
if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try {
for (const file of uploadedFiles.value) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/noita/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
return;
}
const result = await response.json();
console.log("Submission successful:", result);
}
uploadedFiles.value = [];
alert("Run submitted successfully!");
// Refresh objectives, score, and rank after successful submission
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
} catch (error) {
console.error("Error submitting run:", error);
alert("Error submitting run. Please try again.");
} finally {
isUploading.value = false;
}
};
const goHome = () => {
window.location.href = "/";
};
const fetchObjectives = async () => {
isLoadingObjectives.value = true;
try {
const response = await fetch("/api/noita/objectives");
if (!response.ok) throw new Error("Failed to fetch objectives");
objectives.value = await response.json();
} catch (error) {
console.error("Error fetching objectives:", error);
} finally {
isLoadingObjectives.value = false;
}
};
const fetchUserResults = async () => {
try {
const response = await fetch("/api/noita/results");
if (!response.ok) throw new Error("Failed to fetch results");
const results = await response.json();
userInfo.value.score = results.total_score;
userInfo.value.runsSubmitted = results.objectives.length;
} catch (error) {
console.error("Error fetching results:", error);
}
};
const fetchLeaderboard = async () => {
isLoadingLeaderboard.value = true;
try {
const response = await fetch("/api/noita/leaderboard");
if (!response.ok) throw new Error("Failed to fetch leaderboard");
const data = await response.json();
leaderboard.value = data.leaderboard;
// Find current user's rank
const userRank = leaderboard.value.find(
(entry: any) => entry.username === userInfo.value.username
);
if (userRank) {
userInfo.value.rank = userRank.rank;
userInfo.value.score = userRank.total_score;
}
} catch (error) {
console.error("Error fetching leaderboard:", error);
} finally {
isLoadingLeaderboard.value = false;
}
};
const loadUserData = async () => {
// Get user info first
try {
const response = await fetch("/api/user");
if (response.ok) {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
}
}
} catch (error) {
console.error("Error fetching user info:", error);
}
// Fetch objectives, results, and leaderboard
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
};
onMounted(() => {
loadUserData();
});
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
</button>
<h1 class="text-xl font-bold">Noita Submitter</h1>
<div class="flex-1"></div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left Column: User Ranking -->
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg sticky top-8">
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
<i class="mdi mdi-trophy text-4xl"></i>
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
</div>
<div class="card-body">
<div class="text-center mb-6">
<p class="text-sm text-base-content/70">Player</p>
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
</div>
<div class="divider"></div>
<div v-if="isLoadingLeaderboard" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="space-y-4">
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
#{{ userInfo.rank }}
</p>
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
</div>
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
</div>
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
</div>
</div>
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
<i class="mdi mdi-trophy mr-1"></i>
View Full Leaderboard
</button>
</div>
</div>
</div>
<!-- Right Column: Upload -->
<div class="lg:col-span-2">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-cloud-upload text-purple-500 mr-2"></i>
Submit Your Run
</h2>
<!-- Upload Area -->
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
accept="video/*,image/*" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
<div>
<p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Video or image files (MP4, PNG, etc.)</p>
</div>
</label>
</div>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">Selected Files:</p>
<div class="space-y-2">
<div v-for="(file, index) in uploadedFiles" :key="index"
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
<i class="mdi mdi-file text-primary"></i>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ (file.size / 1024 / 1024).toFixed(2) }} MB</p>
</div>
<button @click="uploadedFiles.splice(index, 1)" class="btn btn-ghost btn-xs">
<i class="mdi mdi-close"></i>
</button>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex gap-3">
<label for="file-upload" class="btn btn-outline flex-1">
<i class="mdi mdi-folder-open mr-2"></i>
Choose Files
</label>
<button @click="submitRun" :disabled="uploadedFiles.length === 0 || isUploading"
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
{{ isUploading ? 'Submitting...' : 'Submit Run' }}
</button>
</div>
<p class="text-xs text-base-content/70 text-center mt-4">
Maximum file size: 256 MB per file
</p>
</div>
</div>
<!-- Objectives Table -->
<div class="card bg-base-100 shadow-lg mt-8">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
Your Objectives
</h2>
<div v-if="isLoadingObjectives" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="objectives.length === 0" class="text-center py-8">
<p class="text-base-content/70 mb-2">No objectives completed yet</p>
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
</div>
<div v-else class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Objective ID</th>
<th class="text-right">Count</th>
</tr>
</thead>
<tbody>
<tr v-for="obj in objectives" :key="obj.objectiv_id">
<td class="font-medium">{{ obj.objectiv_id }}</td>
<td class="text-right">
<span class="badge badge-primary badge-lg">{{ obj.count }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Leaderboard Modal -->
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
Global Leaderboard
</h3>
<button
@click="isLeaderboardModalOpen = false"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Leaderboard Table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Rank</th>
<th>Username</th>
<th class="text-right">Objectives</th>
<th class="text-right">Score</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in leaderboard"
:key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }"
>
<td class="font-bold">
<span
v-if="entry.rank === 1"
class="badge badge-warning badge-lg"
>
🏆 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 2" class="badge badge-lg">
🥈 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 3" class="badge badge-lg">
🥉 #{{ entry.rank }}
</span>
<span v-else>#{{ entry.rank }}</span>
</td>
<td class="font-medium">
{{ entry.username }}
<span
v-if="entry.username === userInfo.username"
class="badge badge-primary badge-sm ml-2"
>
You
</span>
</td>
<td class="text-right">{{ entry.objectives_count }}</td>
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="leaderboard.length === 0" class="text-center py-8">
<p class="text-base-content/70">No entries yet</p>
</div>
</div>
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
</div>
</div>
</template>

View File

@ -108,17 +108,24 @@ const findPuzzleByName = (ocrPuzzleName: string) => {
const reloadPage = () => {
window.location.reload();
};
const goHome = () => {
window.location.href = "/";
};
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto">
<div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div>
<div class="flex items-start justify-between">
<div class="container mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
</button>
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div
v-if="userInfo?.is_authenticated"
class="flex items-center gap-2"
@ -133,12 +140,8 @@ const reloadPage = () => {
</div>
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
<div class="flex flex-col items-end gap-2">
<a href="/api/docs" class="btn btn-xs">API docs</a>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
</div>

View File

@ -0,0 +1,280 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface User {
id: number;
username: string;
first_name?: string;
last_name?: string;
}
interface Puzzle {
id: number;
title: string;
}
interface PuzzleResponse {
id: number;
puzzle_id: number;
submission_id: string;
cost?: number;
cycles?: number;
area?: number;
rank_points?: number;
}
interface ResultsData {
users: User[];
puzzles: Puzzle[];
responses_by_userid: Record<number, PuzzleResponse[]>;
ranking_by_puzzle: Record<number, PuzzleResponse[]>;
}
const isLoading = ref(true);
const resultsData = ref<ResultsData | null>(null);
const selectedTab = ref<"overall" | "byPuzzle">("overall");
const expandedPuzzleId = ref<number | null>(null);
const fetchResults = async () => {
isLoading.value = true;
try {
const response = await fetch("/api/results/results");
if (!response.ok) throw new Error("Failed to fetch results");
resultsData.value = await response.json();
} catch (error) {
console.error("Error fetching results:", error);
} finally {
isLoading.value = false;
}
};
const getOverallRanking = () => {
if (!resultsData.value) return [];
const userScores = resultsData.value.users.map((user) => {
const responses = resultsData.value!.responses_by_userid[user.id] || [];
const totalPoints = responses.reduce((sum, r) => sum + (r.rank_points || 0), 0);
const count = responses.length;
return {
username: user.username,
totalPoints,
puzzlesSolved: count,
};
});
return userScores.sort((a, b) => b.totalPoints - a.totalPoints);
};
const getPuzzleRanking = (puzzleId: number) => {
if (!resultsData.value) return [];
const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || [];
return ranking.map((response) => {
console.log(response)
const user = resultsData.value!.users.find((u) => u.id === response.user_id);
return {
username: user?.username || "Unknown",
cost: response.final_cost,
cycles: response.final_cycles,
area: response.final_area,
points: response.points,
rank_points: response.rank_points || 0,
};
});
};
const togglePuzzleExpanded = (puzzleId: number) => {
expandedPuzzleId.value = expandedPuzzleId.value === puzzleId ? null : puzzleId;
};
onMounted(() => {
fetchResults();
});
</script>
<template>
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
General Results
</h2>
<div v-if="isLoading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="!resultsData" class="text-center py-8">
<p class="text-base-content/70">No results available yet</p>
</div>
<div v-else class="space-y-6">
<!-- Tabs -->
<div class="tabs tabs-boxed">
<button @click="selectedTab = 'overall'" :class="[
'tab',
selectedTab === 'overall' ? 'tab-active' : '',
]">
<i class="mdi mdi-chart-line mr-2"></i>
Overall Ranking
</button>
<button @click="selectedTab = 'byPuzzle'" :class="[
'tab',
selectedTab === 'byPuzzle' ? 'tab-active' : '',
]">
<i class="mdi mdi-puzzle mr-2"></i>
By Puzzle
</button>
</div>
<!-- Overall Ranking -->
<div v-show="selectedTab === 'overall'" class="space-y-4">
<div v-if="getOverallRanking().length === 0" class="text-center py-8">
<p class="text-base-content/70">No submissions yet</p>
</div>
<div v-else class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th class="text-right">Puzzles Solved</th>
<th class="text-right">Total Points</th>
</tr>
</thead>
<tbody>
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
<td class="font-bold">
<span v-if="index === 0" class="badge badge-warning badge-lg">
🏆 #1
</span>
<span v-else-if="index === 1" class="badge badge-lg">
🥈 #2
</span>
<span v-else-if="index === 2" class="badge badge-lg">
🥉 #3
</span>
<span v-else>#{{ index + 1 }}</span>
</td>
<td class="font-medium">{{ user.username }}</td>
<td class="text-right">{{ user.puzzlesSolved }}</td>
<td class="text-right font-bold">{{ user.totalPoints }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- By Puzzle Ranking -->
<div v-show="selectedTab === 'byPuzzle'" class="space-y-6">
<div v-for="puzzle in resultsData.puzzles" :key="puzzle.id" class="card bg-base-100 border border-base-300">
<button @click="togglePuzzleExpanded(puzzle.id)"
class="btn btn-ghost btn-lg w-full justify-start text-lg font-bold hover:bg-primary/20 rounded-b-none">
<i :class="['mdi mr-2', expandedPuzzleId === puzzle.id ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
{{ puzzle.title }}
<span class="ml-auto badge badge-sm">
{{ getPuzzleRanking(puzzle.id).length }} submissions
</span>
</button>
<!-- Expanded Details -->
<div v-if="expandedPuzzleId === puzzle.id" class="card-body">
<div v-if="getPuzzleRanking(puzzle.id).length === 0" class="text-center py-8">
<p class="text-base-content/70 text-lg">No submissions yet</p>
</div>
<div v-else class="space-y-6">
<!-- Top 3 Podium -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
class="card bg-base-200">
<div class="card-body p-4">
<div class="text-xs text-base-content/70 font-bold">
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
</div>
<h4 class="font-bold text-lg">{{ response.username }}</h4>
<div class="divider my-2"></div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span>Cost</span>
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span>Cycles</span>
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span>Area</span>
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
</div>
<div class="flex justify-between pt-2 border-t">
<span>Total (with coef.)</span>
<span class="badge badge-sm">{{ response.points || 'N/A' }}</span>
</div>
<div class="flex justify-between pt-2 border-t">
<span class="font-bold">Points</span>
<span class="badge badge-primary">{{ response.rank_points }} pts</span>
</div>
</div>
</div>
</div>
</div>
<!-- Full Ranking Table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full table-sm">
<thead>
<tr>
<th class="w-12">Rank</th>
<th>Player</th>
<th class="text-center">Cost</th>
<th class="text-center">Cycles</th>
<th class="text-center">Area</th>
<th class="text-center">Total (with coef.)</th>
<th class="text-right">Points</th>
</tr>
</thead>
<tbody>
<tr v-for="(response, index) in getPuzzleRanking(puzzle.id)" :key="index"
:class="{ 'bg-primary/10': index < 3 }">
<td class="font-bold">
<span v-if="index === 0" class="badge badge-warning">🏆</span>
<span v-else-if="index === 1" class="badge">🥈</span>
<span v-else-if="index === 2" class="badge">🥉</span>
<span v-else>#{{ index + 1 }}</span>
</td>
<td class="font-medium">{{ response.username }}</td>
<td class="text-center">
<span v-if="response.cost" class="badge badge-sm">{{ response.cost }}</span>
<span v-else class="text-base-content/40"></span>
</td>
<td class="text-center">
<span v-if="response.cycles" class="badge badge-sm">{{ response.cycles }}</span>
<span v-else class="text-base-content/40"></span>
</td>
<td class="text-center">
<span v-if="response.area" class="badge badge-sm">{{ response.area }}</span>
<span v-else class="text-base-content/40"></span>
</td>
<td class="text-center">
<span v-if="response.points" class="badge badge-sm">{{ response.points }}</span>
<span v-else class="text-base-content/40"></span>
</td>
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import Home from '@/Home.vue'
import '@/style.css'
// const app = createApp(App)
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Home, { ...mountData?.dataset })
app.mount(selector)

View File

@ -0,0 +1,8 @@
import { createApp } from 'vue'
import Noita from '@/Noita.vue'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Noita, { ...mountData?.dataset })
app.mount(selector)

View File

@ -1,11 +1,11 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import OpusMagnum from '@/OpusMagnum.vue'
import { pinia } from '@/stores'
import '@/style.css'
// const app = createApp(App)
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(App, { ...mountData?.dataset })
const app = createApp(OpusMagnum, { ...mountData?.dataset })
app.use(pinia)
app.mount(selector)

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -7,7 +7,7 @@ from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
from opus_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission
from submissions.utils import verify_and_validate_ocr_date_for_submission
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .schemas import (

Some files were not shown because too many files have changed in this diff Show More