feat: clock + place games
This commit is contained in:
@@ -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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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 "",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from .consumers import ClockConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/clock/(?P<event_path>[\w-]+)/$", ClockConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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|<end>|<now>|<duration>|<bonus>|<next> initial state, once per connection
|
||||||
|
t|<end>|<now>|<pressed> shared timer update ("pressed" is "1" or "")
|
||||||
|
k|<next>|<now> your press was accepted
|
||||||
|
r|c|<next>|<now> 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)
|
||||||
@@ -7,17 +7,25 @@ from events.models import Event
|
|||||||
from .models import Game
|
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):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request, *args, **kwargs):
|
def wrapper(request, *args, **kwargs):
|
||||||
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
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 (
|
if not games.exists():
|
||||||
not Game.objects.enabled_for_event(event)
|
|
||||||
.filter(steam_app_id=steam_app_id)
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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 = "<HH"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/canvas")
|
||||||
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
|
def canvas(request: HttpRequest, event_path: str):
|
||||||
|
"""The one-time full-grid load. Every placement after that arrives as a
|
||||||
|
single websocket message, never a re-fetch."""
|
||||||
|
place_settings = PlaceSettings.current(request.event)
|
||||||
|
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
|
||||||
|
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||||
|
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
|
||||||
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
|
def evolution_gif(request: HttpRequest, event_path: str):
|
||||||
|
"""Admin-only: replays the full placement history as an animated GIF."""
|
||||||
|
return HttpResponse(build_evolution_gif(request.event), content_type="image/gif")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "place"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def place_group(event_path: str) -> str:
|
||||||
|
"""Channel group for one event's place room - every event has its own board."""
|
||||||
|
return f"place_room_{event_path}"
|
||||||
@@ -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
|
||||||
@@ -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"]))
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from .consumers import PlaceConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/place/(?P<event_path>[\w-]+)/$", PlaceConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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|<next>|<width>|<height> initial personal state, once per connection
|
||||||
|
(next = epoch ms cooldown ends, or empty if free now)
|
||||||
|
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
|
||||||
|
including the placer (no separate ack needed)
|
||||||
|
k|<next> your own placement was accepted; carries your
|
||||||
|
new cooldown end
|
||||||
|
r|c|<next> rejected: still on cooldown
|
||||||
|
r|a rejected: not logged in
|
||||||
|
r|i rejected: invalid x/y/color
|
||||||
|
e|<width>|<height> 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|<x>|<y>|<c>
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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 games.api import router as games_router, event_router as games_event_router
|
||||||
from market.api import router as market_router
|
from market.api import router as market_router
|
||||||
from submissions.api import router as submissions_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
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@@ -50,6 +52,8 @@ api.add_router("", results_router, tags=["results"])
|
|||||||
api.add_router("", noita_router, tags=["noita"])
|
api.add_router("", noita_router, tags=["noita"])
|
||||||
api.add_router("", games_event_router, tags=["games"])
|
api.add_router("", games_event_router, tags=["games"])
|
||||||
api.add_router("", submissions_router, tags=["submissions"])
|
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:
|
if settings.MARKET_ENABLED:
|
||||||
api.add_router("", market_router, tags=["market"])
|
api.add_router("", market_router, tags=["market"])
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
|||||||
|
|
||||||
django_asgi_app = get_asgi_application()
|
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(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ INSTALLED_APPS = [
|
|||||||
"games",
|
"games",
|
||||||
"market",
|
"market",
|
||||||
"submissions",
|
"submissions",
|
||||||
|
"clock",
|
||||||
|
"place",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
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)
|
# Django Tasks Configuration (with RQ backend)
|
||||||
TASKS = {
|
TASKS = {
|
||||||
"default": {
|
"default": {
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ def noita_home(request: HttpRequest, event_path: str):
|
|||||||
return render(request, "app.html")
|
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 = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||||
@@ -69,6 +83,8 @@ urlpatterns = [
|
|||||||
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"),
|
||||||
|
path("<slug:event_path>/clock", clock_home, name="clock.home"),
|
||||||
|
path("<slug:event_path>/place", place_home, name="place.home"),
|
||||||
path(
|
path(
|
||||||
"<slug:event_path>/submissions/<slug:game_path>",
|
"<slug:event_path>/submissions/<slug:game_path>",
|
||||||
submission_home,
|
submission_home,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
|
import { useClockSocket } from "@/composables/useClockSocket";
|
||||||
|
import { provideEventPath } from "@/composables/useEventPath";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
provideEventPath(props.eventPath);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("clock.pageTitle")));
|
||||||
|
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
remainingMs,
|
||||||
|
myPoints,
|
||||||
|
myRank,
|
||||||
|
totalPlayers,
|
||||||
|
pointsAbove,
|
||||||
|
pointsBelow,
|
||||||
|
cooldownRemainingMs,
|
||||||
|
cooldownDurationSeconds,
|
||||||
|
press,
|
||||||
|
} = useClockSocket();
|
||||||
|
|
||||||
|
const remainingDisplay = computed(() => {
|
||||||
|
const totalSeconds = Math.floor(remainingMs.value / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
});
|
||||||
|
const cooldownSeconds = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
|
||||||
|
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
|
||||||
|
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
|
||||||
|
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="font-bold text-7xl tabular-nums">
|
||||||
|
{{ remainingDisplay }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<button class="btn btn-error btn-xl w-48 h-48 rounded-full text-3xl shadow-lg" :disabled="onCooldown"
|
||||||
|
@click="press">
|
||||||
|
<span v-if="onCooldown">{{ cooldownSeconds }}s</span>
|
||||||
|
<span v-else>{{ t("clock.press") }}</span>
|
||||||
|
</button>
|
||||||
|
<progress v-if="onCooldown" class="progress progress-error w-48"
|
||||||
|
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-64 flex flex-col gap-1 text-sm">
|
||||||
|
<div v-if="pointsAbove !== null" class="flex justify-between items-center px-3 py-1.5 opacity-60">
|
||||||
|
<span>{{ t("clock.nextPlayer") }}</span>
|
||||||
|
<span class="font-mono">{{ pointsAbove.toFixed(0) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center px-3 py-2 rounded-box bg-base-300 font-bold">
|
||||||
|
<span>{{ t("clock.yourRank", { rank: myRank ?? "-", total: totalPlayers }) }}</span>
|
||||||
|
<span class="font-mono">{{ myPoints.toFixed(0) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pointsBelow !== null" class="flex justify-between items-center px-3 py-1.5 opacity-60">
|
||||||
|
<span>{{ t("clock.chasingYou") }}</span>
|
||||||
|
<span class="font-mono">{{ pointsBelow.toFixed(0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
|
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||||
|
import { provideEventPath } from "@/composables/useEventPath";
|
||||||
|
import { PALETTE } from "@/palette";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
provideEventPath(props.eventPath);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("place.pageTitle")));
|
||||||
|
|
||||||
|
// Internal resolution of the grid overlay canvas - independent of the pixel
|
||||||
|
// canvas's own resolution, and scaled per grid cell (not to a fixed square)
|
||||||
|
// so grid lines and the hover highlight stay crisp regardless of the
|
||||||
|
// admin-configured grid size.
|
||||||
|
const PX_PER_CELL = 8;
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const gridCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const selectedColorIndex = ref(5);
|
||||||
|
const hoveredCell = ref<Cell | null>(null);
|
||||||
|
const showGrid = ref(true);
|
||||||
|
let pixelCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
let gridCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
|
function drawPixel(x: number, y: number, colorIndex: number) {
|
||||||
|
if (!pixelCtx) return;
|
||||||
|
pixelCtx.fillStyle = PALETTE[colorIndex];
|
||||||
|
pixelCtx.fillRect(x, y, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
cooldownRemainingMs,
|
||||||
|
cooldownDurationSeconds,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
place,
|
||||||
|
} = usePlaceSocket(drawPixel);
|
||||||
|
|
||||||
|
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
|
||||||
|
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
|
||||||
|
|
||||||
|
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
|
||||||
|
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||||
|
|
||||||
|
// The overlay canvas never touches the pixel colors - it only draws grid
|
||||||
|
// lines plus a preview of the hovered cell, so it can freely redraw on
|
||||||
|
// every mouse move without ever risking the actual placed pixels. The grid
|
||||||
|
// lines are cosmetic and can be toggled off, but the hover preview is the
|
||||||
|
// only way to know which cell a click will hit, so it always stays on.
|
||||||
|
function drawOverlay() {
|
||||||
|
if (!gridCtx) return;
|
||||||
|
const width = overlayWidth.value;
|
||||||
|
const height = overlayHeight.value;
|
||||||
|
gridCtx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (showGrid.value) {
|
||||||
|
gridCtx.strokeStyle = "rgba(128, 128, 128, 0.35)";
|
||||||
|
gridCtx.lineWidth = 1;
|
||||||
|
for (let i = 1; i < gridWidth.value; i++) {
|
||||||
|
const pos = i * PX_PER_CELL + 0.5;
|
||||||
|
gridCtx.beginPath();
|
||||||
|
gridCtx.moveTo(pos, 0);
|
||||||
|
gridCtx.lineTo(pos, height);
|
||||||
|
gridCtx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < gridHeight.value; i++) {
|
||||||
|
const pos = i * PX_PER_CELL + 0.5;
|
||||||
|
gridCtx.beginPath();
|
||||||
|
gridCtx.moveTo(0, pos);
|
||||||
|
gridCtx.lineTo(width, pos);
|
||||||
|
gridCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoveredCell.value && !onCooldown.value) {
|
||||||
|
const { x, y } = hoveredCell.value;
|
||||||
|
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
|
||||||
|
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
|
||||||
|
gridCtx.strokeStyle = "rgba(0, 0, 0, 0.6)";
|
||||||
|
gridCtx.lineWidth = 2;
|
||||||
|
gridCtx.strokeRect(x * PX_PER_CELL + 1, y * PX_PER_CELL + 1, PX_PER_CELL - 2, PX_PER_CELL - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
|
||||||
|
gridCtx = gridCanvasRef.value?.getContext("2d") ?? null;
|
||||||
|
drawOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
function cellFromEvent(event: MouseEvent): Cell {
|
||||||
|
const rect = pixelCanvasRef.value!.getBoundingClientRect();
|
||||||
|
const x = Math.floor(((event.clientX - rect.left) / rect.width) * gridWidth.value);
|
||||||
|
const y = Math.floor(((event.clientY - rect.top) / rect.height) * gridHeight.value);
|
||||||
|
return {
|
||||||
|
x: Math.min(gridWidth.value - 1, Math.max(0, x)),
|
||||||
|
y: Math.min(gridHeight.value - 1, Math.max(0, y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event: MouseEvent) {
|
||||||
|
hoveredCell.value = cellFromEvent(event);
|
||||||
|
drawOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
hoveredCell.value = null;
|
||||||
|
drawOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
if (onCooldown.value) return;
|
||||||
|
const { x, y } = cellFromEvent(event);
|
||||||
|
place(x, y, selectedColorIndex.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
|
||||||
|
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
|
||||||
|
{{ connected ? t("place.connected") : t("place.reconnecting") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
|
||||||
|
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
|
||||||
|
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
cursor: onCooldown ? 'not-allowed' : 'crosshair',
|
||||||
|
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
|
||||||
|
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
|
||||||
|
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
|
||||||
|
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
||||||
|
<button v-for="(color, index) in PALETTE" :key="index" class="w-8 h-8 rounded-btn border-2"
|
||||||
|
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
|
||||||
|
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<span v-if="onCooldown" class="font-bold text-2xl tabular-nums">
|
||||||
|
{{ cooldownSecondsDisplay }}s
|
||||||
|
</span>
|
||||||
|
<progress v-if="onCooldown" class="progress progress-primary w-48"
|
||||||
|
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
File diff suppressed because one or more lines are too long
@@ -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, 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<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> & {
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +272,39 @@ export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = fals
|
|||||||
*/
|
*/
|
||||||
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
|
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current State
|
||||||
|
*/
|
||||||
|
export const clockApiCurrentState = <ThrowOnError extends boolean = false>(options: Options<ClockApiCurrentStateData, ThrowOnError>) => (options.client ?? client).get<ClockApiCurrentStateResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/clock/current', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My Stats
|
||||||
|
*/
|
||||||
|
export const clockApiMyStats = <ThrowOnError extends boolean = false>(options: Options<ClockApiMyStatsData, ThrowOnError>) => (options.client ?? client).get<ClockApiMyStatsResponses, unknown, ThrowOnError>({ 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 = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evolution Gif
|
||||||
|
*
|
||||||
|
* Admin-only: replays the full placement history as an animated GIF.
|
||||||
|
*/
|
||||||
|
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
|
||||||
|
security: [{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}],
|
||||||
|
url: '/api/events/{event_path}/place/evolution.gif',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List Markets
|
* List Markets
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1020,6 +1020,62 @@ export type ImageCropIn = {
|
|||||||
label?: string | null;
|
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
|
* MarketListSchema
|
||||||
*/
|
*/
|
||||||
@@ -1991,6 +2047,86 @@ export type SubmissionsApiProcessCropOcrResponses = {
|
|||||||
|
|
||||||
export type SubmissionsApiProcessCropOcrResponse = SubmissionsApiProcessCropOcrResponses[keyof 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 = {
|
export type MarketApiListMarketsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -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<number | null>(null)
|
||||||
|
const offsetMs = ref(0)
|
||||||
|
const remainingMs = ref(0)
|
||||||
|
const myPoints = ref(0)
|
||||||
|
const myRank = ref<number | null>(null)
|
||||||
|
const totalPlayers = ref(0)
|
||||||
|
const pointsAbove = ref<number | null>(null)
|
||||||
|
const pointsBelow = ref<number | null>(null)
|
||||||
|
const cooldownEndMs = ref<number | null>(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<string>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number | null>(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<string>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,24 @@
|
|||||||
"submitResults": "Submit results",
|
"submitResults": "Submit results",
|
||||||
"footer": "Select a game above to begin submitting"
|
"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": {
|
"market": {
|
||||||
"pageTitle": "Market",
|
"pageTitle": "Market",
|
||||||
"header": "Market",
|
"header": "Market",
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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 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";
|
||||||
import OpusMagnum from "@/OpusMagnum.vue";
|
import OpusMagnum from "@/OpusMagnum.vue";
|
||||||
|
import Place from "@/Place.vue";
|
||||||
import Submissions from "@/Submissions.vue";
|
import Submissions from "@/Submissions.vue";
|
||||||
import SubmissionsSelect from "@/SubmissionsSelect.vue";
|
import SubmissionsSelect from "@/SubmissionsSelect.vue";
|
||||||
import { usePageHeaderStore } from "@/stores/pageHeader";
|
import { usePageHeaderStore } from "@/stores/pageHeader";
|
||||||
@@ -52,6 +54,20 @@ export const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { titleKey: "noita.header", getBackRoute: eventHome },
|
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",
|
path: "/:eventPath/submissions/:gamePath",
|
||||||
name: "submissions-select",
|
name: "submissions-select",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"sentry-sdk[django]",
|
"sentry-sdk[django]",
|
||||||
"furl",
|
"furl",
|
||||||
"django-redis",
|
"django-redis",
|
||||||
|
"redis",
|
||||||
"django-storages[s3]",
|
"django-storages[s3]",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
"django-channels",
|
"django-channels",
|
||||||
|
|||||||
@@ -1651,6 +1651,7 @@ dependencies = [
|
|||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg" },
|
{ name = "psycopg" },
|
||||||
{ name = "pytesseract" },
|
{ name = "pytesseract" },
|
||||||
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sentry-sdk", extra = ["django"] },
|
{ name = "sentry-sdk", extra = ["django"] },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
@@ -1691,6 +1692,7 @@ requires-dist = [
|
|||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg" },
|
{ name = "psycopg" },
|
||||||
{ name = "pytesseract" },
|
{ name = "pytesseract" },
|
||||||
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sentry-sdk", extras = ["django"] },
|
{ name = "sentry-sdk", extras = ["django"] },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
|
|||||||
Reference in New Issue
Block a user