feat: clock + place games

This commit is contained in:
2026-09-17 01:33:54 +02:00
parent d104480188
commit 6866b45f66
46 changed files with 2043 additions and 10 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)