Compare commits

..

18 Commits

Author SHA1 Message Date
47812ffd09
feat(market): settings toggle 2026-05-24 19:11:02 +02:00
a5fe8aacaf
feat(migration): frontend migration submissions -> opus-magnum 2026-05-24 18:54:14 +02:00
b437210eb3
feat(migration): old submissions -> opus magnum app 2026-05-24 18:51:01 +02:00
5584e54b58
migrate to opus-magnum app 2026-05-24 18:48:14 +02:00
35ea54ecea
chore: market build app 2026-05-24 18:19:06 +02:00
821e453bc0
feat(noita): stores 2026-05-24 09:29:30 +02:00
9fd0122a67
chore: market app build 2026-05-24 09:24:56 +02:00
e557fe2cda
feat(market): add draft status and multiplier 2026-05-24 09:20:01 +02:00
79e7cef3ba
feat(market): resolve for admin 2026-05-23 20:33:21 +02:00
a264336bd8
feat(market): track user points change 2026-05-23 20:26:59 +02:00
42e3571fab
fix(market): better market card display 2026-05-23 20:06:17 +02:00
43b314bb20
fix(openapi-ts): replace api call with generated api client 2026-05-23 19:51:08 +02:00
f1afb2096f
fix(openapi-ts): first pass for replacing with openapi-ts 2026-05-23 18:53:33 +02:00
62a81e57ad
feat(openapi-ts): add generated api client 2026-05-23 18:50:26 +02:00
303b9e1c8a
feat(frontend): add openapi-ts 2026-05-23 18:50:19 +02:00
f7c7eba4da
feat(market): basic page + submit 2026-05-23 18:30:50 +02:00
ce30539808
feat(market): base models 2026-05-23 17:48:27 +02:00
544112b204
feat(games): add games to disable + path 2026-05-23 14:18:44 +02:00
112 changed files with 6449 additions and 295 deletions

View File

@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
return obj.get_cas_groups_display()
get_cas_groups_display.short_description = "CAS Groups"
def has_delete_permission(self, request, obj=None):
return False

View File

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

View File

@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True)
# Market points balance
points = models.IntegerField(default=1000)
def __str__(self):
return f"{self.username} ({self.cas_user_id})"

View File

@ -17,7 +17,7 @@ from animations.schemas import (
WinnerResponseOut,
WinnerFileOut,
)
from submissions.models import PuzzleResponse, SteamCollectionItem, SteamCollection
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router()

View File

@ -1,8 +1,8 @@
from ninja import ModelSchema, Schema
from typing import List, Optional
from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut
from opus_magnum.models import PuzzleResponse
from opus_magnum.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema):

View File

@ -0,0 +1,11 @@
from django.contrib import admin
from .models import Game
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
list_filter = ["enabled"]
search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"]

View File

@ -0,0 +1,13 @@
from typing import List
from ninja import Router
from .models import Game
from .schemas import GameOut
router = Router()
@router.get("", response=List[GameOut])
def list_games(request):
return Game.objects.filter(enabled=True)

View File

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

View File

@ -0,0 +1,22 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
from .models import Game
def require_game_enabled(steam_app_id: int):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
try:
game = Game.objects.get(steam_app_id=steam_app_id)
except Game.DoesNotExist:
raise PermissionDenied
if not game.enabled:
raise PermissionDenied
return view_func(request, *args, **kwargs)
return wrapper
return decorator

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Game",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("steam_app_id", models.PositiveIntegerField(unique=True)),
("name", models.CharField(max_length=255)),
("enabled", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
]

View File

@ -0,0 +1,31 @@
from django.db import migrations
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
def seed_games(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.get_or_create(
steam_app_id=NOITA_APP_ID,
defaults={"name": "Noita", "enabled": True},
)
Game.objects.get_or_create(
steam_app_id=OPUS_MAGNUM_APP_ID,
defaults={"name": "Opus Magnum", "enabled": True},
)
def unseed_games(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.RunPython(seed_games, reverse_code=unseed_games),
]

View File

@ -0,0 +1,31 @@
from django.db import migrations, models
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
PATHS = {
NOITA_APP_ID: "/noita",
OPUS_MAGNUM_APP_ID: "/opus-magnum",
}
def set_paths(apps, schema_editor):
Game = apps.get_model("games", "Game")
for app_id, path in PATHS.items():
Game.objects.filter(steam_app_id=app_id).update(path=path)
class Migration(migrations.Migration):
dependencies = [
("games", "0002_seed_noita_and_opus_magnum"),
]
operations = [
migrations.AddField(
model_name="game",
name="path",
field=models.CharField(default="", max_length=100),
preserve_default=False,
),
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,17 @@
from django.db import models
class Game(models.Model):
steam_app_id = models.PositiveIntegerField(unique=True)
name = models.CharField(max_length=255)
path = models.CharField(max_length=100)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.steam_app_id})"

View File

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

View File

@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,8 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.http import HttpRequest
@login_required
def market_home(request: HttpRequest):
return render(request, "market.html", {})

View File

@ -12,18 +12,22 @@ from django.db.models import (
)
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.decorators import decorate_view
from noita.schemas import ResultsOut, LeaderboardOut
from noita.services.objectives import parse_objectives_and_store
from games.decorators import require_game_enabled
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
from .schemas import NoitaSubmissionOut
router = Router()
NOITA_APP_ID = 881100
@router.get("results", response=ResultsOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
cached_data = cache.get(cache_key)
@ -127,6 +131,7 @@ def get_results(request: HttpRequest):
@router.get("leaderboard", response=LeaderboardOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_leaderboard(request: HttpRequest):
"""
Get the global leaderboard for all users ranked by total score.
@ -232,6 +237,7 @@ def get_leaderboard(request: HttpRequest):
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
@decorate_view(require_game_enabled(NOITA_APP_ID))
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
"""
Submit a Noita run file (log file, screenshot, or video).

View File

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

View File

@ -0,0 +1,6 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/',
});

View File

@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from submissions.models import (
from opus_magnum.models import (
SteamAPIKey,
SteamCollection,
SteamCollectionItem,

View File

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

View File

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

View File

@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
"""
from django.core.management.base import BaseCommand, CommandError
from submissions.utils import create_or_update_collection
from submissions.models import SteamAPIKey, SteamCollection
from opus_magnum.utils import create_or_update_collection
from opus_magnum.models import SteamAPIKey, SteamCollection
class Command(BaseCommand):
@ -34,7 +34,7 @@ class Command(BaseCommand):
try:
# Check if collection already exists
from submissions.utils import SteamCollectionFetcher
from opus_magnum.utils import SteamCollectionFetcher
fetcher = SteamCollectionFetcher(api_key.api_key)
collection_id = fetcher.extract_collection_id(url)

View File

@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from submissions.utils import verify_and_validate_ocr_date_for_submission
from submissions.models import SubmissionFile
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from opus_magnum.models import SubmissionFile
class Command(BaseCommand):

View File

@ -205,7 +205,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="submissions.steamcollection",
to="opus_magnum.steamcollection",
),
),
],

View File

@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submissions", "0001_initial"),
("opus_magnum", "0001_initial"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0002_delete_collection"),
("opus_magnum", "0002_delete_collection"),
]
operations = [

View File

@ -1,7 +1,7 @@
# Generated by Django 5.2.7 on 2025-10-29 01:32
import django.db.models.deletion
import submissions.models
import opus_magnum.models
import uuid
from django.conf import settings
from django.db import migrations, models
@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0003_steamapikey"),
("opus_magnum", "0003_steamapikey"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -160,7 +160,7 @@ class Migration(migrations.Migration):
help_text="The puzzle this response is for",
on_delete=django.db.models.deletion.CASCADE,
related_name="responses",
to="submissions.steamcollectionitem",
to="opus_magnum.steamcollectionitem",
),
),
(
@ -168,7 +168,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="responses",
to="submissions.submission",
to="opus_magnum.submission",
),
),
],
@ -195,7 +195,7 @@ class Migration(migrations.Migration):
"file",
models.FileField(
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(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="submissions.puzzleresponse",
to="opus_magnum.puzzleresponse",
),
),
],

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0004_submission_puzzleresponse_submissionfile"),
("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0005_alter_submission_notes"),
("opus_magnum", "0005_alter_submission_notes"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submissions", "0007_submission_manual_validation_requested"),
("opus_magnum", "0007_submission_manual_validation_requested"),
]
operations = [

View File

@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0001_initial"),
("submissions", "0008_alter_puzzleresponse_unique_together"),
("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0009_steamcollectionitem_points_factor"),
("opus_magnum", "0009_steamcollectionitem_points_factor"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
]
operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"submissions",
"opus_magnum",
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
),
]

View File

@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0002_puzzlepointsvalue"),
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0013_steamcollectionitem_points_value"),
("opus_magnum", "0013_steamcollectionitem_points_value"),
]
operations = [

View File

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

View File

@ -221,6 +221,7 @@ class UserInfoOut(Schema):
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
points: int = 0
is_authenticated: bool
is_staff: bool
is_superuser: bool

View File

@ -0,0 +1 @@
# Create your tests here.

View File

@ -4,7 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re
import requests
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
from datetime import datetime
from django.utils import timezone
from django.conf import settings

View File

@ -7,7 +7,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"schema": "openapi-ts"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
@ -21,6 +22,7 @@
"vue": "^3.5.22"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.97.2",
"@mdi/font": "^7.4.47",
"@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^6.0.1",

View File

@ -36,6 +36,9 @@ importers:
specifier: ^3.5.22
version: 3.5.22(typescript@5.9.3)
devDependencies:
'@hey-api/openapi-ts':
specifier: ^0.97.2
version: 0.97.2(typescript@5.9.3)
'@mdi/font':
specifier: ^7.4.47
version: 7.4.47
@ -236,6 +239,31 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -252,6 +280,13 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
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':
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
@ -471,6 +506,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@24.9.2':
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
@ -566,16 +604,54 @@ packages:
alien-signals@3.0.3:
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:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
bmp-js@0.1.0:
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:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -585,10 +661,32 @@ packages:
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:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@ -605,6 +703,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -619,6 +720,13 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
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:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@ -632,9 +740,23 @@ packages:
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
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:
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:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
@ -642,10 +764,21 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
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:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@ -739,6 +872,13 @@ packages:
encoding:
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:
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
hasBin: true
@ -746,9 +886,19 @@ packages:
path-browserify@1.0.1:
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:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -765,13 +915,30 @@ packages:
typescript:
optional: true
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
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:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@ -780,6 +947,23 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -886,6 +1070,15 @@ packages:
whatwg-url@5.0.0:
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:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
@ -987,6 +1180,56 @@ snapshots:
'@esbuild/win32-x64@0.25.11':
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':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -1006,6 +1249,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsdevtools/ono@7.1.3': {}
'@lukeed/ms@2.0.2': {}
'@mdi/font@7.4.47': {}
'@rolldown/pluginutils@1.0.0-beta.29': {}
@ -1153,6 +1400,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.9.2':
dependencies:
undici-types: 7.16.0
@ -1281,22 +1530,76 @@ snapshots:
alien-signals@3.0.3: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
birpc@2.6.1: {}
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:
dependencies:
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: {}
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: {}
dotenv@17.4.2: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@ -1335,6 +1638,8 @@ snapshots:
estree-walker@2.0.2: {}
exsolve@1.0.8: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -1342,6 +1647,12 @@ snapshots:
fsevents@2.3.3:
optional: true
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
giget@3.2.0: {}
graceful-fs@4.2.11: {}
hookable@5.5.3: {}
@ -1350,14 +1661,32 @@ snapshots:
install@0.13.0: {}
is-docker@3.0.0: {}
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-what@5.5.0: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.6.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
lightningcss-android-arm64@1.30.2:
optional: true
@ -1421,12 +1750,29 @@ snapshots:
dependencies:
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: {}
path-browserify@1.0.1: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@ -1438,14 +1784,31 @@ snapshots:
optionalDependencies:
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:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.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: {}
resolve-pkg-maps@1.0.0: {}
rfdc@1.4.1: {}
rollup@4.52.5:
@ -1476,6 +1839,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.52.5
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: {}
speakingurl@14.0.1: {}
@ -1558,6 +1931,15 @@ snapshots:
tr46: 0.0.3
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:
optional: true

View File

@ -1,10 +1,13 @@
from ninja import NinjaAPI
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from opus_magnum.api import router as submissions_router
from opus_magnum.schemas import UserInfoOut
from animations.api import router as results_router
from noita.api import router as noita_router
from games.api import router as games_router
from market.api import router as market_router
# Create the main API instance
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
# api.auth = django_auth # Uncomment if you want global auth
# Include the submissions router
api.add_router("/submissions/", submissions_router, tags=["submissions"])
# Include the opus_magnum router
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
api.add_router("/results/", results_router, tags=["results"])
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
@ -63,20 +70,10 @@ def get_user_info(request):
user = request.user
if user.is_authenticated:
return {
"id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"is_authenticated": True,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"cas_groups": getattr(user, "cas_groups", []),
}
else:
return {
"is_authenticated": False,
"is_staff": False,
"is_superuser": False,
}
return user
return {
"is_authenticated": False,
"is_staff": False,
"is_superuser": False,
}

View File

@ -41,8 +41,10 @@ INSTALLED_APPS = [
"django_vite",
"accounts",
"animations",
"submissions",
"opus_magnum",
"noita",
"games",
"market",
]
MIDDLEWARE = [
@ -167,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
"video/webm",
]
# Market app settings
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
# Authentication backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
@ -193,7 +198,7 @@ STATICFILES_DIRS = [
from polylan_submitter.settingsLocal import * # noqa
import sentry_sdk
import sentry_sdk # noqa
sentry_sdk.init(
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",

View File

@ -23,17 +23,31 @@ from django.contrib.auth.decorators import login_required
from django.conf import settings
from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from games.decorators import require_game_enabled
from market.views import market_home
from .api import api
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
@login_required
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
@require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest):
from submissions.models import SteamCollection
from opus_magnum.models import SteamCollection
return render(
request,
@ -45,6 +59,7 @@ def opus_magnum_home(request: HttpRequest):
@login_required
@require_game_enabled(NOITA_APP_ID)
def noita_home(request: HttpRequest):
return render(request, "noita.html", {})
@ -54,6 +69,7 @@ urlpatterns = [
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls),
path("market", market_home, name="market.home"),
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("noita", noita_home, name="noita.home"),
path("", home, name="home"),

View File

@ -1,23 +1,18 @@
<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(() => [
{
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",
},
]);
interface Props {
marketEnabled?: string | boolean;
}
const props = withDefaults(defineProps<Props>(), {
marketEnabled: true,
});
const games = ref<GamesApiListGamesResponse | undefined>();
const loading = ref(true);
const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (appId: number) => {
@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
const navigate = (path: string) => {
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>
<template>
@ -44,13 +55,38 @@ const navigate = (path: string) => {
</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- 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">
<figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
<i class="mdi mdi-gamepad-variant text-5xl"></i>
@ -58,8 +94,7 @@ const navigate = (path: string) => {
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ game.title }}</h2>
<p class="text-base-content/70">{{ game.description }}</p>
<h2 class="card-title text-2xl">{{ game.name }}</h2>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
@ -75,7 +110,5 @@ const navigate = (path: string) => {
<p>Select a game above to begin submitting</p>
</div>
</div>
</div>
</template>

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>

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
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,
@ -12,32 +14,11 @@ import {
type SortingState,
} from "@tanstack/vue-table";
interface Objective {
objectiv_id: string;
display_string: string;
first_seen_at: string | null;
count: number;
max_count: number;
seed: string | null;
points_per_objectiv: number;
total_points: number;
}
const userInfo = ref({
username: "Player",
rank: null as number | null,
score: 0,
runsSubmitted: 0,
deathsCount: 0,
isStaff: false,
});
const noitaStore = useNoitaStore();
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false);
const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const columnHelper = createColumnHelper<Objective>();
const sorting = ref<SortingState>([]);
@ -153,40 +134,13 @@ const handleDrop = (event: DragEvent) => {
const submitRun = async () => {
if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try {
for (const file of uploadedFiles.value) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/noita/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
return;
}
const result = await response.json();
console.log("Submission successful:", result);
}
await noitaStore.submitRun(uploadedFiles.value);
uploadedFiles.value = [];
alert("Run submitted successfully!");
// Refresh objectives, score, and rank after successful submission
await Promise.all([
fetchUserResults(),
fetchLeaderboard(),
]);
} catch (error) {
console.error("Error submitting run:", error);
alert("Error submitting run. Please try again.");
} finally {
isUploading.value = false;
}
};
@ -194,94 +148,18 @@ const goHome = () => {
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.deathsCount = results.deaths_count;
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;
userInfo.value.deathsCount = userRank.deaths_count;
}
} catch (error) {
console.error("Error fetching leaderboard:", error);
} finally {
isLoadingLeaderboard.value = false;
}
};
const clearCache = async () => {
try {
const response = await fetch("/api/cache/clear", {
method: "POST",
});
if (response.ok) {
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"}`);
}
await noitaStore.clearCache();
alert("Cache cleared successfully!");
} catch (error) {
console.error("Error clearing cache:", error);
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(() => {
loadUserData();
noitaStore.loadUserData();
});
</script>

View File

@ -7,7 +7,8 @@ import Results from "@/components/Results.vue";
import Winners from "@/components/Winners.vue";
import PuzzleResults from "@/components/PuzzleResults.vue";
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
import { apiService, errorHelpers } from "@/services/apiService";
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
import { errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
@ -66,9 +67,9 @@ async function initialize() {
// Load user info
console.log("Loading user info...");
const userResponse = await apiService.getUserInfo();
const userResponse = await polylanSubmitterApiGetUserInfo();
if (userResponse.data) {
userInfo.value = userResponse.data;
userInfo.value = userResponse.data as UserInfo;
console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) {
console.warn("User info error:", userResponse.error);
@ -76,9 +77,9 @@ async function initialize() {
// Load collection data
console.log("Loading collection...");
const collectionResponse = await apiService.getCollection();
const collectionResponse = await opusMagnumApiGetCollection();
if (collectionResponse.data) {
collection.value = collectionResponse.data;
collection.value = collectionResponse.data as SteamCollection;
console.log("Collection loaded:", collectionResponse.data);
} else if (collectionResponse.error) {
console.warn("Collection error:", collectionResponse.error);

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>());

View File

@ -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;
};

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';

View File

@ -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'>);

View File

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

View File

@ -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;
};

View File

@ -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();
},
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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 };
}

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];
};

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;
}

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';

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

View File

@ -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>

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useMarketStore } from "../stores/market";
defineEmits<{
refresh: [];
}>();
const marketStore = useMarketStore();
const { userBets } = storeToRefs(marketStore);
const loading = computed(() => marketStore.isLoading);
const isWonBetsExpanded = ref(false);
const totalBetAmount = computed(() => {
return userBets.value.reduce((sum, bet) => sum + bet.amount, 0);
});
const winningBets = computed(() => {
return userBets.value.filter(bet => {
const market = bet.market;
return market?.status === "resolved" && market?.winning_option?.uuid === bet.option.uuid;
}).reverse();
});
const losingBets = computed(() => {
return userBets.value.filter(bet => {
const market = bet.market;
return market?.status === "resolved" && market?.winning_option?.uuid !== bet.option.uuid;
});
});
const openBets = computed(() => {
return userBets.value.filter(bet => bet.market?.status === "open");
});
const totalWinnings = computed(() => {
return winningBets.value.reduce((sum, bet) => sum + bet.amount, 0);
});
</script>
<template>
<div class="space-y-6">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Empty State -->
<div v-else-if="userBets.length === 0" class="alert">
<i class="mdi mdi-information mr-2"></i>
<span>You haven't placed any bets yet</span>
</div>
<!-- Content -->
<template v-else>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Total Bets</div>
<div class="stat-value text-2xl">{{ userBets.length }}</div>
<div class="stat-desc text-xs">{{ totalBetAmount }} points</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Active</div>
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
<div class="stat-desc text-xs">Waiting for result</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Won</div>
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
<div class="stat-desc text-xs text-success">+{{ totalWinnings }} points</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Lost</div>
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
<div class="stat-desc text-xs">Better luck next time</div>
</div>
</div>
<!-- Bets Sections -->
<div class="space-y-6">
<!-- Open Bets -->
<div v-if="openBets.length > 0">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-progress-clock text-info"></i>
Active Bets ({{ openBets.length }})
</h3>
<div class="space-y-4">
<div
v-for="bet in openBets"
:key="bet.uuid"
class="card bg-base-100 shadow hover:shadow-lg transition-shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
<p class="text-sm text-base-content/70">
Bet on: <span class="font-medium">{{ bet.option.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold">{{ bet.amount }} pts</div>
<div class="text-xs text-base-content/60 mt-1">
Status: <span class="badge badge-info badge-sm">{{ bet.market?.status }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Winning Bets -->
<div v-if="winningBets.length > 0">
<button @click="isWonBetsExpanded = !isWonBetsExpanded" class="text-xl font-bold mb-4 flex items-center gap-2 cursor-pointer hover:opacity-70 transition-opacity">
<i :class="['mdi', isWonBetsExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
<i class="mdi mdi-check-circle text-success"></i>
Won Bets ({{ winningBets.length }})
</button>
<div v-if="isWonBetsExpanded" class="space-y-4">
<div
v-for="bet in winningBets"
:key="bet.uuid"
class="card bg-success/10 border border-success shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
<p class="text-sm text-base-content/70">
Correct! You bet on: <span class="font-medium text-success">{{ bet.option.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-success">+{{ bet.amount }} pts</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Losing Bets -->
<div v-if="losingBets.length > 0">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-close-circle text-error"></i>
Lost Bets ({{ losingBets.length }})
</h3>
<div class="space-y-4">
<div
v-for="bet in losingBets"
:key="bet.uuid"
class="card bg-error/10 border border-error shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
<p class="text-sm text-base-content/70">
You bet on: <span class="font-medium">{{ bet.option.text }}</span>
</p>
<p class="text-sm text-base-content/60 mt-1">
Winner: <span class="font-medium">{{ bet.market?.winning_option?.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-error">-{{ bet.amount }} pts</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import type {
Game,
SteamCollection,
SteamCollectionItem,
Submission,
@ -6,7 +7,9 @@ import type {
SubmissionFile,
UserInfo,
TournamentSubmissions,
TournamentPuzzleResults
TournamentPuzzleResults,
Market,
UserBet
} from '../types'
// API Configuration
@ -99,13 +102,18 @@ export class ApiService {
}
}
// Games endpoint
async getGames(): Promise<ApiResponse<Game[]>> {
return this.request<Game[]>('/games/')
}
// Puzzle endpoints
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
return this.request<SteamCollectionItem[]>('/opus-magnum/puzzles')
}
async getCollection(): Promise<ApiResponse<SteamCollection>> {
return this.request<SteamCollection>('/submissions/collection')
return this.request<SteamCollection>('/opus-magnum/collection')
}
async getTopSubmissions(limit = 5): Promise<ApiResponse<TournamentSubmissions>> {
@ -119,12 +127,12 @@ export class ApiService {
// Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(
`/submissions/submissions?limit=${limit}&offset=${offset}`
`/opus-magnum/submissions?limit=${limit}&offset=${offset}`
)
}
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${id}`)
return this.request<Submission>(`/opus-magnum/submissions/${id}`)
}
async createSubmission(
@ -155,7 +163,7 @@ export class ApiService {
formData.append('files', file)
})
return this.uploadRequest<Submission>('/submissions/submissions', formData)
return this.uploadRequest<Submission>('/opus-magnum/submissions', formData)
}
// Admin endpoints (require staff permissions)
@ -167,37 +175,37 @@ export class ApiService {
validated_area?: number
}
): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
return this.request<PuzzleResponse>(`/opus-magnum/responses/${responseId}/validate`, {
method: 'PUT',
body: JSON.stringify(validationData),
})
}
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
return this.request<PuzzleResponse>(`/opus-magnum/responses/${responseId}/validate/auto`, {
method: 'PUT',
})
}
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
return this.request<PuzzleResponse[]>('/opus-magnum/responses/needs-validation')
}
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
return this.request<Submission>(`/opus-magnum/submissions/${submissionId}/validate`, {
method: 'POST',
})
}
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
return this.request<{ detail: string }>(`/opus-magnum/submissions/${submissionId}`, {
method: 'DELETE',
})
}
// Statistics endpoint
async getStats(): Promise<ApiResponse<SubmissionStats>> {
return this.request<SubmissionStats>('/submissions/stats')
return this.request<SubmissionStats>('/opus-magnum/stats')
}
// Health check
@ -209,6 +217,38 @@ export class ApiService {
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
return this.request<UserInfo>('/user')
}
// Market endpoints
async getMarkets(): Promise<ApiResponse<Market[]>> {
return this.request<Market[]>('/market/')
}
async placeBet(marketUuid: string, betData: {
option_uuid: string
amount: number
}): Promise<ApiResponse<UserBet>> {
return this.request<UserBet>(`/market/${marketUuid}/bets`, {
method: 'POST',
body: JSON.stringify(betData),
})
}
async getUserBets(): Promise<ApiResponse<UserBet[]>> {
return this.request<UserBet[]>('/market/user/bets')
}
async closeMarket(marketUuid: string): Promise<ApiResponse<{ status: string }>> {
return this.request<{ status: string }>(`/market/${marketUuid}/actions/close`, {
method: 'POST',
})
}
async resolveMarket(marketUuid: string, winningOptionUuid: string): Promise<ApiResponse<Market>> {
return this.request<Market>(`/market/${marketUuid}/actions/resolve`, {
method: 'POST',
body: JSON.stringify({ winning_option_uuid: winningOptionUuid }),
})
}
}
// Singleton instance

View File

@ -0,0 +1,94 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets } from '@/api'
import type { Market } from '@/types'
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
export const useMarketStore = defineStore('market', () => {
// State
const markets = ref<Market[]>([])
const userInfo = ref<UserInfoOut | undefined>()
const userBets = ref<UserBetSchema[]>([])
const isLoading = ref(true)
const error = ref<string>('')
// Actions
const loadUserInfo = async () => {
try {
const response = await polylanSubmitterApiGetUserInfo()
if (response.data) {
userInfo.value = response.data
}
} catch (err) {
error.value = 'Failed to load user info'
console.error('Error loading user info:', err)
}
}
const loadMarkets = async () => {
try {
const response = await marketApiListMarkets()
if (response.data) {
markets.value = response.data as unknown as Market[]
}
} catch (err) {
error.value = 'Failed to load markets'
console.error('Error loading markets:', err)
}
}
const loadUserBets = async () => {
try {
const response = await marketApiListUserBets()
if (response.data) {
userBets.value = response.data
}
} catch (err) {
error.value = 'Failed to load user bets'
console.error('Error loading user bets:', err)
}
}
const initializeMarketPage = async () => {
isLoading.value = true
error.value = ''
try {
await Promise.all([
loadUserInfo(),
loadMarkets(),
])
// Load user bets if authenticated
if (userInfo.value?.is_authenticated) {
await loadUserBets()
}
} finally {
isLoading.value = false
}
}
const refreshPage = async () => {
await Promise.all([
loadUserInfo(),
loadMarkets(),
userInfo.value?.is_authenticated ? loadUserBets() : Promise.resolve(),
])
}
return {
// State
markets,
userInfo,
userBets,
isLoading,
error,
// Actions
loadUserInfo,
loadMarkets,
loadUserBets,
initializeMarketPage,
refreshPage,
}
})

View File

@ -0,0 +1,167 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen'
export type Objective = ObjectivResultOut
interface UserInfo {
username: string
rank: number | null
score: number
runsSubmitted: number
deathsCount: number
isStaff: boolean
}
export const useNoitaStore = defineStore('noita', () => {
// State
const userInfo = ref<UserInfo>({
username: 'Player',
rank: null,
score: 0,
runsSubmitted: 0,
deathsCount: 0,
isStaff: false,
})
const objectives = ref<Objective[]>([])
const leaderboard = ref<LeaderboardEntryOut[]>([])
const isLoadingLeaderboard = ref(false)
const isUploading = ref(false)
const error = ref<string>('')
// Actions
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.deathsCount = results.deaths_count
userInfo.value.runsSubmitted = results.objectives.length
objectives.value = results.objectives
} catch (err) {
error.value = 'Failed to fetch user results'
console.error('Error fetching results:', err)
}
}
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) => entry.username === userInfo.value.username
)
if (userRank) {
userInfo.value.rank = userRank.rank
userInfo.value.score = userRank.total_score
userInfo.value.deathsCount = userRank.deaths_count
}
} catch (err) {
error.value = 'Failed to fetch leaderboard'
console.error('Error fetching leaderboard:', err)
} finally {
isLoadingLeaderboard.value = false
}
}
const loadUserData = async () => {
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 (err) {
console.error('Error fetching user info:', err)
}
await Promise.all([fetchUserResults(), fetchLeaderboard()])
}
const submitRun = async (files: File[]) => {
if (files.length === 0) return
isUploading.value = true
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/noita/submit', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Unknown error')
}
const result = await response.json()
console.log('Submission successful:', result)
}
// Refresh objectives, score, and rank after successful submission
await Promise.all([fetchUserResults(), fetchLeaderboard()])
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
error.value = `Error submitting run: ${errorMessage}`
throw err
} finally {
isUploading.value = false
}
}
const clearCache = async () => {
try {
const response = await fetch('/api/cache/clear', {
method: 'POST',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Unknown error')
}
await Promise.all([fetchUserResults(), fetchLeaderboard()])
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
error.value = `Error clearing cache: ${errorMessage}`
throw err
}
}
const refreshData = async () => {
await Promise.all([fetchUserResults(), fetchLeaderboard()])
}
return {
// State
userInfo,
objectives,
leaderboard,
isLoadingLeaderboard,
isUploading,
error,
// Actions
fetchUserResults,
fetchLeaderboard,
loadUserData,
submitRun,
clearCache,
refreshData,
}
})

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { opusMagnumApiListPuzzles } from '@/api'
import type { SteamCollectionItem } from '@/types'
import { apiService } from '@/services/apiService'
export const usePuzzlesStore = defineStore('puzzles', () => {
// State
@ -14,7 +14,7 @@ export const usePuzzlesStore = defineStore('puzzles', () => {
const findPuzzleByName = computed(() => (name: string): SteamCollectionItem | null => {
if (!name) return null
// First try exact match (case insensitive)
const exactMatch = puzzles.value.find(
puzzle => puzzle.title.toLowerCase() === name.toLowerCase()
@ -26,27 +26,27 @@ export const usePuzzlesStore = defineStore('puzzles', () => {
puzzle => puzzle.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(puzzle.title.toLowerCase())
)
return partialMatch || null
})
// Actions
const loadPuzzles = async () => {
if (puzzles.value.length > 0) return // Already loaded
try {
isLoading.value = true
error.value = ''
const response = await apiService.getPuzzles()
const response = await opusMagnumApiListPuzzles()
if (response.error) {
error.value = response.error
error.value = String(response.error)
console.error('Failed to load puzzles:', response.error)
return
}
if (response.data) {
puzzles.value = response.data
puzzles.value = response.data as unknown as SteamCollectionItem[]
}
} catch (err) {
error.value = 'Failed to load puzzles'

View File

@ -1,3 +1,9 @@
export interface Game {
steam_app_id: number
name: string
path: string
}
export interface SteamCollection {
id: number
steam_id: string
@ -24,9 +30,9 @@ export interface SteamCollectionItem {
title: string
author_name: string
description: string
tags: string[]
order_index: number
collection: number
tags?: string[]
order_index?: number
steam_url: string
points_factor?: PointsFactor
created_at: string
updated_at: string
@ -163,3 +169,30 @@ export interface PuzzleResults {
export interface TournamentPuzzleResults {
results: PuzzleResults[]
}
export interface MarketOption {
uuid: string
text: string
total_bets: number
}
export interface Market {
uuid: string
title: string
description: string
status: 'draft' | 'open' | 'closed' | 'resolved'
end_date: string
multiplier: number
created_at: string
options: MarketOption[]
winning_option?: MarketOption | null
userHasBet?: boolean
}
export interface UserBet {
uuid: string
amount: number
created_at: string
option: MarketOption
market: Market
}

View File

@ -1 +0,0 @@
import{k as t,l as a,p as n,v as s}from"./style-C9QoPxDN.js";const c={key:0,class:"flex justify-center"},k={key:0,class:"badge badge-warning badge-lg"},d={key:1,class:"badge badge-lg"},l={key:2,class:"badge badge-lg"},o={key:3,class:"badge badge-lg"},g={key:1,class:"text-2xl text-base-content/50"},y=t({__name:"RankBadge",props:{rank:{}},setup(e){return(i,r)=>e.rank!==null?(n(),a("div",c,[e.rank===1?(n(),a("span",k," 🏆 #"+s(e.rank),1)):e.rank===2?(n(),a("span",d," 🥈 #"+s(e.rank),1)):e.rank===3?(n(),a("span",l," 🥉 #"+s(e.rank),1)):(n(),a("span",o," #"+s(e.rank),1))])):(n(),a("div",g," No rank yet "))}});export{y as _};

View File

@ -0,0 +1 @@
import{d as v,r as l,q as g,a as o,o as r,b as e,j as f,F as x,g as h,t as y,f as _,E as w}from"./style-BkYIZIDm.js";import{g as k}from"./sdk.gen-CA3PL0uK.js";const j={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},S={class:"w-full max-w-6xl"},E={key:0,class:"flex justify-center py-20"},C={key:1,class:"grid grid-cols-1 md:grid-cols-2 gap-8"},N=["onClick"],$={class:"relative h-60 bg-base-300 overflow-hidden"},A=["src","alt","onError"],B={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},L={class:"card-body"},P={class:"card-title text-2xl"},V=v({__name:"Home",setup(F){const i=l(),n=l(!0),d=l(new Set),u=s=>`https://cdn.akamai.steamstatic.com/steam/apps/${s}/header.jpg`,b=s=>{d.value.add(s)},c=s=>{window.location.href=s};return g(async()=>{const s=await k();s.data&&(i.value=s.data),n.value=!1}),(s,t)=>(r(),o("div",j,[e("div",S,[t[6]||(t[6]=e("div",{class:"text-center mb-12"},[e("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),e("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),n.value?(r(),o("div",E,[...t[1]||(t[1]=[e("span",{class:"loading loading-spinner loading-lg"},null,-1)])])):(r(),o("div",C,[e("div",{onClick:t[0]||(t[0]=a=>c("/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"},[...t[2]||(t[2]=[f('<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>',2)])]),(r(!0),o(x,null,h(i.value,a=>(r(),o("div",{key:a.steam_app_id,onClick:p=>c(a.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"},[e("figure",$,[d.value.has(a.steam_app_id)?(r(),o("div",B,[...t[3]||(t[3]=[e("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(r(),o("img",{key:0,src:u(a.steam_app_id),alt:a.name,onError:p=>b(a.steam_app_id),class:"w-full h-full object-cover"},null,40,A)),t[4]||(t[4]=e("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),e("div",L,[e("h2",P,y(a.name),1),t[5]||(t[5]=e("div",{class:"card-actions justify-end mt-4"},[e("button",{class:"btn btn-primary"},[e("i",{class:"mdi mdi-arrow-right mr-2"}),_(" Submit results ")])],-1))])],8,N))),128))])),t[7]||(t[7]=e("div",{class:"text-center mt-12 text-base-content/50"},[e("p",null,"Select a game above to begin submitting")],-1))])]))}}),m="#app",q=document.querySelector(m),D=w(V,{...q?.dataset});D.mount(m);

View File

@ -1 +0,0 @@
import{k as b,c as v,r as g,l as a,p as n,s as t,F as h,y as x,v as i,x as f,O as _}from"./style-C9QoPxDN.js";const y={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},w={class:"w-full max-w-6xl"},k={class:"grid grid-cols-1 md:grid-cols-2 gap-8"},S=["onClick"],I={class:"relative h-60 bg-base-300 overflow-hidden"},j=["src","alt","onError"],E={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},N={class:"card-body"},C={class:"card-title text-2xl"},B={class:"text-base-content/70"},O=b({__name:"Home",setup(A){const c=v(()=>[{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"}]),r=g(new Set),d=o=>`https://cdn.akamai.steamstatic.com/steam/apps/${o}/header.jpg`,u=o=>{r.value.add(o)},p=o=>{window.location.href=o};return(o,e)=>(n(),a("div",y,[t("div",w,[e[3]||(e[3]=t("div",{class:"text-center mb-12"},[t("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),t("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),t("div",k,[(n(!0),a(h,null,x(c.value,s=>(n(),a("div",{key:s.id,onClick:m=>p(s.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"},[t("figure",I,[r.value.has(s.appId)?(n(),a("div",E,[...e[0]||(e[0]=[t("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(n(),a("img",{key:0,src:d(s.appId),alt:s.title,onError:m=>u(s.appId),class:"w-full h-full object-cover"},null,40,j)),e[1]||(e[1]=t("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),t("div",N,[t("h2",C,i(s.title),1),t("p",B,i(s.description),1),e[2]||(e[2]=t("div",{class:"card-actions justify-end mt-4"},[t("button",{class:"btn btn-primary"},[t("i",{class:"mdi mdi-arrow-right mr-2"}),f(" Submit results ")])],-1))])],8,S))),128))]),e[4]||(e[4]=t("div",{class:"text-center mt-12 text-base-content/50"},[t("p",null,"Select a game above to begin submitting")],-1))])]))}}),l="#app",$=document.querySelector(l),z=_(O,{...$?.dataset});z.mount(l);

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