feat(place): final end time + destruction wave
This commit is contained in:
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
|
||||
|
||||
@admin.register(PlaceSettings)
|
||||
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ("event", "width", "height", "placement_enabled")
|
||||
list_editable = ("width", "height", "placement_enabled")
|
||||
list_display = ("event", "width", "height", "placement_ends_at")
|
||||
list_editable = ("width", "height", "placement_ends_at")
|
||||
list_filter = ("event",)
|
||||
change_actions = ("resync_canvas",)
|
||||
|
||||
|
||||
@@ -35,10 +35,14 @@ HEADER_FORMAT = "<HH"
|
||||
@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."""
|
||||
single websocket message, never a re-fetch. Once placement is over the
|
||||
board is shown wiped (Redis keeps the real final board, see tasks.py)."""
|
||||
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)
|
||||
if place_settings.placement_ended:
|
||||
pixels = bytes(place_settings.width * place_settings.height)
|
||||
else:
|
||||
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettin
|
||||
from .wire import encode, to_epoch_ms
|
||||
|
||||
|
||||
def _epoch_ms_or_none(dt):
|
||||
return None if dt is None else to_epoch_ms(dt)
|
||||
|
||||
|
||||
class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
||||
@@ -45,7 +49,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
await self.send(text_data=encode("r", "a"))
|
||||
return
|
||||
|
||||
if not place_settings.placement_enabled:
|
||||
if place_settings.placement_ended:
|
||||
await self.send(text_data=encode("r", "d"))
|
||||
return
|
||||
|
||||
@@ -146,7 +150,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
next_placement_at,
|
||||
place_settings.width,
|
||||
place_settings.height,
|
||||
int(place_settings.placement_enabled),
|
||||
_epoch_ms_or_none(place_settings.placement_ends_at),
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
@@ -162,6 +166,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
"e",
|
||||
event["width"],
|
||||
event["height"],
|
||||
int(event["placement_enabled"]),
|
||||
event["ends_at"],
|
||||
)
|
||||
)
|
||||
|
||||
async def wipe(self, event):
|
||||
await self.send(text_data=encode("w"))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-25 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("place", "0002_placesettings_placement_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="placesettings",
|
||||
name="placement_enabled",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="placesettings",
|
||||
name="placement_ends_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -47,13 +48,22 @@ class PlaceSettings(models.Model):
|
||||
)
|
||||
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
|
||||
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT)
|
||||
# Freezes the board without taking the game down: clients can still
|
||||
# connect and watch, but every placement is rejected.
|
||||
placement_enabled = models.BooleanField(default=True)
|
||||
# Past this, the game is over without taking it down: every placement
|
||||
# is rejected and the board is shown wiped (see tasks.py). Only visual -
|
||||
# the PixelPlacement history and the Redis canvas are kept as-is, so
|
||||
# admin stats/heatmaps still see the final board. Empty = no end.
|
||||
placement_ends_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
|
||||
|
||||
@property
|
||||
def placement_ended(self) -> bool:
|
||||
return (
|
||||
self.placement_ends_at is not None
|
||||
and self.placement_ends_at <= timezone.now()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def current(cls, event: Event) -> "PlaceSettings":
|
||||
obj, _ = cls.objects.get_or_create(event=event)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size, placement
|
||||
toggle) by:
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size, end time) by:
|
||||
|
||||
1. Rebuilding that event's Redis canvas from history at the (possibly new)
|
||||
dimensions - a resize invalidates the old Redis bytes (wrong length,
|
||||
@@ -9,6 +8,7 @@ toggle) by:
|
||||
2. Pushing the new settings 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.
|
||||
3. Queueing the wipe for the end time, if any (see tasks.py).
|
||||
"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -19,11 +19,14 @@ from django.dispatch import receiver
|
||||
from .broadcast import place_group
|
||||
from .canvas import rebuild_canvas_from_history
|
||||
from .models import PlaceSettings
|
||||
from .tasks import schedule_wipe
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
|
||||
@receiver(post_save, sender=PlaceSettings)
|
||||
def on_settings_saved(sender, instance, **kwargs):
|
||||
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
|
||||
schedule_wipe(instance)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
@@ -34,6 +37,10 @@ def on_settings_saved(sender, instance, **kwargs):
|
||||
"type": "settings_changed",
|
||||
"width": instance.width,
|
||||
"height": instance.height,
|
||||
"placement_enabled": instance.placement_enabled,
|
||||
"ends_at": (
|
||||
None
|
||||
if instance.placement_ends_at is None
|
||||
else to_epoch_ms(instance.placement_ends_at)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Ends the game at PlaceSettings.placement_ends_at: a task queued to run at
|
||||
that time broadcasts the wipe to everyone connected, so they all see the
|
||||
board cleared at the same moment (no client-side timer to drift).
|
||||
|
||||
The wipe is only visual. Nothing is deleted, the Redis canvas still holds the
|
||||
final board (admin stats, heatmaps, resync keep working), it's just that the
|
||||
canvas endpoint serves a blank board once placement is over (see api.py).
|
||||
"""
|
||||
|
||||
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.tasks import task
|
||||
from django.utils import timezone
|
||||
|
||||
from .broadcast import place_group
|
||||
from .models import PlaceSettings
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
# Kept a bit past the end time, so re-saving the settings with the same end
|
||||
# time (or with only the size changed) doesn't queue the same wipe twice.
|
||||
WIPE_GUARD_MARGIN = timedelta(hours=1)
|
||||
|
||||
|
||||
def _wipe_guard_key(settings_id: int, ends_at_ms: int) -> str:
|
||||
return f"place:wipe:{settings_id}:{ends_at_ms}"
|
||||
|
||||
|
||||
def schedule_wipe(place_settings: PlaceSettings) -> bool:
|
||||
"""Queue the wipe for this end time, unless it's already queued or the end
|
||||
time is already past. Safe to call on every save."""
|
||||
ends_at = place_settings.placement_ends_at
|
||||
if ends_at is None or ends_at <= timezone.now():
|
||||
return False
|
||||
|
||||
ends_at_ms = to_epoch_ms(ends_at)
|
||||
timeout = int((ends_at - timezone.now() + WIPE_GUARD_MARGIN).total_seconds())
|
||||
if not cache.add(_wipe_guard_key(place_settings.id, ends_at_ms), True, timeout):
|
||||
return False
|
||||
|
||||
wipe_canvas_task.using(run_after=ends_at).enqueue(place_settings.id, ends_at_ms)
|
||||
return True
|
||||
|
||||
|
||||
@task
|
||||
def wipe_canvas_task(settings_id: int, ends_at_ms: int):
|
||||
"""Broadcast the wipe wave, if `ends_at_ms` is still the event's end time -
|
||||
an admin moving or clearing it leaves the old job queued, it just stops
|
||||
here."""
|
||||
place_settings = (
|
||||
PlaceSettings.objects.filter(id=settings_id).select_related("event").first()
|
||||
)
|
||||
if place_settings is None or place_settings.placement_ends_at is None:
|
||||
return
|
||||
if to_epoch_ms(place_settings.placement_ends_at) != ends_at_ms:
|
||||
return
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
return
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
place_group(place_settings.event.path), {"type": "wipe"}
|
||||
)
|
||||
@@ -1,14 +1,21 @@
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from events.models import Event
|
||||
from games.models import EventGame, Game
|
||||
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
from .tasks import schedule_wipe, wipe_canvas_task
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
URL = "/api/events/event/place/stats"
|
||||
CANVAS_URL = "/api/events/event/place/canvas"
|
||||
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
|
||||
@mock.patch("place.stats.read_canvas")
|
||||
@@ -85,3 +92,77 @@ class PlaceStatsTests(TestCase):
|
||||
self.assertEqual(colors[5]["on_canvas"], 1)
|
||||
self.assertEqual(colors[0]["on_canvas"], 6)
|
||||
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
|
||||
|
||||
|
||||
@override_settings(CACHES=LOCMEM_CACHE)
|
||||
@mock.patch("place.signals.rebuild_canvas_from_history")
|
||||
class PlacementEndTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
|
||||
def settings(self, ends_at):
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
return PlaceSettings.objects.create(
|
||||
event=self.event, width=2, height=1, placement_ends_at=ends_at
|
||||
)
|
||||
|
||||
@mock.patch("place.api.read_canvas")
|
||||
def test_canvas_is_blank_once_ended(self, read_canvas, _):
|
||||
read_canvas.return_value = bytes([5, 3])
|
||||
place_settings = self.settings(timezone.now() + timedelta(hours=1))
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes([5, 3]))
|
||||
|
||||
place_settings.placement_ends_at = timezone.now() - timedelta(seconds=1)
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
place_settings.save()
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes(2))
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_wipe_is_scheduled_once_per_end_time(self, task, _):
|
||||
ends_at = timezone.now() + timedelta(hours=1)
|
||||
place_settings = self.settings(ends_at)
|
||||
|
||||
self.assertTrue(schedule_wipe(place_settings))
|
||||
self.assertFalse(schedule_wipe(place_settings))
|
||||
|
||||
task.using.assert_called_once_with(run_after=ends_at)
|
||||
task.using.return_value.enqueue.assert_called_once_with(
|
||||
place_settings.id, to_epoch_ms(ends_at)
|
||||
)
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_no_wipe_without_future_end(self, task, _):
|
||||
self.assertFalse(schedule_wipe(self.settings(None)))
|
||||
past = PlaceSettings(placement_ends_at=timezone.now() - timedelta(seconds=1))
|
||||
self.assertFalse(schedule_wipe(past))
|
||||
|
||||
task.using.assert_not_called()
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_broadcasts_wipe(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at)
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_awaited_once_with("place_room_event", {"type": "wipe"})
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_skips_moved_end_time(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at + timedelta(hours=1))
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_not_awaited()
|
||||
|
||||
@@ -6,10 +6,10 @@ 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>|<on>
|
||||
s|<next>|<width>|<height>|<end>
|
||||
initial personal state, once per connection
|
||||
(next = epoch ms cooldown ends, or empty if free now;
|
||||
on = 1 if placing pixels is allowed, 0 if frozen)
|
||||
end = epoch ms placement ends, or empty if never)
|
||||
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
|
||||
@@ -17,13 +17,16 @@ table:
|
||||
r|c|<next> rejected: still on cooldown
|
||||
r|a rejected: not logged in
|
||||
r|i rejected: invalid x/y/color
|
||||
r|d rejected: placement is disabled by an admin
|
||||
e|<width>|<height>|<on>
|
||||
r|d rejected: placement is over (past <end>)
|
||||
e|<width>|<height>|<end>
|
||||
an admin edited this event's settings (grid size
|
||||
and/or placement toggle) - broadcast to everyone.
|
||||
and/or end time) - broadcast to everyone.
|
||||
If the size changed, a client should re-fetch
|
||||
/api/.../place/canvas rather than try to remap its
|
||||
local pixels.
|
||||
w placement just ended - broadcast to everyone at
|
||||
<end> (see tasks.py). Clients play the wipe wave
|
||||
and blank their board.
|
||||
|
||||
Client -> server has exactly one message shape, the same as the broadcast:
|
||||
p|<x>|<y>|<c>
|
||||
|
||||
Reference in New Issue
Block a user