Compare commits
5
Commits
4f2cf08b27
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407cc22911
|
||
|
|
5fc79197f6
|
||
|
|
73b279d7fe
|
||
|
|
1844b973e9
|
||
|
|
6cee1135a8
|
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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",)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user