from django.conf import settings from django.db import models from django.utils import timezone from events.models import Event from events.scoping import EventScopedManager DEFAULT_GRID_WIDTH = 64 DEFAULT_GRID_HEIGHT = 64 PLACE_COOLDOWN_SECONDS = 30 # 16-entry palette, classic r/place-style colors. The index into this list is # what's sent over the wire (see wire.py) - frontend/src/palette.ts must # mirror this list exactly, in the same order. PALETTE = [ "#FFFFFF", # 0 white "#E4E4E4", # 1 light grey "#888888", # 2 grey "#222222", # 3 black "#FFA7D1", # 4 pink "#E50000", # 5 red "#E59500", # 6 orange "#A06A42", # 7 brown "#E5D900", # 8 yellow "#94E044", # 9 light green "#02BE01", # 10 green "#00D3DD", # 11 cyan "#0083C7", # 12 blue "#0000EA", # 13 dark blue "#CF6EE4", # 14 purple "#820080", # 15 dark purple ] class PlaceSettings(models.Model): """One row per event, same event-scoped-singleton pattern as clock.models.ClockTimer. width/height are the live grid dimensions - the canonical source of truth is here, not a fixed constant, so an admin can resize the board per event (see signals.py for how that propagates). Unlike pplace's original PlaceSettings, there's no is_enabled here - the games registry (EventGame.enabled) is the single on/off switch, checked before a client can reach this app at all. """ event = models.OneToOneField( Event, on_delete=models.CASCADE, related_name="place_settings" ) width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH) height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT) # Past this, the game is over without taking it down: every placement # is rejected and the board is shown wiped (see tasks.py). Only visual - # the PixelPlacement history and the Redis canvas are kept as-is, so # admin stats/heatmaps still see the final board. Empty = no end. placement_ends_at = models.DateTimeField(null=True, blank=True) def __str__(self): return f"PlaceSettings({self.event}, size={self.width}x{self.height})" @property def placement_ended(self) -> bool: return ( self.placement_ends_at is not None and self.placement_ends_at <= timezone.now() ) @classmethod def current(cls, event: Event) -> "PlaceSettings": obj, _ = cls.objects.get_or_create(event=event) return obj class PixelPlacement(models.Model): event = models.ForeignKey( Event, on_delete=models.CASCADE, related_name="pixel_placements" ) player = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pixel_placements", ) x = models.PositiveSmallIntegerField() y = models.PositiveSmallIntegerField() color_index = models.PositiveSmallIntegerField() placed_at = models.DateTimeField(auto_now_add=True) objects = models.Manager() for_event = EventScopedManager() class Meta: ordering = ["-placed_at"] def __str__(self): return f"{self.player} placed ({self.x}, {self.y})=#{self.color_index} ({self.event})"