diff --git a/polylan_submitter/clock/__init__.py b/polylan_submitter/clock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/clock/admin.py b/polylan_submitter/clock/admin.py new file mode 100644 index 0000000..007b414 --- /dev/null +++ b/polylan_submitter/clock/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import ClockTimer, Press + + +@admin.register(ClockTimer) +class ClockTimerAdmin(admin.ModelAdmin): + list_display = ("event", "end_time", "initial_duration") + list_editable = ("initial_duration",) + list_filter = ("event",) + + +@admin.register(Press) +class PressAdmin(admin.ModelAdmin): + list_display = ( + "id", + "event", + "player", + "pressed_at", + "remaining_before_press", + "seconds_added", + ) + list_filter = ("event", "player") diff --git a/polylan_submitter/clock/api.py b/polylan_submitter/clock/api.py new file mode 100644 index 0000000..59f72b8 --- /dev/null +++ b/polylan_submitter/clock/api.py @@ -0,0 +1,79 @@ +from datetime import timedelta + +from django.http import HttpRequest +from django.utils import timezone +from ninja import Router +from ninja.decorators import decorate_view + +from games.decorators import require_game_enabled + +from .models import PRESS_BONUS_SECONDS, ClockTimer, Press +from .schemas import ClockStateOut, PlayerStatsOut +from .scoring import ranked_players + +router = Router() + +PREFIX = "/events/{event_path}/clock" + + +@router.get(f"{PREFIX}/current", response=ClockStateOut) +@decorate_view(require_game_enabled(path="clock")) +def current_state(request: HttpRequest, event_path: str): + timer = ClockTimer.current(request.event) + timer.reset_if_expired() + now = timezone.now() + + next_press_at = None + cooldown_seconds = None + if request.user.is_authenticated: + last_press = ( + Press.objects.filter(player=request.user, event=request.event) + .order_by("-pressed_at") + .first() + ) + if last_press is not None: + candidate = last_press.pressed_at + timedelta( + seconds=last_press.cooldown_seconds + ) + if candidate > now: + next_press_at = candidate + cooldown_seconds = last_press.cooldown_seconds + + return { + "server_time": now, + "end_time": timer.end_time, + "initial_duration": timer.initial_duration, + "press_bonus_seconds": PRESS_BONUS_SECONDS, + "next_press_at": next_press_at, + "cooldown_seconds": cooldown_seconds, + } + + +@router.get(f"{PREFIX}/me", response=PlayerStatsOut) +@decorate_view(require_game_enabled(path="clock")) +def my_stats(request: HttpRequest, event_path: str): + ranked = list( + ranked_players(request.event) + .values("id", "points", "rank") + .order_by("-points", "id") + ) + total_players = len(ranked) + my_index = next( + (i for i, row in enumerate(ranked) if row["id"] == request.user.id), None + ) + + mine = ranked[my_index] if my_index is not None else None + above = ranked[my_index - 1] if my_index is not None and my_index > 0 else None + below = ( + ranked[my_index + 1] + if my_index is not None and my_index + 1 < total_players + else None + ) + + return { + "points": mine["points"] if mine else 0.0, + "rank": mine["rank"] if mine else total_players, + "total_players": total_players, + "points_above": above["points"] if above else None, + "points_below": below["points"] if below else None, + } diff --git a/polylan_submitter/clock/apps.py b/polylan_submitter/clock/apps.py new file mode 100644 index 0000000..9a7733e --- /dev/null +++ b/polylan_submitter/clock/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ClockConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "clock" + + def ready(self): + from . import signals # noqa: F401 diff --git a/polylan_submitter/clock/broadcast.py b/polylan_submitter/clock/broadcast.py new file mode 100644 index 0000000..c8ae7c0 --- /dev/null +++ b/polylan_submitter/clock/broadcast.py @@ -0,0 +1,3 @@ +def clock_group(event_path: str) -> str: + """Channel group for one event's clock room - every event ticks independently.""" + return f"clock_room_{event_path}" diff --git a/polylan_submitter/clock/consumers.py b/polylan_submitter/clock/consumers.py new file mode 100644 index 0000000..5ca2ec4 --- /dev/null +++ b/polylan_submitter/clock/consumers.py @@ -0,0 +1,152 @@ +from datetime import timedelta + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer +from django.db import transaction +from django.utils import timezone + +from events.models import Event +from games.models import Game + +from .broadcast import clock_group +from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds +from .wire import encode, to_epoch_ms + + +class ClockConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.event_path = self.scope["url_route"]["kwargs"]["event_path"] + self.event = await self._get_enabled_event(self.event_path) + if self.event is None: + await self.close() + return + + self.group_name = clock_group(self.event_path) + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + await self.send(text_data=await self._initial_state(self.scope.get("user"))) + + async def disconnect(self, close_code): + if getattr(self, "group_name", None): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): + if text_data == "press": + await self._handle_press() + + async def _handle_press(self): + user = self.scope.get("user") + if user is None or not user.is_authenticated: + await self.send(text_data=encode("r", "a")) + return + + result = await self._record_press(user) + + if result["rejected"]: + await self.send(text_data=encode("r", "c", result["next"], result["now"])) + return + + # The cooldown outcome is only useful to the player who just pressed - + # broadcasting it to every connection would be pure waste. + await self.send(text_data=encode("k", result["next"], result["now"])) + + # Everyone else only needs to know the shared timer moved. + await self.channel_layer.group_send( + self.group_name, + { + "type": "tick", + "end": result["end"], + "now": result["now"], + "pressed": True, + }, + ) + + @database_sync_to_async + def _get_enabled_event(self, event_path): + event = Event.objects.filter(path=event_path).first() + if event is None: + return None + if not Game.objects.enabled_for_event(event).filter(path="clock").exists(): + return None + return event + + @database_sync_to_async + def _record_press(self, user): + with transaction.atomic(): + timer = ClockTimer.objects.select_for_update().get(event=self.event) + now = timezone.now() + timer.reset_if_expired(now) + + last_press = ( + Press.objects.filter(player=user, event=self.event) + .order_by("-pressed_at") + .first() + ) + if last_press is not None: + next_allowed = last_press.pressed_at + timedelta( + seconds=last_press.cooldown_seconds + ) + if next_allowed > now: + return { + "rejected": True, + "next": to_epoch_ms(next_allowed), + "now": to_epoch_ms(now), + } + + remaining = (timer.end_time - now).total_seconds() + cooldown = random_cooldown_seconds() + press = Press.objects.create( + event=self.event, + player=user, + remaining_before_press=remaining, + seconds_added=PRESS_BONUS_SECONDS, + cooldown_seconds=cooldown, + ) + timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS) + timer.save(update_fields=["end_time"]) + + return { + "rejected": False, + "end": to_epoch_ms(timer.end_time), + "now": to_epoch_ms(now), + "next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)), + } + + @database_sync_to_async + def _initial_state(self, user): + timer = ClockTimer.current(self.event) + timer.reset_if_expired() + now = timezone.now() + + next_press_at = None + if user is not None and user.is_authenticated: + last_press = ( + Press.objects.filter(player=user, event=self.event) + .order_by("-pressed_at") + .first() + ) + if last_press is not None: + candidate = last_press.pressed_at + timedelta( + seconds=last_press.cooldown_seconds + ) + if candidate > now: + next_press_at = to_epoch_ms(candidate) + + return encode( + "s", + to_epoch_ms(timer.end_time), + to_epoch_ms(now), + timer.initial_duration, + PRESS_BONUS_SECONDS, + next_press_at, + ) + + async def tick(self, event): + await self.send( + text_data=encode( + "t", + event["end"], + event["now"], + "1" if event.get("pressed") else "", + ) + ) diff --git a/polylan_submitter/clock/migrations/0001_initial.py b/polylan_submitter/clock/migrations/0001_initial.py new file mode 100644 index 0000000..884b890 --- /dev/null +++ b/polylan_submitter/clock/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.0.5 on 2026-09-16 23:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("events", "0002_seed_polylan_43"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ClockTimer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("initial_duration", models.PositiveIntegerField(default=60)), + ("end_time", models.DateTimeField()), + ( + "event", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="clock_timer", + to="events.event", + ), + ), + ], + ), + migrations.CreateModel( + name="Press", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("pressed_at", models.DateTimeField(auto_now_add=True)), + ("remaining_before_press", models.FloatField()), + ("seconds_added", models.PositiveIntegerField(default=5)), + ("cooldown_seconds", models.FloatField()), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="clock_presses", + to="events.event", + ), + ), + ( + "player", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="clock_presses", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-pressed_at"], + }, + ), + ] diff --git a/polylan_submitter/clock/migrations/__init__.py b/polylan_submitter/clock/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/clock/models.py b/polylan_submitter/clock/models.py new file mode 100644 index 0000000..dfa8297 --- /dev/null +++ b/polylan_submitter/clock/models.py @@ -0,0 +1,79 @@ +import random +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from events.models import Event +from events.scoping import EventScopedManager + +DEFAULT_TIMER_DURATION = 60 +PRESS_BONUS_SECONDS = 5 +PRESS_COOLDOWN_MIN_SECONDS = PRESS_BONUS_SECONDS +PRESS_COOLDOWN_MAX_SECONDS = PRESS_BONUS_SECONDS * 2 + + +def random_cooldown_seconds() -> float: + return random.uniform(PRESS_COOLDOWN_MIN_SECONDS, PRESS_COOLDOWN_MAX_SECONDS) + + +class ClockTimer(models.Model): + """The shared countdown for one event. One row per event, reset in place. + + Unlike the enable/disable toggle for every other game (EventGame.enabled, + checked before a client can even reach this app), there's no separate + is_enabled flag here - the registry is the single source of truth. + """ + + event = models.OneToOneField( + Event, on_delete=models.CASCADE, related_name="clock_timer" + ) + initial_duration = models.PositiveIntegerField(default=DEFAULT_TIMER_DURATION) + end_time = models.DateTimeField() + + def __str__(self): + return f"ClockTimer({self.event}, end_time={self.end_time.isoformat()})" + + @classmethod + def current(cls, event: Event) -> "ClockTimer": + """Fetch the event's singleton timer, creating it on first-ever run.""" + obj, _ = cls.objects.get_or_create( + event=event, + defaults={ + "end_time": timezone.now() + timedelta(seconds=DEFAULT_TIMER_DURATION) + }, + ) + return obj + + def reset_if_expired(self, now=None) -> bool: + """Resets end_time back to a fresh window if it has passed. Returns True if reset.""" + now = now or timezone.now() + if self.end_time > now: + return False + + self.end_time = now + timedelta(seconds=self.initial_duration) + self.save(update_fields=["end_time"]) + return True + + +class Press(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="clock_presses") + player = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses" + ) + pressed_at = models.DateTimeField(auto_now_add=True) + remaining_before_press = models.FloatField() + seconds_added = models.PositiveIntegerField(default=PRESS_BONUS_SECONDS) + # Randomized 1x-2x PRESS_BONUS_SECONDS at press time and stored, since it's no + # longer a fixed constant that can be recomputed later. + cooldown_seconds = models.FloatField() + + objects = models.Manager() + for_event = EventScopedManager() + + class Meta: + ordering = ["-pressed_at"] + + def __str__(self): + return f"{self.player} pressed at {self.remaining_before_press:.1f}s left ({self.event})" diff --git a/polylan_submitter/clock/routing.py b/polylan_submitter/clock/routing.py new file mode 100644 index 0000000..a6d6c9f --- /dev/null +++ b/polylan_submitter/clock/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from .consumers import ClockConsumer + +websocket_urlpatterns = [ + re_path(r"^ws/clock/(?P[\w-]+)/$", ClockConsumer.as_asgi()), +] diff --git a/polylan_submitter/clock/schemas.py b/polylan_submitter/clock/schemas.py new file mode 100644 index 0000000..54a574b --- /dev/null +++ b/polylan_submitter/clock/schemas.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from ninja import Schema + + +class ClockStateOut(Schema): + server_time: datetime + end_time: datetime + initial_duration: int + press_bonus_seconds: int + next_press_at: datetime | None + cooldown_seconds: float | None + + +class PlayerStatsOut(Schema): + points: float + rank: int + total_players: int + points_above: float | None + points_below: float | None diff --git a/polylan_submitter/clock/scoring.py b/polylan_submitter/clock/scoring.py new file mode 100644 index 0000000..34f1a02 --- /dev/null +++ b/polylan_submitter/clock/scoring.py @@ -0,0 +1,37 @@ +from django.db.models import ExpressionWrapper, F, FloatField, Q, Sum, Value, Window +from django.db.models.functions import Coalesce, Power, Rank + +from events.models import Event + +# points = 1000 * (1 / (remaining_seconds + 0.5)) ** 2, computed per press and +# summed per player. Never stored - always derived live from Press rows. +POINTS_BASE = 1000.0 +POINTS_OFFSET = 0.5 +POINTS_EXPONENT = -2.0 + + +def _press_points_expression(): + base = ExpressionWrapper( + F("clock_presses__remaining_before_press") + Value(POINTS_OFFSET), + output_field=FloatField(), + ) + return ExpressionWrapper( + Value(POINTS_BASE) * Power(base, Value(POINTS_EXPONENT)), + output_field=FloatField(), + ) + + +def ranked_players(event: Event): + """All users annotated with their live total points for this event and + DB-computed RANK(). Points only ever come from presses made in `event` - + a player's history in another event never leaks into this leaderboard.""" + from accounts.models import CustomUser + + points = Coalesce( + Sum(_press_points_expression(), filter=Q(clock_presses__event=event)), + Value(0.0), + output_field=FloatField(), + ) + return CustomUser.objects.annotate(points=points).annotate( + rank=Window(expression=Rank(), order_by=F("points").desc()) + ) diff --git a/polylan_submitter/clock/signals.py b/polylan_submitter/clock/signals.py new file mode 100644 index 0000000..a160394 --- /dev/null +++ b/polylan_submitter/clock/signals.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from games.models import EventGame + +from .tasks import start_clock_heartbeat + + +@receiver(post_save, sender=EventGame) +def on_event_game_saved(sender, instance, **kwargs): + if instance.game.path == "clock" and instance.enabled: + start_clock_heartbeat() diff --git a/polylan_submitter/clock/tasks.py b/polylan_submitter/clock/tasks.py new file mode 100644 index 0000000..394da9a --- /dev/null +++ b/polylan_submitter/clock/tasks.py @@ -0,0 +1,79 @@ +"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer` +management command with the project's existing self-rescheduling django-tasks +pattern (see submissions.tasks.sync_event_game_scopes_task). + +Resets any event's timer once it expires - even if nobody presses or +reconnects to notice - and gives already-connected clients a periodic tick. +Note this is a safety net, not the only way the timer gets reset: every press +and every new connection (see consumers.py) also calls reset_if_expired(), so +the game works correctly even if this chain is never started or dies - +connected-but-idle clients just won't see the countdown restart until someone +acts. +""" + +from datetime import timedelta + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.core.cache import cache +from django.db import transaction +from django.tasks import task +from django.utils import timezone + +from games.models import EventGame + +from .broadcast import clock_group +from .models import ClockTimer +from .wire import to_epoch_ms + +HEARTBEAT_INTERVAL_SECONDS = 1 +# One chain ticks every event with the game enabled - this lock keeps a +# second chain from starting underneath an already-running one (e.g. two +# EventGame saves close together). Refreshed every tick, wide enough that a +# single slow tick doesn't let a second chain slip in. +HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock" +HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5 + + +@task +def clock_heartbeat_task(): + event_games = list( + EventGame.objects.filter(game__path="clock", enabled=True).select_related( + "event" + ) + ) + + if not event_games: + # Nothing left to tick - let the chain die. start_clock_heartbeat() + # starts a fresh one next time an event enables the game. + cache.delete(HEARTBEAT_LOCK_KEY) + return + + channel_layer = get_channel_layer() + for event_game in event_games: + event = event_game.event + ClockTimer.current(event) + with transaction.atomic(): + timer = ClockTimer.objects.select_for_update().get(event=event) + timer.reset_if_expired() + + async_to_sync(channel_layer.group_send)( + clock_group(event.path), + { + "type": "tick", + "end": to_epoch_ms(timer.end_time), + "now": to_epoch_ms(timezone.now()), + "pressed": False, + }, + ) + + cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS) + clock_heartbeat_task.using( + run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS) + ).enqueue() + + +def start_clock_heartbeat(): + """Enqueues the heartbeat chain, unless one is already running.""" + if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS): + clock_heartbeat_task.enqueue() diff --git a/polylan_submitter/clock/wire.py b/polylan_submitter/clock/wire.py new file mode 100644 index 0000000..1416441 --- /dev/null +++ b/polylan_submitter/clock/wire.py @@ -0,0 +1,33 @@ +"""Compact wire format for the clock websocket. + +JSON's braces/quotes/keys are pure overhead for messages this small and this +frequent (a heartbeat every second, to every connection). Each message is +instead a short string: a one-letter type tag followed by '|'-separated +fields, in a fixed order the client already knows from this table (missing/ +null fields are written as an empty segment): + + s||||| initial state, once per connection + t||| shared timer update ("pressed" is "1" or "") + k|| your press was accepted + r|c|| your press was rejected: on cooldown + r|a your press was rejected: not logged in + +Client -> server has exactly one message, sent as the bare literal "press" +(no fields, so no delimiter needed). + +Whether the game is enabled at all for this event (EventGame.enabled) is +checked once, at connect time - a connection is refused outright rather than +accepted-but-disabled, so there's no "disabled" case in this wire format. +""" + +DELIMITER = "|" + + +def encode(*parts) -> str: + return DELIMITER.join("" if p is None else str(p) for p in parts) + + +def to_epoch_ms(dt) -> int: + """Epoch-ms integer: far cheaper than an ISO-8601 string and needs no + client-side parsing (the frontend already does its clock math in epoch ms).""" + return int(dt.timestamp() * 1000) diff --git a/polylan_submitter/games/decorators.py b/polylan_submitter/games/decorators.py index 212468a..b1cd10f 100644 --- a/polylan_submitter/games/decorators.py +++ b/polylan_submitter/games/decorators.py @@ -7,17 +7,25 @@ from events.models import Event from .models import Game -def require_game_enabled(steam_app_id: int): +def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None): + """Gate a view (or, via @decorate_view, an API operation) behind an + EventGame being enabled for the current event. Steam games are looked up + by steam_app_id (the original, positional form); custom non-Steam games + (no steam_app_id) are looked up by their Game.path instead, e.g. + require_game_enabled(path="clock").""" + def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): event = get_object_or_404(Event, path=kwargs.get("event_path")) + games = Game.objects.enabled_for_event(event) + games = ( + games.filter(steam_app_id=steam_app_id) + if steam_app_id is not None + else games.filter(path=path) + ) - if ( - not Game.objects.enabled_for_event(event) - .filter(steam_app_id=steam_app_id) - .exists() - ): + if not games.exists(): raise PermissionDenied return view_func(request, *args, **kwargs) diff --git a/polylan_submitter/place/__init__.py b/polylan_submitter/place/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/place/admin.py b/polylan_submitter/place/admin.py new file mode 100644 index 0000000..ec7cff0 --- /dev/null +++ b/polylan_submitter/place/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from django_object_actions import DjangoObjectActions, action + +from .canvas import rebuild_canvas_from_history +from .models import PixelPlacement, PlaceSettings + + +@admin.register(PlaceSettings) +class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin): + list_display = ("event", "width", "height") + list_editable = ("width", "height") + list_filter = ("event",) + change_actions = ("resync_canvas",) + + @action( + label="Resync canvas from DB", + description="Rebuild the live Redis canvas by replaying the full PixelPlacement history", + ) + def resync_canvas(self, request, obj): + count = rebuild_canvas_from_history(obj.event, obj.width, obj.height) + self.message_user( + request, f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}." + ) + + +@admin.register(PixelPlacement) +class PixelPlacementAdmin(admin.ModelAdmin): + list_display = ("id", "event", "player", "x", "y", "color_index", "placed_at") + list_filter = ("event", "player") diff --git a/polylan_submitter/place/api.py b/polylan_submitter/place/api.py new file mode 100644 index 0000000..040f412 --- /dev/null +++ b/polylan_submitter/place/api.py @@ -0,0 +1,39 @@ +import struct + +from django.http import HttpRequest, HttpResponse +from ninja import Router +from ninja.decorators import decorate_view +from ninja.security import django_auth_is_staff + +from games.decorators import require_game_enabled + +from .canvas import read_canvas +from .gif import build_evolution_gif +from .models import PlaceSettings + +router = Router() + +PREFIX = "/events/{event_path}/place" + +# 4-byte little-endian header (uint16 width, uint16 height) prefixed to the +# canvas payload - the grid can be resized by an admin, so the frontend +# can't hardcode dimensions and needs to read them off the payload itself. +HEADER_FORMAT = " str: + """Channel group for one event's place room - every event has its own board.""" + return f"place_room_{event_path}" diff --git a/polylan_submitter/place/canvas.py b/polylan_submitter/place/canvas.py new file mode 100644 index 0000000..acfeac5 --- /dev/null +++ b/polylan_submitter/place/canvas.py @@ -0,0 +1,62 @@ +"""Redis-backed live canvas state, one canvas per event. + +Redis is read/written on the hot path (every placement); the DB +(PixelPlacement) is the durable history and is never queried on the hot +path. Each event's canvas is its own string key, one byte per pixel +(row-major), holding the palette index - SETRANGE self-pads with zero +bytes, so there's no separate "ensure canvas exists" bootstrap step. + +The grid's width/height aren't fixed - they live on PlaceSettings (see +models.py) and an admin can change them per event - so every function here +takes them as arguments rather than reading a constant. Callers that already +have a PlaceSettings instance in hand (consumers, the resync admin action) +just pass its width/height through. +""" + +import redis +from django.conf import settings + +from .models import PixelPlacement + +_client = redis.Redis.from_url(settings.PLACE_REDIS_URL) + + +def _canvas_key(event_path: str) -> str: + return f"place:canvas:{event_path}" + + +def read_canvas(event_path: str, width: int, height: int) -> bytes: + data = _client.get(_canvas_key(event_path)) + if data is None: + return bytes(width * height) + return data + + +def write_pixel(event_path: str, x: int, y: int, color_index: int, width: int) -> None: + _client.setrange(_canvas_key(event_path), y * width + x, bytes([color_index])) + + +def rebuild_canvas_from_history(event, width: int, height: int) -> int: + """Replays the durable PixelPlacement history for `event`, in placement + order, into a fresh canvas sized width x height and overwrites the live + Redis copy with it in one atomic SET. Used to repair Redis after it's + lost/diverged from the DB (a flush, a restart without persistence, a + resize - the old Redis bytes don't match the new dimensions - or a write + that raced another one to the same pixel and landed out of order). + Placements that fall outside the current dimensions (from a placement + made before a shrink) are skipped rather than raising. Returns the number + of placements replayed. + """ + grid = bytearray(width * height) + count = 0 + placements = PixelPlacement.objects.filter(event=event).order_by( + "placed_at" + ).values_list("x", "y", "color_index") + for x, y, color_index in placements.iterator(): + if x >= width or y >= height: + continue + grid[y * width + x] = color_index + count += 1 + + _client.set(_canvas_key(event.path), bytes(grid)) + return count diff --git a/polylan_submitter/place/consumers.py b/polylan_submitter/place/consumers.py new file mode 100644 index 0000000..35cc966 --- /dev/null +++ b/polylan_submitter/place/consumers.py @@ -0,0 +1,149 @@ +from datetime import timedelta + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer +from django.utils import timezone + +from events.models import Event +from games.models import Game + +from .broadcast import place_group +from .canvas import write_pixel +from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettings +from .wire import encode, to_epoch_ms + + +class PlaceConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.event_path = self.scope["url_route"]["kwargs"]["event_path"] + self.event = await self._get_enabled_event(self.event_path) + if self.event is None: + await self.close() + return + + self.group_name = place_group(self.event_path) + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + await self.send(text_data=await self._initial_state(self.scope.get("user"))) + + async def disconnect(self, close_code): + if getattr(self, "group_name", None): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): + if text_data is None or not text_data.startswith("p|"): + return + await self._handle_place(text_data) + + async def _handle_place(self, text_data): + place_settings = await self._get_settings() + + user = self.scope.get("user") + if user is None or not user.is_authenticated: + await self.send(text_data=encode("r", "a")) + return + + parts = text_data.split("|") + if len(parts) != 4: + await self.send(text_data=encode("r", "i")) + return + + try: + x, y, color_index = int(parts[1]), int(parts[2]), int(parts[3]) + except ValueError: + await self.send(text_data=encode("r", "i")) + return + + # An unvalidated x/y would let a client make Redis compute a + # huge/negative SETRANGE offset - this check is required, not just + # a UX nicety. + if not ( + 0 <= x < place_settings.width + and 0 <= y < place_settings.height + and 0 <= color_index < len(PALETTE) + ): + await self.send(text_data=encode("r", "i")) + return + + result = await self._record_placement(user, x, y, color_index, place_settings.width) + + if result["rejected"]: + await self.send(text_data=encode("r", "c", result["next"])) + return + + await self.send(text_data=encode("k", result["next"])) + + await self.channel_layer.group_send( + self.group_name, {"type": "pixel", "x": x, "y": y, "c": color_index} + ) + + @database_sync_to_async + def _get_enabled_event(self, event_path): + event = Event.objects.filter(path=event_path).first() + if event is None: + return None + if not Game.objects.enabled_for_event(event).filter(path="place").exists(): + return None + return event + + @database_sync_to_async + def _record_placement(self, user, x, y, color_index, width): + now = timezone.now() + + last_placement = ( + PixelPlacement.objects.filter(player=user, event=self.event) + .order_by("-placed_at") + .first() + ) + if last_placement is not None: + next_allowed = last_placement.placed_at + timedelta( + seconds=PLACE_COOLDOWN_SECONDS + ) + if next_allowed > now: + return {"rejected": True, "next": to_epoch_ms(next_allowed)} + + # Durable history first, then hot Redis state. + placement = PixelPlacement.objects.create( + event=self.event, player=user, x=x, y=y, color_index=color_index + ) + write_pixel(self.event_path, x, y, color_index, width) + + return { + "rejected": False, + "next": to_epoch_ms(placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)), + } + + @database_sync_to_async + def _initial_state(self, user): + place_settings = PlaceSettings.current(self.event) + + next_placement_at = None + if user is not None and user.is_authenticated: + last_placement = ( + PixelPlacement.objects.filter(player=user, event=self.event) + .order_by("-placed_at") + .first() + ) + if last_placement is not None: + candidate = last_placement.placed_at + timedelta( + seconds=PLACE_COOLDOWN_SECONDS + ) + if candidate > timezone.now(): + next_placement_at = to_epoch_ms(candidate) + + return encode( + "s", + next_placement_at, + place_settings.width, + place_settings.height, + ) + + @database_sync_to_async + def _get_settings(self): + return PlaceSettings.current(self.event) + + async def pixel(self, event): + await self.send(text_data=encode("p", event["x"], event["y"], event["c"])) + + async def settings_changed(self, event): + await self.send(text_data=encode("e", event["width"], event["height"])) diff --git a/polylan_submitter/place/gif.py b/polylan_submitter/place/gif.py new file mode 100644 index 0000000..cec48d5 --- /dev/null +++ b/polylan_submitter/place/gif.py @@ -0,0 +1,75 @@ +"""Replays one event's pixel placement history into an animated GIF. + +Unlike canvas.py (which only ever reflects the current state), this reads +the durable PixelPlacement history and reconstructs every intermediate +state of the grid, one frame per placement (or sampled down if there are +more placements than MAX_FRAMES), so an admin can watch that event's board +evolve. +""" + +from io import BytesIO + +from PIL import Image + +from events.models import Event + +from .models import PALETTE, PixelPlacement, PlaceSettings + +SCALE = 8 +MAX_FRAMES = 300 +FRAME_DURATION_MS = 80 +FINAL_FRAME_HOLD_MS = 3000 + +_PALETTE_RGB = [ + tuple(int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) for hex_color in PALETTE +] + + +def build_evolution_gif(event: Event) -> bytes: + """Renders `event`'s full placement history as an animated GIF, upscaled + for visibility. Returns GIF bytes, even if there are zero placements. + + Always replays at the *current* grid dimensions - a placement made + before a resize that now falls outside them is skipped rather than + raising.""" + place_settings = PlaceSettings.current(event) + width, height = place_settings.width, place_settings.height + + placements = PixelPlacement.objects.filter(event=event).order_by( + "placed_at" + ).values_list("x", "y", "color_index") + placements = list(placements) + + grid = bytearray(width * height) + frames: list[Image.Image] = [] + + step = max(1, len(placements) // MAX_FRAMES) + for i, (x, y, color_index) in enumerate(placements): + if x >= width or y >= height: + continue + grid[y * width + x] = color_index + if i % step == 0 or i == len(placements) - 1: + frames.append(_render_frame(grid, width, height)) + + if not frames: + frames.append(_render_frame(grid, width, height)) + + buffer = BytesIO() + durations = [FRAME_DURATION_MS] * len(frames) + durations[-1] = FINAL_FRAME_HOLD_MS + frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=durations, + loop=0, + disposal=2, + ) + return buffer.getvalue() + + +def _render_frame(grid: bytearray, width: int, height: int) -> Image.Image: + image = Image.new("RGB", (width, height)) + image.putdata([_PALETTE_RGB[c] for c in grid]) + return image.resize((width * SCALE, height * SCALE), Image.NEAREST) diff --git a/polylan_submitter/place/migrations/0001_initial.py b/polylan_submitter/place/migrations/0001_initial.py new file mode 100644 index 0000000..5cae1b5 --- /dev/null +++ b/polylan_submitter/place/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.0.5 on 2026-09-16 23:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("events", "0002_seed_polylan_43"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PixelPlacement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("x", models.PositiveSmallIntegerField()), + ("y", models.PositiveSmallIntegerField()), + ("color_index", models.PositiveSmallIntegerField()), + ("placed_at", models.DateTimeField(auto_now_add=True)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pixel_placements", + to="events.event", + ), + ), + ( + "player", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pixel_placements", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-placed_at"], + }, + ), + migrations.CreateModel( + name="PlaceSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("width", models.PositiveSmallIntegerField(default=64)), + ("height", models.PositiveSmallIntegerField(default=64)), + ( + "event", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="place_settings", + to="events.event", + ), + ), + ], + ), + ] diff --git a/polylan_submitter/place/migrations/__init__.py b/polylan_submitter/place/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/place/models.py b/polylan_submitter/place/models.py new file mode 100644 index 0000000..2cb1401 --- /dev/null +++ b/polylan_submitter/place/models.py @@ -0,0 +1,81 @@ +from django.conf import settings +from django.db import models + +from events.models import Event +from events.scoping import EventScopedManager + +DEFAULT_GRID_WIDTH = 64 +DEFAULT_GRID_HEIGHT = 64 +PLACE_COOLDOWN_SECONDS = 30 + +# 16-entry palette, classic r/place-style colors. The index into this list is +# what's sent over the wire (see wire.py) - frontend/src/palette.ts must +# mirror this list exactly, in the same order. +PALETTE = [ + "#FFFFFF", # 0 white + "#E4E4E4", # 1 light grey + "#888888", # 2 grey + "#222222", # 3 black + "#FFA7D1", # 4 pink + "#E50000", # 5 red + "#E59500", # 6 orange + "#A06A42", # 7 brown + "#E5D900", # 8 yellow + "#94E044", # 9 light green + "#02BE01", # 10 green + "#00D3DD", # 11 cyan + "#0083C7", # 12 blue + "#0000EA", # 13 dark blue + "#CF6EE4", # 14 purple + "#820080", # 15 dark purple +] + + +class PlaceSettings(models.Model): + """One row per event, same event-scoped-singleton pattern as + clock.models.ClockTimer. width/height are the live grid dimensions - the + canonical source of truth is here, not a fixed constant, so an admin can + resize the board per event (see signals.py for how that propagates). + + Unlike pplace's original PlaceSettings, there's no is_enabled here - the + games registry (EventGame.enabled) is the single on/off switch, checked + before a client can reach this app at all. + """ + + event = models.OneToOneField( + Event, on_delete=models.CASCADE, related_name="place_settings" + ) + width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH) + height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT) + + def __str__(self): + return f"PlaceSettings({self.event}, size={self.width}x{self.height})" + + @classmethod + def current(cls, event: Event) -> "PlaceSettings": + obj, _ = cls.objects.get_or_create(event=event) + return obj + + +class PixelPlacement(models.Model): + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="pixel_placements" + ) + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="pixel_placements", + ) + x = models.PositiveSmallIntegerField() + y = models.PositiveSmallIntegerField() + color_index = models.PositiveSmallIntegerField() + placed_at = models.DateTimeField(auto_now_add=True) + + objects = models.Manager() + for_event = EventScopedManager() + + class Meta: + ordering = ["-placed_at"] + + def __str__(self): + return f"{self.player} placed ({self.x}, {self.y})=#{self.color_index} ({self.event})" diff --git a/polylan_submitter/place/routing.py b/polylan_submitter/place/routing.py new file mode 100644 index 0000000..639cc69 --- /dev/null +++ b/polylan_submitter/place/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from .consumers import PlaceConsumer + +websocket_urlpatterns = [ + re_path(r"^ws/place/(?P[\w-]+)/$", PlaceConsumer.as_asgi()), +] diff --git a/polylan_submitter/place/signals.py b/polylan_submitter/place/signals.py new file mode 100644 index 0000000..9ecf6da --- /dev/null +++ b/polylan_submitter/place/signals.py @@ -0,0 +1,37 @@ +"""Reacts to an admin editing a PlaceSettings row (grid size) by: + +1. Rebuilding that event's Redis canvas from history at the (possibly new) + dimensions - a resize invalidates the old Redis bytes (wrong length, + wrong meaning), so this keeps Redis self-healing instead of relying on + the admin remembering to click "Resync canvas from DB" after every + resize. +2. Pushing the new size to that event's already-connected clients. Unlike + the clock app, place has no periodic heartbeat broadcast to piggyback + this onto, so it's broadcast explicitly here instead. +""" + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .broadcast import place_group +from .canvas import rebuild_canvas_from_history +from .models import PlaceSettings + + +@receiver(post_save, sender=PlaceSettings) +def on_settings_saved(sender, instance, **kwargs): + rebuild_canvas_from_history(instance.event, instance.width, instance.height) + + channel_layer = get_channel_layer() + if channel_layer is None: + return + async_to_sync(channel_layer.group_send)( + place_group(instance.event.path), + { + "type": "settings_changed", + "width": instance.width, + "height": instance.height, + }, + ) diff --git a/polylan_submitter/place/wire.py b/polylan_submitter/place/wire.py new file mode 100644 index 0000000..e6e4143 --- /dev/null +++ b/polylan_submitter/place/wire.py @@ -0,0 +1,39 @@ +"""Compact wire format for the place websocket. + +Same convention as clock/wire.py (kept separate on purpose - the two games +don't share code, mirroring how the clock and place apps don't share code +either). Each message is a short string: a one-letter type tag followed by +'|'-separated fields, in a fixed order the client already knows from this +table: + + s||| initial personal state, once per connection + (next = epoch ms cooldown ends, or empty if free now) + p||| a pixel was placed - broadcast to everyone, + including the placer (no separate ack needed) + k| your own placement was accepted; carries your + new cooldown end + r|c| rejected: still on cooldown + r|a rejected: not logged in + r|i rejected: invalid x/y/color + e|| an admin resized the grid for this event - + broadcast to everyone. A client that sees this + should re-fetch /api/.../place/canvas rather than + try to remap its local pixels. + +Client -> server has exactly one message shape, the same as the broadcast: +p||| + +Whether the game is enabled at all for this event (EventGame.enabled) is +checked once, at connect time - a connection is refused outright rather than +accepted-but-disabled, so there's no "disabled" case in this wire format. +""" + +DELIMITER = "|" + + +def encode(*parts) -> str: + return DELIMITER.join("" if p is None else str(p) for p in parts) + + +def to_epoch_ms(dt) -> int: + return int(dt.timestamp() * 1000) diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index 9ff1725..e2c9824 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -9,6 +9,8 @@ from noita.api import router as noita_router from games.api import router as games_router, event_router as games_event_router from market.api import router as market_router from submissions.api import router as submissions_router +from clock.api import router as clock_router +from place.api import router as place_router # Create the main API instance api = NinjaAPI( @@ -50,6 +52,8 @@ api.add_router("", results_router, tags=["results"]) api.add_router("", noita_router, tags=["noita"]) api.add_router("", games_event_router, tags=["games"]) api.add_router("", submissions_router, tags=["submissions"]) +api.add_router("", clock_router, tags=["clock"]) +api.add_router("", place_router, tags=["place"]) if settings.MARKET_ENABLED: api.add_router("", market_router, tags=["market"]) diff --git a/polylan_submitter/polylan_submitter/asgi.py b/polylan_submitter/polylan_submitter/asgi.py index 0211751..5bd8f31 100644 --- a/polylan_submitter/polylan_submitter/asgi.py +++ b/polylan_submitter/polylan_submitter/asgi.py @@ -17,7 +17,15 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings") django_asgi_app = get_asgi_application() -from submissions.routing import websocket_urlpatterns +from submissions.routing import websocket_urlpatterns as submissions_websocket_urlpatterns +from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns +from place.routing import websocket_urlpatterns as place_websocket_urlpatterns + +websocket_urlpatterns = [ + *submissions_websocket_urlpatterns, + *clock_websocket_urlpatterns, + *place_websocket_urlpatterns, +] application = ProtocolTypeRouter( { diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index de634ce..5a0d105 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -54,6 +54,8 @@ INSTALLED_APPS = [ "games", "market", "submissions", + "clock", + "place", ] MIDDLEWARE = [ @@ -195,6 +197,10 @@ CHANNEL_LAYERS = { }, } +# Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit +# for django-redis's cache API, so it gets its own raw redis-py client). +PLACE_REDIS_URL = os.environ.get("PLACE_REDIS_URL", "redis://127.0.0.1:6379/15") + # Django Tasks Configuration (with RQ backend) TASKS = { "default": { diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index b6c56c9..3ee3135 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -61,6 +61,20 @@ def noita_home(request: HttpRequest, event_path: str): return render(request, "app.html") +@login_required +@require_game_enabled(path="clock") +def clock_home(request: HttpRequest, event_path: str): + get_object_or_404(Event, path=event_path) + return render(request, "app.html") + + +@login_required +@require_game_enabled(path="place") +def place_home(request: HttpRequest, event_path: str): + get_object_or_404(Event, path=event_path) + return render(request, "app.html") + + urlpatterns = [ path("admin/", admin.site.urls), path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), @@ -69,6 +83,8 @@ urlpatterns = [ 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("/clock", clock_home, name="clock.home"), + path("/place", place_home, name="place.home"), path( "/submissions/", submission_home, diff --git a/polylan_submitter/src/Clock.vue b/polylan_submitter/src/Clock.vue new file mode 100644 index 0000000..a234731 --- /dev/null +++ b/polylan_submitter/src/Clock.vue @@ -0,0 +1,78 @@ + + + diff --git a/polylan_submitter/src/Place.vue b/polylan_submitter/src/Place.vue new file mode 100644 index 0000000..532904c --- /dev/null +++ b/polylan_submitter/src/Place.vue @@ -0,0 +1,170 @@ + + + diff --git a/polylan_submitter/src/api/index.ts b/polylan_submitter/src/api/index.ts index bb51bef..a102f44 100644 --- a/polylan_submitter/src/api/index.ts +++ b/polylan_submitter/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetConfig, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen'; -export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, ConfigOut, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, 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, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponse, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; +export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetConfig, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen'; +export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponse, ClockApiMyStatsResponses, ClockStateOut, ConfigOut, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlayerStatsOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponse, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; diff --git a/polylan_submitter/src/api/sdk.gen.ts b/polylan_submitter/src/api/sdk.gen.ts index a80754a..b56bdc1 100644 --- a/polylan_submitter/src/api/sdk.gen.ts +++ b/polylan_submitter/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ 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, GamesApiListEventGamesData, GamesApiListEventGamesResponses, 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, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, 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, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, 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 = Options2 & { /** @@ -272,6 +272,39 @@ export const submissionsApiProcessFileOcr = (options: Options) => (options.client ?? client).post({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options }); +/** + * Current State + */ +export const clockApiCurrentState = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/clock/current', ...options }); + +/** + * My Stats + */ +export const clockApiMyStats = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/clock/me', ...options }); + +/** + * Canvas + * + * The one-time full-grid load. Every placement after that arrives as a + * single websocket message, never a re-fetch. + */ +export const placeApiCanvas = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/place/canvas', ...options }); + +/** + * Evolution Gif + * + * Admin-only: replays the full placement history as an animated GIF. + */ +export const placeApiEvolutionGif = (options: Options) => (options.client ?? client).get({ + security: [{ + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + }], + url: '/api/events/{event_path}/place/evolution.gif', + ...options +}); + /** * List Markets * diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts index 7a29117..b8c6bf4 100644 --- a/polylan_submitter/src/api/types.gen.ts +++ b/polylan_submitter/src/api/types.gen.ts @@ -1020,6 +1020,62 @@ export type ImageCropIn = { label?: string | null; }; +/** + * ClockStateOut + */ +export type ClockStateOut = { + /** + * Server Time + */ + server_time: string; + /** + * End Time + */ + end_time: string; + /** + * Initial Duration + */ + initial_duration: number; + /** + * Press Bonus Seconds + */ + press_bonus_seconds: number; + /** + * Next Press At + */ + next_press_at: string | null; + /** + * Cooldown Seconds + */ + cooldown_seconds: number | null; +}; + +/** + * PlayerStatsOut + */ +export type PlayerStatsOut = { + /** + * Points + */ + points: number; + /** + * Rank + */ + rank: number; + /** + * Total Players + */ + total_players: number; + /** + * Points Above + */ + points_above: number | null; + /** + * Points Below + */ + points_below: number | null; +}; + /** * MarketListSchema */ @@ -1991,6 +2047,86 @@ export type SubmissionsApiProcessCropOcrResponses = { export type SubmissionsApiProcessCropOcrResponse = SubmissionsApiProcessCropOcrResponses[keyof SubmissionsApiProcessCropOcrResponses]; +export type ClockApiCurrentStateData = { + body?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; + query?: never; + url: '/api/events/{event_path}/clock/current'; +}; + +export type ClockApiCurrentStateResponses = { + /** + * OK + */ + 200: ClockStateOut; +}; + +export type ClockApiCurrentStateResponse = ClockApiCurrentStateResponses[keyof ClockApiCurrentStateResponses]; + +export type ClockApiMyStatsData = { + body?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; + query?: never; + url: '/api/events/{event_path}/clock/me'; +}; + +export type ClockApiMyStatsResponses = { + /** + * OK + */ + 200: PlayerStatsOut; +}; + +export type ClockApiMyStatsResponse = ClockApiMyStatsResponses[keyof ClockApiMyStatsResponses]; + +export type PlaceApiCanvasData = { + body?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; + query?: never; + url: '/api/events/{event_path}/place/canvas'; +}; + +export type PlaceApiCanvasResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PlaceApiEvolutionGifData = { + body?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; + query?: never; + url: '/api/events/{event_path}/place/evolution.gif'; +}; + +export type PlaceApiEvolutionGifResponses = { + /** + * OK + */ + 200: unknown; +}; + export type MarketApiListMarketsData = { body?: never; path: { diff --git a/polylan_submitter/src/composables/useClockSocket.ts b/polylan_submitter/src/composables/useClockSocket.ts new file mode 100644 index 0000000..38a70d3 --- /dev/null +++ b/polylan_submitter/src/composables/useClockSocket.ts @@ -0,0 +1,143 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { useWebSocket } from '@vueuse/core' +import { clockApiMyStats } from '@/api' +import { useEventPath } from './useEventPath' + +// Mirrors clock/wire.py: a one-letter type tag then '|'-separated fields, in a +// fixed order the server always uses. See that file for the message table. +// Everyone reaching this page is already authenticated (the Django view is +// behind @login_required), so unlike pplace's original there's no +// "not logged in" branch to handle here. +const RECONNECT_DELAY_MS = 1000 + +export function useClockSocket() { + const eventPath = useEventPath() + + const endTimeMs = ref(null) + const offsetMs = ref(0) + const remainingMs = ref(0) + const myPoints = ref(0) + const myRank = ref(null) + const totalPlayers = ref(0) + const pointsAbove = ref(null) + const pointsBelow = ref(null) + const cooldownEndMs = ref(null) + const cooldownRemainingMs = ref(0) + const cooldownDurationSeconds = ref(10) + + let animationFrame: number | null = null + + // Every server message carries its own send-time (epoch ms) so the client + // can keep recalibrating its clock offset, regardless of message type. + function calibrateOffset(nowMs: number) { + offsetMs.value = nowMs - Date.now() + } + + function applyClock(endMs: number, nowMs: number) { + endTimeMs.value = endMs + calibrateOffset(nowMs) + } + + function applyCooldown(nextMs: number | null) { + cooldownEndMs.value = nextMs ?? null + } + + function tickClock() { + remainingMs.value = + endTimeMs.value !== null ? Math.max(0, endTimeMs.value - (Date.now() + offsetMs.value)) : 0 + + cooldownRemainingMs.value = + cooldownEndMs.value !== null + ? Math.max(0, cooldownEndMs.value - (Date.now() + offsetMs.value)) + : 0 + + animationFrame = requestAnimationFrame(tickClock) + } + + async function refreshStats() { + const response = await clockApiMyStats({ path: { event_path: eventPath } }) + const stats = response.data + if (!stats) return + myPoints.value = stats.points + myRank.value = stats.rank + totalPlayers.value = stats.total_players + pointsAbove.value = stats.points_above + pointsBelow.value = stats.points_below + } + + function handleMessage(_ws: WebSocket, event: MessageEvent) { + const parts = event.data.split('|') + + switch (parts[0]) { + case 's': { + const [, end, now, , bonus, next] = parts + cooldownDurationSeconds.value = Number(bonus) * 2 + applyClock(Number(end), Number(now)) + applyCooldown(next !== '' ? Number(next) : null) + break + } + case 't': { + const [, end, now, pressed] = parts + applyClock(Number(end), Number(now)) + // Anyone's press can shift my rank relative to them, so refresh on + // every press-triggered tick, not just my own presses. + if (pressed === '1') refreshStats() + break + } + case 'k': { + const [, next, now] = parts + calibrateOffset(Number(now)) + applyCooldown(Number(next)) + break + } + case 'r': { + if (parts[1] === 'c') { + const [, , next, now] = parts + calibrateOffset(Number(now)) + applyCooldown(Number(next)) + } + break + } + default: + break + } + } + + const wsUrl = computed(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/ws/clock/${eventPath}/` + }) + + const { status, send } = useWebSocket(wsUrl, { + onConnected: () => refreshStats(), + onMessage: handleMessage, + autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS }, + }) + + const connected = computed(() => status.value === 'OPEN') + + function press() { + send('press') + } + + onMounted(() => { + animationFrame = requestAnimationFrame(tickClock) + }) + + onBeforeUnmount(() => { + if (animationFrame !== null) cancelAnimationFrame(animationFrame) + }) + + return { + connected, + remainingMs, + myPoints, + myRank, + totalPlayers, + pointsAbove, + pointsBelow, + cooldownRemainingMs, + cooldownDurationSeconds, + press, + } +} diff --git a/polylan_submitter/src/composables/usePlaceSocket.ts b/polylan_submitter/src/composables/usePlaceSocket.ts new file mode 100644 index 0000000..099d896 --- /dev/null +++ b/polylan_submitter/src/composables/usePlaceSocket.ts @@ -0,0 +1,153 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { useWebSocket } from '@vueuse/core' +import { placeApiCanvas } from '@/api' +import { useEventPath } from './useEventPath' + +// Mirrors place/wire.py: a one-letter type tag then '|'-separated fields, in +// a fixed order the server always uses. See that file for the message table. +// Everyone reaching this page is already authenticated (the Django view is +// behind @login_required), so unlike pplace's original there's no +// "not logged in" branch to handle here. +const RECONNECT_DELAY_MS = 1000 +const DEFAULT_GRID_SIZE = 64 +// Mirrors place/models.py's PLACE_COOLDOWN_SECONDS - the server is the +// source of truth for enforcement, this is only used to size the cooldown +// progress bar. +const PLACE_COOLDOWN_SECONDS = 30 + +export type OnPixel = (x: number, y: number, colorIndex: number) => void +export type OnResize = (width: number, height: number) => void + +// Plain (non-reactive) - a several-thousand-cell reactive array is +// pointless overhead. Kept module-level, alongside its dimensions, so the +// grid survives across component remounts. The grid isn't a fixed size - +// an admin can resize it - so it's reallocated whenever the dimensions +// change (see applyGridSize below). +let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE) +const gridWidth = ref(DEFAULT_GRID_SIZE) +const gridHeight = ref(DEFAULT_GRID_SIZE) + +export function usePlaceSocket(onPixel?: OnPixel, onResize?: OnResize) { + const eventPath = useEventPath() + + const cooldownEndMs = ref(null) + const cooldownRemainingMs = ref(0) + + let animationFrame: number | null = null + + function applyCooldown(nextMs: number | null) { + cooldownEndMs.value = nextMs ?? null + } + + function tickCooldown() { + cooldownRemainingMs.value = + cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0 + + animationFrame = requestAnimationFrame(tickCooldown) + } + + // Returns true if the size actually changed, so callers can decide + // whether to re-fetch the canvas (the old pixel buffer is meaningless at + // the new dimensions). + function applyGridSize(width: number, height: number): boolean { + if (width === gridWidth.value && height === gridHeight.value) return false + gridWidth.value = width + gridHeight.value = height + grid = new Uint8Array(width * height) + onResize?.(width, height) + return true + } + + function setPixel(x: number, y: number, colorIndex: number) { + grid[y * gridWidth.value + x] = colorIndex + onPixel?.(x, y, colorIndex) + } + + function handleMessage(_ws: WebSocket, event: MessageEvent) { + const parts = event.data.split('|') + + switch (parts[0]) { + case 's': { + const [, next, width, height] = parts + applyCooldown(next !== '' ? Number(next) : null) + applyGridSize(Number(width), Number(height)) + break + } + case 'p': { + const [, x, y, c] = parts + setPixel(Number(x), Number(y), Number(c)) + break + } + case 'k': { + applyCooldown(Number(parts[1])) + break + } + case 'r': { + if (parts[1] === 'c') applyCooldown(Number(parts[2])) + break + } + case 'e': { + const [, width, height] = parts + // The server already rebuilt its canvas at the new size (see + // place/signals.py) - just re-fetch it rather than guess how to + // remap our local pixels. + if (applyGridSize(Number(width), Number(height))) loadCanvas() + break + } + default: + break + } + } + + const wsUrl = computed(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/ws/place/${eventPath}/` + }) + + const { status, send } = useWebSocket(wsUrl, { + onMessage: handleMessage, + autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS }, + }) + + const connected = computed(() => status.value === 'OPEN') + + async function loadCanvas() { + const response = await placeApiCanvas({ path: { event_path: eventPath } }) + if (!response.data) return + const bytes = new Uint8Array(await (response.data as Blob).arrayBuffer()) + // 4-byte little-endian header (uint16 width, uint16 height) - see + // place/api.py - then width*height pixel bytes. + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const width = view.getUint16(0, true) + const height = view.getUint16(2, true) + applyGridSize(width, height) + grid.set(bytes.subarray(4)) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + onPixel?.(x, y, grid[y * width + x]) + } + } + } + + function place(x: number, y: number, colorIndex: number) { + send(`p|${x}|${y}|${colorIndex}`) + } + + onMounted(() => { + loadCanvas() + animationFrame = requestAnimationFrame(tickCooldown) + }) + + onBeforeUnmount(() => { + if (animationFrame !== null) cancelAnimationFrame(animationFrame) + }) + + return { + connected, + cooldownRemainingMs, + cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS, + gridWidth, + gridHeight, + place, + } +} diff --git a/polylan_submitter/src/locales/en.json b/polylan_submitter/src/locales/en.json index 424b8a6..d6c0eb8 100644 --- a/polylan_submitter/src/locales/en.json +++ b/polylan_submitter/src/locales/en.json @@ -71,6 +71,24 @@ "submitResults": "Submit results", "footer": "Select a game above to begin submitting" }, + "clock": { + "pageTitle": "Beat the Clock", + "header": "Beat the Clock", + "connected": "connected", + "reconnecting": "reconnecting…", + "press": "PRESS", + "nextPlayer": "▲ next player", + "chasingYou": "▼ chasing you", + "yourRank": "you · rank #{rank} of {total}" + }, + "place": { + "pageTitle": "Place", + "header": "Place", + "connected": "connected", + "reconnecting": "reconnecting…", + "showGrid": "Show grid", + "hideGrid": "Hide grid" + }, "market": { "pageTitle": "Market", "header": "Market", diff --git a/polylan_submitter/src/palette.ts b/polylan_submitter/src/palette.ts new file mode 100644 index 0000000..e400722 --- /dev/null +++ b/polylan_submitter/src/palette.ts @@ -0,0 +1,20 @@ +// Mirrors place/models.py's PALETTE exactly, same order - the color index +// is what's sent over the wire, so the two lists must agree. +export const PALETTE = [ + '#FFFFFF', // 0 white + '#E4E4E4', // 1 light grey + '#888888', // 2 grey + '#222222', // 3 black + '#FFA7D1', // 4 pink + '#E50000', // 5 red + '#E59500', // 6 orange + '#A06A42', // 7 brown + '#E5D900', // 8 yellow + '#94E044', // 9 light green + '#02BE01', // 10 green + '#00D3DD', // 11 cyan + '#0083C7', // 12 blue + '#0000EA', // 13 dark blue + '#CF6EE4', // 14 purple + '#820080', // 15 dark purple +] diff --git a/polylan_submitter/src/router/index.ts b/polylan_submitter/src/router/index.ts index 89a8e34..d2d8877 100644 --- a/polylan_submitter/src/router/index.ts +++ b/polylan_submitter/src/router/index.ts @@ -1,8 +1,10 @@ import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router"; +import Clock from "@/Clock.vue"; import Home from "@/Home.vue"; import Market from "@/Market.vue"; import Noita from "@/Noita.vue"; import OpusMagnum from "@/OpusMagnum.vue"; +import Place from "@/Place.vue"; import Submissions from "@/Submissions.vue"; import SubmissionsSelect from "@/SubmissionsSelect.vue"; import { usePageHeaderStore } from "@/stores/pageHeader"; @@ -52,6 +54,20 @@ export const router = createRouter({ props: true, meta: { titleKey: "noita.header", getBackRoute: eventHome }, }, + { + path: "/:eventPath/clock", + name: "clock", + component: Clock, + props: true, + meta: { titleKey: "clock.header", getBackRoute: eventHome }, + }, + { + path: "/:eventPath/place", + name: "place", + component: Place, + props: true, + meta: { titleKey: "place.header", getBackRoute: eventHome }, + }, { path: "/:eventPath/submissions/:gamePath", name: "submissions-select", diff --git a/pyproject.toml b/pyproject.toml index 543e060..d0bd30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "sentry-sdk[django]", "furl", "django-redis", + "redis", "django-storages[s3]", "whitenoise", "django-channels", diff --git a/uv.lock b/uv.lock index ebe1926..818b090 100644 --- a/uv.lock +++ b/uv.lock @@ -1651,6 +1651,7 @@ dependencies = [ { name = "pillow" }, { name = "psycopg" }, { name = "pytesseract" }, + { name = "redis" }, { name = "requests" }, { name = "sentry-sdk", extra = ["django"] }, { name = "whitenoise" }, @@ -1691,6 +1692,7 @@ requires-dist = [ { name = "pillow" }, { name = "psycopg" }, { name = "pytesseract" }, + { name = "redis" }, { name = "requests" }, { name = "sentry-sdk", extras = ["django"] }, { name = "whitenoise" },