feat(games): add games to disable + path

This commit is contained in:
Loïc Gremaud 2026-05-23 14:18:44 +02:00
parent 9f94fb3974
commit 544112b204
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
20 changed files with 235 additions and 27 deletions

View File

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"]

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)

View File

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

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

View File

@ -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"],
},
),
]

View File

@ -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),
]

View File

@ -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),
]

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})"

View File

@ -0,0 +1,7 @@
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
name: str
path: str

View File

@ -12,18 +12,22 @@ from django.db.models import (
)
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)
@ -127,6 +131,7 @@ def get_results(request: HttpRequest):
@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.
@ -232,6 +237,7 @@ def get_leaderboard(request: HttpRequest):
@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).

View File

@ -72,7 +72,6 @@ POINTS = {
"NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5,
"OMEGA": 10,
"BURST_2": 10,
"BURST_3": 15,
"BURST_4": 20,

View File

@ -5,6 +5,7 @@ from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from animations.api import router as results_router
from noita.api import router as noita_router
from games.api import router as games_router
# Create the main API instance
api = NinjaAPI(
@ -34,6 +35,7 @@ The Noita Submission API allows clients to upload the result of the log file of
api.add_router("/submissions/", submissions_router, tags=["submissions"])
api.add_router("/results/", results_router, tags=["results"])
api.add_router("/noita/", noita_router, tags=["noita"])
api.add_router("/games/", games_router, tags=["games"])
# Health check endpoint

View File

@ -43,6 +43,7 @@ INSTALLED_APPS = [
"animations",
"submissions",
"noita",
"games",
]
MIDDLEWARE = [
@ -193,7 +194,7 @@ STATICFILES_DIRS = [
from polylan_submitter.settingsLocal import * # noqa
import sentry_sdk
import sentry_sdk # noqa
sentry_sdk.init(
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",

View File

@ -23,8 +23,12 @@ from django.contrib.auth.decorators import login_required
from django.conf import settings
from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from games.decorators import require_game_enabled
from .api import api
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
@login_required
def home(request: HttpRequest):
@ -32,6 +36,7 @@ def home(request: HttpRequest):
@login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest):
from submissions.models import SteamCollection
@ -45,6 +50,7 @@ def opus_magnum_home(request: HttpRequest):
@login_required
@require_game_enabled(NOITA_APP_ID)
def noita_home(request: HttpRequest):
return render(request, "noita.html", {})

View File

@ -1,22 +1,10 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { ref, onMounted } from "vue";
import { apiService } from "./services/apiService";
import type { Game } from "./types";
const games = computed(() => [
{
id: "opus-magnum",
title: "Opus Magnum",
description: "Submit your best Opus Magnum puzzle solutions",
appId: 558990,
path: "/opus-magnum",
},
{
id: "noita",
title: "Noita",
description: "Submit your greatest Noita achievements",
appId: 881100,
path: "/noita",
},
]);
const games = ref<Game[]>([]);
const loading = ref(true);
const imageErrors = ref<Set<number>>(new Set());
@ -31,6 +19,14 @@ const onImageError = (appId: number) => {
const navigate = (path: string) => {
window.location.href = path;
};
onMounted(async () => {
const response = await apiService.getGames();
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script>
<template>
@ -44,13 +40,18 @@ const navigate = (path: string) => {
</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
<i class="mdi mdi-gamepad-variant text-5xl"></i>
@ -58,8 +59,7 @@ const navigate = (path: string) => {
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ game.title }}</h2>
<p class="text-base-content/70">{{ game.description }}</p>
<h2 class="card-title text-2xl">{{ game.name }}</h2>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
@ -75,7 +75,5 @@ const navigate = (path: string) => {
<p>Select a game above to begin submitting</p>
</div>
</div>
</div>
</template>

View File

@ -1,4 +1,5 @@
import type {
Game,
SteamCollection,
SteamCollectionItem,
Submission,
@ -99,6 +100,11 @@ export class ApiService {
}
}
// Games endpoint
async getGames(): Promise<ApiResponse<Game[]>> {
return this.request<Game[]>('/games/')
}
// Puzzle endpoints
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')

View File

@ -1,3 +1,9 @@
export interface Game {
steam_app_id: number
name: string
path: string
}
export interface SteamCollection {
id: number
steam_id: string

View File

@ -9,7 +9,9 @@ from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
from games.decorators import require_game_enabled
from submissions.utils import verify_and_validate_ocr_date_for_submission
from ninja.decorators import decorate_view
from .models import (
Submission,
@ -28,9 +30,11 @@ from .schemas import (
)
router = Router()
OPUS_MAGNUM_APP_ID = 558990
@router.get("/puzzles", response=List[SteamCollectionItemOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_puzzles(request):
"""Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").filter(
@ -39,6 +43,7 @@ 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)
@ -46,6 +51,7 @@ def get_collection(request):
@router.get("/submissions", response=List[SubmissionOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
@paginate
def list_submissions(request):
"""Get paginated list of submissions"""
@ -55,6 +61,7 @@ def list_submissions(request):
@router.get("/submissions/{submission_id}", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_submission(request, submission_id: str):
"""Get detailed submission by ID"""
return get_object_or_404(
@ -66,6 +73,7 @@ def get_submission(request, submission_id: str):
@router.post("/submissions", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission(
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
):
@ -198,6 +206,7 @@ def create_submission(
@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):
"""Manually validate a puzzle response"""
@ -233,6 +242,7 @@ def validate_response(request, response_id: int, data: ValidationIn):
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, response_id: int):
"""Try to auto validate a puzzle response"""
@ -248,6 +258,7 @@ def validate_auto(request, response_id: int):
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_responses_needing_validation(request):
"""Get all responses that need manual validation"""
@ -263,6 +274,7 @@ def list_responses_needing_validation(request):
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_submission(request, submission_id: str):
"""Mark entire submission as validated"""
@ -291,6 +303,7 @@ def validate_submission(request, submission_id: str):
@router.delete("/submissions/{submission_id}")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, submission_id: str):
"""Delete a submission (admin only)"""
@ -307,6 +320,7 @@ def delete_submission(request, submission_id: str):
@router.get("/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request):
"""Get submission statistics"""