Compare commits

..
5 Commits
Author SHA1 Message Date
Legrems 407cc22911 opus-stats-score 2026-10-25 14:20:49 +01:00
Legrems 5fc79197f6 feat: add final.png export 2026-10-25 14:07:57 +01:00
Legrems 73b279d7fe feat(place): final end time + destruction wave 2026-10-25 12:55:24 +01:00
Legrems 1844b973e9 feat: add admin stats 2026-10-25 10:48:54 +01:00
Legrems 6cee1135a8 feat: add option to disable pixel placement 2026-10-25 10:42:58 +01:00
24 changed files with 1113 additions and 36 deletions
+42
View File
@@ -1,3 +1,5 @@
import csv
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.pagination import paginate
@@ -5,6 +7,8 @@ from ninja.errors import HttpError
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from ninja.security import django_auth_is_staff
from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
@@ -395,3 +399,41 @@ def get_stats(request, event_path: str):
if total_responses
else 0,
}
@router.get(f"{PREFIX}/points.csv", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def points_csv(request: HttpRequest, event_path: str):
"""Admin-only: points per player for each puzzle of the active collection,
one column per puzzle (1 puzzle = 1 round), `;` separated."""
puzzles = list(
SteamCollectionItem.objects.filter(
collection__is_active=True, collection__event=request.event
).order_by("order_index")
)
points = {}
for response in (
PuzzleResponse.objects.filter(
needs_manual_validation=False,
submission__event=request.event,
submission__user__isnull=False,
puzzle__in=puzzles,
)
.filter_user_best_response()
.select_related("submission__user")
):
user_points = points.setdefault(response.submission.user.username, {})
user_points[response.puzzle_id] = response.rank_points or 0
# text/plain so browsers render it inline - most force-download text/csv
response = HttpResponse(content_type="text/plain; charset=utf-8")
writer = csv.writer(response, delimiter=";")
writer.writerow(["username", *(puzzle.title for puzzle in puzzles)])
for username, user_points in sorted(
points.items(), key=lambda item: (-sum(item[1].values()), item[0])
):
writer.writerow(
[username, *(user_points.get(puzzle.id, 0) for puzzle in puzzles)]
)
return response
+74 -1
View File
@@ -1 +1,74 @@
# Create your tests here.
from django.test import TestCase
from accounts.models import CustomUser
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
from events.models import Event
from games.models import EventGame, Game
from .api import OPUS_MAGNUM_APP_ID
from .models import PuzzleResponse, SteamCollection, SteamCollectionItem, Submission
URL = "/api/events/event/opus-magnum/points.csv"
class PointsCsvTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Opus Magnum", steam_app_id=OPUS_MAGNUM_APP_ID)
EventGame.objects.create(event=self.event, game=game)
collection = SteamCollection.objects.create(
event=self.event, steam_id="1", url="https://example.com", is_active=True
)
factor = PuzzlePointsFactor.objects.create(cost=1, cycles=1, area=1)
value = PuzzlePointsValue.objects.create(points=[10, 5, 1])
self.p1, self.p2 = (
SteamCollectionItem.objects.create(
collection=collection,
steam_item_id=str(i),
title=f"Puzzle {i}",
order_index=i,
points_factor=factor,
points_value=value,
)
for i in (1, 2)
)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.bob = CustomUser.objects.create_user(username="bob", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def respond(self, user, puzzle, score):
submission = Submission.objects.create(event=self.event, user=user)
PuzzleResponse.objects.create(
submission=submission,
puzzle=puzzle,
puzzle_name=puzzle.title,
validated_cost=score,
validated_cycles=0,
validated_area=0,
)
def test_requires_staff(self):
self.client.force_login(self.alice)
response = self.client.get(URL)
self.assertEqual(response.status_code, 401)
def test_points_per_puzzle(self):
self.respond(self.alice, self.p1, 10)
self.respond(self.bob, self.p1, 20)
self.respond(self.bob, self.p2, 30)
self.client.force_login(self.admin)
response = self.client.get(URL)
self.assertEqual(
response.content.decode().splitlines(),
[
"username;Puzzle 1;Puzzle 2",
"bob;5;10",
"alice;10;0",
],
)
+2 -2
View File
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
@admin.register(PlaceSettings)
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ("event", "width", "height")
list_editable = ("width", "height")
list_display = ("event", "width", "height", "placement_ends_at")
list_editable = ("width", "height", "placement_ends_at")
list_filter = ("event",)
change_actions = ("resync_canvas",)
+24 -1
View File
@@ -10,6 +10,7 @@ from ninja.security import django_auth_is_staff
from games.decorators import require_game_enabled
from .canvas import read_canvas
from .export import build_final_png
from .gif import build_evolution_gif
from .heatmap import (
DecayMode,
@@ -18,6 +19,8 @@ from .heatmap import (
build_heatmap_png,
)
from .models import PixelPlacement, PlaceSettings
from .schemas import PlaceStatsOut
from .stats import build_place_stats
router = Router()
@@ -33,9 +36,13 @@ HEADER_FORMAT = "<HH"
@decorate_view(require_game_enabled(path="place"))
def canvas(request: HttpRequest, event_path: str):
"""The one-time full-grid load. Every placement after that arrives as a
single websocket message, never a re-fetch."""
single websocket message, never a re-fetch. Once placement is over the
board is shown wiped (Redis keeps the real final board, see tasks.py)."""
place_settings = PlaceSettings.current(request.event)
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
if place_settings.placement_ended:
pixels = bytes(place_settings.width * place_settings.height)
else:
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
return HttpResponse(header + pixels, content_type="application/octet-stream")
@@ -53,6 +60,14 @@ def evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
)
@router.get(f"{PREFIX}/final.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def final_png(request: HttpRequest, event_path: str):
"""Admin-only: the real final board as a PNG, even once placement is
over and everyone else sees it wiped."""
return HttpResponse(build_final_png(request.event), content_type="image/png")
@router.get(f"{PREFIX}/heatmap.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def heatmap_png(request: HttpRequest, event_path: str, players: str = ""):
@@ -104,6 +119,14 @@ def heatmap_decay_gif(
)
@router.get(f"{PREFIX}/stats", auth=django_auth_is_staff, response=PlaceStatsOut)
@decorate_view(require_game_enabled(path="place"))
def stats(request: HttpRequest, event_path: str):
"""Admin-only: aggregated stats about the game - totals, coverage, top
players, most contested pixels, color usage and activity per hour."""
return build_place_stats(request.event)
@router.get(f"{PREFIX}/placements.csv", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def placements_csv(request: HttpRequest, event_path: str):
+20 -1
View File
@@ -13,6 +13,10 @@ from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettin
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"]
@@ -45,6 +49,10 @@ class PlaceConsumer(AsyncWebsocketConsumer):
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"))
@@ -142,6 +150,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
next_placement_at,
place_settings.width,
place_settings.height,
_epoch_ms_or_none(place_settings.placement_ends_at),
)
@database_sync_to_async
@@ -152,4 +161,14 @@ class PlaceConsumer(AsyncWebsocketConsumer):
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"]))
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"))
+31
View File
@@ -0,0 +1,31 @@
"""Exports one event's final board as a PNG.
Reads the live Redis canvas rather than replaying the history like gif.py -
once placement is over the canvas endpoint serves a wiped board, but Redis
still holds the real final one (see tasks.py).
"""
from io import BytesIO
from events.models import Event
from .canvas import read_canvas
from .gif import _render_frame
from .models import PlaceSettings
def build_final_png(event: Event) -> bytes:
"""Renders `event`'s current board as a PNG, upscaled like gif.py.
Redis only pads up to the last written pixel, so a short canvas is
padded with white (and a longer one, e.g. from before a shrink, cut)
to the current grid dimensions."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
size = width * height
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
buffer = BytesIO()
_render_frame(bytearray(canvas), width, height).save(buffer, format="PNG")
return buffer.getvalue()
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-10-25 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("place", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="placesettings",
name="placement_enabled",
field=models.BooleanField(default=True),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 6.0.5 on 2026-10-25 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("place", "0002_placesettings_placement_enabled"),
]
operations = [
migrations.RemoveField(
model_name="placesettings",
name="placement_enabled",
),
migrations.AddField(
model_name="placesettings",
name="placement_ends_at",
field=models.DateTimeField(blank=True, null=True),
),
]
+13
View File
@@ -1,5 +1,6 @@
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
@@ -47,10 +48,22 @@ class PlaceSettings(models.Model):
)
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)
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from ninja import Schema
class PlayerCountOut(Schema):
username: str
placements: int
class PixelCountOut(Schema):
x: int
y: int
placements: int
players: int
class ColorCountOut(Schema):
color_index: int
color: str
placements: int
# How many pixels of the live board currently have this color
on_canvas: int
class HourCountOut(Schema):
hour: datetime
placements: int
players: int
class PlaceStatsOut(Schema):
width: int
height: int
total_placements: int
total_players: int
first_placement_at: datetime | None
last_placement_at: datetime | None
# Distinct pixels placed at least once (within the current grid)
pixels_touched: int
coverage: float
# Placements that landed on an already placed pixel
overwrites: int
avg_placements_per_player: float
top_players: list[PlayerCountOut]
most_contested_pixels: list[PixelCountOut]
colors: list[ColorCountOut]
per_hour: list[HourCountOut]
+11 -2
View File
@@ -1,13 +1,14 @@
"""Reacts to an admin editing a PlaceSettings row (grid size) by:
"""Reacts to an admin editing a PlaceSettings row (grid size, end time) by:
1. Rebuilding that event's Redis canvas from history at the (possibly new)
dimensions - a resize invalidates the old Redis bytes (wrong length,
wrong meaning), so this keeps Redis self-healing instead of relying on
the admin remembering to click "Resync canvas from DB" after every
resize.
2. Pushing the new size to that event's already-connected clients. Unlike
2. Pushing the new settings to that event's already-connected clients. Unlike
the clock app, place has no periodic heartbeat broadcast to piggyback
this onto, so it's broadcast explicitly here instead.
3. Queueing the wipe for the end time, if any (see tasks.py).
"""
from asgiref.sync import async_to_sync
@@ -18,11 +19,14 @@ from django.dispatch import receiver
from .broadcast import place_group
from .canvas import rebuild_canvas_from_history
from .models import PlaceSettings
from .tasks import schedule_wipe
from .wire import to_epoch_ms
@receiver(post_save, sender=PlaceSettings)
def on_settings_saved(sender, instance, **kwargs):
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
schedule_wipe(instance)
channel_layer = get_channel_layer()
if channel_layer is None:
@@ -33,5 +37,10 @@ def on_settings_saved(sender, instance, **kwargs):
"type": "settings_changed",
"width": instance.width,
"height": instance.height,
"ends_at": (
None
if instance.placement_ends_at is None
else to_epoch_ms(instance.placement_ends_at)
),
},
)
+97
View File
@@ -0,0 +1,97 @@
"""Aggregated stats about one event's place game, for the admin stats
endpoint. Everything comes from the durable PixelPlacement history, except
the colors currently on the board, which are read off the live Redis canvas.
"""
from django.db.models import Count, Max, Min
from django.db.models.functions import TruncHour
from events.models import Event
from .canvas import read_canvas
from .models import PALETTE, PixelPlacement, PlaceSettings
TOP_N = 10
def build_place_stats(event: Event) -> dict:
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
placements = PixelPlacement.objects.filter(event=event)
totals = placements.aggregate(
total=Count("id"),
players=Count("player", distinct=True),
first=Min("placed_at"),
last=Max("placed_at"),
)
total, players = totals["total"], totals["players"]
# Same as heatmap.py, placements outside the current grid are skipped
in_grid = placements.filter(x__lt=width, y__lt=height)
pixels_touched = in_grid.values("x", "y").distinct().count()
top_players = (
placements.values("player__username")
.annotate(count=Count("id"))
.order_by("-count", "player__username")[:TOP_N]
)
contested = (
in_grid.values("x", "y")
.annotate(count=Count("id"), players=Count("player", distinct=True))
.order_by("-count", "-players", "y", "x")[:TOP_N]
)
color_counts = dict(
placements.values_list("color_index")
.annotate(count=Count("id"))
.order_by()
)
# Redis only pads up to the last written pixel, and keeps old bytes
# after a shrink, so normalize to the current grid size
size = width * height
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
per_hour = (
placements.annotate(hour=TruncHour("placed_at"))
.values("hour")
.annotate(count=Count("id"), players=Count("player", distinct=True))
.order_by("hour")
)
return {
"width": width,
"height": height,
"total_placements": total,
"total_players": players,
"first_placement_at": totals["first"],
"last_placement_at": totals["last"],
"pixels_touched": pixels_touched,
"coverage": pixels_touched / size if size else 0.0,
"overwrites": in_grid.count() - pixels_touched,
"avg_placements_per_player": total / players if players else 0.0,
"top_players": [
{"username": row["player__username"], "placements": row["count"]}
for row in top_players
],
"most_contested_pixels": [
{
"x": row["x"],
"y": row["y"],
"placements": row["count"],
"players": row["players"],
}
for row in contested
],
"colors": [
{
"color_index": index,
"color": color,
"placements": color_counts.get(index, 0),
"on_canvas": canvas.count(index),
}
for index, color in enumerate(PALETTE)
],
"per_hour": [
{"hour": row["hour"], "placements": row["count"], "players": row["players"]}
for row in per_hour
],
}
+65
View File
@@ -0,0 +1,65 @@
"""Ends the game at PlaceSettings.placement_ends_at: a task queued to run at
that time broadcasts the wipe to everyone connected, so they all see the
board cleared at the same moment (no client-side timer to drift).
The wipe is only visual. Nothing is deleted, the Redis canvas still holds the
final board (admin stats, heatmaps, resync keep working), it's just that the
canvas endpoint serves a blank board once placement is over (see api.py).
"""
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from .broadcast import place_group
from .models import PlaceSettings
from .wire import to_epoch_ms
# Kept a bit past the end time, so re-saving the settings with the same end
# time (or with only the size changed) doesn't queue the same wipe twice.
WIPE_GUARD_MARGIN = timedelta(hours=1)
def _wipe_guard_key(settings_id: int, ends_at_ms: int) -> str:
return f"place:wipe:{settings_id}:{ends_at_ms}"
def schedule_wipe(place_settings: PlaceSettings) -> bool:
"""Queue the wipe for this end time, unless it's already queued or the end
time is already past. Safe to call on every save."""
ends_at = place_settings.placement_ends_at
if ends_at is None or ends_at <= timezone.now():
return False
ends_at_ms = to_epoch_ms(ends_at)
timeout = int((ends_at - timezone.now() + WIPE_GUARD_MARGIN).total_seconds())
if not cache.add(_wipe_guard_key(place_settings.id, ends_at_ms), True, timeout):
return False
wipe_canvas_task.using(run_after=ends_at).enqueue(place_settings.id, ends_at_ms)
return True
@task
def wipe_canvas_task(settings_id: int, ends_at_ms: int):
"""Broadcast the wipe wave, if `ends_at_ms` is still the event's end time -
an admin moving or clearing it leaves the old job queued, it just stops
here."""
place_settings = (
PlaceSettings.objects.filter(id=settings_id).select_related("event").first()
)
if place_settings is None or place_settings.placement_ends_at is None:
return
if to_epoch_ms(place_settings.placement_ends_at) != ends_at_ms:
return
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
place_group(place_settings.event.path), {"type": "wipe"}
)
+210
View File
@@ -0,0 +1,210 @@
from datetime import timedelta
from io import BytesIO
from unittest import mock
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from PIL import Image
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
from .models import PixelPlacement, PlaceSettings
from .tasks import schedule_wipe, wipe_canvas_task
from .wire import to_epoch_ms
URL = "/api/events/event/place/stats"
CANVAS_URL = "/api/events/event/place/canvas"
FINAL_URL = "/api/events/event/place/final.png"
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@mock.patch("place.stats.read_canvas")
class PlaceStatsTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
PlaceSettings.objects.create(event=self.event, width=4, height=2)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.bob = CustomUser.objects.create_user(username="bob", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def place(self, player, x, y, color_index):
PixelPlacement.objects.create(
event=self.event, player=player, x=x, y=y, color_index=color_index
)
def test_requires_staff(self, _):
self.client.force_login(self.alice)
response = self.client.get(URL)
self.assertEqual(response.status_code, 401)
def test_empty_game(self, read_canvas):
read_canvas.return_value = b""
self.client.force_login(self.admin)
data = self.client.get(URL).json()
self.assertEqual(data["total_placements"], 0)
self.assertEqual(data["total_players"], 0)
self.assertEqual(data["coverage"], 0.0)
self.assertEqual(data["avg_placements_per_player"], 0.0)
self.assertEqual(data["colors"][0]["on_canvas"], 8)
self.assertEqual(data["per_hour"], [])
def test_stats(self, read_canvas):
self.place(self.alice, 0, 0, 5)
self.place(self.bob, 0, 0, 3)
self.place(self.alice, 0, 0, 5)
self.place(self.alice, 1, 0, 3)
# Outside the current grid, e.g. from before a shrink
self.place(self.bob, 9, 9, 3)
# Short canvas, Redis only padded up to the last written pixel
read_canvas.return_value = bytes([5, 3])
self.client.force_login(self.admin)
data = self.client.get(URL).json()
self.assertEqual(data["total_placements"], 5)
self.assertEqual(data["total_players"], 2)
self.assertEqual(data["pixels_touched"], 2)
self.assertEqual(data["coverage"], 0.25)
self.assertEqual(data["overwrites"], 2)
self.assertEqual(data["avg_placements_per_player"], 2.5)
self.assertEqual(
data["top_players"],
[
{"username": "alice", "placements": 3},
{"username": "bob", "placements": 2},
],
)
self.assertEqual(
data["most_contested_pixels"][0],
{"x": 0, "y": 0, "placements": 3, "players": 2},
)
colors = {row["color_index"]: row for row in data["colors"]}
self.assertEqual(colors[5]["placements"], 2)
self.assertEqual(colors[3]["placements"], 3)
self.assertEqual(colors[5]["on_canvas"], 1)
self.assertEqual(colors[0]["on_canvas"], 6)
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
@mock.patch("place.export.read_canvas")
class FinalPngTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
PlaceSettings.objects.create(
event=self.event,
width=2,
height=1,
# Ended, the real board is still exported
placement_ends_at=timezone.now() - timedelta(hours=1),
)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def test_requires_staff(self, _):
self.client.force_login(self.alice)
response = self.client.get(FINAL_URL)
self.assertEqual(response.status_code, 401)
def test_final_png(self, read_canvas):
# Short canvas, Redis only padded up to the last written pixel
read_canvas.return_value = bytes([5])
self.client.force_login(self.admin)
response = self.client.get(FINAL_URL)
self.assertEqual(response["Content-Type"], "image/png")
image = Image.open(BytesIO(response.content))
self.assertEqual(image.size, (16, 8))
self.assertEqual(image.getpixel((0, 0)), (229, 0, 0))
self.assertEqual(image.getpixel((15, 0)), (255, 255, 255))
@override_settings(CACHES=LOCMEM_CACHE)
@mock.patch("place.signals.rebuild_canvas_from_history")
class PlacementEndTests(TestCase):
def setUp(self):
cache.clear()
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
def settings(self, ends_at):
with mock.patch("place.signals.schedule_wipe"):
return PlaceSettings.objects.create(
event=self.event, width=2, height=1, placement_ends_at=ends_at
)
@mock.patch("place.api.read_canvas")
def test_canvas_is_blank_once_ended(self, read_canvas, _):
read_canvas.return_value = bytes([5, 3])
place_settings = self.settings(timezone.now() + timedelta(hours=1))
self.client.force_login(self.alice)
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes([5, 3]))
place_settings.placement_ends_at = timezone.now() - timedelta(seconds=1)
with mock.patch("place.signals.schedule_wipe"):
place_settings.save()
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes(2))
@mock.patch("place.tasks.wipe_canvas_task")
def test_wipe_is_scheduled_once_per_end_time(self, task, _):
ends_at = timezone.now() + timedelta(hours=1)
place_settings = self.settings(ends_at)
self.assertTrue(schedule_wipe(place_settings))
self.assertFalse(schedule_wipe(place_settings))
task.using.assert_called_once_with(run_after=ends_at)
task.using.return_value.enqueue.assert_called_once_with(
place_settings.id, to_epoch_ms(ends_at)
)
@mock.patch("place.tasks.wipe_canvas_task")
def test_no_wipe_without_future_end(self, task, _):
self.assertFalse(schedule_wipe(self.settings(None)))
past = PlaceSettings(placement_ends_at=timezone.now() - timedelta(seconds=1))
self.assertFalse(schedule_wipe(past))
task.using.assert_not_called()
@mock.patch("place.tasks.get_channel_layer")
def test_task_broadcasts_wipe(self, get_channel_layer, _):
ends_at = timezone.now()
place_settings = self.settings(ends_at)
layer = get_channel_layer.return_value
layer.group_send = mock.AsyncMock()
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
layer.group_send.assert_awaited_once_with("place_room_event", {"type": "wipe"})
@mock.patch("place.tasks.get_channel_layer")
def test_task_skips_moved_end_time(self, get_channel_layer, _):
ends_at = timezone.now()
place_settings = self.settings(ends_at + timedelta(hours=1))
layer = get_channel_layer.return_value
layer.group_send = mock.AsyncMock()
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
layer.group_send.assert_not_awaited()
+14 -6
View File
@@ -6,8 +6,10 @@ either). Each message is a short string: a one-letter type tag followed by
'|'-separated fields, in a fixed order the client already knows from this
table:
s|<next>|<width>|<height> initial personal state, once per connection
(next = epoch ms cooldown ends, or empty if free now)
s|<next>|<width>|<height>|<end>
initial personal state, once per connection
(next = epoch ms cooldown ends, or empty if free now;
end = epoch ms placement ends, or empty if never)
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
including the placer (no separate ack needed)
k|<next> your own placement was accepted; carries your
@@ -15,10 +17,16 @@ table:
r|c|<next> rejected: still on cooldown
r|a rejected: not logged in
r|i rejected: invalid x/y/color
e|<width>|<height> an admin resized the grid for this event -
broadcast to everyone. A client that sees this
should re-fetch /api/.../place/canvas rather than
try to remap its local pixels.
r|d rejected: placement is over (past <end>)
e|<width>|<height>|<end>
an admin edited this event's settings (grid size
and/or end time) - broadcast to everyone.
If the size changed, a client should re-fetch
/api/.../place/canvas rather than try to remap its
local pixels.
w placement just ended - broadcast to everyone at
<end> (see tasks.py). Clients play the wipe wave
and blank their board.
Client -> server has exactly one message shape, the same as the broadcast:
p|<x>|<y>|<c>
+30 -7
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
import { PALETTE } from "@/palette";
import { playWipeWave } from "@/placeWipe";
interface Props {
eventPath: string;
@@ -40,14 +41,26 @@ function drawPixel(x: number, y: number, colorIndex: number) {
pixelCtx.fillRect(x, y, 1, 1);
}
let cancelWipe: (() => void) | null = null;
function wipe() {
if (!pixelCtx) return;
cancelWipe?.();
cancelWipe = playWipeWave(pixelCtx, gridWidth.value, gridHeight.value);
}
onBeforeUnmount(() => cancelWipe?.());
const {
connected,
cooldownRemainingMs,
placementEndMs,
placementEnded,
cooldownDurationSeconds,
gridWidth,
gridHeight,
place,
} = usePlaceSocket(props.eventPath, drawPixel);
} = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe);
useLiveBadge("place", connected);
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
@@ -55,6 +68,12 @@ const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
const canPlace = computed(() => !placementEnded.value && !onCooldown.value);
const placementEndDisplay = computed(() =>
placementEndMs.value !== null
? new Date(placementEndMs.value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: null,
);
// The overlay canvas never touches the pixel colors - it only draws grid
// lines plus a preview of the hovered cell, so it can freely redraw on
@@ -86,7 +105,7 @@ function drawOverlay() {
}
}
if (hoveredCell.value && !onCooldown.value) {
if (hoveredCell.value && canPlace.value) {
const { x, y } = hoveredCell.value;
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
@@ -96,7 +115,7 @@ function drawOverlay() {
}
}
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
@@ -125,7 +144,7 @@ function handleMouseLeave() {
}
function handleClick(event: MouseEvent) {
if (onCooldown.value) return;
if (!canPlace.value) return;
const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value);
}
@@ -142,7 +161,7 @@ function handleClick(event: MouseEvent) {
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
imageRendering: 'pixelated',
cursor: onCooldown ? 'not-allowed' : 'crosshair',
cursor: canPlace ? 'crosshair' : 'not-allowed',
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
@@ -154,11 +173,15 @@ function handleClick(event: MouseEvent) {
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
</button>
<template v-if="onCooldown">
<span v-if="placementEnded" class="badge badge-warning">{{ t("place.placementEnded") }}</span>
<template v-else-if="onCooldown">
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
<progress class="progress progress-primary w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
</template>
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
{{ t("place.endsAt", { time: placementEndDisplay }) }}
</span>
</div>
<div class="flex flex-wrap justify-center gap-2 max-w-md">
+18 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { PALETTE } from "@/palette";
import { playWipeWave } from "@/placeWipe";
interface Props {
eventPath: string;
@@ -22,7 +23,22 @@ function drawPixel(x: number, y: number, colorIndex: number) {
ctx.fillRect(x, y, 1, 1);
}
const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel);
let cancelWipe: (() => void) | null = null;
function wipe() {
if (!ctx) return;
cancelWipe?.();
cancelWipe = playWipeWave(ctx, gridWidth.value, gridHeight.value);
}
onBeforeUnmount(() => cancelWipe?.());
const { connected, gridWidth, gridHeight } = usePlaceSocket(
props.eventPath,
drawPixel,
undefined,
wipe,
);
onMounted(() => {
ctx = canvasRef.value?.getContext("2d") ?? null;
@@ -3,8 +3,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
import { client } from '../client.gen';
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventPlanning, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponseFiles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, placeApiHeatmapDecayGif, placeApiHeatmapEvolutionGif, placeApiHeatmapPng, placeApiPlacementsCsv, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventPlanningData, GamesApiListEventPlanningResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesError, OpusMagnumApiListResponseFilesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapPngData, PlaceApiPlacementsCsvData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen';
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventPlanning, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponseFiles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, placeApiFinalPng, placeApiHeatmapDecayGif, placeApiHeatmapEvolutionGif, placeApiHeatmapPng, placeApiPlacementsCsv, placeApiStats, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventPlanningData, GamesApiListEventPlanningResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesError, OpusMagnumApiListResponseFilesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PlaceApiFinalPngData, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapPngData, PlaceApiPlacementsCsvData, PlaceApiStatsData, PlaceApiStatsResponse, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -876,7 +876,8 @@ export const placeApiCanvasQueryKey = (options: Options<PlaceApiCanvasData>) =>
* Canvas
*
* The one-time full-grid load. Every placement after that arrives as a
* single websocket message, never a re-fetch.
* single websocket message, never a re-fetch. Once placement is over the
* board is shown wiped (Redis keeps the real final board, see tasks.py).
*/
export const placeApiCanvasOptions = (options: Options<PlaceApiCanvasData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiCanvasQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
@@ -914,6 +915,27 @@ export const placeApiEvolutionGifOptions = (options: Options<PlaceApiEvolutionGi
queryKey: placeApiEvolutionGifQueryKey(options)
});
export const placeApiFinalPngQueryKey = (options: Options<PlaceApiFinalPngData>) => createQueryKey('placeApiFinalPng', options);
/**
* Final Png
*
* Admin-only: the real final board as a PNG, even once placement is
* over and everyone else sees it wiped.
*/
export const placeApiFinalPngOptions = (options: Options<PlaceApiFinalPngData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiFinalPngQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await placeApiFinalPng({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: placeApiFinalPngQueryKey(options)
});
export const placeApiHeatmapPngQueryKey = (options: Options<PlaceApiHeatmapPngData>) => createQueryKey('placeApiHeatmapPng', options);
/**
@@ -987,6 +1009,27 @@ export const placeApiHeatmapDecayGifOptions = (options: Options<PlaceApiHeatmapD
queryKey: placeApiHeatmapDecayGifQueryKey(options)
});
export const placeApiStatsQueryKey = (options: Options<PlaceApiStatsData>) => createQueryKey('placeApiStats', options);
/**
* Stats
*
* Admin-only: aggregated stats about the game - totals, coverage, top
* players, most contested pixels, color usage and activity per hour.
*/
export const placeApiStatsOptions = (options: Options<PlaceApiStatsData>) => queryOptions<PlaceApiStatsResponse, DefaultError, PlaceApiStatsResponse, ReturnType<typeof placeApiStatsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await placeApiStats({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: placeApiStatsQueryKey(options)
});
export const placeApiPlacementsCsvQueryKey = (options: Options<PlaceApiPlacementsCsvData>) => createQueryKey('placeApiPlacementsCsv', options);
/**
File diff suppressed because one or more lines are too long
+35 -2
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesErrors, OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapDecayGifResponses, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapEvolutionGifResponses, PlaceApiHeatmapPngData, PlaceApiHeatmapPngResponses, PlaceApiPlacementsCsvData, PlaceApiPlacementsCsvResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesErrors, OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlaceApiFinalPngData, PlaceApiFinalPngResponses, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapDecayGifResponses, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapEvolutionGifResponses, PlaceApiHeatmapPngData, PlaceApiHeatmapPngResponses, PlaceApiPlacementsCsvData, PlaceApiPlacementsCsvResponses, PlaceApiStatsData, PlaceApiStatsResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -334,7 +334,8 @@ export const clockApiMyStats = <ThrowOnError extends boolean = false>(options: O
* Canvas
*
* The one-time full-grid load. Every placement after that arrives as a
* single websocket message, never a re-fetch.
* single websocket message, never a re-fetch. Once placement is over the
* board is shown wiped (Redis keeps the real final board, see tasks.py).
*/
export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options });
@@ -356,6 +357,22 @@ export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(optio
...options
});
/**
* Final Png
*
* Admin-only: the real final board as a PNG, even once placement is
* over and everyone else sees it wiped.
*/
export const placeApiFinalPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiFinalPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiFinalPngResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/final.png',
...options
});
/**
* Heatmap Png
*
@@ -414,6 +431,22 @@ export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(op
...options
});
/**
* Stats
*
* Admin-only: aggregated stats about the game - totals, coverage, top
* players, most contested pixels, color usage and activity per hour.
*/
export const placeApiStats = <ThrowOnError extends boolean = false>(options: Options<PlaceApiStatsData, ThrowOnError>) => (options.client ?? client).get<PlaceApiStatsResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/stats',
...options
});
/**
* Placements Csv
*
+178
View File
@@ -1264,6 +1264,144 @@ export type PlayerStatsOut = {
points_below: number | null;
};
/**
* ColorCountOut
*/
export type ColorCountOut = {
/**
* Color Index
*/
color_index: number;
/**
* Color
*/
color: string;
/**
* Placements
*/
placements: number;
/**
* On Canvas
*/
on_canvas: number;
};
/**
* HourCountOut
*/
export type HourCountOut = {
/**
* Hour
*/
hour: string;
/**
* Placements
*/
placements: number;
/**
* Players
*/
players: number;
};
/**
* PixelCountOut
*/
export type PixelCountOut = {
/**
* X
*/
x: number;
/**
* Y
*/
y: number;
/**
* Placements
*/
placements: number;
/**
* Players
*/
players: number;
};
/**
* PlaceStatsOut
*/
export type PlaceStatsOut = {
/**
* Width
*/
width: number;
/**
* Height
*/
height: number;
/**
* Total Placements
*/
total_placements: number;
/**
* Total Players
*/
total_players: number;
/**
* First Placement At
*/
first_placement_at: string | null;
/**
* Last Placement At
*/
last_placement_at: string | null;
/**
* Pixels Touched
*/
pixels_touched: number;
/**
* Coverage
*/
coverage: number;
/**
* Overwrites
*/
overwrites: number;
/**
* Avg Placements Per Player
*/
avg_placements_per_player: number;
/**
* Top Players
*/
top_players: Array<PlayerCountOut>;
/**
* Most Contested Pixels
*/
most_contested_pixels: Array<PixelCountOut>;
/**
* Colors
*/
colors: Array<ColorCountOut>;
/**
* Per Hour
*/
per_hour: Array<HourCountOut>;
};
/**
* PlayerCountOut
*/
export type PlayerCountOut = {
/**
* Username
*/
username: string;
/**
* Placements
*/
placements: number;
};
/**
* MarketListSchema
*/
@@ -2743,6 +2881,25 @@ export type PlaceApiEvolutionGifResponses = {
200: unknown;
};
export type PlaceApiFinalPngData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/place/final.png';
};
export type PlaceApiFinalPngResponses = {
/**
* OK
*/
200: unknown;
};
export type PlaceApiHeatmapPngData = {
body?: never;
path: {
@@ -2823,6 +2980,27 @@ export type PlaceApiHeatmapDecayGifResponses = {
200: unknown;
};
export type PlaceApiStatsData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/place/stats';
};
export type PlaceApiStatsResponses = {
/**
* OK
*/
200: PlaceStatsOut;
};
export type PlaceApiStatsResponse = PlaceApiStatsResponses[keyof PlaceApiStatsResponses];
export type PlaceApiPlacementsCsvData = {
body?: never;
path: {
@@ -16,6 +16,7 @@ const PLACE_COOLDOWN_SECONDS = 30
export type OnPixel = (x: number, y: number, colorIndex: number) => void
export type OnResize = (width: number, height: number) => void
export type OnWipe = () => void
// Plain (non-reactive) - a several-thousand-cell reactive array is
// pointless overhead. Kept module-level, alongside its dimensions, so the
@@ -26,9 +27,18 @@ let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE)
const gridWidth = ref(DEFAULT_GRID_SIZE)
const gridHeight = ref(DEFAULT_GRID_SIZE)
export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
export function usePlaceSocket(
eventPath: string,
onPixel?: OnPixel,
onResize?: OnResize,
onWipe?: OnWipe,
) {
const cooldownEndMs = ref<number | null>(null)
const cooldownRemainingMs = ref(0)
// PlaceSettings.placement_ends_at - the server rejects placements past it
// anyway, this just lets the UI say so.
const placementEndMs = ref<number | null>(null)
const placementEnded = ref(false)
let animationFrame: number | null = null
@@ -36,9 +46,23 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
cooldownEndMs.value = nextMs ?? null
}
// Returns true if placement went from over to open again (an admin moved
// the end time later or cleared it), so the caller can bring the real
// board back - the server serves it again.
function applyPlacementEnd(endMs: string): boolean {
const wasEnded = placementEnded.value
placementEndMs.value = endMs !== '' ? Number(endMs) : null
placementEnded.value = placementEndMs.value !== null && placementEndMs.value <= Date.now()
return wasEnded && !placementEnded.value
}
function tickCooldown() {
const now = Date.now()
cooldownRemainingMs.value =
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - now) : 0
// Only ever flips it on: the server's own 'w'/'r|d' may beat a skewed
// local clock, and reopening goes through applyPlacementEnd.
if (placementEndMs.value !== null && placementEndMs.value <= now) placementEnded.value = true
animationFrame = requestAnimationFrame(tickCooldown)
}
@@ -65,9 +89,10 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
switch (parts[0]) {
case 's': {
const [, next, width, height] = parts
const [, next, width, height, end] = parts
applyCooldown(next !== '' ? Number(next) : null)
applyGridSize(Number(width), Number(height))
applyPlacementEnd(end)
break
}
case 'p': {
@@ -81,14 +106,24 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
}
case 'r': {
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
if (parts[1] === 'd') placementEnded.value = true
break
}
case 'e': {
const [, width, height] = parts
const [, width, height, end] = parts
const reopened = applyPlacementEnd(end)
// The server already rebuilt its canvas at the new size (see
// place/signals.py) - just re-fetch it rather than guess how to
// remap our local pixels.
if (applyGridSize(Number(width), Number(height))) loadCanvas()
if (applyGridSize(Number(width), Number(height)) || reopened) loadCanvas()
break
}
case 'w': {
// Placement is over: the server now serves a blank canvas, match it
// locally and let the page animate the wipe instead of redrawing.
placementEnded.value = true
grid.fill(0)
onWipe?.()
break
}
default:
@@ -151,6 +186,8 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
return {
connected,
cooldownRemainingMs,
placementEndMs,
placementEnded,
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
gridWidth,
gridHeight,
+3 -1
View File
@@ -97,7 +97,9 @@
"pageTitle": "Place",
"header": "Place",
"showGrid": "Show grid",
"hideGrid": "Hide grid"
"hideGrid": "Hide grid",
"placementEnded": "Pixel placement is over",
"endsAt": "Ends at {time}"
},
"market": {
"pageTitle": "Market",
+68
View File
@@ -0,0 +1,68 @@
// The end-of-game wipe (see place/tasks.py): a wavy front sweeps the board
// diagonally from the top-left, a cyan crest leaving plain white (palette
// index 0, what the server's blank canvas holds) behind it.
const WIPE_DURATION_MS = 2200
// Crest thickness and front wobble, in cells
const CREST_WIDTH = 6
const WOBBLE = 2.5
const CREST_RGB = [0x00, 0xd3, 0xdd]
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
// Works on a snapshot of the canvas through ImageData rather than per-cell
// fillRect, so it stays smooth on a big grid. Returns a cancel function.
export function playWipeWave(ctx: CanvasRenderingContext2D, width: number, height: number) {
const before = ctx.getImageData(0, 0, width, height)
const frame = ctx.createImageData(width, height)
const out = frame.data
const src = before.data
// Distance of each cell from the start corner, wobbled so the front reads
// as a wave rather than a straight line.
const distance = new Float32Array(width * height)
const wobblePeriod = Math.max(width, height) / 5
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
distance[y * width + x] = x + y + WOBBLE * Math.sin((x - y) / wobblePeriod)
}
}
const travel = width + height + 2 * WOBBLE + CREST_WIDTH
let start: number | null = null
let animationFrame: number | null = null
function step(now: number) {
start ??= now
const progress = Math.min(1, (now - start) / WIPE_DURATION_MS)
const front = easeInOut(progress) * travel - WOBBLE
for (let i = 0; i < distance.length; i++) {
const o = i * 4
const behind = front - distance[i]
if (behind <= 0) {
out[o] = src[o]
out[o + 1] = src[o + 1]
out[o + 2] = src[o + 2]
} else if (behind < CREST_WIDTH) {
// Cyan at the leading edge, fading to white at the back of the crest
const t = behind / CREST_WIDTH
out[o] = CREST_RGB[0] + (255 - CREST_RGB[0]) * t
out[o + 1] = CREST_RGB[1] + (255 - CREST_RGB[1]) * t
out[o + 2] = CREST_RGB[2] + (255 - CREST_RGB[2]) * t
} else {
out[o] = out[o + 1] = out[o + 2] = 255
}
out[o + 3] = 255
}
ctx.putImageData(frame, 0, 0)
animationFrame = progress < 1 ? requestAnimationFrame(step) : null
}
animationFrame = requestAnimationFrame(step)
return () => {
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
}
}