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
+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)