Compare commits

..
36 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
134 changed files with 8331 additions and 817 deletions
+3
View File
@@ -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),
),
]
+3
View File
@@ -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})"
+195 -2
View File
@@ -1,11 +1,23 @@
from collections import defaultdict from collections import defaultdict
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Router from ninja import Router
from ninja.errors import HttpError
from django.core.cache import cache from django.core.cache import cache
from django.shortcuts import get_object_or_404
from accounts.models import CustomUser from accounts.models import CustomUser
from animations.schemas import RankingSchema from animations.schemas import (
from submissions.models import PuzzleResponse, SteamCollectionItem RankingSchema,
TournamentSubmissionsOut,
TournamentPuzzleResultsOut,
PuzzleSubmissionsOut,
PuzzleResultsOut,
PuzzleSubmissionWithRankOut,
PuzzlePointsFactorOut,
WinnerResponseOut,
WinnerFileOut,
)
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router() router = Router()
@@ -46,3 +58,184 @@ def results(request: HttpRequest) -> dict:
cache.set("api:results:results", data, 300) cache.set("api:results:results", data, 300)
return data 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
+75 -2
View File
@@ -1,7 +1,8 @@
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from typing import List, Optional
from submissions.models import PuzzleResponse from opus_magnum.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut from opus_magnum.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema): class PuzzleResponseRankingOut(ModelSchema):
@@ -33,6 +34,7 @@ class PuzzleResponseRankingOut(ModelSchema):
class UserDisplayOut(Schema): class UserDisplayOut(Schema):
id: int id: int
username: str username: str
is_staff: bool
class RankingSchema(Schema): class RankingSchema(Schema):
@@ -40,3 +42,74 @@ class RankingSchema(Schema):
puzzles: list[SteamCollectionItemOut] puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]] responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: 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)
@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class SubmissionsConfig(AppConfig): class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "submissions" 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
+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
+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", {})
+32 -3
View File
@@ -1,5 +1,7 @@
from django.contrib import admin from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
@admin.register(LogfileSubmission) @admin.register(LogfileSubmission)
@@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
("Processing", {"fields": ("processed",)}), ("Processing", {"fields": ("processed",)}),
) )
actions = ["validate_submission"]
def validate_submission(self, request, queryset):
for logfile in queryset:
parse_objectives_and_store(logfile)
self.message_user(request, f"{queryset.count()} submissions validated.")
@admin.register(Objectiv) @admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin): class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count") list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
list_filter = ("objectiv_id", "user") list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username") search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",) readonly_fields = ("user", "first_seen_at")
def get_user_objectiv_count(self, obj):
return Objectiv.objects.filter(
objectiv_id=obj.objectiv_id, user=obj.user
).count()
get_user_objectiv_count.short_description = "Count"
@admin.register(ObjectivPoint) @admin.register(ObjectivPoint)
@@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
("Objective Information", {"fields": ("objectiv_id", "display_string")}), ("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}), ("Scoring", {"fields": ("max_count", "point")}),
) )
@admin.register(DeathCounter)
class DeathCounterAdmin(admin.ModelAdmin):
list_display = ("user_id", "seed", "seen_at")
list_filter = ("user_id", "seed")
search_fields = ("user_id", "seed")
fieldsets = (
("User Information", {"fields": ("user_id",)}),
("Scoring", {"fields": ("seed",)}),
)
+92 -60
View File
@@ -5,33 +5,29 @@ from django.db.models import (
F, F,
Case, Case,
When, When,
Sum,
Count, Count,
IntegerField, IntegerField,
Subquery, Subquery,
OuterRef, OuterRef,
Window,
) )
from django.db.models.functions import Rank
from ninja import Router, File from ninja import Router, File
from ninja.files import UploadedFile from ninja.files import UploadedFile
from ninja.decorators import decorate_view
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut from noita.schemas import ResultsOut, LeaderboardOut
from noita.services.objectives import parse_objectives_and_store from noita.services.objectives import parse_objectives_and_store
from games.decorators import require_game_enabled
from .models import LogfileSubmission, Objectiv, ObjectivPoint from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
from .schemas import NoitaSubmissionOut from .schemas import NoitaSubmissionOut
router = Router() router = Router()
NOITA_APP_ID = 881100
@router.get("objectives", response=list[ObjectivOut])
def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user)
@router.get("results", response=ResultsOut) @router.get("results", response=ResultsOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_results(request: HttpRequest): def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}" cache_key = f"api:noita:results:{request.user.id}"
cached_data = cache.get(cache_key) cached_data = cache.get(cache_key)
@@ -44,8 +40,15 @@ def get_results(request: HttpRequest):
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
Uses Django ORM annotate for efficient queryset computation. Uses Django ORM annotate for efficient queryset computation.
""" """
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
.values("objectiv_id")
.annotate(count=Count("id"))
)
# Fetch points from ObjectivPoint using Subquery # Fetch points from ObjectivPoint using Subquery
user_objectives = Objectiv.objects.filter(user=request.user).annotate( user_objectives = user_objectives.annotate(
# Get points per objective from ObjectivPoint # Get points per objective from ObjectivPoint
points_per_objectiv=Subquery( points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values( ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
@@ -81,26 +84,46 @@ def get_results(request: HttpRequest):
total_points=F("points_per_objectiv") * F("capped_count"), total_points=F("points_per_objectiv") * F("capped_count"),
) )
# Get total score # Annotate seed + first-seen-at
total_score_result = user_objectives.aggregate(Sum("total_points"))[ user_objectives = user_objectives.annotate(
"total_points__sum" seed=F("seed"),
] first_seen_at=F("first_seen_at"),
total_score = total_score_result or 0 )
# Build response with all objectives # Build response with all objectives and compute total score
objectives_with_points = [ total_score = 0
{ with_points = {}
for obj in ObjectivPoint.objects.all():
with_points[obj.objectiv_id] = {
"objectiv_id": obj.objectiv_id, "objectiv_id": obj.objectiv_id,
"count": obj.count, "display_string": obj.display_string,
"points_per_objectiv": obj.points_per_objectiv or 0, "count": 0,
"total_points": obj.total_points or 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")
] 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 = { data = {
"total_score": total_score, "total_score": total_score,
"objectives": objectives_with_points, "deaths_count": deaths_count,
"objectives": list(with_points.values()),
} }
cache.set(f"api:noita:results:{request.user.id}", data, 300) cache.set(f"api:noita:results:{request.user.id}", data, 300)
@@ -108,6 +131,7 @@ def get_results(request: HttpRequest):
@router.get("leaderboard", response=LeaderboardOut) @router.get("leaderboard", response=LeaderboardOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_leaderboard(request: HttpRequest): def get_leaderboard(request: HttpRequest):
""" """
Get the global leaderboard for all users ranked by total score. Get the global leaderboard for all users ranked by total score.
@@ -124,9 +148,11 @@ def get_leaderboard(request: HttpRequest):
User = get_user_model() User = get_user_model()
# Get all objectives with calculated points # Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = ( all_objectives = (
Objectiv.objects.annotate( Objectiv.objects.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery # Fetch points from ObjectivPoint using Subquery
points_per_objectiv=Subquery( points_per_objectiv=Subquery(
ObjectivPoint.objects.filter( ObjectivPoint.objects.filter(
@@ -161,51 +187,57 @@ def get_leaderboard(request: HttpRequest):
) )
) )
# Get user totals using subquery # Build user totals by iterating through objectives
user_totals = ( user_totals_dict = {}
all_objectives.values("user") for obj in all_objectives:
.annotate(total_score=Sum("total_points")) user_id = obj["user"]
.values("user", "total_score") points = obj["total_points"] or 0
) if user_id not in user_totals_dict:
user_totals_dict[user_id] = 0
user_totals_dict[user_id] += points
# Get unique users and their scores, then apply ranking # Get unique users and their scores, then apply ranking
leaderboard = ( users_with_scores = []
User.objects.filter(objectiv__isnull=False) for user_id, total_score in user_totals_dict.items():
user = User.objects.get(id=user_id)
objectives_count = (
Objectiv.objects.filter(user_id=user_id)
.values("objectiv_id")
.distinct() .distinct()
.annotate( .count()
total_score=Subquery(
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
output_field=IntegerField(),
) )
) deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
.annotate(objectives_count=Count("objectiv", distinct=True)) users_with_scores.append(
.annotate(
rank=Window(
expression=Rank(),
order_by=F("total_score").desc(),
)
)
.values("rank", "username", "total_score", "objectives_count")
.order_by("rank")
)
data = {
"leaderboard": [
{ {
"rank": entry["rank"], "user_id": user_id,
"username": entry["username"], "user": user,
"total_score": entry["total_score"] or 0, "total_score": total_score,
"objectives_count": entry["objectives_count"], "objectives_count": objectives_count,
} "deaths_count": deaths_count,
for entry in leaderboard
]
} }
)
# Sort by score and add rank
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
leaderboard = [
{
"rank": idx + 1,
"username": entry["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) cache.set("api:noita:leaderboard", data, 300)
return data return data
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict}) @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(...)): def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
""" """
Submit a Noita run file (log file, screenshot, or video). Submit a Noita run file (log file, screenshot, or video).
@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from noita.models import ObjectivPoint from noita.models import ObjectivPoint
from noita.services.decode import POINTS from noita.services.decode import POINTS
from noita.services.spells import ALL_PERKS, ALL_SPELLS
class Command(BaseCommand): class Command(BaseCommand):
@@ -55,6 +56,20 @@ class Command(BaseCommand):
) )
) )
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"\nCreated: {created_count}"))
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}")) self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
self.stdout.write( self.stdout.write(
@@ -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"
),
),
]
+26 -1
View File
@@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils import timezone
import uuid import uuid
@@ -49,7 +50,17 @@ class Objectiv(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1) first_seen_at = models.DateTimeField(default=timezone.now)
seed = models.CharField(max_length=32)
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["objectiv_id", "user", "first_seen_at"],
name="unique_objectiv_per_user_timestamp",
)
]
class ObjectivPoint(models.Model): class ObjectivPoint(models.Model):
@@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
display_string = models.CharField(max_length=255) display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1) max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0) point = models.IntegerField(default=0)
class DeathCounter(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user_id", "seen_at"],
name="unique_death_per_seen_at",
)
]
+9 -10
View File
@@ -1,14 +1,6 @@
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from ninja import Schema, ModelSchema from ninja import Schema
from noita.models import Objectiv
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
class NoitaSubmissionOut(Schema): class NoitaSubmissionOut(Schema):
@@ -23,21 +15,28 @@ class NoitaSubmissionOut(Schema):
class ObjectivResultOut(Schema): class ObjectivResultOut(Schema):
objectiv_id: str objectiv_id: str
display_string: str
first_seen_at: datetime | None
count: int count: int
max_count: int
seed: str | None
points_per_objectiv: int points_per_objectiv: int
total_points: int total_points: int | None
class ResultsOut(Schema): class ResultsOut(Schema):
total_score: int total_score: int
deaths_count: int
objectives: list[ObjectivResultOut] objectives: list[ObjectivResultOut]
class LeaderboardEntryOut(Schema): class LeaderboardEntryOut(Schema):
rank: int rank: int
username: str username: str
is_staff: bool
total_score: int total_score: int
objectives_count: int objectives_count: int
deaths_count: int
class LeaderboardOut(Schema): class LeaderboardOut(Schema):
@@ -72,7 +72,6 @@ POINTS = {
"NOLLA": 10, "NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10, "CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5, "DUPLICATE": 5,
"OMEGA": 10,
"BURST_2": 10, "BURST_2": 10,
"BURST_3": 15, "BURST_3": 15,
"BURST_4": 20, "BURST_4": 20,
+30 -16
View File
@@ -1,22 +1,21 @@
from noita.models import LogfileSubmission, Objectiv from noita.models import LogfileSubmission, Objectiv, DeathCounter
from noita.services.decode import parse_log, resolve from noita.services.decode import parse_log, resolve
from collections import Counter def parse_objectives_from_logfile(
logfile: LogfileSubmission,
) -> list[tuple[str, str, str]]:
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
"""Parse a log file, and output a count for each ID.""" """Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode() file_data = logfile.file.read().decode()
ids = [] entries: list[tuple[str, str, str]] = []
for entry in parse_log(file_data): for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"]) idx, seed = resolve(entry["hash"], entry["ts"])
if idx: if idx and seed:
ids.append(idx) entries.append((idx, str(seed), entry["ts"]))
return Counter(ids) return entries
def parse_objectives_and_store(logfile: LogfileSubmission) -> None: def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
@@ -25,16 +24,31 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
if not logfile.user: if not logfile.user:
return return
counter = parse_objectives_from_logfile(logfile) objectives = []
deaths = []
for idx, count in counter.items(): for idx, seed, ts in parse_objectives_from_logfile(logfile):
if idx in {"-", "DEBUG", "polylan-mod"}: if idx in {"-", "DEBUG", "polylan-mod"}:
continue continue
obj, created = Objectiv.objects.get_or_create( if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
continue
objectives.append(
Objectiv(
objectiv_id=idx, objectiv_id=idx,
user=logfile.user, user=logfile.user,
first_seen_at=ts,
seed=seed,
submission=logfile,
)
) )
obj.count += count Objectiv.objects.bulk_create(
obj.save(update_fields=["count"]) objectives,
update_conflicts=True,
update_fields=["seed", "submission"],
unique_fields=["objectiv_id", "user", "first_seen_at"],
)
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
+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 submissions.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")}),
) )
@@ -1,6 +1,7 @@
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.core.cache import cache
@@ -8,21 +9,32 @@ 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 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(
@@ -30,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"""
@@ -40,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(
@@ -51,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(...)
): ):
@@ -65,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():
@@ -126,7 +157,6 @@ def create_submission(
# 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]
@@ -172,11 +202,11 @@ def create_submission(
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"""
@@ -212,6 +242,7 @@ def validate_response(request, response_id: int, data: ValidationIn):
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut) @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): def validate_auto(request, response_id: int):
"""Try to auto validate a puzzle response""" """Try to auto validate a puzzle response"""
@@ -227,6 +258,7 @@ def validate_auto(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"""
@@ -242,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"""
@@ -270,6 +303,7 @@ def validate_submission(request, submission_id: str):
@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)"""
@@ -286,6 +320,7 @@ def delete_submission(request, submission_id: str):
@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):
@@ -34,7 +34,7 @@ class Command(BaseCommand):
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)
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from submissions.utils import verify_and_validate_ocr_date_for_submission from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from submissions.models import SubmissionFile from opus_magnum.models import SubmissionFile
class Command(BaseCommand): class Command(BaseCommand):
@@ -205,7 +205,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="items", related_name="items",
to="submissions.steamcollection", to="opus_magnum.steamcollection",
), ),
), ),
], ],
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0001_initial"), ("opus_magnum", "0001_initial"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0002_delete_collection"), ("opus_magnum", "0002_delete_collection"),
] ]
operations = [ operations = [
@@ -1,7 +1,7 @@
# Generated by Django 5.2.7 on 2025-10-29 01:32 # Generated by Django 5.2.7 on 2025-10-29 01:32
import django.db.models.deletion import django.db.models.deletion
import submissions.models import opus_magnum.models
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0003_steamapikey"), ("opus_magnum", "0003_steamapikey"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -160,7 +160,7 @@ class Migration(migrations.Migration):
help_text="The puzzle this response is for", help_text="The puzzle this response is for",
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="responses", related_name="responses",
to="submissions.steamcollectionitem", to="opus_magnum.steamcollectionitem",
), ),
), ),
( (
@@ -168,7 +168,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="responses", related_name="responses",
to="submissions.submission", to="opus_magnum.submission",
), ),
), ),
], ],
@@ -195,7 +195,7 @@ class Migration(migrations.Migration):
"file", "file",
models.FileField( models.FileField(
help_text="Uploaded file (image/gif)", help_text="Uploaded file (image/gif)",
upload_to=submissions.models.submission_file_upload_path, upload_to=opus_magnum.models.submission_file_upload_path,
), ),
), ),
( (
@@ -239,7 +239,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="files", related_name="files",
to="submissions.puzzleresponse", to="opus_magnum.puzzleresponse",
), ),
), ),
], ],
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0004_submission_puzzleresponse_submissionfile"), ("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0005_alter_submission_notes"), ("opus_magnum", "0005_alter_submission_notes"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"), ("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0007_submission_manual_validation_requested"), ("opus_magnum", "0007_submission_manual_validation_requested"),
] ]
operations = [ operations = [
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("animations", "0001_initial"), ("animations", "0001_initial"),
("submissions", "0008_alter_puzzleresponse_unique_together"), ("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0009_steamcollectionitem_points_factor"), ("opus_magnum", "0009_steamcollectionitem_points_factor"),
] ]
operations = [ operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"), ("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
] ]
operations = [ operations = [
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
( (
"submissions", "opus_magnum",
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more", "0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
), ),
] ]
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("animations", "0002_puzzlepointsvalue"), ("animations", "0002_puzzlepointsvalue"),
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"), ("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
] ]
operations = [ operations = [
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-05-21 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0013_steamcollectionitem_points_value"),
]
operations = [
migrations.AddField(
model_name="steamcollection",
name="accepting_submissions",
field=models.BooleanField(
default=True,
help_text="Whether the tournament is accepting new submissions",
),
),
]
@@ -0,0 +1,66 @@
# Data migration: Copy all data from submissions app tables to opus_magnum app tables
from django.db import migrations
def migrate_data_forward(apps, schema_editor):
"""Copy data from submissions_* tables to opus_magnum_* tables"""
# Use raw SQL to copy data while preserving all fields
with schema_editor.connection.cursor() as cursor:
# Copy SteamAPIKey
cursor.execute("""
INSERT INTO opus_magnum_steamapikey
SELECT * FROM submissions_steamapikey
""")
# Copy SteamCollection
cursor.execute("""
INSERT INTO opus_magnum_steamcollection
SELECT * FROM submissions_steamcollection
""")
# Copy SteamCollectionItem
cursor.execute("""
INSERT INTO opus_magnum_steamcollectionitem
SELECT * FROM submissions_steamcollectionitem
""")
# Copy Submission
cursor.execute("""
INSERT INTO opus_magnum_submission
SELECT * FROM submissions_submission
""")
# Copy PuzzleResponse
cursor.execute("""
INSERT INTO opus_magnum_puzzleresponse
SELECT * FROM submissions_puzzleresponse
""")
# Copy SubmissionFile
cursor.execute("""
INSERT INTO opus_magnum_submissionfile
SELECT * FROM submissions_submissionfile
""")
def migrate_data_backward(apps, schema_editor):
"""Delete all data from opus_magnum_* tables"""
with schema_editor.connection.cursor() as cursor:
# Delete in reverse order of foreign key dependencies
cursor.execute("DELETE FROM opus_magnum_submissionfile")
cursor.execute("DELETE FROM opus_magnum_puzzleresponse")
cursor.execute("DELETE FROM opus_magnum_submission")
cursor.execute("DELETE FROM opus_magnum_steamcollectionitem")
cursor.execute("DELETE FROM opus_magnum_steamcollection")
cursor.execute("DELETE FROM opus_magnum_steamapikey")
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0014_steamcollection_accepting_submissions"),
]
operations = [
migrations.RunPython(migrate_data_forward, migrate_data_backward),
]
@@ -157,6 +157,9 @@ class SteamCollection(models.Model):
is_active = models.BooleanField( is_active = models.BooleanField(
default=True, help_text="Whether this collection is actively tracked" default=True, help_text="Whether this collection is actively tracked"
) )
accepting_submissions = models.BooleanField(
default=True, help_text="Whether the tournament is accepting new submissions"
)
fetch_error = models.TextField( fetch_error = models.TextField(
blank=True, help_text="Last error encountered when fetching data" blank=True, help_text="Last error encountered when fetching data"
) )
@@ -3,7 +3,13 @@ from typing import List, Optional
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
# Input Schemas # Input Schemas
@@ -132,10 +138,39 @@ class ValidationIn(Schema):
# Collection Schemas # Collection Schemas
class PuzzlePointsFactorOut(Schema):
"""Schema for puzzle points factor output"""
cost: int
cycles: int
area: int
class SteamCollectionOut(ModelSchema):
"""Schema for Steam collection output"""
class Meta:
model = SteamCollection
fields = [
"id",
"steam_id",
"title",
"description",
"author_name",
"total_items",
"unique_visitors",
"current_favorites",
"accepting_submissions",
"created_at",
"updated_at",
]
class SteamCollectionItemOut(ModelSchema): class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output""" """Schema for Steam collection item output"""
steam_url: str steam_url: str
points_factor: Optional[PuzzlePointsFactorOut] = None
class Meta: class Meta:
model = SteamCollectionItem model = SteamCollectionItem
@@ -151,6 +186,16 @@ class SteamCollectionItemOut(ModelSchema):
"updated_at", "updated_at",
] ]
@staticmethod
def resolve_points_factor(obj) -> Optional[PuzzlePointsFactorOut]:
if obj.points_factor:
return PuzzlePointsFactorOut(
cost=obj.points_factor.cost,
cycles=obj.points_factor.cycles,
area=obj.points_factor.area,
)
return None
# Error Schemas # Error Schemas
class ErrorOut(Schema): class ErrorOut(Schema):
@@ -176,6 +221,7 @@ class UserInfoOut(Schema):
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
points: int = 0
is_authenticated: bool is_authenticated: bool
is_staff: bool is_staff: bool
is_superuser: bool is_superuser: bool
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
@@ -4,7 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re import re
import requests import requests
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
from datetime import datetime from datetime import datetime
from django.utils import timezone from django.utils import timezone
from django.conf import settings from django.conf import settings
+4 -1
View File
@@ -7,12 +7,14 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"schema": "openapi-ts"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"dayjs": "^1.11.20",
"install": "^0.13.0", "install": "^0.13.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
@@ -20,6 +22,7 @@
"vue": "^3.5.22" "vue": "^3.5.22"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.97.2",
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
+390
View File
@@ -17,6 +17,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3)) version: 14.0.0(vue@3.5.22(typescript@5.9.3))
dayjs:
specifier: ^1.11.20
version: 1.11.20
install: install:
specifier: ^0.13.0 specifier: ^0.13.0
version: 0.13.0 version: 0.13.0
@@ -33,6 +36,9 @@ importers:
specifier: ^3.5.22 specifier: ^3.5.22
version: 3.5.22(typescript@5.9.3) version: 3.5.22(typescript@5.9.3)
devDependencies: devDependencies:
'@hey-api/openapi-ts':
specifier: ^0.97.2
version: 0.97.2(typescript@5.9.3)
'@mdi/font': '@mdi/font':
specifier: ^7.4.47 specifier: ^7.4.47
version: 7.4.47 version: 7.4.47
@@ -233,6 +239,31 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@hey-api/codegen-core@0.8.1':
resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==}
engines: {node: '>=22.13.0'}
'@hey-api/json-schema-ref-parser@1.4.2':
resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==}
engines: {node: '>=22.13.0'}
'@hey-api/openapi-ts@0.97.2':
resolution: {integrity: sha512-nA+y0/I5O9loQMeJKumi6BQ40/Y71N0hIMmXZ/I7rh8jEOzYxSxmf5a4TBEI2Ap4RAfZyh7RJzJfVzT98KUYQQ==}
engines: {node: '>=22.13.0'}
hasBin: true
peerDependencies:
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
'@hey-api/shared@0.4.4':
resolution: {integrity: sha512-UZgaQNEdo/OSGLeNXhSv0VQTHQQm5Q2mHOuoYhFPJkNvLVrz7KZtGdKR8O4QPrhyblshxY+caJli08WKM0gREg==}
engines: {node: '>=22.13.0'}
'@hey-api/spec-types@0.2.0':
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
'@hey-api/types@0.1.4':
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -249,6 +280,13 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@mdi/font@7.4.47': '@mdi/font@7.4.47':
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==} resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
@@ -468,6 +506,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@24.9.2': '@types/node@24.9.2':
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
@@ -563,26 +604,89 @@ packages:
alien-signals@3.0.3: alien-signals@3.0.3:
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==} resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
birpc@2.6.1: birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
bmp-js@0.1.0: bmp-js@0.1.0:
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
magicast: '*'
peerDependenciesMeta:
magicast:
optional: true
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
copy-anything@4.0.5: copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
daisyui@5.3.10: daisyui@5.3.10:
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==} resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
enhanced-resolve@5.18.3: enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -599,6 +703,9 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -613,6 +720,13 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
giget@3.2.0:
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
hasBin: true
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -626,9 +740,23 @@ packages:
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-electron@2.2.2: is-electron@2.2.2:
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-url@1.2.4: is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
@@ -636,10 +764,21 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'} engines: {node: '>=18'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -733,6 +872,13 @@ packages:
encoding: encoding:
optional: true optional: true
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
opencollective-postinstall@2.0.3: opencollective-postinstall@2.0.3:
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
hasBin: true hasBin: true
@@ -740,9 +886,19 @@ packages:
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -759,13 +915,30 @@ packages:
typescript: typescript:
optional: true optional: true
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
postcss@8.5.6: postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
regenerator-runtime@0.13.11: regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -774,6 +947,23 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -880,6 +1070,15 @@ packages:
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
yaml@2.8.4: yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
@@ -981,6 +1180,56 @@ snapshots:
'@esbuild/win32-x64@0.25.11': '@esbuild/win32-x64@0.25.11':
optional: true optional: true
'@hey-api/codegen-core@0.8.1':
dependencies:
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
c12: 3.3.4
color-support: 1.1.3
transitivePeerDependencies:
- magicast
'@hey-api/json-schema-ref-parser@1.4.2':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@hey-api/openapi-ts@0.97.2(typescript@5.9.3)':
dependencies:
'@hey-api/codegen-core': 0.8.1
'@hey-api/json-schema-ref-parser': 1.4.2
'@hey-api/shared': 0.4.4
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
'@lukeed/ms': 2.0.2
ansi-colors: 4.1.3
color-support: 1.1.3
commander: 14.0.3
get-tsconfig: 4.14.0
typescript: 5.9.3
transitivePeerDependencies:
- magicast
'@hey-api/shared@0.4.4':
dependencies:
'@hey-api/codegen-core': 0.8.1
'@hey-api/json-schema-ref-parser': 1.4.2
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
cross-spawn: 7.0.6
open: 11.0.0
semver: 7.7.4
transitivePeerDependencies:
- magicast
'@hey-api/spec-types@0.2.0':
dependencies:
'@hey-api/types': 0.1.4
'@hey-api/types@0.1.4': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1000,6 +1249,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@jsdevtools/ono@7.1.3': {}
'@lukeed/ms@2.0.2': {}
'@mdi/font@7.4.47': {} '@mdi/font@7.4.47': {}
'@rolldown/pluginutils@1.0.0-beta.29': {} '@rolldown/pluginutils@1.0.0-beta.29': {}
@@ -1147,6 +1400,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.9.2': '@types/node@24.9.2':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
@@ -1275,20 +1530,76 @@ snapshots:
alien-signals@3.0.3: {} alien-signals@3.0.3: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
birpc@2.6.1: {} birpc@2.6.1: {}
bmp-js@0.1.0: {} bmp-js@0.1.0: {}
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
c12@3.3.4:
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
defu: 6.1.7
dotenv: 17.4.2
exsolve: 1.0.8
giget: 3.2.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
pkg-types: 2.3.1
rc9: 3.0.1
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
color-support@1.1.3: {}
commander@14.0.3: {}
confbox@0.2.4: {}
copy-anything@4.0.5: copy-anything@4.0.5:
dependencies: dependencies:
is-what: 5.5.0 is-what: 5.5.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
csstype@3.1.3: {} csstype@3.1.3: {}
daisyui@5.3.10: {} daisyui@5.3.10: {}
dayjs@1.11.20: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-lazy-prop@3.0.0: {}
defu@6.1.7: {}
destr@2.0.5: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dotenv@17.4.2: {}
enhanced-resolve@5.18.3: enhanced-resolve@5.18.3:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -1327,6 +1638,8 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
exsolve@1.0.8: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.3):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
@@ -1334,6 +1647,12 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
giget@3.2.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
hookable@5.5.3: {} hookable@5.5.3: {}
@@ -1342,14 +1661,32 @@ snapshots:
install@0.13.0: {} install@0.13.0: {}
is-docker@3.0.0: {}
is-electron@2.2.2: {} is-electron@2.2.2: {}
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-url@1.2.4: {} is-url@1.2.4: {}
is-what@5.5.0: {} is-what@5.5.0: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true
@@ -1413,12 +1750,29 @@ snapshots:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
ohash@2.0.11: {}
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
opencollective-postinstall@2.0.3: {} opencollective-postinstall@2.0.3: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {} perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
@@ -1430,14 +1784,31 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
pkg-types@2.3.1:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
powershell-utils@0.1.0: {}
rc9@3.0.1:
dependencies:
defu: 6.1.7
destr: 2.0.5
readdirp@5.0.0: {}
regenerator-runtime@0.13.11: {} regenerator-runtime@0.13.11: {}
resolve-pkg-maps@1.0.0: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
rollup@4.52.5: rollup@4.52.5:
@@ -1468,6 +1839,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.52.5 '@rollup/rollup-win32-x64-msvc': 4.52.5
fsevents: 2.3.3 fsevents: 2.3.3
run-applescript@7.1.0: {}
semver@7.7.4: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
@@ -1550,6 +1931,15 @@ snapshots:
tr46: 0.0.3 tr46: 0.0.3
webidl-conversions: 3.0.1 webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
yaml@2.8.4: yaml@2.8.4:
optional: true optional: true
+13 -16
View File
@@ -1,10 +1,13 @@
from ninja import NinjaAPI from ninja import NinjaAPI
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
from submissions.api import router as submissions_router from opus_magnum.api import router as submissions_router
from submissions.schemas import UserInfoOut from opus_magnum.schemas import UserInfoOut
from animations.api import router as results_router from animations.api import router as results_router
from noita.api import router as noita_router from noita.api import router as noita_router
from games.api import router as games_router
from market.api import router as market_router
# Create the main API instance # Create the main API instance
api = NinjaAPI( api = NinjaAPI(
@@ -30,10 +33,14 @@ The Noita Submission API allows clients to upload the result of the log file of
# Add authentication for protected endpoints # Add authentication for protected endpoints
# api.auth = django_auth # Uncomment if you want global auth # api.auth = django_auth # Uncomment if you want global auth
# Include the submissions router # Include the opus_magnum router
api.add_router("/submissions/", submissions_router, tags=["submissions"]) api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
api.add_router("/results/", results_router, tags=["results"]) api.add_router("/results/", results_router, tags=["results"])
api.add_router("/noita/", noita_router, tags=["noita"]) api.add_router("/noita/", noita_router, tags=["noita"])
api.add_router("/games/", games_router, tags=["games"])
if settings.MARKET_ENABLED:
api.add_router("/market/", market_router)
# Health check endpoint # Health check endpoint
@@ -63,18 +70,8 @@ def get_user_info(request):
user = request.user user = request.user
if user.is_authenticated: if user.is_authenticated:
return { return user
"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 { return {
"is_authenticated": False, "is_authenticated": False,
"is_staff": False, "is_staff": False,
@@ -41,8 +41,10 @@ INSTALLED_APPS = [
"django_vite", "django_vite",
"accounts", "accounts",
"animations", "animations",
"submissions", "opus_magnum",
"noita", "noita",
"games",
"market",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -167,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
"video/webm", "video/webm",
] ]
# Market app settings
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
# Authentication backends # Authentication backends
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@@ -193,7 +198,7 @@ STATICFILES_DIRS = [
from polylan_submitter.settingsLocal import * # noqa from polylan_submitter.settingsLocal import * # noqa
import sentry_sdk import sentry_sdk # noqa
sentry_sdk.init( sentry_sdk.init(
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12", dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
+18 -2
View File
@@ -23,17 +23,31 @@ from django.contrib.auth.decorators import login_required
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from games.decorators import require_game_enabled
from market.views import market_home
from .api import api from .api import api
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
@login_required @login_required
def home(request: HttpRequest): def home(request: HttpRequest):
return render(request, "home.html", {}) from django.conf import settings
return render(
request,
"home.html",
{
"market_enabled": settings.MARKET_ENABLED,
},
)
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest): def opus_magnum_home(request: HttpRequest):
from submissions.models import SteamCollection from opus_magnum.models import SteamCollection
return render( return render(
request, request,
@@ -45,6 +59,7 @@ def opus_magnum_home(request: HttpRequest):
@login_required @login_required
@require_game_enabled(NOITA_APP_ID)
def noita_home(request: HttpRequest): def noita_home(request: HttpRequest):
return render(request, "noita.html", {}) return render(request, "noita.html", {})
@@ -54,6 +69,7 @@ urlpatterns = [
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("market", market_home, name="market.home"),
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("noita", noita_home, name="noita.home"), path("noita", noita_home, name="noita.home"),
path("", home, name="home"), path("", home, name="home"),
+58 -25
View File
@@ -1,23 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { ref, onMounted } from "vue";
import { gamesApiListGames } from "./api";
import type { GamesApiListGamesResponse } from "./api/types.gen";
const games = computed(() => [ interface Props {
{ marketEnabled?: string | boolean;
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 props = withDefaults(defineProps<Props>(), {
marketEnabled: true,
});
const games = ref<GamesApiListGamesResponse | undefined>();
const loading = ref(true);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (appId: number) => { const getHeaderImage = (appId: number) => {
@@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
const navigate = (path: string) => { const navigate = (path: string) => {
window.location.href = path; window.location.href = path;
}; };
const isMarketEnabled = () => {
if (typeof props.marketEnabled === 'string') {
return props.marketEnabled === 'true';
}
return Boolean(props.marketEnabled);
};
onMounted(async () => {
// Fetch games list
const response = await gamesApiListGames();
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script> </script>
<template> <template>
@@ -44,13 +55,38 @@ const navigate = (path: string) => {
</p> </p>
</div> </div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Cards Grid --> <!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.id" @click="navigate(game.path)" <!-- Market Card -->
<div v-if="isMarketEnabled()" @click="navigate('/market')"
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-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
<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">Market</h2>
<p class="text-base-content/70">Place your bets and compete</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
Place bets
</button>
</div>
</div>
</div>
<!-- Game Cards -->
<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"> 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"> <figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title" <img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
@error="onImageError(game.appId)" class="w-full h-full object-cover" /> @error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
<div v-else <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"> 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> <i class="mdi mdi-gamepad-variant text-5xl"></i>
@@ -58,8 +94,7 @@ const navigate = (path: string) => {
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div> <div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ game.title }}</h2> <h2 class="card-title text-2xl">{{ game.name }}</h2>
<p class="text-base-content/70">{{ game.description }}</p>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-primary"> <button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i> <i class="mdi mdi-arrow-right mr-2"></i>
@@ -75,7 +110,5 @@ const navigate = (path: string) => {
<p>Select a game above to begin submitting</p> <p>Select a game above to begin submitting</p>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useMarketStore } from "./stores/market";
import MarketCard from "./components/MarketCard.vue";
import UserBets from "./components/UserBets.vue";
const marketStore = useMarketStore();
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
const goHome = () => {
window.location.href = "/";
};
const reloadPage = async () => {
await marketStore.refreshPage();
};
onMounted(() => {
marketStore.initializeMarketPage();
});
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
</button>
<h1 class="text-xl font-bold">Market</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h2 class="text-3xl font-bold mb-2">Market</h2>
<p class="text-base-content/70">Place your bets on upcoming events</p>
</div>
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Content -->
<template v-else>
<div class="space-y-8">
<!-- My Bets Section -->
<div v-if="userInfo?.is_authenticated">
<div class="flex justify-between items-center mb-4">
<h3 class="text-2xl font-bold flex items-center gap-2">
<i class="mdi mdi-heart text-error"></i>
My Bets
</h3>
<div class="text-lg font-semibold">
<span class="text-primary">{{ userInfo.points }}</span>
<span class="text-base-content/60 ml-1">pts</span>
</div>
</div>
<UserBets :markets="markets" @refresh="reloadPage" />
</div>
<!-- All Markets Section -->
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-2xl font-bold flex items-center gap-2">
<i class="mdi mdi-list"></i>
All Markets
</h3>
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus"></i>
Create Market
</a>
</div>
<div v-if="markets.length === 0" class="alert">
<i class="mdi mdi-information mr-2"></i>
<span>No markets available</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MarketCard v-for="market in markets" :key="market.uuid" :market="market" @refresh="reloadPage" />
</div>
</div>
</div>
</template>
</div>
</div>
</template>
+208 -276
View File
@@ -1,81 +1,107 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import dayjs from "dayjs";
import RankBadge from "@/components/RankBadge.vue";
import { useNoitaStore, type Objective } from "@/stores/noita";
import {
createColumnHelper,
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/vue-table";
interface Objective { const noitaStore = useNoitaStore();
objectiv_id: string; const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
count: number;
points_per_objectiv?: number;
total_points?: number;
}
const userInfo = ref({
username: "Player",
rank: null as number | null,
score: 0,
runsSubmitted: 0,
isStaff: false,
});
const uploadedFiles = ref<File[]>([]); const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false);
const isDragover = ref(false); const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const objectiveSearchQuery = ref("");
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
const objectiveSortDesc = ref(false);
const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const filteredObjectives = computed(() => { const columnHelper = createColumnHelper<Objective>();
const query = objectiveSearchQuery.value.toLowerCase(); const sorting = ref<SortingState>([]);
const columnFilters = ref<ColumnFiltersState>([]);
let filtered = objectives.value; const formatDate = (dateString: string | null) => {
if (!dateString) {
if (query) { return ""
filtered = filtered.filter(
(obj) =>
obj.objectiv_id.toLowerCase().includes(query) ||
obj.count.toString().includes(query)
);
}
const sorted = [...filtered].sort((a, b) => {
let aValue: number | string;
let bValue: number | string;
switch (objectiveSortBy.value) {
case "points_per":
aValue = a.points_per_objectiv || 0;
bValue = b.points_per_objectiv || 0;
break;
case "total_points":
aValue = a.total_points || 0;
bValue = b.total_points || 0;
break;
case "id":
default:
aValue = a.objectiv_id.toLowerCase();
bValue = b.objectiv_id.toLowerCase();
}
if (aValue < bValue) return objectiveSortDesc.value ? 1 : -1;
if (aValue > bValue) return objectiveSortDesc.value ? -1 : 1;
return 0;
});
return sorted;
});
const toggleObjectiveSort = (column: "id" | "points_per" | "total_points") => {
if (objectiveSortBy.value === column) {
objectiveSortDesc.value = !objectiveSortDesc.value;
} else {
objectiveSortBy.value = column;
objectiveSortDesc.value = false;
} }
const date = dayjs(dateString);
return date.format("MMM DD, YYYY HH:mm");
}; };
const columns = [
columnHelper.accessor("objectiv_id", {
header: "Objective ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("total_points", {
header: "Your points",
cell: (info) => info.getValue() || 0,
}),
columnHelper.accessor("first_seen_at", {
header: "First seen",
cell: (info) => formatDate(info.getValue()),
sortingFn: (rowA, rowB) => {
const dateA = dayjs(rowA.original.first_seen_at);
const dateB = dayjs(rowB.original.first_seen_at);
if (!rowA.original.first_seen_at) {
return rowB.original.first_seen_at ? 1 : 0
}
if (!rowB.original.first_seen_at) {
return rowA.original.first_seen_at ? 0 : 1
}
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
},
}),
columnHelper.accessor("seed", {
header: "Seed",
cell: (info) => info.getValue(),
}),
];
const table = computed(() =>
useVueTable({
get data() {
return objectives.value;
},
columns,
state: {
get sorting() {
return sorting.value;
},
get columnFilters() {
return columnFilters.value;
},
},
onSortingChange: (updater) => {
sorting.value =
typeof updater === "function" ? updater(sorting.value) : updater;
},
onColumnFiltersChange: (updater) => {
columnFilters.value =
typeof updater === "function" ? updater(columnFilters.value) : updater;
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
filterFns: {
fuzzy: (row, columnId, value) => {
const itemData = row.getValue(columnId);
const searchValue = value.toLowerCase();
if (columnId === "first_seen_at") {
return formatDate(itemData as string).includes(searchValue)
}
return String(itemData).toLowerCase().includes(searchValue);
},
},
})
);
const filteredObjectives = computed(() => table.value.getRowModel().rows);
const handleFileUpload = (event: Event) => { const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files) { if (input.files) {
@@ -108,40 +134,13 @@ const handleDrop = (event: DragEvent) => {
const submitRun = async () => { const submitRun = async () => {
if (uploadedFiles.value.length === 0) return; if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try { try {
for (const file of uploadedFiles.value) { await noitaStore.submitRun(uploadedFiles.value);
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/noita/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
return;
}
const result = await response.json();
console.log("Submission successful:", result);
}
uploadedFiles.value = []; uploadedFiles.value = [];
alert("Run submitted successfully!"); alert("Run submitted successfully!");
// Refresh objectives, score, and rank after successful submission
await Promise.all([
fetchUserResults(),
fetchLeaderboard(),
]);
} catch (error) { } catch (error) {
console.error("Error submitting run:", error); console.error("Error submitting run:", error);
alert("Error submitting run. Please try again."); alert("Error submitting run. Please try again.");
} finally {
isUploading.value = false;
} }
}; };
@@ -149,92 +148,18 @@ const goHome = () => {
window.location.href = "/"; window.location.href = "/";
}; };
const fetchUserResults = async () => {
try {
const response = await fetch("/api/noita/results");
if (!response.ok) throw new Error("Failed to fetch results");
const results = await response.json();
userInfo.value.score = results.total_score;
userInfo.value.runsSubmitted = results.objectives.length;
objectives.value = results.objectives;
} catch (error) {
console.error("Error fetching results:", error);
}
};
const fetchLeaderboard = async () => {
isLoadingLeaderboard.value = true;
try {
const response = await fetch("/api/noita/leaderboard");
if (!response.ok) throw new Error("Failed to fetch leaderboard");
const data = await response.json();
leaderboard.value = data.leaderboard;
// Find current user's rank
const userRank = leaderboard.value.find(
(entry: any) => entry.username === userInfo.value.username
);
if (userRank) {
userInfo.value.rank = userRank.rank;
userInfo.value.score = userRank.total_score;
}
} catch (error) {
console.error("Error fetching leaderboard:", error);
} finally {
isLoadingLeaderboard.value = false;
}
};
const clearCache = async () => { const clearCache = async () => {
try { try {
const response = await fetch("/api/cache/clear", { await noitaStore.clearCache();
method: "POST",
});
if (response.ok) {
alert("Cache cleared successfully!"); alert("Cache cleared successfully!");
// Refresh data after clearing cache
await Promise.all([
fetchUserResults(),
fetchLeaderboard(),
]);
} else {
const error = await response.json();
alert(`Error clearing cache: ${error.detail || "Unknown error"}`);
}
} catch (error) { } catch (error) {
console.error("Error clearing cache:", error); console.error("Error clearing cache:", error);
alert("Error clearing cache. Please try again."); alert("Error clearing cache. Please try again.");
} }
}; };
const loadUserData = async () => {
// Get user info first
try {
const response = await fetch("/api/user");
if (response.ok) {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
}
}
} catch (error) {
console.error("Error fetching user info:", error);
}
// Fetch results and leaderboard
await Promise.all([
fetchUserResults(),
fetchLeaderboard(),
]);
};
onMounted(() => { onMounted(() => {
loadUserData(); noitaStore.loadUserData();
}); });
</script> </script>
@@ -242,7 +167,7 @@ onMounted(() => {
<div class="min-h-screen bg-base-200"> <div class="min-h-screen bg-base-200">
<!-- Header --> <!-- Header -->
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto w-full flex items-center gap-4"> <div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm"> <button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
Back Back
@@ -255,19 +180,21 @@ onMounted(() => {
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="container mx-auto px-4 py-8"> <div class="container min-w-3/4 mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: User Ranking --> <!-- Left Column: User Ranking -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg sticky top-8"> <div class="card bg-base-100 shadow-lg sticky top-8">
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl"> <div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
<i class="mdi mdi-trophy text-4xl"></i> <h2 class="text-3xl font-bold">
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2> <i class="mdi mdi-trophy text-3xl"></i>
Your Ranking
</h2>
</div> </div>
<div class="card-body"> <div class="card-body p-8">
<div class="text-center mb-6"> <div class="text-center mb-8">
<p class="text-sm text-base-content/70">Player</p> <p class="text-base text-base-content/70">Player</p>
<p class="text-3xl font-bold">{{ userInfo.username }}</p> <p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@@ -276,30 +203,66 @@ onMounted(() => {
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-6">
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p> <p class="text-base text-base-content/70 mb-3">Current Rank</p>
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary"> <RankBadge :rank="userInfo.rank" />
#{{ userInfo.rank }}
</p>
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Total Score</p> <p class="text-base text-base-content/70 mb-2">Total Score</p>
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p> <p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p> <p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p> <p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
</div>
<div class="text-center">
<p class="text-base text-base-content/70 mb-2">Deaths</p>
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
</div> </div>
</div> </div>
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6"> <!-- Leaderboard Table -->
<i class="mdi mdi-trophy mr-1"></i> <div class="mt-6">
View Full Leaderboard <h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
</button> <div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th class="text-right">Score</th>
<th class="text-right">Objectives</th>
<th class="text-right">Deaths</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in leaderboard" :key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
<td class="font-bold">
<RankBadge :rank="entry.rank" />
</td>
<td class="text-sm">
{{ entry.username }}
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
You
</span>
<span v-if="entry.is_staff" class="badge badge-warning badge-sm ml-1">
admin
</span>
</td>
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
</td>
<td class="text-right text-sm">{{ entry.objectives_count }}</td>
<td class="text-right text-sm text-error">{{ entry.deaths_count }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3"> <button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
<i class="mdi mdi-cache-clear mr-1"></i> <i class="mdi mdi-cache-clear mr-1"></i>
@@ -310,7 +273,7 @@ onMounted(() => {
</div> </div>
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-2"> <div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-6"> <h2 class="card-title text-2xl mb-6">
@@ -377,7 +340,7 @@ onMounted(() => {
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-6"> <h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-view-list text-purple-500 mr-2"></i> <i class="mdi mdi-view-list text-purple-500 mr-2"></i>
Your Objectives Objectives
</h2> </h2>
<div v-if="objectives.length === 0" class="text-center py-8"> <div v-if="objectives.length === 0" class="text-center py-8">
@@ -387,8 +350,12 @@ onMounted(() => {
<div v-if="objectives.length > 0" class="space-y-4"> <div v-if="objectives.length > 0" class="space-y-4">
<!-- Search Input --> <!-- Search Input -->
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..." <input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
class="input input-bordered w-full" /> (e) => {
const target = e.target as HTMLInputElement;
table.getColumn('objectiv_id')?.setFilterValue(target.value);
}
" type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
<!-- Results Summary --> <!-- Results Summary -->
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
@@ -400,31 +367,54 @@ onMounted(() => {
<table class="table table-zebra w-full"> <table class="table table-zebra w-full">
<thead> <thead>
<tr> <tr>
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')"> <th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
Objective ID 'cursor-pointer hover:bg-base-300',
<i v-if="objectiveSortBy === 'id'" header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i> ]" @click="header.column.toggleSorting()">
</th> <div class="flex items-center justify-between">
<th class="text-right cursor-pointer hover:bg-base-300" <span v-if="header.column.columnDef.id === 'objectiv_id'">
@click="toggleObjectiveSort('total_points')"> {{ header.isPlaceholder ? null : header.column.columnDef.header }}
Total Points </span>
<i v-if="objectiveSortBy === 'total_points'" <span v-else class="ml-auto">
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i> {{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span>
<i v-if="header.column.getIsSorted()" :class="[
'mdi ml-2',
header.column.getIsSorted() === 'desc'
? 'mdi-arrow-down'
: 'mdi-arrow-up',
]"></i>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id"> <tr v-for="row in filteredObjectives" :key="row.id">
<td class="font-medium"> <td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank"> cell.column.id === 'objectiv_id'
{{ obj.objectiv_id }} ? 'font-medium'
: 'text-right',
]">
<template v-if="cell.column.id === 'objectiv_id'">
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
{{ row.original.display_string }}
<i class="mdi mdi-open-in-new"></i> <i class="mdi mdi-open-in-new"></i>
</a> </a>
</td> </template>
<td class="text-right font-bold text-success"> <template v-else-if="cell.column.id === 'total_points'">
{{ obj.total_points || 0 }} <span :class="row.original.count >= row.original.max_count ? 'text-primary' : 'text-error'">
</td> {{ row.original.total_points }} / {{ row.original.points_per_objectiv *
<td class="text-right"> row.original.max_count }}
</span>
</template>
<template v-else-if="cell.column.id === 'first_seen_at'">
<span :title="formatDate(row.original.first_seen_at)">
{{ formatDate(row.original.first_seen_at) }}
</span>
</template>
<template v-else>
{{ cell.renderValue() }}
</template>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -442,63 +432,5 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Leaderboard Modal -->
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
Global Leaderboard
</h3>
<button @click="isLeaderboardModalOpen = false" class="btn btn-sm btn-circle btn-ghost">
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Leaderboard Table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Rank</th>
<th>Username</th>
<th class="text-right">Objectives</th>
<th class="text-right">Score</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in leaderboard" :key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
<td class="font-bold">
<span v-if="entry.rank === 1" class="badge badge-warning badge-lg">
🏆 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 2" class="badge badge-lg">
🥈 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 3" class="badge badge-lg">
🥉 #{{ entry.rank }}
</span>
<span v-else>#{{ entry.rank }}</span>
</td>
<td class="font-medium">
{{ entry.username }}
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-2">
You
</span>
</td>
<td class="text-right">{{ entry.objectives_count }}</td>
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="leaderboard.length === 0" class="text-center py-8">
<p class="text-base-content/70">No entries yet</p>
</div>
</div>
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
</div>
</div> </div>
</template> </template>
+51 -34
View File
@@ -4,10 +4,14 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue"; import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue"; import AdminPanel from "@/components/AdminPanel.vue";
import Results from "@/components/Results.vue"; import Results from "@/components/Results.vue";
import { apiService, errorHelpers } from "@/services/apiService"; import Winners from "@/components/Winners.vue";
import PuzzleResults from "@/components/PuzzleResults.vue";
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
import { errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles"; import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions"; import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types"; import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core"; import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@@ -26,6 +30,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
// Local state // Local state
const userInfo = ref<UserInfo | null>(null); const userInfo = ref<UserInfo | null>(null);
const collection = ref<SteamCollection | null>(null);
const isLoading = ref(true); const isLoading = ref(true);
const error = ref<string>(""); const error = ref<string>("");
@@ -34,6 +39,10 @@ const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false; return userInfo.value?.is_superuser || false;
}); });
const isTournamentClosed = computed(() => {
return !!(collection.value && !collection.value.accepting_submissions);
});
// Computed property to get responses grouped by puzzle // Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => { const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {}; const grouped: Record<number, PuzzleResponse[]> = {};
@@ -58,14 +67,24 @@ async function initialize() {
// Load user info // Load user info
console.log("Loading user info..."); console.log("Loading user info...");
const userResponse = await apiService.getUserInfo(); const userResponse = await polylanSubmitterApiGetUserInfo();
if (userResponse.data) { if (userResponse.data) {
userInfo.value = userResponse.data; userInfo.value = userResponse.data as UserInfo;
console.log("User info loaded:", userResponse.data); console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) { } else if (userResponse.error) {
console.warn("User info error:", userResponse.error); console.warn("User info error:", userResponse.error);
} }
// Load collection data
console.log("Loading collection...");
const collectionResponse = await opusMagnumApiGetCollection();
if (collectionResponse.data) {
collection.value = collectionResponse.data as SteamCollection;
console.log("Collection loaded:", collectionResponse.data);
} else if (collectionResponse.error) {
console.warn("Collection error:", collectionResponse.error);
}
// Load puzzles from API using store // Load puzzles from API using store
console.log("Loading puzzles..."); console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles(); await puzzlesStore.loadPuzzles();
@@ -118,7 +137,7 @@ const goHome = () => {
<div class="min-h-screen bg-base-200"> <div class="min-h-screen bg-base-200">
<!-- Header --> <!-- Header -->
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto w-full flex items-center gap-4"> <div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm"> <button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
Back Back
@@ -126,17 +145,10 @@ const goHome = () => {
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1> <h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div <div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
v-if="userInfo?.is_authenticated"
class="flex items-center gap-2"
>
<div class="text-sm"> <div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span> <span class="font-medium">{{ userInfo.username }}</span>
<span <span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
v-if="userInfo.is_superuser"
class="badge badge-warning badge-xs ml-1"
>Admin</span
>
</div> </div>
</div> </div>
<div v-else class="text-sm text-base-content/70">Not logged in</div> <div v-else class="text-sm text-base-content/70">Not logged in</div>
@@ -147,7 +159,7 @@ const goHome = () => {
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="container mx-auto px-4 py-8"> <div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- Loading State --> <!-- Loading State -->
<div v-if="userInfo?.is_superuser" class="flex justify-center"> <div v-if="userInfo?.is_superuser" class="flex justify-center">
<div class="text-center"> <div class="text-center">
@@ -158,10 +170,7 @@ const goHome = () => {
</div> </div>
</div> </div>
<div <div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
v-if="isLoading"
class="flex justify-center items-center min-h-[400px]"
>
<div class="text-center"> <div class="text-center">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-base-content/70">Loading puzzles...</p> <p class="mt-4 text-base-content/70">Loading puzzles...</p>
@@ -183,6 +192,16 @@ const goHome = () => {
<!-- Main Content --> <!-- Main Content -->
<div v-else class="space-y-8"> <div v-else class="space-y-8">
<!-- Results Section (only when tournament is closed) -->
<div v-if="isTournamentClosed" class="space-y-8">
<TopUsersLeaderboard />
<PuzzleResults />
<Winners />
</div>
<template v-else>
<!-- Collection Info --> <!-- Collection Info -->
<div class="mb-8"> <div class="mb-8">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
@@ -192,11 +211,18 @@ const goHome = () => {
{{ props.collectionDescription }} {{ props.collectionDescription }}
</p> </p>
<div class="flex flex-wrap gap-4 mt-4"> <div class="flex flex-wrap gap-4 mt-4">
<button @click="openSubmissionModal" class="btn btn-primary"> <button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
<i class="mdi mdi-plus mr-2"></i> <i class="mdi mdi-plus mr-2"></i>
Submit Solution Submit Solution
</button> </button>
</div> </div>
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
<i class="mdi mdi-alert-circle text-xl"></i>
<div>
<h3 class="font-bold">Tournament Closed</h3>
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -210,12 +236,8 @@ const goHome = () => {
<!-- Puzzles Grid --> <!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard <PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
v-for="puzzle in puzzlesStore.puzzles" :responses="responsesByPuzzle[puzzle.id] || []" />
:key="puzzle.id"
:puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []"
/>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
@@ -226,6 +248,7 @@ const goHome = () => {
Check back later for new puzzle collections! Check back later for new puzzle collections!
</p> </p>
</div> </div>
</template>
</div> </div>
</div> </div>
@@ -234,18 +257,12 @@ const goHome = () => {
<div class="modal-box max-w-6xl"> <div class="modal-box max-w-6xl">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3> <h3 class="font-bold text-lg">Submit Solution</h3>
<button <button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
@click="closeSubmissionModal"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
<SubmissionForm <SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
/>
</div> </div>
<div class="modal-backdrop" @click="closeSubmissionModal"></div> <div class="modal-backdrop" @click="closeSubmissionModal"></div>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>());
@@ -0,0 +1,277 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined,
};
if (opts.security) {
await setAuthParams(opts);
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts: resolvedOpts, url };
};
const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const responseStyle = options.responseStyle ?? _config.responseStyle;
let request: Request | undefined;
let response: Response | undefined;
try {
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
response = await _fetch(request);
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
throw jsonError ?? textError;
} catch (error) {
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
}
}
finalError = finalError || {};
if (throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response,
};
}
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};
+25
View File
@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
@@ -0,0 +1,217 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>
extends
Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onRequest'
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
/** request may be undefined, because error may be from building the request object itself */
request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
@@ -0,0 +1,316 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export async function setAuthParams(
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
headers: Headers;
},
): Promise<void> {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
}
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
/** response may be undefined due to a network error where no response object is produced */
response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});
@@ -0,0 +1,41 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};
@@ -0,0 +1,82 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};
@@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};
@@ -0,0 +1,171 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
@@ -0,0 +1,117 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};
@@ -0,0 +1,242 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export function createSseClient<TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
}
+104
View File
@@ -0,0 +1,104 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
};
+140
View File
@@ -0,0 +1,140 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}
+4
View File
@@ -0,0 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+241
View File
@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Health Check
*
* Health check endpoint
*/
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
/**
* Clear Cache
*
* Clear all API caches (admin only)
*/
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, unknown, ThrowOnError>({ url: '/api/cache/clear', ...options });
/**
* Get User Info
*
* Get current user information
*/
export const polylanSubmitterApiGetUserInfo = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetUserInfoData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetUserInfoResponses, unknown, ThrowOnError>({ url: '/api/user', ...options });
/**
* List Puzzles
*
* Get list of available puzzles
*/
export const opusMagnumApiListPuzzles = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListPuzzlesData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListPuzzlesResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/puzzles', ...options });
/**
* Get Collection
*
* Get the active collection details
*/
export const opusMagnumApiGetCollection = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetCollectionData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetCollectionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/collection', ...options });
/**
* List Submissions
*
* Get paginated list of submissions
*/
export const opusMagnumApiListSubmissions = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions', ...options });
/**
* Create Submission
*
* Create a new submission with multiple puzzle responses
*/
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer,
url: '/api/opus-magnum/submissions',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* Delete Submission
*
* Delete a submission (admin only)
*/
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
/**
* Get Submission
*
* Get detailed submission by ID
*/
export const opusMagnumApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
/**
* Validate Response
*
* Manually validate a puzzle response
*/
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, unknown, ThrowOnError>({
url: '/api/opus-magnum/responses/{response_id}/validate',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Validate Auto
*
* Try to auto validate a puzzle response
*/
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/{response_id}/validate/auto', ...options });
/**
* List Responses Needing Validation
*
* Get all responses that need manual validation
*/
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/needs-validation', ...options });
/**
* Validate Submission
*
* Mark entire submission as validated
*/
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}/validate', ...options });
/**
* Get Stats
*
* Get submission statistics
*/
export const opusMagnumApiGetStats = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetStatsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetStatsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/stats', ...options });
/**
* Results
*/
export const animationsApiResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiResultsResponses, unknown, ThrowOnError>({ url: '/api/results/results', ...options });
/**
* Top Submissions
*
* Get tournament top submissions for each puzzle. Only available when tournament is closed.
*/
export const animationsApiTopSubmissions = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiTopSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiTopSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/results/top-submissions', ...options });
/**
* Puzzle Results
*
* Get tournament results organized by puzzle with coefficients. Only available when tournament is closed.
*/
export const animationsApiPuzzleResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiPuzzleResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiPuzzleResultsResponses, unknown, ThrowOnError>({ url: '/api/results/puzzle-results', ...options });
/**
* Get Results
*/
export const noitaApiGetResults = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetResultsData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetResultsResponses, unknown, ThrowOnError>({ url: '/api/noita/results', ...options });
/**
* Get Leaderboard
*
* Get the global leaderboard for all users ranked by total score.
*
* Uses Window functions to rank users by their total score in descending order.
*/
export const noitaApiGetLeaderboard = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetLeaderboardData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetLeaderboardResponses, unknown, ThrowOnError>({ url: '/api/noita/leaderboard', ...options });
/**
* Submit Log 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
*/
export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(options: Options<NoitaApiSubmitLogFileData, ThrowOnError>) => (options.client ?? client).post<NoitaApiSubmitLogFileResponses, NoitaApiSubmitLogFileErrors, ThrowOnError>({
...formDataBodySerializer,
url: '/api/noita/submit',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* List Games
*/
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
/**
* List Markets
*
* List all markets (excludes draft markets).
*/
export const marketApiListMarkets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListMarketsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListMarketsResponses, unknown, ThrowOnError>({ url: '/api/market/', ...options });
/**
* List User Bets
*
* List all bets placed by the current user.
*/
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListUserBetsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/market/user/bets', ...options });
/**
* Close Market
*
* Close a market. Admin only.
*/
export const marketApiCloseMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiCloseMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiCloseMarketResponses, unknown, ThrowOnError>({ url: '/api/market/{market_uuid}/actions/close', ...options });
/**
* Resolve Market
*
* Resolve a market with a winning option. Admin only.
*/
export const marketApiResolveMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiResolveMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiResolveMarketResponses, unknown, ThrowOnError>({
url: '/api/market/{market_uuid}/actions/resolve',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Create Bet
*
* Place a bet on a market option.
*/
export const marketApiCreateBet = <ThrowOnError extends boolean = false>(options: Options<MarketApiCreateBetData, ThrowOnError>) => (options.client ?? client).post<MarketApiCreateBetResponses, unknown, ThrowOnError>({
url: '/api/market/{market_uuid}/bets',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api";
import { useMarketStore } from "../stores/market";
import type { Market, MarketOption } from "../types";
import type { UserBetSchema } from "../api/types.gen";
const props = defineProps<{
market: Market;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const marketStore = useMarketStore();
const { userInfo, userBets } = storeToRefs(marketStore);
const selectedOption = ref<string | null>(null);
const betAmount = ref<number>(0);
const loading = ref(false);
const error = ref<string>("");
const existingBet = ref<UserBetSchema | null>(null);
const showResolveModal = ref(false);
const selectedWinningOption = ref<string | null>(null);
const resolveLoading = ref(false);
const timeRemaining = computed(() => {
const endDate = new Date(props.market.end_date).getTime();
const now = new Date().getTime();
const diff = endDate - now;
if (diff <= 0) return "Ended";
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
});
const statusColor = computed(() => {
switch (props.market.status) {
case "draft":
return "badge-secondary";
case "open":
return "badge-success";
case "closed":
return "badge-warning";
case "resolved":
return "badge-info";
default:
return "badge-ghost";
}
});
const canBet = computed(() => {
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
});
const totalPot = computed(() => {
return props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0);
});
const getMultiplier = (option: MarketOption) => {
if (option.total_bets === 0) return 0;
return totalPot.value / option.total_bets;
};
const getPotentialGain = (option: MarketOption) => {
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
const multiplier = getMultiplier(option);
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
};
const closeMarket = async () => {
loading.value = true;
error.value = "";
try {
await marketApiCloseMarket({
path: { market_uuid: props.market.uuid },
});
emit("refresh");
} catch (e) {
error.value = "Error closing market";
} finally {
loading.value = false;
}
};
const resolveMarket = async () => {
if (!selectedWinningOption.value) {
error.value = "Please select a winning option";
return;
}
resolveLoading.value = true;
error.value = "";
try {
await marketApiResolveMarket({
path: { market_uuid: props.market.uuid },
body: { winning_option_uuid: selectedWinningOption.value },
});
emit("refresh");
showResolveModal.value = false;
selectedWinningOption.value = null;
} catch (e) {
const err = e as any;
if (typeof err === 'object' && err?.detail) {
error.value = err.detail;
} else if (typeof err === 'string') {
error.value = err;
} else {
error.value = "Error resolving market";
}
} finally {
resolveLoading.value = false;
}
};
const placeBet = async () => {
if (!selectedOption.value || !betAmount.value) return;
loading.value = true;
error.value = "";
try {
const response = await marketApiCreateBet({
path: {
market_uuid: props.market.uuid
},
body: {
option_uuid: selectedOption.value,
amount: betAmount.value,
},
});
if (!response.error) {
// Reload user bets to reflect the new bet
await marketStore.loadUserBets();
updateExistingBet();
betAmount.value = 0;
} else {
const err = response.error as any;
if (typeof err === 'object' && err?.detail) {
error.value = err.detail;
} else if (typeof err === 'string') {
error.value = err;
} else {
error.value = "Failed to place bet";
}
}
} catch (e) {
error.value = "Error placing bet";
} finally {
loading.value = false;
}
};
onMounted(() => {
// Update existing bet when component mounts or userBets changes
updateExistingBet();
});
const updateExistingBet = () => {
if (userBets.value) {
existingBet.value = userBets.value.find(bet => bet.market?.uuid === props.market.uuid) || null;
if (existingBet.value) {
selectedOption.value = existingBet.value.option.uuid;
betAmount.value = existingBet.value.amount;
}
}
};
</script>
<template>
<div class="card bg-base-100 shadow-xl">
<!-- Header -->
<div class="card-body pb-3">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<h2 class="card-title text-2xl mb-2">{{ market.title }}</h2>
<p class="text-sm text-base-content/70">{{ market.description }}</p>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex gap-2 items-center">
<div :class="['badge', statusColor, 'text-white']">
{{ market.status }}
</div>
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
{{ market.multiplier }}x
</div>
</div>
<div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
<div>{{ timeRemaining }}</div>
<div class="text-xs">until close</div>
</div>
</div>
</div>
</div>
<div class="divider my-0"></div>
<!-- Admin Actions -->
<div v-if="userInfo?.is_superuser" class="card-body py-4 bg-base-100">
<div class="flex gap-2">
<button v-if="market.status === 'open'" @click="closeMarket" :disabled="loading" class="btn btn-sm btn-warning">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<span v-else>Close Market</span>
</button>
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
Resolve Market
</button>
</div>
</div>
<!-- Resolve Modal -->
<dialog v-if="showResolveModal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Resolve Market - Select Winner</h3>
<div class="space-y-2 mb-4">
<div v-for="option in market.options" :key="option.uuid" class="form-control">
<label class="label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition w-full flex justify-between">
<span class="label-text font-medium">{{ option.text }}</span>
<input type="radio" :value="option.uuid" v-model="selectedWinningOption" class="radio radio-primary" />
</label>
</div>
</div>
<div v-if="error" class="alert alert-error mb-4">
<i class="mdi mdi-alert-circle"></i>
<span>{{ error }}</span>
</div>
<div class="modal-action">
<button @click="showResolveModal = false" class="btn" :disabled="resolveLoading">
Cancel
</button>
<button @click="resolveMarket" :disabled="!selectedWinningOption || resolveLoading" class="btn btn-primary">
<span v-if="resolveLoading" class="loading loading-spinner loading-sm"></span>
<span v-else>Resolve & Distribute Points</span>
</button>
</div>
</div>
<div class="modal-backdrop" @click="showResolveModal = false"></div>
</dialog>
<!-- Options -->
<div class="card-body py-4">
<div class="flex flex-col gap-3">
<div v-for="option in market.options" :key="option.uuid" class="form-control">
<label :class="[
'label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition h-full w-full',
existingBet && existingBet.option.uuid === option.uuid ? 'border-primary border-2 bg-primary/5' : ''
]">
<div class="flex flex-col gap-2 flex-1">
<span class="label-text font-medium">{{ option.text }}</span>
<div class="text-xs text-base-content/60">
<div>Pool: {{ option.total_bets }} pts</div>
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
</div>
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
<span class="badge badge-primary badge-sm w-fit">
Your bet: {{ existingBet.amount }} pts
</span>
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
Potential: {{ getPotentialGain(option) }} pts
</span>
</div>
</div>
<input type="radio" :value="option.uuid" v-model="selectedOption" class="radio radio-primary"
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)" />
</label>
</div>
</div>
</div>
<!-- Bet Input -->
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
<div class="form-control gap-3">
<label class="label">
<span class="label-text">
<span v-if="existingBet">Increase bet (current: {{ existingBet.amount }} pts)</span>
<span v-else>Points to bet</span>
</span>
</label>
<input v-model.number="betAmount" type="number"
:placeholder="existingBet ? `Enter amount to increase by` : 'Enter points'" class="input input-bordered"
min="1" :disabled="loading" />
<div v-if="error" class="alert alert-error">
<i class="mdi mdi-alert-circle"></i>
<span>{{ error }}</span>
</div>
<button @click="placeBet" :disabled="!canBet || loading" class="btn btn-primary">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<span v-else-if="existingBet">Increase Bet</span>
<span v-else>Place Bet</span>
</button>
</div>
</div>
<!-- Show existing bet (market closed/resolved) -->
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
<div class="text-sm">
<div class="font-semibold">Your Bet</div>
<div class="text-base-content/70 mt-1">
Option: <span class="font-semibold">{{ existingBet.option.text }}</span>
</div>
<div class="text-base-content/70">
Amount: <span class="font-semibold">{{ existingBet.amount }} pts</span>
</div>
</div>
</div>
<!-- Result -->
<div v-else-if="market.status === 'resolved' && market.winning_option" class="card-body py-4 bg-base-200">
<div class="text-sm">
<div class="font-semibold"
:class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid"> You Won!</span>
<span v-else-if="existingBet"> You Lost</span>
<span v-else>Resolved</span>
</div>
<div class="text-base-content/70 mt-1">
Winner: <span class="font-semibold">{{ market.winning_option.text }}</span>
</div>
</div>
</div>
</div>
</template>

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