Compare commits

...
61 Commits
Author SHA1 Message Date
Legrems 3e91ef77b6 chore: vite build 2026-05-24 19:21:05 +02:00
Legrems 215d68a0e1 chore: vite build 2026-05-24 19:14:08 +02:00
Legrems 47812ffd09 feat(market): settings toggle 2026-05-24 19:11:02 +02:00
Legrems a5fe8aacaf feat(migration): frontend migration submissions -> opus-magnum 2026-05-24 18:54:14 +02:00
Legrems b437210eb3 feat(migration): old submissions -> opus magnum app 2026-05-24 18:51:01 +02:00
Legrems 5584e54b58 migrate to opus-magnum app 2026-05-24 18:48:14 +02:00
Legrems 35ea54ecea chore: market build app 2026-05-24 18:19:06 +02:00
Legrems 821e453bc0 feat(noita): stores 2026-05-24 09:29:30 +02:00
Legrems 9fd0122a67 chore: market app build 2026-05-24 09:24:56 +02:00
Legrems e557fe2cda feat(market): add draft status and multiplier 2026-05-24 09:20:01 +02:00
Legrems 79e7cef3ba feat(market): resolve for admin 2026-05-23 20:33:21 +02:00
Legrems a264336bd8 feat(market): track user points change 2026-05-23 20:26:59 +02:00
Legrems 42e3571fab fix(market): better market card display 2026-05-23 20:06:17 +02:00
Legrems 43b314bb20 fix(openapi-ts): replace api call with generated api client 2026-05-23 19:51:08 +02:00
Legrems f1afb2096f fix(openapi-ts): first pass for replacing with openapi-ts 2026-05-23 18:53:33 +02:00
Legrems 62a81e57ad feat(openapi-ts): add generated api client 2026-05-23 18:50:26 +02:00
Legrems 303b9e1c8a feat(frontend): add openapi-ts 2026-05-23 18:50:19 +02:00
Legrems f7c7eba4da feat(market): basic page + submit 2026-05-23 18:30:50 +02:00
Legrems ce30539808 feat(market): base models 2026-05-23 17:48:27 +02:00
Legrems 544112b204 feat(games): add games to disable + path 2026-05-23 14:18:44 +02:00
Legrems 9f94fb3974 fix: add puzzle title in results page 2026-05-22 17:24:29 +02:00
Legrems 25072e8eb4 cache for result api 2026-05-22 17:21:53 +02:00
Legrems 9e5ab8539a top winners + by puzzle 2026-05-22 09:32:13 +02:00
Legrems 4ba6a48246 fix ranking order + display winning gifs 2026-05-22 06:32:18 +02:00
Legrems 92dddca964 tournament outputs 2026-05-22 06:18:11 +02:00
Legrems bf21a5eae6 fix(noita): better display name 2026-05-15 21:11:23 +02:00
Legrems 89faa7cd40 chore: build assets 2026-05-15 20:47:22 +02:00
Legrems a800c7fff7 fix(noita): all objectives display 2026-05-15 20:43:30 +02:00
Legrems 2ee8cd6be3 chore: vite build 2026-05-15 14:51:05 +02:00
Legrems 35824de310 admin display 2026-05-15 14:50:59 +02:00
Legrems 65cc2e555f fix: parse + store 2026-05-15 03:31:20 +02:00
Legrems 6d996a4e2f chore: vite build 2026-05-15 03:26:35 +02:00
Legrems f774ff3340 feat(opus): add coef subcards 2026-05-15 03:24:07 +02:00
Legrems 779393106d fix(opus): wrong frontend ordering points -> rank-points 2026-05-15 03:05:33 +02:00
Legrems 3e04f8312a feat(noita): death counter + ranking display 2026-05-15 02:15:00 +02:00
Legrems 7cfab20826 rework noita objectives + deathcounter 2026-05-15 01:50:34 +02:00
Legrems 754b0b0803 noita update 2026-05-10 23:09:42 +02:00
Legrems 79f469a393 chore: build vite assets 2026-05-10 04:01:57 +02:00
Legrems 5aeff9c218 feat(api): add cache + busting for admin 2026-05-10 04:00:53 +02:00
Legrems 2264401a91 fix: correct mime type for noita log file, opus card status display 2026-05-10 03:47:37 +02:00
Legrems 9662181d4d fix(cas): https in prod hihi 2026-05-10 03:28:13 +02:00
Legrems 90f1ce13cf fix: login with furl 2026-05-10 03:24:18 +02:00
Legrems f90377ac69 chore: remove building part in pyproject 2026-05-10 03:12:11 +02:00
Legrems d8d09c21d4 setup sentry 2026-05-10 03:11:10 +02:00
Legrems 3c86ca4c91 vite missing files 2026-05-10 03:05:45 +02:00
Legrems 0b138e315c vite assets build 2026-05-10 02:44:09 +02:00
Legrems d2a9dbe4a4 opus-magnum results view 2026-05-10 02:38:56 +02:00
Legrems fa53d74295 noita leaderboard + view + management commands 2026-05-10 02:11:30 +02:00
Legrems 52a6a4adb2 noita objectives + leaderboard 2026-05-10 02:01:10 +02:00
Legrems 69b6b46ee2 basic noita parsing 2026-05-10 01:45:29 +02:00
Legrems 119fdc2a51 basic noita submissions 2026-05-10 00:57:51 +02:00
Legrems 01b0dbd1d9 base for noita submitter 2026-05-10 00:36:15 +02:00
Legrems 19cc52c9f8 home page + opus result api working again 2026-05-10 00:17:20 +02:00
Legrems 07f5c76a52 opus-magnum split 2026-05-09 23:56:10 +02:00
Legrems 8584102402 remove not working endpoint 2026-05-09 23:42:26 +02:00
Legrems 404af4f90d chore: opus-submitter -> polylan-submitter 2026-05-09 23:25:34 +02:00
Legrems eb1eed852b chore: ruff format + fix 2026-05-09 22:13:40 +02:00
Legrems 11229a3906 frontend build 2025-11-28 14:35:20 +01:00
Legrems 38e2b5e858 remove custom manager on user 2025-11-28 14:34:29 +01:00
Legrems 1e4251c796 remove psycopg2, use psycopg 2025-11-28 14:25:59 +01:00
Legrems fa76fbce92 mutiple fixes 2025-11-28 14:05:26 +01:00
208 changed files with 11547 additions and 780 deletions
-78
View File
@@ -1,78 +0,0 @@
from ninja import NinjaAPI
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
# Create the main API instance
api = NinjaAPI(
title="Opus Magnum Submission API",
version="1.0.0",
description="""API for managing Opus Magnum puzzle submissions.
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.
""",
openapi_extra={
"info": {
"contact": {
"name": "Legrems",
"email": "loic.gremaud@polylan.ch",
},
}
},
)
# Add authentication for protected endpoints
# api.auth = django_auth # Uncomment if you want global auth
# Include the submissions router
api.add_router("/submissions/", submissions_router, tags=["submissions"])
# 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",
],
}
# User info endpoint
@api.get("/user", response=UserInfoOut)
def get_user_info(request):
"""Get current user information"""
user = request.user
if user.is_authenticated:
return {
"id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"is_authenticated": True,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"cas_groups": getattr(user, "cas_groups", []),
}
else:
return {
"is_authenticated": False,
"is_staff": False,
"is_superuser": False,
}
-102
View File
@@ -1,102 +0,0 @@
export interface SteamCollection {
id: number
steam_id: string
title: string
description: string
author_name: string
total_items: number
unique_visitors: number
current_favorites: number
created_at: string
updated_at: string
}
export interface SteamCollectionItem {
id: number
steam_item_id: string
title: string
author_name: string
description: string
tags: string[]
order_index: number
collection: number
created_at: string
updated_at: string
}
export interface OpusMagnumData {
puzzle: string
cost: string
cycles: string
area: string
confidence: {
puzzle: number
cost: number
cycles: number
area: number
overall: number
}
}
export interface SubmissionFile {
file: File
file_url: string
preview: string
type: 'image' | 'gif'
ocrData?: OpusMagnumData
ocrProcessing?: boolean
ocrError?: string
original_filename?: string
manualPuzzleSelection?: string
needsManualPuzzleSelection?: boolean
}
export interface PuzzleResponse {
id?: number
// puzzle: number | SteamCollectionItem
puzzle: number
puzzle_name: string
cost?: string
cycles?: string
area?: string
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
validated_cost?: string
validated_cycles?: string
validated_area?: string
final_cost?: string
final_cycles?: string
final_area?: string
files?: SubmissionFile[]
created_at?: string
updated_at?: string
}
export interface Submission {
id?: string
user?: number | null
responses: PuzzleResponse[]
notes?: string
is_validated?: boolean
validated_by?: number | null
validated_at?: string | null
manual_validation_requested?: boolean
total_responses?: number
needs_validation?: boolean
created_at?: string
updated_at?: string
}
export interface UserInfo {
id?: number
username?: string
first_name?: string
last_name?: string
email?: string
is_authenticated: boolean
is_staff: boolean
is_superuser: boolean
cas_groups?: string[]
}
-33
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"
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-NIi3b_aN.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-CYuvChoP.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
}
}
@@ -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

File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
return obj.get_cas_groups_display() return obj.get_cas_groups_display()
get_cas_groups_display.short_description = "CAS Groups" get_cas_groups_display.short_description = "CAS Groups"
def has_delete_permission(self, request, obj=None):
return False
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="customuser",
name="points",
field=models.IntegerField(default=1000),
),
]
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS # Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True) cas_attributes = models.JSONField(default=dict, blank=True)
# Market points balance
points = models.IntegerField(default=1000)
def __str__(self): def __str__(self):
return f"{self.username} ({self.cas_user_id})" return f"{self.username} ({self.cas_user_id})"
+59
View File
@@ -0,0 +1,59 @@
from django.contrib import admin
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
@admin.register(PuzzlePointsFactor)
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
list_display = [
"id",
"cost",
"cycles",
"area",
"special_notes",
]
list_filter = ["cost", "cycles", "area", "special_notes"]
search_fields = ["cost", "cycles", "area", "special_notes"]
readonly_fields = ["created_at", "updated_at"]
fieldsets = (
(
"Basic Information",
{"fields": ("cost", "cycles", "area")},
),
(
"Special notes",
{
"fields": ("special_notes",),
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
@admin.register(PuzzlePointsValue)
class PuzzlePointsValueAdmin(admin.ModelAdmin):
list_display = ["id", "points"]
list_filter = ["points"]
search_fields = ["points"]
readonly_fields = ["created_at", "updated_at"]
fieldsets = (
(
"Basic Information",
{"fields": ("points",)},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
+241
View File
@@ -0,0 +1,241 @@
from collections import defaultdict
from django.http import HttpRequest
from ninja import Router
from ninja.errors import HttpError
from django.core.cache import cache
from django.shortcuts import get_object_or_404
from accounts.models import CustomUser
from animations.schemas import (
RankingSchema,
TournamentSubmissionsOut,
TournamentPuzzleResultsOut,
PuzzleSubmissionsOut,
PuzzleResultsOut,
PuzzleSubmissionWithRankOut,
PuzzlePointsFactorOut,
WinnerResponseOut,
WinnerFileOut,
)
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router()
@router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict:
cache_key = "api:results:results"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
responses_by_userid = defaultdict(list)
responses_by_puzzleid = defaultdict(list)
for response in list(
PuzzleResponse.objects.filter(needs_manual_validation=False)
.filter_user_best_response()
.prefetch_related("submission__user")
):
responses_by_userid[response.submission.user.id].append(response)
responses_by_puzzleid[response.puzzle.id].append(response)
ranking = {}
for puzzle_id, responses in responses_by_puzzleid.items():
ranking[puzzle_id] = sorted(
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
)
data = {
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
"puzzles": list(SteamCollectionItem.objects.all()),
"responses_by_userid": dict(responses_by_userid),
"ranking_by_puzzle": ranking,
}
cache.set("api:results:results", data, 300)
return data
@router.get("top-submissions", response=TournamentSubmissionsOut)
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
cache_key = f"api:results:top_submissions:{limit}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
# Only allow access when tournament is closed
if collection.accepting_submissions:
raise HttpError(403, "Tournament is still accepting submissions")
# Get all puzzles
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
"order_index"
)
# Build response
submissions_list = []
for puzzle in puzzles:
# Get the top N responses for this puzzle (ranked by points, highest first)
top_responses = (
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
.filter_user_best_response()
.annotate_rank_points()
.order_by("-rank_points")[:limit]
)
# Build submission list for this puzzle
puzzle_submissions = []
for response in top_responses:
# Get submission files
files = response.files.all()
response_files = [
WinnerFileOut(
file_url=file.file_url or "",
original_filename=file.original_filename,
)
for file in files
]
# Calculate total coefficient
total_coef = None
if (
puzzle.points_factor
and response.final_cost is not None
and response.final_cycles is not None
and response.final_area is not None
):
total_coef = (
puzzle.points_factor.cost * response.final_cost
+ puzzle.points_factor.cycles * response.final_cycles
+ puzzle.points_factor.area * response.final_area
)
submission_data = WinnerResponseOut(
user_id=response.submission.user.id if response.submission.user else 0,
username=response.submission.user.username
if response.submission.user
else "Anonymous",
final_cost=response.final_cost,
final_cycles=response.final_cycles,
final_area=response.final_area,
rank_points=response.rank_points,
total_coef=total_coef,
files=response_files,
)
puzzle_submissions.append(submission_data)
submissions_list.append(
PuzzleSubmissionsOut(
puzzle_id=puzzle.id,
puzzle_title=puzzle.title,
submissions=puzzle_submissions,
)
)
data = {"submissions": submissions_list}
cache.set(f"api:results:top_submissions:{limit}", data, 300)
return data
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
cache_key = f"api:results:puzzle_results:{limit}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
# Only allow access when tournament is closed
if collection.accepting_submissions:
raise HttpError(403, "Tournament is still accepting submissions")
# Get all puzzles
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
"order_index"
)
# Build response
results_list = []
for puzzle in puzzles:
# Get the top N responses for this puzzle (ranked by points)
top_responses = (
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
.filter_user_best_response()
.annotate_rank_points()
.order_by("-rank_points")[:limit]
)
# Build submission list for this puzzle with rank
puzzle_submissions = []
for rank, response in enumerate(top_responses, 1):
# Get submission files
files = response.files.all()
response_files = [
WinnerFileOut(
file_url=file.file_url or "",
original_filename=file.original_filename,
)
for file in files
]
# Calculate total coefficient
total_coef = None
if (
puzzle.points_factor
and response.final_cost is not None
and response.final_cycles is not None
and response.final_area is not None
):
total_coef = (
puzzle.points_factor.cost * response.final_cost
+ puzzle.points_factor.cycles * response.final_cycles
+ puzzle.points_factor.area * response.final_area
)
submission_data = PuzzleSubmissionWithRankOut(
rank=rank,
user_id=response.submission.user.id if response.submission.user else 0,
username=response.submission.user.username
if response.submission.user
else "Anonymous",
final_cost=response.final_cost,
final_cycles=response.final_cycles,
final_area=response.final_area,
rank_points=response.rank_points,
total_coef=total_coef,
files=response_files,
)
puzzle_submissions.append(submission_data)
# Get points factor if available
points_factor = None
if puzzle.points_factor:
points_factor = PuzzlePointsFactorOut(
cost=puzzle.points_factor.cost,
cycles=puzzle.points_factor.cycles,
area=puzzle.points_factor.area,
)
results_list.append(
PuzzleResultsOut(
puzzle_id=puzzle.id,
puzzle_title=puzzle.title,
points_factor=points_factor,
submissions=puzzle_submissions,
)
)
data = {"results": results_list}
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
return data
@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class SubmissionsConfig(AppConfig): class AnimationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "submissions" name = "animations"
@@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2025-11-23 22:33
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="PuzzlePointsFactor",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("cost", models.IntegerField()),
("cycles", models.IntegerField()),
("area", models.IntegerField()),
("special_notes", models.TextField(blank=True)),
],
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-11-24 01:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="PuzzlePointsValue",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("points", models.JSONField(default=[])),
],
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-05 13:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0002_puzzlepointsvalue"),
]
operations = [
migrations.AlterField(
model_name="puzzlepointsvalue",
name="points",
field=models.JSONField(default=list),
),
]
+24
View File
@@ -0,0 +1,24 @@
from django.db import models
class PuzzlePointsFactor(models.Model):
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
cost = models.IntegerField()
cycles = models.IntegerField()
area = models.IntegerField()
special_notes = models.TextField(blank=True)
def __str__(self) -> str:
return f"{self.cost} - {self.cycles} - {self.area}"
class PuzzlePointsValue(models.Model):
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
points = models.JSONField(default=list)
+115
View File
@@ -0,0 +1,115 @@
from ninja import ModelSchema, Schema
from typing import List, Optional
from opus_magnum.models import PuzzleResponse
from opus_magnum.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema):
class Meta:
model = PuzzleResponse
fields = [
"id",
"puzzle_name",
"created_at",
"updated_at",
]
points: int | None = None
rank_points: int | None = None
puzzle_user_rank: int
user_response_rank: int
user_id: int
final_cost: int | None
final_cycles: int | None
final_area: int | None
@staticmethod
def resolve_user_id(obj) -> int:
return obj.submission.user.id
class UserDisplayOut(Schema):
id: int
username: str
is_staff: bool
class RankingSchema(Schema):
users: list[UserDisplayOut]
puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
class WinnerFileOut(Schema):
"""Schema for winner submission file"""
file_url: str
original_filename: str
class WinnerResponseOut(Schema):
"""Schema for winner response with files"""
user_id: int
username: str
final_cost: Optional[int]
final_cycles: Optional[int]
final_area: Optional[int]
rank_points: Optional[int]
total_coef: Optional[int]
files: List[WinnerFileOut]
class PuzzleSubmissionsOut(Schema):
"""Schema for puzzle with all top submissions"""
puzzle_id: int
puzzle_title: str
submissions: List[WinnerResponseOut]
class TournamentSubmissionsOut(Schema):
"""Schema for tournament top submissions results"""
submissions: List[PuzzleSubmissionsOut]
class PuzzlePointsFactorOut(Schema):
"""Schema for puzzle points factor"""
cost: int
cycles: int
area: int
class PuzzleSubmissionWithRankOut(Schema):
"""Schema for puzzle submission with rank"""
rank: int
user_id: int
username: str
final_cost: Optional[int]
final_cycles: Optional[int]
final_area: Optional[int]
rank_points: Optional[int]
total_coef: Optional[int]
files: List[WinnerFileOut]
class PuzzleResultsOut(Schema):
"""Schema for puzzle-specific results with coefficients"""
puzzle_id: int
puzzle_title: str
points_factor: Optional[PuzzlePointsFactorOut]
submissions: List[PuzzleSubmissionWithRankOut]
class TournamentPuzzleResultsOut(Schema):
"""Schema for tournament puzzle-specific results"""
results: List[PuzzleResultsOut]
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from .models import Game
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
list_filter = ["enabled"]
search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"]
+13
View File
@@ -0,0 +1,13 @@
from typing import List
from ninja import Router
from .models import Game
from .schemas import GameOut
router = Router()
@router.get("", response=List[GameOut])
def list_games(request):
return Game.objects.filter(enabled=True)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
+22
View File
@@ -0,0 +1,22 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
from .models import Game
def require_game_enabled(steam_app_id: int):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
try:
game = Game.objects.get(steam_app_id=steam_app_id)
except Game.DoesNotExist:
raise PermissionDenied
if not game.enabled:
raise PermissionDenied
return view_func(request, *args, **kwargs)
return wrapper
return decorator
@@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Game",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("steam_app_id", models.PositiveIntegerField(unique=True)),
("name", models.CharField(max_length=255)),
("enabled", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
]
@@ -0,0 +1,31 @@
from django.db import migrations
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
def seed_games(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.get_or_create(
steam_app_id=NOITA_APP_ID,
defaults={"name": "Noita", "enabled": True},
)
Game.objects.get_or_create(
steam_app_id=OPUS_MAGNUM_APP_ID,
defaults={"name": "Opus Magnum", "enabled": True},
)
def unseed_games(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.RunPython(seed_games, reverse_code=unseed_games),
]
@@ -0,0 +1,31 @@
from django.db import migrations, models
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
PATHS = {
NOITA_APP_ID: "/noita",
OPUS_MAGNUM_APP_ID: "/opus-magnum",
}
def set_paths(apps, schema_editor):
Game = apps.get_model("games", "Game")
for app_id, path in PATHS.items():
Game.objects.filter(steam_app_id=app_id).update(path=path)
class Migration(migrations.Migration):
dependencies = [
("games", "0002_seed_noita_and_opus_magnum"),
]
operations = [
migrations.AddField(
model_name="game",
name="path",
field=models.CharField(default="", max_length=100),
preserve_default=False,
),
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
]
+17
View File
@@ -0,0 +1,17 @@
from django.db import models
class Game(models.Model):
steam_app_id = models.PositiveIntegerField(unique=True)
name = models.CharField(max_length=255)
path = models.CharField(max_length=100)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.steam_app_id})"
+7
View File
@@ -0,0 +1,7 @@
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
name: str
path: str
@@ -7,7 +7,7 @@ import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
+92
View File
@@ -0,0 +1,92 @@
from django.contrib import admin
from market.models import Market, MarketOption, UserBet, UserPointChange
class MarketOptionInline(admin.TabularInline):
model = MarketOption
extra = 1
fields = ["text"]
@admin.register(Market)
class MarketAdmin(admin.ModelAdmin):
list_display = ["title", "status", "end_date", "created_by", "created_at"]
list_filter = ["status", "created_at"]
search_fields = ["uuid", "title"]
readonly_fields = [
"uuid",
"created_at",
"updated_at",
"created_by",
"winning_option",
]
inlines = [MarketOptionInline]
fieldsets = (
("Info", {"fields": ["uuid", "title", "description"]}),
("Configuration", {"fields": ["end_date", "multiplier"]}),
("Status", {"fields": ["status", "winning_option"]}),
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
)
def has_change_permission(self, request, obj=None):
# Prevent any changes to resolved markets
if obj and obj.status == Market.Status.RESOLVED:
return False
return super().has_change_permission(request, obj)
def save_model(self, request, obj, form, change):
if not change: # Creating new market
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.action(description="Publish selected draft markets")
def publish_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.DRAFT).update(
status=Market.Status.OPEN
)
self.message_user(request, f"Published {updated} market(s).")
@admin.action(description="Close selected markets")
def close_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.OPEN).update(
status=Market.Status.CLOSED
)
self.message_user(request, f"Closed {updated} market(s).")
actions = ["publish_markets", "close_markets"]
@admin.register(MarketOption)
class MarketOptionAdmin(admin.ModelAdmin):
list_display = ["text", "market"]
list_filter = ["market"]
search_fields = ["uuid", "text", "market__title"]
readonly_fields = ["uuid"]
@admin.register(UserBet)
class UserBetAdmin(admin.ModelAdmin):
list_display = ["user", "option", "amount", "created_at"]
list_filter = ["user", "created_at", "option__market"]
search_fields = ["uuid", "user__username", "option__text"]
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(UserPointChange)
class UserPointChangeAdmin(admin.ModelAdmin):
list_display = ["user", "market", "amount", "reason", "created_at"]
list_filter = ["user", "reason", "created_at", "market"]
search_fields = ["uuid", "user__username", "market__title"]
readonly_fields = ["uuid", "created_at", "updated_at"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
+184
View File
@@ -0,0 +1,184 @@
from typing import List
from ninja import Router
from ninja.errors import HttpError
from django.shortcuts import get_object_or_404
from django.db.models import Sum, Prefetch
from django.db.models.functions import Coalesce
from django.db import transaction
from market.models import Market, MarketOption, UserBet, UserPointChange
from market.schemas import (
MarketListSchema,
ResolveMarketSchema,
UserBetCreateSchema,
UserBetSchema,
)
router = Router(tags=["market"])
@router.get("/", response=List[MarketListSchema])
def list_markets(request):
"""List all markets (excludes draft markets)."""
markets = Market.objects.exclude(status=Market.Status.DRAFT)
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
options_queryset = MarketOption.objects.annotate(
total_bets=Coalesce(Sum("user_bets__amount"), 0)
).order_by("-total_bets", "text")
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
@router.get("/user/bets", response=List[UserBetSchema])
def list_user_bets(request):
"""List all bets placed by the current user."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
return (
UserBet.objects.filter(user=request.user)
.select_related("option__market")
.prefetch_related("option__market__options")
)
@router.post("/{market_uuid}/actions/close")
def close_market(request, market_uuid: str):
"""Close a market. Admin only."""
if not request.user.is_staff:
raise HttpError(403, "Permission denied")
market = get_object_or_404(Market, uuid=market_uuid)
market.status = Market.Status.CLOSED
market.save(update_fields=["status", "updated_at"])
return {"status": Market.Status.CLOSED}
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
"""Resolve a market with a winning option. Admin only."""
if not request.user.is_staff:
raise HttpError(403, "Permission denied")
market = get_object_or_404(Market, uuid=market_uuid)
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
if winning_option.market_id != market.id:
raise HttpError(400, "Option does not belong to this market")
market.winning_option = winning_option
market.status = Market.Status.RESOLVED
market.save(update_fields=["winning_option", "status", "updated_at"])
# Calculate and distribute winnings
all_bets = list(
UserBet.objects.filter(option__market=market).select_related("user")
)
# Calculate total pot
total_pot = sum(bet.amount for bet in all_bets)
if total_pot == 0:
return market
# Separate winning and losing bets
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
total_winning = sum(bet.amount for bet in winning_bets)
point_changes = []
users_to_update = []
with transaction.atomic():
# Award payouts to winners with multiplier
if total_winning > 0:
for bet in winning_bets:
payout = round(
bet.amount / total_winning * total_pot * market.multiplier
)
bet.user.points += payout
users_to_update.append(bet.user)
point_changes.append(
UserPointChange(
user=bet.user,
market=market,
amount=payout,
reason=UserPointChange.Reason.BET_WON,
)
)
# Record losing bets (points already deducted)
for bet in losing_bets:
point_changes.append(
UserPointChange(
user=bet.user,
market=market,
amount=-bet.amount,
reason=UserPointChange.Reason.BET_LOST,
)
)
# Bulk update users
for user in users_to_update:
user.save(update_fields=["points"])
# Bulk create point changes
UserPointChange.objects.bulk_create(point_changes)
return market
@router.post("/{market_uuid}/bets", response=UserBetSchema)
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
"""Place a bet on a market option."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
market = get_object_or_404(Market, uuid=market_uuid)
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
if option.market_id != market.id:
raise HttpError(400, "Option does not belong to this market")
if market.status != Market.Status.OPEN:
raise HttpError(400, "Market is not open for betting")
# Check if user already has a bet on a different option in this market
existing_bet_on_market = (
UserBet.objects.filter(user=request.user, option__market=market)
.exclude(option=option)
.first()
)
if existing_bet_on_market:
raise HttpError(400, "You can only bet on one option per market")
# Check if user already has a bet on this option
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
if existing_bet and payload.amount < existing_bet.amount:
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
# Calculate delta (amount to deduct from user's points)
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
# Check if user has enough points
if request.user.points < delta:
raise HttpError(400, "Insufficient points for this bet")
user_bet, created = UserBet.objects.update_or_create(
user=request.user,
option=option,
defaults={"amount": payload.amount},
)
# Deduct points and record the change
request.user.points -= delta
request.user.save(update_fields=["points"])
UserPointChange.objects.create(
user=request.user,
market=market,
amount=-delta,
reason=UserPointChange.Reason.BET_PLACED,
)
return user_bet
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MarketConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "market"
@@ -0,0 +1,187 @@
# Generated by Django 5.2.7 on 2026-05-23 15:45
import django.db.models.deletion
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="Market",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"type",
models.CharField(
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
default="yes_no",
max_length=10,
),
),
(
"status",
models.CharField(
choices=[
("open", "Open"),
("closed", "Closed"),
("resolved", "Resolved"),
],
default="open",
max_length=10,
),
),
("end_date", models.DateTimeField()),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="MarketOption",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("text", models.CharField(max_length=255)),
("position", models.PositiveIntegerField(default=0)),
(
"market",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="options",
to="market.market",
),
),
],
options={
"ordering": ["position"],
},
),
migrations.AddField(
model_name="market",
name="winning_option",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="market_won",
to="market.marketoption",
),
),
migrations.CreateModel(
name="UserBet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.PositiveIntegerField()),
(
"option",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_bets",
to="market.marketoption",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bets",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddIndex(
model_name="marketoption",
index=models.Index(
fields=["market", "position"], name="market_mark_market__8679ce_idx"
),
),
migrations.AddConstraint(
model_name="marketoption",
constraint=models.UniqueConstraint(
fields=("market", "position"), name="unique_market_option_position"
),
),
migrations.AddIndex(
model_name="market",
index=models.Index(
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
),
),
migrations.AddIndex(
model_name="market",
index=models.Index(
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
),
),
migrations.AddIndex(
model_name="userbet",
index=models.Index(
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
),
),
migrations.AddConstraint(
model_name="userbet",
constraint=models.UniqueConstraint(
fields=("user", "option"), name="unique_user_bet_per_option"
),
),
]
@@ -0,0 +1,73 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserPointChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.IntegerField()),
(
"reason",
models.CharField(
choices=[
("bet_placed", "Bet Placed"),
("bet_won", "Bet Won"),
("bet_lost", "Bet Lost"),
],
max_length=20,
),
),
(
"market",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="point_changes",
to="market.market",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="point_changes",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["user", "-created_at"],
name="market_user_user_id_631ba9_idx",
)
],
},
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-05-23 18:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("market", "0002_userpointchange"),
]
operations = [
migrations.RemoveField(
model_name="market",
name="type",
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2026-05-23 18:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0003_remove_market_type"),
]
operations = [
migrations.AlterModelOptions(
name="marketoption",
options={"ordering": ["text"]},
),
migrations.RemoveConstraint(
model_name="marketoption",
name="unique_market_option_position",
),
migrations.RemoveIndex(
model_name="marketoption",
name="market_mark_market__8679ce_idx",
),
migrations.RemoveField(
model_name="marketoption",
name="position",
),
migrations.AddIndex(
model_name="marketoption",
index=models.Index(
fields=["market"], name="market_mark_market__67f63b_idx"
),
),
migrations.AddConstraint(
model_name="marketoption",
constraint=models.UniqueConstraint(
fields=("market", "text"), name="unique_market_option_text"
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0004_alter_marketoption_options_and_more"),
]
operations = [
migrations.AddField(
model_name="market",
name="multiplier",
field=models.FloatField(default=1.0),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2026-05-23 18:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0005_market_multiplier"),
]
operations = [
migrations.AlterField(
model_name="market",
name="status",
field=models.CharField(
choices=[
("draft", "Draft"),
("open", "Open"),
("closed", "Closed"),
("resolved", "Resolved"),
],
default="draft",
max_length=10,
),
),
]
+114
View File
@@ -0,0 +1,114 @@
import uuid
from django.db import models
class BaseModel(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Market(BaseModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
CLOSED = "closed", "Closed"
RESOLVED = "resolved", "Resolved"
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.DRAFT
)
end_date = models.DateTimeField()
multiplier = models.FloatField(default=1.0)
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
winning_option = models.ForeignKey(
"MarketOption",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="market_won",
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-created_at"]),
models.Index(fields=["end_date"]),
]
def __str__(self):
return self.title
class MarketOption(BaseModel):
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
text = models.CharField(max_length=255)
class Meta:
ordering = ["text"]
constraints = [
models.UniqueConstraint(
fields=["market", "text"],
name="unique_market_option_text",
),
]
indexes = [
models.Index(fields=["market"]),
]
def __str__(self):
return f"{self.market.title} - {self.text}"
class UserBet(BaseModel):
user = models.ForeignKey(
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
)
option = models.ForeignKey(
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
)
amount = models.PositiveIntegerField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "option"],
name="unique_user_bet_per_option",
),
]
indexes = [
models.Index(fields=["user", "option"]),
]
def __str__(self):
return f"{self.user.username} bet {self.amount} on {self.option.text}"
class UserPointChange(BaseModel):
class Reason(models.TextChoices):
BET_PLACED = "bet_placed", "Bet Placed"
BET_WON = "bet_won", "Bet Won"
BET_LOST = "bet_lost", "Bet Lost"
user = models.ForeignKey(
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
)
market = models.ForeignKey(
Market, on_delete=models.CASCADE, related_name="point_changes"
)
amount = models.IntegerField()
reason = models.CharField(max_length=20, choices=Reason.choices)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "-created_at"]),
]
def __str__(self):
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from typing import List, Optional, Any
from uuid import UUID
from ninja import Schema
from pydantic import field_serializer, model_validator
class MarketOptionSchema(Schema):
uuid: UUID
text: str
total_bets: int = 0
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
class MarketListSchema(Schema):
uuid: UUID
title: str
description: str
status: str
end_date: datetime
multiplier: float = 1.0
created_at: datetime
options: List[MarketOptionSchema]
winning_option: Optional[MarketOptionSchema] = None
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
class ResolveMarketSchema(Schema):
winning_option_uuid: str
class UserBetCreateSchema(Schema):
option_uuid: str
amount: int
class UserBetSchema(Schema):
uuid: UUID
amount: int
created_at: datetime
option: MarketOptionSchema
market: Optional[MarketListSchema] = None
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
@model_validator(mode="before")
@classmethod
def resolve_market_from_option(cls, data: Any) -> Any:
if hasattr(data, "option") and hasattr(data.option, "market"):
data.market = data.option.market
return data
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+8
View File
@@ -0,0 +1,8 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.http import HttpRequest
@login_required
def market_home(request: HttpRequest):
return render(request, "market.html", {})
View File
+72
View File
@@ -0,0 +1,72 @@
from django.contrib import admin
from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
@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",)}),
)
actions = ["validate_submission"]
def validate_submission(self, request, queryset):
for logfile in queryset:
parse_objectives_and_store(logfile)
self.message_user(request, f"{queryset.count()} submissions validated.")
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user", "first_seen_at")
def get_user_objectiv_count(self, obj):
return Objectiv.objects.filter(
objectiv_id=obj.objectiv_id, user=obj.user
).count()
get_user_objectiv_count.short_description = "Count"
@admin.register(ObjectivPoint)
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")}),
)
@admin.register(DeathCounter)
class DeathCounterAdmin(admin.ModelAdmin):
list_display = ("user_id", "seed", "seen_at")
list_filter = ("user_id", "seed")
search_fields = ("user_id", "seed")
fieldsets = (
("User Information", {"fields": ("user_id",)}),
("Scoring", {"fields": ("seed",)}),
)
+301
View File
@@ -0,0 +1,301 @@
from django.http import HttpRequest
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.db.models import (
F,
Case,
When,
Count,
IntegerField,
Subquery,
OuterRef,
)
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.decorators import decorate_view
from noita.schemas import ResultsOut, LeaderboardOut
from noita.services.objectives import parse_objectives_and_store
from games.decorators import require_game_enabled
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
from .schemas import NoitaSubmissionOut
router = Router()
NOITA_APP_ID = 881100
@router.get("results", response=ResultsOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
"""
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.
"""
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
.values("objectiv_id")
.annotate(count=Count("id"))
)
# Fetch points from ObjectivPoint using Subquery
user_objectives = user_objectives.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"),
)
# Annotate seed + first-seen-at
user_objectives = user_objectives.annotate(
seed=F("seed"),
first_seen_at=F("first_seen_at"),
)
# Build response with all objectives and compute total score
total_score = 0
with_points = {}
for obj in ObjectivPoint.objects.all():
with_points[obj.objectiv_id] = {
"objectiv_id": obj.objectiv_id,
"display_string": obj.display_string,
"count": 0,
"max_count": obj.max_count,
"points_per_objectiv": obj.point,
"total_points": 0,
"first_seen_at": None,
"seed": None,
}
for obj in user_objectives.order_by("-total_points"):
points = obj["total_points"] or 0
with_points[obj["objectiv_id"]].update(
{
"count": obj["count"],
"total_points": points,
"first_seen_at": obj["first_seen_at"],
"seed": obj["seed"],
}
)
total_score += points
# Count deaths for the user
deaths_count = DeathCounter.objects.filter(user=request.user).count()
data = {
"total_score": total_score,
"deaths_count": deaths_count,
"objectives": list(with_points.values()),
}
cache.set(f"api:noita:results:{request.user.id}", data, 300)
return data
@router.get("leaderboard", response=LeaderboardOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
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.
"""
cache_key = "api:noita:leaderboard"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
from django.contrib.auth import get_user_model
User = get_user_model()
# Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = (
Objectiv.objects.values("user", "objectiv_id")
.annotate(count=Count("id"))
.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"),
)
)
# Build user totals by iterating through objectives
user_totals_dict = {}
for obj in all_objectives:
user_id = obj["user"]
points = obj["total_points"] or 0
if user_id not in user_totals_dict:
user_totals_dict[user_id] = 0
user_totals_dict[user_id] += points
# Get unique users and their scores, then apply ranking
users_with_scores = []
for user_id, total_score in user_totals_dict.items():
user = User.objects.get(id=user_id)
objectives_count = (
Objectiv.objects.filter(user_id=user_id)
.values("objectiv_id")
.distinct()
.count()
)
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
users_with_scores.append(
{
"user_id": user_id,
"user": user,
"total_score": total_score,
"objectives_count": objectives_count,
"deaths_count": deaths_count,
}
)
# Sort by score and add rank
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
leaderboard = [
{
"rank": idx + 1,
"username": entry["user"].username,
"is_staff": entry["user"].is_staff,
"total_score": entry["total_score"],
"objectives_count": entry["objectives_count"],
"deaths_count": entry["deaths_count"],
}
for idx, entry in enumerate(users_with_scores)
]
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300)
return data
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
@decorate_view(require_game_enabled(NOITA_APP_ID))
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",
]
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
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
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
View 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,77 @@
from django.core.management.base import BaseCommand
from noita.models import ObjectivPoint
from noita.services.decode import POINTS
from noita.services.spells import ALL_PERKS, ALL_SPELLS
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"
)
)
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
op.display_string = f"Spell: {op.display_string}"
op.save()
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
op.display_string = f"Perk: {op.display_string}"
op.save()
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
b, k, c = op.display_string.split(" ")
op.display_string = f"Perk: {op.display_string}"
op.display_string = f"{b} {k} (with {c} orbs)"
op.save()
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()}")
)
@@ -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",
),
]
@@ -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,
),
),
],
),
]
@@ -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",
),
]
@@ -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)),
],
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-05-11 08:20
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0006_objectivpoint"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="count",
),
migrations.AddField(
model_name="objectiv",
name="first_seen_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddConstraint(
model_name="objectiv",
constraint=models.UniqueConstraint(
fields=("objectiv_id", "user", "first_seen_at"),
name="unique_objectiv_per_user_timestamp",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-11 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="seed",
field=models.CharField(default="", max_length=32),
preserve_default=False,
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 08:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0008_objectiv_seed"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="submission",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="noita.logfilesubmission",
),
preserve_default=False,
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-05-14 23:36
from django.db import migrations, models
def fw_func(apps, _schema_editor):
Objectiv = apps.get_model("noita", "Objectiv")
Objectiv.objects.filter(objectiv_id="DEATH").delete()
class Migration(migrations.Migration):
dependencies = [
("noita", "0009_objectiv_submission"),
]
operations = [
migrations.CreateModel(
name="DeathCounter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("seed", models.CharField(max_length=32)),
("seen_at", models.DateTimeField()),
],
),
migrations.RunPython(fw_func, migrations.RunPython.noop),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-05-14 23:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0010_deathcounter"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="deathcounter",
name="user",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
preserve_default=False,
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-05-14 23:45
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0011_deathcounter_user"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="deathcounter",
constraint=models.UniqueConstraint(
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
),
),
]
+84
View File
@@ -0,0 +1,84 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
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)
first_seen_at = models.DateTimeField(default=timezone.now)
seed = models.CharField(max_length=32)
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["objectiv_id", "user", "first_seen_at"],
name="unique_objectiv_per_user_timestamp",
)
]
class ObjectivPoint(models.Model):
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)
class DeathCounter(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user_id", "seen_at"],
name="unique_death_per_seen_at",
)
]
+43
View File
@@ -0,0 +1,43 @@
from typing import Optional
from datetime import datetime
from ninja import Schema
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
display_string: str
first_seen_at: datetime | None
count: int
max_count: int
seed: str | None
points_per_objectiv: int
total_points: int | None
class ResultsOut(Schema):
total_score: int
deaths_count: int
objectives: list[ObjectivResultOut]
class LeaderboardEntryOut(Schema):
rank: int
username: str
is_staff: bool
total_score: int
objectives_count: int
deaths_count: int
class LeaderboardOut(Schema):
leaderboard: list[LeaderboardEntryOut]
+334
View File
@@ -0,0 +1,334 @@
"""
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: 3 for sid in ALL_SPELLS},
"ADD_TRIGGER": 10,
"ADD_TIMER": 10,
"ADD_DEATH_TRIGGER": 10,
"NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5,
"BURST_2": 10,
"BURST_3": 15,
"BURST_4": 20,
"BURST_8": 20,
"BURST_X": 20,
"HEAL_BULLET": 5,
"ANTIHEAL": 5,
"NUKE": 5,
"NUKE_GIGA": 5,
"TELEPORT_PROJECTILE": 5,
"TELEPORT_PROJECTILE_SHORT": 5,
"TOUCH_BLOOD": 100,
"TOUCH_GOLD": 100,
"TOUCH_PISS": 100,
"TOUCH_GRASS": 100,
"TOUCH_OIL": 100,
"TOUCH_SMOKE": 100,
"TOUCH_ALCOHOL": 100,
"TOUCH_WATER": 100,
"SPELLS_TO_POWER": 10,
"ALL_SPELLS": 100,
"DIVIDE_2": 10,
"DIVIDE_3": 15,
"DIVIDE_4": 20,
"DIVIDE_10": 50,
"ALPHA": 50,
"GAMMA": 50,
"MU": 50,
"OMEGA": 50,
"PHI": 50,
"SIGMA": 50,
"TAU": 50,
"ZETA": 50,
"DISC_BULLET_BIGGER": 20,
"SUMMON_WANDGHOST": 50,
"ALL_BLACKHOLES": 20,
"ALL_DEATHCROSSES": 20,
"ALL_ROCKETS": 20,
"ALL_NUKES": 20,
"ALL_DISCS": 20,
# ── Perks ─────────────────────────────────────────────────────────────────
**{pid: 15 for pid in ALL_PERKS},
"PROTECTION_FIRE": 30,
"PROTECTION_RADIOACTIVITY": 30,
"PROTECTION_EXPLOSION": 30,
"PROTECTION_MELEE": 30,
"PROTECTION_ELECTRICITY": 30,
# ── Spell combos ─────────────────────────────────────────────────────────
"PING_PONG_DRILL": 40,
"HEAVY_SHOT_DISC": 40,
"TWO_ARC_MODIFIERS": 50,
"TWO_TRAIL_MODIFIERS": 50,
# ── Perk combos ──────────────────────────────────────────────────────────
"CRIMSON_ALCHEMIST": 50,
"GREEDY_GOBLIN_KING": 50,
"STORM_TOUCHED_ASCENDANT": 50,
"ARCHMAGE_OF_CONTROL": 50,
"HAUNTED_MAGE": 50,
"GLASS_CANNON_MESSIAH": 50,
"INFINITE_ENGINE": 50,
"PERFECT_ACCURACY_LOOP": 50,
"HOMING_DEATH_SWARM": 50,
"UNTOUCHABLE_FIELD": 50,
"ELECTRIC_SUSTAIN_LOOP": 50,
"IMMORTAL_LEECH_CORE": 50,
"PROJECTILE_OVERLOAD": 50,
"CLOSE_RANGE_DEATH_MACHINE": 50,
"CRITICAL_MASS": 50,
"HOLY_MOUNTAIN_ABUSER": 50,
"DEFLECTOR_MATRIX": 50,
"STORMBORNE_LEVITATOR": 50,
# ── Objectives ───────────────────────────────────────────────────────────
"HP_200": 50,
"HP_500": 50,
"HP_2000": 100,
"HP_5000": 500,
"GOLD_1000": 5,
"GOLD_10000": 50,
"GOLD_100000": 100,
"GOLD_1000000": 200,
**{f"ORB_{i}": 25 for i in range(34)},
**{f"BOSS_KILL_{i}": 75 * i for i in range(34)},
"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.")
@@ -0,0 +1,54 @@
from noita.models import LogfileSubmission, Objectiv, DeathCounter
from noita.services.decode import parse_log, resolve
def parse_objectives_from_logfile(
logfile: LogfileSubmission,
) -> list[tuple[str, str, str]]:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
entries: list[tuple[str, str, str]] = []
for entry in parse_log(file_data):
idx, seed = resolve(entry["hash"], entry["ts"])
if idx and seed:
entries.append((idx, str(seed), entry["ts"]))
return entries
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
"""Parse a logfile and store output."""
if not logfile.user:
return
objectives = []
deaths = []
for idx, seed, ts in parse_objectives_from_logfile(logfile):
if idx in {"-", "DEBUG", "polylan-mod"}:
continue
if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
continue
objectives.append(
Objectiv(
objectiv_id=idx,
user=logfile.user,
first_seen_at=ts,
seed=seed,
submission=logfile,
)
)
Objectiv.objects.bulk_create(
objectives,
update_conflicts=True,
update_fields=["seed", "submission"],
unique_fields=["objectiv_id", "user", "first_seen_at"],
)
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
+535
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",
]
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/',
});
@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.utils import timezone from django.utils import timezone
from .models import ( from opus_magnum.models import (
SteamAPIKey, SteamAPIKey,
SteamCollection, SteamCollection,
SteamCollectionItem, SteamCollectionItem,
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
"current_favorites", "current_favorites",
"last_fetched", "last_fetched",
"is_active", "is_active",
"accepting_submissions",
] ]
list_filter = ["is_active", "last_fetched", "created_at"] list_filter = ["is_active", "last_fetched", "created_at"]
search_fields = ["title", "steam_id", "author_name", "description"] search_fields = ["title", "steam_id", "author_name", "description"]
@@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
) )
}, },
), ),
("Status", {"fields": ("fetch_error",)}), ("Status", {"fields": ("fetch_error", "accepting_submissions")}),
) )
@@ -148,6 +149,7 @@ class SteamCollectionItemAdmin(admin.ModelAdmin):
("Author Information", {"fields": ("author_name", "author_steam_id")}), ("Author Information", {"fields": ("author_name", "author_steam_id")}),
("Metadata", {"fields": ("tags",)}), ("Metadata", {"fields": ("tags",)}),
("Timestamps", {"fields": ("created_at", "updated_at")}), ("Timestamps", {"fields": ("created_at", "updated_at")}),
("Points factor", {"fields": ("points_factor", "points_value")}),
) )
@@ -1,27 +1,40 @@
from ninja import Router, File from ninja import Router, File
from ninja.files import UploadedFile from ninja.files import UploadedFile
from ninja.pagination import paginate from ninja.pagination import paginate
from ninja.errors import HttpError
from django.db import transaction from django.db import transaction
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.cache import cache
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from typing import List from typing import List
from opus_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission from games.decorators import require_game_enabled
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from ninja.decorators import decorate_view
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
from .schemas import ( from .schemas import (
SubmissionIn, SubmissionIn,
SubmissionOut, SubmissionOut,
PuzzleResponseOut, PuzzleResponseOut,
ValidationIn, ValidationIn,
SteamCollectionItemOut, SteamCollectionItemOut,
SteamCollectionOut,
) )
router = Router() router = Router()
OPUS_MAGNUM_APP_ID = 558990
@router.get("/puzzles", response=List[SteamCollectionItemOut]) @router.get("/puzzles", response=List[SteamCollectionItemOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_puzzles(request): def list_puzzles(request):
"""Get list of available puzzles""" """Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").filter( return SteamCollectionItem.objects.select_related("collection").filter(
@@ -29,7 +42,16 @@ def list_puzzles(request):
) )
@router.get("/collection", response=SteamCollectionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_collection(request):
"""Get the active collection details"""
collection = get_object_or_404(SteamCollection, is_active=True)
return collection
@router.get("/submissions", response=List[SubmissionOut]) @router.get("/submissions", response=List[SubmissionOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
@paginate @paginate
def list_submissions(request): def list_submissions(request):
"""Get paginated list of submissions""" """Get paginated list of submissions"""
@@ -39,6 +61,7 @@ def list_submissions(request):
@router.get("/submissions/{submission_id}", response=SubmissionOut) @router.get("/submissions/{submission_id}", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_submission(request, submission_id: str): def get_submission(request, submission_id: str):
"""Get detailed submission by ID""" """Get detailed submission by ID"""
return get_object_or_404( return get_object_or_404(
@@ -50,6 +73,7 @@ def get_submission(request, submission_id: str):
@router.post("/submissions", response=SubmissionOut) @router.post("/submissions", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission( def create_submission(
request, data: SubmissionIn, files: List[UploadedFile] = File(...) request, data: SubmissionIn, files: List[UploadedFile] = File(...)
): ):
@@ -64,7 +88,15 @@ def create_submission(
if len(files) < len(data.responses): if len(files) < len(data.responses):
return 400, {"detail": "Not enough files for all responses"} return 400, {"detail": "Not enough files for all responses"}
print(data, files) # Check if collection is accepting submissions
if data.responses:
for puzzle in data.responses:
if not get_object_or_404(
SteamCollectionItem, id=puzzle.puzzle_id
).collection.accepting_submissions:
raise HttpError(
403, "This tournament is no longer accepting submissions"
)
try: try:
with transaction.atomic(): with transaction.atomic():
@@ -112,12 +144,19 @@ def create_submission(
ocr_confidence_cost=response_data.ocr_confidence_cost, ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles, ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area, ocr_confidence_area=response_data.ocr_confidence_area,
**{
# Put validated if not manual validation is needed
"validated_cost": response_data.cost,
"validated_cycles": response_data.cycles,
"validated_area": response_data.area,
}
if not data.manual_validation_requested
else {},
) )
# Process files for this response # Process files for this response
# For simplicity, we'll take one file per response # For simplicity, we'll take one file per response
# In a real implementation, you'd need better file-to-response mapping # In a real implementation, you'd need better file-to-response mapping
print("FI", file_index, files)
if file_index < len(files): if file_index < len(files):
uploaded_file = files[file_index] uploaded_file = files[file_index]
@@ -157,14 +196,17 @@ def create_submission(
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission.id) ).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
return submission return submission
except Exception as e: except Exception as e:
print(e)
return 500, {"detail": f"Error creating submission: {str(e)}"} return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut) @router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_response(request, response_id: int, data: ValidationIn): def validate_response(request, response_id: int, data: ValidationIn):
"""Manually validate a puzzle response""" """Manually validate a puzzle response"""
@@ -193,11 +235,15 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save() response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
return response return response
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut) @router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
def validate_response(request, response_id: int): @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, response_id: int):
"""Try to auto validate a puzzle response""" """Try to auto validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff: if not request.user.is_authenticated or not request.user.is_staff:
@@ -212,6 +258,7 @@ def validate_response(request, response_id: int):
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut]) @router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_responses_needing_validation(request): def list_responses_needing_validation(request):
"""Get all responses that need manual validation""" """Get all responses that need manual validation"""
@@ -227,6 +274,7 @@ def list_responses_needing_validation(request):
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut) @router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_submission(request, submission_id: str): def validate_submission(request, submission_id: str):
"""Mark entire submission as validated""" """Mark entire submission as validated"""
@@ -248,10 +296,14 @@ def validate_submission(request, submission_id: str):
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission.id) ).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
return submission return submission
@router.delete("/submissions/{submission_id}") @router.delete("/submissions/{submission_id}")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, submission_id: str): def delete_submission(request, submission_id: str):
"""Delete a submission (admin only)""" """Delete a submission (admin only)"""
@@ -260,10 +312,15 @@ def delete_submission(request, submission_id: str):
submission = get_object_or_404(Submission, id=submission_id) submission = get_object_or_404(Submission, id=submission_id)
submission.delete() submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
return {"detail": "Submission deleted successfully"} return {"detail": "Submission deleted successfully"}
@router.get("/stats") @router.get("/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request): def get_stats(request):
"""Get submission statistics""" """Get submission statistics"""
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class OpusMagnumConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "opus_magnum"
@@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
""" """
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from submissions.utils import create_or_update_collection from opus_magnum.utils import create_or_update_collection
from submissions.models import SteamAPIKey, SteamCollection from opus_magnum.models import SteamAPIKey, SteamCollection
class Command(BaseCommand): class Command(BaseCommand):
@@ -27,14 +27,14 @@ class Command(BaseCommand):
api_key = SteamAPIKey.objects.filter(is_active=True).first() api_key = SteamAPIKey.objects.filter(is_active=True).first()
if not api_key: if not api_key:
self.stderr.write(f"No API key defined! Aborting...") self.stderr.write("No API key defined! Aborting...")
return return
self.stdout.write(f"Using api key: {api_key}") self.stdout.write(f"Using api key: {api_key}")
try: try:
# Check if collection already exists # Check if collection already exists
from submissions.utils import SteamCollectionFetcher from opus_magnum.utils import SteamCollectionFetcher
fetcher = SteamCollectionFetcher(api_key.api_key) fetcher = SteamCollectionFetcher(api_key.api_key)
collection_id = fetcher.extract_collection_id(url) collection_id = fetcher.extract_collection_id(url)

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