Compare commits

...
1 Commits
Author SHA1 Message Date
Legrems 5509fdb8db feat(games): add steam pages with steam price/reduc 2026-10-16 00:59:43 +02:00
24 changed files with 774 additions and 6 deletions
+4
View File
@@ -16,4 +16,8 @@ if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
python manage.py migrate --noinput python manage.py migrate --noinput
fi fi
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
# is already queued, so every replica can safely run it on start.
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
exec "$@" exec "$@"
+2
View File
@@ -36,5 +36,7 @@ SENTRY_ENVIRONMENT=production
SENTRY_DSN= SENTRY_DSN=
STEAM_API_KEY= STEAM_API_KEY=
STEAM_STORE_REFRESH_MINUTES=360
STEAM_STORE_COUNTRY=ch
AANDT_API_BASE_URL= AANDT_API_BASE_URL=
SUBMITTER_BASE_URL= SUBMITTER_BASE_URL=
+24 -1
View File
@@ -8,6 +8,7 @@ from django_object_actions import (
from submissions.tasks import sync_event_game_scopes_task from submissions.tasks import sync_event_game_scopes_task
from .models import EventGame, Game from .models import EventGame, Game
from .tasks import refresh_steam_games_task
class EventGameInline(admin.TabularInline): class EventGameInline(admin.TabularInline):
@@ -19,8 +20,30 @@ class EventGameInline(admin.TabularInline):
class GameAdmin(admin.ModelAdmin): class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "updated_at"] list_display = ["name", "steam_app_id", "updated_at"]
search_fields = ["name", "steam_app_id"] search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"] readonly_fields = [
"created_at",
"updated_at",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_is_free",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
]
inlines = [EventGameInline] inlines = [EventGameInline]
actions = ["refresh_steam_info"]
@admin.action(description="Refresh Steam store info")
def refresh_steam_info(self, request, queryset):
game_ids = list(
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
)
refresh_steam_games_task.enqueue(game_ids=game_ids, reschedule=False)
self.message_user(
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
)
@admin.register(EventGame) @admin.register(EventGame)
+12 -1
View File
@@ -3,7 +3,8 @@ from typing import List
from ninja import Router from ninja import Router
from .models import Game from .models import Game
from .schemas import GameOut from .schemas import GameOut, SteamGameOut
from .tasks import schedule_steam_refresh
router = Router() router = Router()
event_router = Router() event_router = Router()
@@ -17,3 +18,13 @@ def list_games(request):
@event_router.get("/events/{event_path}/games", response=List[GameOut]) @event_router.get("/events/{event_path}/games", response=List[GameOut])
def list_event_games(request, event_path: str): def list_event_games(request, event_path: str):
return Game.objects.enabled_for_event(request.event) return Game.objects.enabled_for_event(request.event)
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
def list_event_steam_games(request, event_path: str):
"""The event's Steam games with their store info (price, discount, blurb)."""
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
schedule_steam_refresh()
return Game.objects.enabled_for_event(request.event).filter(
steam_app_id__isnull=False
)
@@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from games.tasks import schedule_steam_refresh
class Command(BaseCommand):
help = "Start the periodic Steam store refresh loop (no-op if already running)"
def handle(self, *args, **options):
if schedule_steam_refresh():
self.stdout.write(self.style.SUCCESS("Steam store refresh scheduled"))
else:
self.stdout.write("Steam store refresh already scheduled")
@@ -0,0 +1,52 @@
# Generated by Django 6.0.5 on 2026-10-15 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0009_game_description"),
]
operations = [
migrations.AddField(
model_name="game",
name="steam_currency",
field=models.CharField(blank=True, default="", max_length=3),
),
migrations.AddField(
model_name="game",
name="steam_discount_percent",
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name="game",
name="steam_header_image",
field=models.URLField(blank=True, default="", max_length=500),
),
migrations.AddField(
model_name="game",
name="steam_info_updated_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_is_free",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="steam_price_final",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_price_initial",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_short_description",
field=models.TextField(blank=True, default=""),
),
]
+11
View File
@@ -16,6 +16,17 @@ class Game(models.Model):
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
# Steam store info, refreshed in the background (see games.tasks).
# Prices are in cents, in steam_currency.
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
steam_currency = models.CharField(max_length=3, blank=True, default="")
steam_is_free = models.BooleanField(default=False)
steam_short_description = models.TextField(blank=True, default="")
steam_header_image = models.URLField(max_length=500, blank=True, default="")
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+60
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional from typing import Optional
from ninja import Schema from ninja import Schema
@@ -11,3 +12,62 @@ class GameOut(Schema):
custom_path: bool custom_path: bool
image: Optional[str] = None image: Optional[str] = None
description: str description: str
@staticmethod
def resolve_description(obj) -> str:
# The admin one wins, Steam games fall back to their store blurb
return obj.description or obj.steam_short_description
class SteamGameOut(Schema):
id: int
name: str
steam_app_id: int
store_url: str
header_image: str
short_description: str
is_free: bool
currency: str
price_initial: Optional[int] = None
price_final: Optional[int] = None
discount_percent: int
updated_at: Optional[datetime] = None
@staticmethod
def resolve_store_url(obj) -> str:
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
@staticmethod
def resolve_header_image(obj) -> str:
return (
obj.steam_header_image
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
)
@staticmethod
def resolve_short_description(obj) -> str:
return obj.steam_short_description
@staticmethod
def resolve_is_free(obj) -> bool:
return obj.steam_is_free
@staticmethod
def resolve_currency(obj) -> str:
return obj.steam_currency
@staticmethod
def resolve_price_initial(obj) -> Optional[int]:
return obj.steam_price_initial
@staticmethod
def resolve_price_final(obj) -> Optional[int]:
return obj.steam_price_final
@staticmethod
def resolve_discount_percent(obj) -> int:
return obj.steam_discount_percent
@staticmethod
def resolve_updated_at(obj) -> Optional[datetime]:
return obj.steam_info_updated_at
@@ -0,0 +1,79 @@
import logging
import httpx
from django.conf import settings
from django.utils import timezone
from ..models import Game
logger = logging.getLogger(__name__)
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
# which is plenty for a handful of games every few hours.
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
class SteamStoreError(Exception):
"""Raised when the Steam store didn't return usable details for an app."""
def fetch_app_details(steam_app_id: int) -> dict:
response = httpx.get(
APP_DETAILS_URL,
params={
"appids": steam_app_id,
"cc": settings.STEAM_STORE_COUNTRY,
"l": "en",
"filters": "basic,price_overview",
},
timeout=10,
)
response.raise_for_status()
entry = (response.json() or {}).get(str(steam_app_id)) or {}
if not entry.get("success"):
raise SteamStoreError(f"No store details for app {steam_app_id}")
return entry["data"]
def refresh_steam_game(game: Game) -> None:
"""Pull the current store info for a Steam game into its steam_* fields."""
data = fetch_app_details(game.steam_app_id)
price = data.get("price_overview") or {}
game.steam_is_free = bool(data.get("is_free"))
game.steam_price_initial = price.get("initial")
game.steam_price_final = price.get("final")
game.steam_discount_percent = price.get("discount_percent") or 0
game.steam_currency = price.get("currency") or ""
game.steam_short_description = data.get("short_description") or ""
game.steam_header_image = data.get("header_image") or ""
game.steam_info_updated_at = timezone.now()
game.save(
update_fields=[
"steam_is_free",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
"updated_at",
]
)
def refresh_steam_games(game_ids: list[int] | None = None) -> None:
"""Refresh every Steam game (or just game_ids). A failing game keeps its
previous info and doesn't stop the others."""
games = Game.objects.filter(steam_app_id__isnull=False)
if game_ids is not None:
games = games.filter(id__in=game_ids)
for game in games:
try:
refresh_steam_game(game)
except (httpx.HTTPError, ValueError, SteamStoreError) as exc:
logger.warning("Steam store refresh failed for %s: %s", game, exc)
+52
View File
@@ -0,0 +1,52 @@
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from .services.steam_store import refresh_steam_games
# Set while a periodic refresh is queued, so there's only ever one loop running,
# whatever the number of workers/containers trying to start it. It expires on
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
# letting the next schedule_steam_refresh() start a new one.
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
def steam_refresh_interval() -> timedelta:
return timedelta(minutes=settings.STEAM_STORE_REFRESH_MINUTES)
def _guard_timeout() -> int:
return int(steam_refresh_interval().total_seconds() * 2)
def schedule_steam_refresh() -> bool:
"""Start the periodic refresh loop right away, unless one is already queued.
Safe to call from anywhere, any number of times."""
if not cache.add(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()):
return False
refresh_steam_games_task.enqueue()
return True
@task
def refresh_steam_games_task(
game_ids: list[int] | None = None, reschedule: bool = True
):
"""Refresh Steam store info, then re-enqueue itself every
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
sync_event_game_scopes_task, no external scheduler needed).
reschedule=False is a one-off run (e.g. the admin action) that leaves the
periodic loop alone."""
try:
refresh_steam_games(game_ids)
finally:
if reschedule:
cache.set(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout())
refresh_steam_games_task.using(
run_after=timezone.now() + steam_refresh_interval()
).enqueue()
+157
View File
@@ -0,0 +1,157 @@
from unittest import mock
import httpx
from django.core.cache import cache
from django.test import TestCase, override_settings
from accounts.models import CustomUser
from events.models import Event
from .models import EventGame, Game
from .services.steam_store import refresh_steam_games
from .tasks import refresh_steam_games_task, schedule_steam_refresh
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
def steam_response(app_id, data, success=True):
return httpx.Response(
200,
json={str(app_id): {"success": success, "data": data}},
request=httpx.Request("GET", "https://store.steampowered.com"),
)
class RefreshSteamGamesTests(TestCase):
def setUp(self):
self.game = Game.objects.create(name="Noita", path="noita", steam_app_id=881100)
@mock.patch("games.services.steam_store.httpx.get")
def test_stores_price_and_discount(self, get):
get.return_value = steam_response(
881100,
{
"is_free": False,
"short_description": "Every pixel is simulated.",
"header_image": "https://example.com/header.jpg",
"price_overview": {
"currency": "CHF",
"initial": 1950,
"final": 780,
"discount_percent": 60,
},
},
)
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_initial, 1950)
self.assertEqual(self.game.steam_price_final, 780)
self.assertEqual(self.game.steam_discount_percent, 60)
self.assertEqual(self.game.steam_currency, "CHF")
self.assertEqual(self.game.steam_short_description, "Every pixel is simulated.")
self.assertIsNotNone(self.game.steam_info_updated_at)
@mock.patch("games.services.steam_store.httpx.get")
def test_free_game_has_no_price(self, get):
get.return_value = steam_response(881100, {"is_free": True})
refresh_steam_games()
self.game.refresh_from_db()
self.assertTrue(self.game.steam_is_free)
self.assertIsNone(self.game.steam_price_final)
@mock.patch("games.services.steam_store.httpx.get")
def test_failure_keeps_previous_info(self, get):
Game.objects.filter(pk=self.game.pk).update(steam_price_final=999)
get.side_effect = httpx.ConnectError("down")
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_final, 999)
@override_settings(CACHES=LOCMEM_CACHE)
class ScheduleSteamRefreshTests(TestCase):
def setUp(self):
cache.clear()
@mock.patch("games.tasks.refresh_steam_games_task")
def test_only_one_loop_is_scheduled(self, task):
self.assertTrue(schedule_steam_refresh())
self.assertFalse(schedule_steam_refresh())
task.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games")
def test_task_reschedules_itself_even_on_failure(self, refresh):
refresh.side_effect = RuntimeError("boom")
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
with self.assertRaises(RuntimeError):
refresh_steam_games_task.call()
patched.using.return_value.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games")
def test_one_off_run_does_not_reschedule(self, refresh):
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(game_ids=[1], reschedule=False)
refresh.assert_called_once_with([1])
patched.using.assert_not_called()
@override_settings(CACHES=LOCMEM_CACHE)
class EventSteamGamesApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
noita = Game.objects.create(
name="Noita", path="noita", steam_app_id=881100, steam_price_final=780
)
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
EventGame.objects.create(event=self.event, game=noita)
EventGame.objects.create(event=self.event, game=clock)
user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(user)
@mock.patch("games.api.schedule_steam_refresh")
def test_lists_only_steam_games_with_store_info(self, schedule):
response = self.client.get("/api/events/event/games/steam")
self.assertEqual(response.status_code, 200)
[game] = response.json()
self.assertEqual(game["name"], "Noita")
self.assertEqual(game["price_final"], 780)
self.assertEqual(game["store_url"], "https://store.steampowered.com/app/881100")
schedule.assert_called_once()
class EventGamesDescriptionTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(self.user)
def test_steam_description_is_fallback_for_admin_one(self):
steam = Game.objects.create(
name="Noita",
path="noita",
steam_app_id=881100,
steam_short_description="From Steam",
)
custom = Game.objects.create(
name="Opus",
path="opus",
steam_app_id=558990,
description="From admin",
steam_short_description="From Steam",
)
EventGame.objects.create(event=self.event, game=steam)
EventGame.objects.create(event=self.event, game=custom)
response = self.client.get("/api/events/event/games")
descriptions = {g["name"]: g["description"] for g in response.json()}
self.assertEqual(descriptions, {"Noita": "From Steam", "Opus": "From admin"})
@@ -231,6 +231,12 @@ LOAD_TEST_LOGIN_ENABLED = (
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
# Steam store info (price, discount, blurb) for the event's Steam games, refreshed
# in the background every STEAM_STORE_REFRESH_MINUTES (see games.tasks)
STEAM_STORE_REFRESH_MINUTES = int(os.environ.get("STEAM_STORE_REFRESH_MINUTES") or 360)
# Store region the prices are fetched for (ISO country code, drives the currency)
STEAM_STORE_COUNTRY = os.environ.get("STEAM_STORE_COUNTRY") or "ch"
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members # Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
# (see submissions.services.aandt_sync) # (see submissions.services.aandt_sync)
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch" AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch"
@@ -50,6 +50,12 @@ def event_home(request: HttpRequest, event_path: str):
return render(request, "app.html") return render(request, "app.html")
@login_required
def games_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID) @require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest, event_path: str): def opus_magnum_home(request: HttpRequest, event_path: str):
@@ -95,6 +101,7 @@ urlpatterns = [
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("<slug:event_path>/games", games_home, name="games.home"),
path("<slug:event_path>/market", market_home, name="market.home"), path("<slug:event_path>/market", market_home, name="market.home"),
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("<slug:event_path>/noita", noita_home, name="noita.home"), path("<slug:event_path>/noita", noita_home, name="noita.home"),
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { gamesApiListEventSteamGames } from "./api";
import type { SteamGameOut } from "./api/types.gen";
import SteamStoreCard from "./components/SteamStoreCard.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("games.title")));
const games = ref<SteamGameOut[]>([]);
const loading = ref(true);
onMounted(async () => {
const response = await gamesApiListEventSteamGames({
path: { event_path: props.eventPath },
});
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-6xl">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!games.length" class="text-center text-base-content/50 py-20">
{{ t("games.empty") }}
</p>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</div>
</div>
</div>
</template>
+10 -1
View File
@@ -18,6 +18,7 @@ useTitle(computed(() => t("home.title")));
const games = ref<GamesApiListEventGamesResponse | undefined>(); const games = ref<GamesApiListEventGamesResponse | undefined>();
const loading = ref(true); const loading = ref(true);
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
@@ -83,7 +84,7 @@ onMounted(async () => {
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2> <h2 class="card-title text-2xl">{{ game.name }}</h2>
<p v-if="game.description" class="text-base-content/70">{{ game.description }}</p> <p v-if="game.description" class="text-sm text-base-content/70 line-clamp-2" :title="game.description">{{ game.description }}</p>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-primary"> <button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i> <i class="mdi mdi-arrow-right mr-2"></i>
@@ -94,6 +95,14 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Link to the Steam games page -->
<div v-if="!loading && hasSteamGames" class="text-center mt-12">
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-steam mr-2"></i>
{{ t("home.getTheGames") }}
</RouterLink>
</div>
<!-- Footer --> <!-- Footer -->
<div class="text-center mt-12 text-base-content/50"> <div class="text-center mt-12 text-base-content/50">
<p>{{ t("home.footer") }}</p> <p>{{ t("home.footer") }}</p>
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -196,6 +196,13 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
*/ */
export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options }); export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options });
/**
* List Event Steam Games
*
* The event's Steam games with their store info (price, discount, blurb).
*/
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
/** /**
* List My Scopes * List My Scopes
*/ */
+77
View File
@@ -924,6 +924,60 @@ export type NoitaSubmissionOut = {
processed: boolean; processed: boolean;
}; };
/**
* SteamGameOut
*/
export type SteamGameOut = {
/**
* Id
*/
id: number;
/**
* Name
*/
name: string;
/**
* Steam App Id
*/
steam_app_id: number;
/**
* Store Url
*/
store_url: string;
/**
* Header Image
*/
header_image: string;
/**
* Short Description
*/
short_description: string;
/**
* Is Free
*/
is_free: boolean;
/**
* Currency
*/
currency: string;
/**
* Price Initial
*/
price_initial?: number | null;
/**
* Price Final
*/
price_final?: number | null;
/**
* Discount Percent
*/
discount_percent: number;
/**
* Updated At
*/
updated_at?: string | null;
};
/** /**
* ScopeOut * ScopeOut
*/ */
@@ -1715,6 +1769,29 @@ export type GamesApiListEventGamesResponses = {
export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses]; export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses];
export type GamesApiListEventSteamGamesData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/games/steam';
};
export type GamesApiListEventSteamGamesResponses = {
/**
* Response
*
* OK
*/
200: Array<SteamGameOut>;
};
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
export type SubmissionsApiListMyScopesData = { export type SubmissionsApiListMyScopesData = {
body?: never; body?: never;
path: { path: {
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { SteamGameOut } from "../api/types.gen";
const props = defineProps<{ game: SteamGameOut }>();
const { t } = useI18n();
// Steam gives prices in cents
const formatPrice = (cents: number) =>
new Intl.NumberFormat(undefined, {
style: "currency",
currency: props.game.currency || "CHF",
}).format(cents / 100);
const hasPrice = computed(() => props.game.price_final != null && !!props.game.currency);
const isDiscounted = computed(() => hasPrice.value && props.game.discount_percent > 0);
</script>
<template>
<div
class="steam-card flex flex-col overflow-hidden rounded-lg shadow-xl transition-transform hover:-translate-y-1">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer">
<img :src="game.header_image" :alt="game.name" loading="lazy"
class="w-full aspect-[460/215] object-cover" />
</a>
<div class="flex flex-col flex-1 min-w-0 p-6 gap-3">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="steam-title text-2xl font-semibold truncate hover:underline">
{{ t("games.buy", { game: game.name }) }}
</a>
<p v-if="game.short_description" class="steam-text line-clamp-3">
{{ game.short_description }}
</p>
<div class="mt-auto pt-2 flex flex-wrap justify-end gap-2">
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`"
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-play"></i>
{{ t("games.play") }}
</a>
<div class="steam-purchase flex items-stretch rounded-sm overflow-hidden">
<template v-if="game.is_free">
<span class="steam-price px-3 py-2 flex items-center">{{ t("games.free") }}</span>
</template>
<template v-else-if="hasPrice">
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
-{{ game.discount_percent }}%
</span>
<span class="steam-price px-3 flex flex-col justify-center leading-tight">
<span v-if="isDiscounted" class="steam-original text-xs line-through">
{{ formatPrice(game.price_initial!) }}
</span>
<span :class="{ 'steam-discounted': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
</span>
</template>
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="steam-button px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-steam"></i>
{{ t("games.viewOnSteam") }}
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Steam store palette, on purpose regardless of the site theme */
.steam-card {
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
}
.steam-title {
color: #ffffff;
}
.steam-text {
color: #c6d4df;
}
.steam-purchase {
background: rgba(0, 0, 0, 0.2);
}
.steam-discount {
background: #4c6b22;
color: #beee11;
}
.steam-price {
color: #c6d4df;
}
.steam-original {
color: #738895;
}
.steam-discounted {
color: #beee11;
}
.steam-button {
color: #d2efa9;
background: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
.steam-button:hover {
color: #ffffff;
background: linear-gradient(to right, #8ed629 5%, #6aa621 95%);
}
.steam-play {
color: #ffffff;
background: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
}
.steam-play:hover {
background: linear-gradient(to right, #6fceff 5%, #2d5fd8 95%);
}
</style>
+11
View File
@@ -64,8 +64,19 @@
"title": "PolyLAN Submitter", "title": "PolyLAN Submitter",
"subtitle": "Choose a game and submit your best solutions", "subtitle": "Choose a game and submit your best solutions",
"submitResults": "Submit results", "submitResults": "Submit results",
"getTheGames": "Get the games",
"footer": "Select a game above to begin submitting" "footer": "Select a game above to begin submitting"
}, },
"games": {
"header": "Games",
"title": "Get the games",
"subtitle": "Don't own them yet? Grab them on Steam",
"empty": "No Steam games for this event",
"buy": "Buy {game}",
"free": "Free to Play",
"viewOnSteam": "View on Steam",
"play": "Play"
},
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
"header": "Beat the Clock", "header": "Beat the Clock",
+8
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router"; import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router";
import Clock from "@/Clock.vue"; import Clock from "@/Clock.vue";
import Games from "@/Games.vue";
import Home from "@/Home.vue"; import Home from "@/Home.vue";
import Market from "@/Market.vue"; import Market from "@/Market.vue";
import Noita from "@/Noita.vue"; import Noita from "@/Noita.vue";
@@ -37,6 +38,13 @@ export const router = createRouter({
component: Home, component: Home,
props: true, props: true,
}, },
{
path: "/:eventPath/games",
name: "games",
component: Games,
props: true,
meta: { titleKey: "games.header", getBackRoute: eventHome },
},
{ {
path: "/:eventPath/market", path: "/:eventPath/market",
name: "market", name: "market",