From 73b279d7fefc731296daa0621ced6c484edcaf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Sun, 25 Oct 2026 12:55:24 +0100 Subject: [PATCH] feat(place): final end time + destruction wave --- polylan_submitter/place/admin.py | 4 +- polylan_submitter/place/api.py | 8 +- polylan_submitter/place/consumers.py | 13 ++- .../0003_placesettings_placement_ends_at.py | 21 +++++ polylan_submitter/place/models.py | 16 +++- polylan_submitter/place/signals.py | 13 ++- polylan_submitter/place/tasks.py | 65 +++++++++++++++ polylan_submitter/place/tests.py | 83 ++++++++++++++++++- polylan_submitter/place/wire.py | 13 +-- polylan_submitter/src/Place.vue | 30 +++++-- polylan_submitter/src/PlaceEmbed.vue | 20 ++++- .../src/composables/usePlaceSocket.ts | 54 +++++++++--- polylan_submitter/src/locales/en.json | 3 +- polylan_submitter/src/placeWipe.ts | 68 +++++++++++++++ 14 files changed, 372 insertions(+), 39 deletions(-) create mode 100644 polylan_submitter/place/migrations/0003_placesettings_placement_ends_at.py create mode 100644 polylan_submitter/place/tasks.py create mode 100644 polylan_submitter/src/placeWipe.ts diff --git a/polylan_submitter/place/admin.py b/polylan_submitter/place/admin.py index c83b7a9..edf590a 100644 --- a/polylan_submitter/place/admin.py +++ b/polylan_submitter/place/admin.py @@ -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",) diff --git a/polylan_submitter/place/api.py b/polylan_submitter/place/api.py index fee104d..cbf29c2 100644 --- a/polylan_submitter/place/api.py +++ b/polylan_submitter/place/api.py @@ -35,10 +35,14 @@ HEADER_FORMAT = " 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) diff --git a/polylan_submitter/place/signals.py b/polylan_submitter/place/signals.py index c2b607e..18fdf30 100644 --- a/polylan_submitter/place/signals.py +++ b/polylan_submitter/place/signals.py @@ -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) + ), }, ) diff --git a/polylan_submitter/place/tasks.py b/polylan_submitter/place/tasks.py new file mode 100644 index 0000000..5895da5 --- /dev/null +++ b/polylan_submitter/place/tasks.py @@ -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"} + ) diff --git a/polylan_submitter/place/tests.py b/polylan_submitter/place/tests.py index bd1088a..7534646 100644 --- a/polylan_submitter/place/tests.py +++ b/polylan_submitter/place/tests.py @@ -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() diff --git a/polylan_submitter/place/wire.py b/polylan_submitter/place/wire.py index 3d9dcef..9b9115c 100644 --- a/polylan_submitter/place/wire.py +++ b/polylan_submitter/place/wire.py @@ -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|||| + s|||| 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||| a pixel was placed - broadcast to everyone, including the placer (no separate ack needed) k| your own placement was accepted; carries your @@ -17,13 +17,16 @@ table: r|c| 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||| + r|d rejected: placement is over (past ) + e||| 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 + (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||| diff --git a/polylan_submitter/src/Place.vue b/polylan_submitter/src/Place.vue index ebdd5b3..7bf846e 100644 --- a/polylan_submitter/src/Place.vue +++ b/polylan_submitter/src/Place.vue @@ -1,10 +1,11 @@