from datetime import timedelta from channels.db import database_sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer from django.db import transaction from django.utils import timezone from events.models import Event from games.models import Game from .broadcast import clock_group from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds from .wire import encode, to_epoch_ms class ClockConsumer(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 = clock_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 == "press": await self._handle_press() async def _handle_press(self): user = self.scope.get("user") if user is None or not user.is_authenticated: await self.send(text_data=encode("r", "a")) return result = await self._record_press(user) if result["rejected"]: await self.send(text_data=encode("r", "c", result["next"], result["now"])) return # The cooldown outcome is only useful to the player who just pressed - # broadcasting it to every connection would be pure waste. await self.send(text_data=encode("k", result["next"], result["now"])) # Everyone else only needs to know the shared timer moved. await self.channel_layer.group_send( self.group_name, { "type": "tick", "end": result["end"], "now": result["now"], "pressed": True, }, ) @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="clock").exists(): return None return event @database_sync_to_async def _record_press(self, user): with transaction.atomic(): timer = ClockTimer.objects.select_for_update().get(event=self.event) now = timezone.now() timer.reset_if_expired(now) last_press = ( Press.objects.filter(player=user, event=self.event) .order_by("-pressed_at") .first() ) if last_press is not None: next_allowed = last_press.pressed_at + timedelta( seconds=last_press.cooldown_seconds ) if next_allowed > now: return { "rejected": True, "next": to_epoch_ms(next_allowed), "now": to_epoch_ms(now), } remaining = (timer.end_time - now).total_seconds() cooldown = random_cooldown_seconds() press = Press.objects.create( event=self.event, player=user, remaining_before_press=remaining, seconds_added=PRESS_BONUS_SECONDS, cooldown_seconds=cooldown, ) timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS) timer.save(update_fields=["end_time"]) return { "rejected": False, "end": to_epoch_ms(timer.end_time), "now": to_epoch_ms(now), "next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)), } @database_sync_to_async def _initial_state(self, user): timer = ClockTimer.current(self.event) timer.reset_if_expired() now = timezone.now() next_press_at = None if user is not None and user.is_authenticated: last_press = ( Press.objects.filter(player=user, event=self.event) .order_by("-pressed_at") .first() ) if last_press is not None: candidate = last_press.pressed_at + timedelta( seconds=last_press.cooldown_seconds ) if candidate > now: next_press_at = to_epoch_ms(candidate) return encode( "s", to_epoch_ms(timer.end_time), to_epoch_ms(now), timer.initial_duration, PRESS_BONUS_SECONDS, next_press_at, ) async def tick(self, event): await self.send( text_data=encode( "t", event["end"], event["now"], "1" if event.get("pressed") else "", ) )