Files

155 lines
5.3 KiB
Python

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, self.scope.get("user")
)
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, 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="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 "",
)
)