Compare commits

..
3 Commits
Author SHA1 Message Date
Legrems e38b610456 feat: add embed for place game 2026-09-17 01:51:38 +02:00
Legrems 9fc9e19d68 fix: useEvent for clock/place games 2026-09-17 01:37:31 +02:00
Legrems 6866b45f66 feat: clock + place games 2026-09-17 01:33:54 +02:00
48 changed files with 2108 additions and 14 deletions
View File
+23
View File
@@ -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")
+79
View File
@@ -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,
}
+9
View File
@@ -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
+3
View File
@@ -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}"
+152
View File
@@ -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"],
},
),
]
+79
View File
@@ -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})"
+7
View File
@@ -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()),
]
+20
View File
@@ -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
+37
View File
@@ -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())
)
+12
View File
@@ -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()
+79
View File
@@ -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()
+33
View File
@@ -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)
+14 -6
View File
@@ -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)
View File
+29
View File
@@ -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")
+39
View File
@@ -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")
+9
View File
@@ -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
+3
View File
@@ -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}"
+62
View File
@@ -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
+149
View File
@@ -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"]))
+75
View File
@@ -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",
),
),
],
),
]
+81
View File
@@ -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})"
+7
View File
@@ -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()),
]
+37
View File
@@ -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,
},
)
+39
View File
@@ -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"])
+9 -1
View File
@@ -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,32 @@ 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")
# Deliberately not behind @login_required: this is meant to be dropped into
# an <iframe> (an OBS browser source, a stream overlay) that can't do the
# CAS SSO login flow. It's read-only anyway - placing a pixel still requires
# a logged-in websocket connection (see place/consumers.py) - so the only
# thing this exposes to anonymous viewers is the live canvas itself, same as
# watching the non-embed page already allows.
@require_game_enabled(path="place")
def place_embed(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 +95,9 @@ 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("<slug:event_path>/place/embed", place_embed, name="place.embed"),
path( path(
"<slug:event_path>/submissions/<slug:game_path>", "<slug:event_path>/submissions/<slug:game_path>",
submission_home, submission_home,
+9 -4
View File
@@ -1,11 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { computed, onMounted } from "vue";
import { RouterView } from "vue-router"; import { RouterView, useRoute } from "vue-router";
import AppHeader from "@/layout/AppHeader.vue"; import AppHeader from "@/layout/AppHeader.vue";
import AppFooter from "@/layout/AppFooter.vue"; import AppFooter from "@/layout/AppFooter.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
const userStore = useUserStore(); const userStore = useUserStore();
const route = useRoute();
// Embed routes (e.g. place/embed) are meant to be dropped into an <iframe>
// as-is - the header/footer chrome would just eat into the fixed space a
// stream overlay or OBS browser source gives it.
const isEmbed = computed(() => route.meta.embed === true);
onMounted(() => { onMounted(() => {
userStore.loadUserData(); userStore.loadUserData();
@@ -14,10 +19,10 @@ onMounted(() => {
<template> <template>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<AppHeader /> <AppHeader v-if="!isEmbed" />
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<RouterView /> <RouterView />
</main> </main>
<AppFooter /> <AppFooter v-if="!isEmbed" />
</div> </div>
</template> </template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { useClockSocket } from "@/composables/useClockSocket";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("clock.pageTitle")));
const {
connected,
remainingMs,
myPoints,
myRank,
totalPlayers,
pointsAbove,
pointsBelow,
cooldownRemainingMs,
cooldownDurationSeconds,
press,
} = useClockSocket(props.eventPath);
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>
+168
View File
@@ -0,0 +1,168 @@
<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 { PALETTE } from "@/palette";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
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(props.eventPath, 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>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { PALETTE } from "@/palette";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
// Read-only: fed straight into an <iframe> elsewhere (a stream overlay, a
// README, another site), so there's no header, palette, or click handling
// here - just the live canvas. Reachable without login (see place_embed in
// urls.py) since an OBS browser source can't do the CAS SSO login flow.
const canvasRef = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
function drawPixel(x: number, y: number, colorIndex: number) {
if (!ctx) return;
ctx.fillStyle = PALETTE[colorIndex];
ctx.fillRect(x, y, 1, 1);
}
const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel);
onMounted(() => {
ctx = canvasRef.value?.getContext("2d") ?? null;
});
</script>
<template>
<div class="w-screen h-screen bg-base-200 flex items-center justify-center overflow-hidden">
<span class="badge badge-xs absolute top-2 left-2 opacity-70"
:class="connected ? 'badge-success' : 'badge-error'" />
<canvas ref="canvasRef" :width="gridWidth" :height="gridHeight" :style="{
width: 'min(100vw, 100vh)',
aspectRatio: `${gridWidth} / ${gridHeight}`,
imageRendering: 'pixelated',
}" />
</div>
</template>
File diff suppressed because one or more lines are too long
+34 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, 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
* *
+136
View File
@@ -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,140 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useWebSocket } from '@vueuse/core'
import { clockApiMyStats } from '@/api'
// 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(eventPath: string) {
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,150 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useWebSocket } from '@vueuse/core'
import { placeApiCanvas } from '@/api'
// 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(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
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,
}
}
+18
View File
@@ -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",
+20
View File
@@ -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
]
+27
View File
@@ -1,8 +1,11 @@
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 PlaceEmbed from "@/PlaceEmbed.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";
@@ -14,6 +17,9 @@ declare module "vue-router" {
// usePageHeaderStore() instead and leave this out. // usePageHeaderStore() instead and leave this out.
titleKey?: string; titleKey?: string;
getBackRoute?: (route: RouteLocationNormalizedLoaded) => RouteLocationRaw; getBackRoute?: (route: RouteLocationNormalizedLoaded) => RouteLocationRaw;
// Skips AppHeader/AppFooter (see App.vue) - for pages meant to be
// dropped into an <iframe> as-is, like place/embed.
embed?: boolean;
} }
} }
@@ -52,6 +58,27 @@ 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/place/embed",
name: "place-embed",
component: PlaceEmbed,
props: true,
meta: { embed: true },
},
{ {
path: "/:eventPath/submissions/:gamePath", path: "/:eventPath/submissions/:gamePath",
name: "submissions-select", name: "submissions-select",
+1
View File
@@ -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",
Generated
+2
View File
@@ -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" },