Compare commits
10 Commits
eb1eed852b
...
d2a9dbe4a4
| Author | SHA1 | Date | |
|---|---|---|---|
| d2a9dbe4a4 | |||
| fa53d74295 | |||
| 52a6a4adb2 | |||
| 69b6b46ee2 | |||
| 119fdc2a51 | |||
| 01b0dbd1d9 | |||
| 19cc52c9f8 | |||
| 07f5c76a52 | |||
| 8584102402 | |||
| 404af4f90d |
@ -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>
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
@ -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()),
|
||||
@ -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]]
|
||||
@ -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:
|
||||
43
polylan_submitter/noita/admin.py
Normal file
43
polylan_submitter/noita/admin.py
Normal 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")}),
|
||||
)
|
||||
252
polylan_submitter/noita/api.py
Normal file
252
polylan_submitter/noita/api.py
Normal 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)}"}
|
||||
6
polylan_submitter/noita/apps.py
Normal file
6
polylan_submitter/noita/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NoitaConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "noita"
|
||||
@ -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()}")
|
||||
)
|
||||
61
polylan_submitter/noita/migrations/0001_initial.py
Normal file
61
polylan_submitter/noita/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
38
polylan_submitter/noita/migrations/0003_objectiv.py
Normal file
38
polylan_submitter/noita/migrations/0003_objectiv.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
polylan_submitter/noita/migrations/0004_objectiv_count.py
Normal file
17
polylan_submitter/noita/migrations/0004_objectiv_count.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
30
polylan_submitter/noita/migrations/0006_objectivpoint.py
Normal file
30
polylan_submitter/noita/migrations/0006_objectivpoint.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
59
polylan_submitter/noita/models.py
Normal file
59
polylan_submitter/noita/models.py
Normal 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)
|
||||
44
polylan_submitter/noita/schemas.py
Normal file
44
polylan_submitter/noita/schemas.py
Normal 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]
|
||||
291
polylan_submitter/noita/services/decode.py
Normal file
291
polylan_submitter/noita/services/decode.py
Normal 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.")
|
||||
40
polylan_submitter/noita/services/objectives.py
Normal file
40
polylan_submitter/noita/services/objectives.py
Normal 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"])
|
||||
535
polylan_submitter/noita/services/spells.py
Normal file
535
polylan_submitter/noita/services/spells.py
Normal 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",
|
||||
]
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "opus_submitter",
|
||||
"name": "polylan_submitter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@ -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: {}
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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()
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
81
polylan_submitter/src/Home.vue
Normal file
81
polylan_submitter/src/Home.vue
Normal 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>
|
||||
419
polylan_submitter/src/Noita.vue
Normal file
419
polylan_submitter/src/Noita.vue
Normal 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>
|
||||
@ -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>
|
||||
280
polylan_submitter/src/components/Results.vue
Normal file
280
polylan_submitter/src/components/Results.vue
Normal 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>
|
||||
9
polylan_submitter/src/home.ts
Normal file
9
polylan_submitter/src/home.ts
Normal 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)
|
||||
8
polylan_submitter/src/noita.ts
Normal file
8
polylan_submitter/src/noita.ts
Normal 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)
|
||||
@ -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)
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
0
polylan_submitter/submissions/__init__.py
Normal file
0
polylan_submitter/submissions/__init__.py
Normal 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
Loading…
Reference in New Issue
Block a user