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 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"] self.event = await self._get_enabled_event( self.event_path, self.scope.get("user") ) 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 if place_settings.placement_ended: await self.send(text_data=encode("r", "d")) 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, user): event = Event.objects.available_to(user).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, _epoch_ms_or_none(place_settings.placement_ends_at), ) @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"], event["ends_at"], ) ) async def wipe(self, event): await self.send(text_data=encode("w"))