Files

175 lines
5.7 KiB
Python

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"))