feat(place): final end time + destruction wave

This commit is contained in:
2026-10-25 12:55:24 +01:00
parent 1844b973e9
commit 73b279d7fe
14 changed files with 372 additions and 39 deletions
+65
View File
@@ -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"}
)