Compare commits
7
Commits
0b34e66d81
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407cc22911
|
||
|
|
5fc79197f6
|
||
|
|
73b279d7fe
|
||
|
|
1844b973e9
|
||
|
|
6cee1135a8
|
||
|
|
4f2cf08b27
|
||
|
|
9bb02463da
|
@@ -1,3 +1,5 @@
|
|||||||
|
import csv
|
||||||
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
@@ -5,6 +7,8 @@ from ninja.errors import HttpError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
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.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -395,3 +399,41 @@ def get_stats(request, event_path: str):
|
|||||||
if total_responses
|
if total_responses
|
||||||
else 0,
|
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)
|
@admin.register(PlaceSettings)
|
||||||
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ("event", "width", "height")
|
list_display = ("event", "width", "height", "placement_ends_at")
|
||||||
list_editable = ("width", "height")
|
list_editable = ("width", "height", "placement_ends_at")
|
||||||
list_filter = ("event",)
|
list_filter = ("event",)
|
||||||
change_actions = ("resync_canvas",)
|
change_actions = ("resync_canvas",)
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ from ninja.security import django_auth_is_staff
|
|||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
from .canvas import read_canvas
|
from .canvas import read_canvas
|
||||||
|
from .export import build_final_png
|
||||||
from .gif import build_evolution_gif
|
from .gif import build_evolution_gif
|
||||||
|
from .heatmap import (
|
||||||
|
DecayMode,
|
||||||
|
build_heatmap_decay_gif,
|
||||||
|
build_heatmap_evolution_gif,
|
||||||
|
build_heatmap_png,
|
||||||
|
)
|
||||||
from .models import PixelPlacement, PlaceSettings
|
from .models import PixelPlacement, PlaceSettings
|
||||||
|
from .schemas import PlaceStatsOut
|
||||||
|
from .stats import build_place_stats
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -27,9 +36,13 @@ HEADER_FORMAT = "<HH"
|
|||||||
@decorate_view(require_game_enabled(path="place"))
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
def canvas(request: HttpRequest, event_path: str):
|
def canvas(request: HttpRequest, event_path: str):
|
||||||
"""The one-time full-grid load. Every placement after that arrives as a
|
"""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)
|
place_settings = PlaceSettings.current(request.event)
|
||||||
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
|
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)
|
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||||
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
||||||
|
|
||||||
@@ -47,6 +60,73 @@ 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 = ""):
|
||||||
|
"""Admin-only: how many times each pixel was placed, as a heatmap PNG.
|
||||||
|
|
||||||
|
`players` is an optional comma-separated list of usernames to only
|
||||||
|
count those players' placements (combined)."""
|
||||||
|
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||||
|
return HttpResponse(
|
||||||
|
build_heatmap_png(request.event, usernames), content_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/heatmap-evolution.gif", auth=django_auth_is_staff)
|
||||||
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
|
def heatmap_evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
|
||||||
|
"""Admin-only: the heatmap building up over time, as an animated GIF.
|
||||||
|
|
||||||
|
`players` is an optional comma-separated list of usernames to only
|
||||||
|
count those players' placements (combined)."""
|
||||||
|
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||||
|
return HttpResponse(
|
||||||
|
build_heatmap_evolution_gif(request.event, usernames),
|
||||||
|
content_type="image/gif",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/heatmap-decay.gif", auth=django_auth_is_staff)
|
||||||
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
|
def heatmap_decay_gif(
|
||||||
|
request: HttpRequest,
|
||||||
|
event_path: str,
|
||||||
|
players: str = "",
|
||||||
|
half_life: float | None = None,
|
||||||
|
mode: DecayMode = "placements",
|
||||||
|
):
|
||||||
|
"""Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||||
|
touches anymore fade back to white.
|
||||||
|
|
||||||
|
`players` is an optional comma-separated list of usernames to only
|
||||||
|
count those players' placements (combined). `mode` is "placements"
|
||||||
|
(decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||||
|
worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||||
|
to 60)."""
|
||||||
|
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||||
|
return HttpResponse(
|
||||||
|
build_heatmap_decay_gif(request.event, usernames, half_life, mode),
|
||||||
|
content_type="image/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)
|
@router.get(f"{PREFIX}/placements.csv", auth=django_auth_is_staff)
|
||||||
@decorate_view(require_game_enabled(path="place"))
|
@decorate_view(require_game_enabled(path="place"))
|
||||||
def placements_csv(request: HttpRequest, event_path: str):
|
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
|
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):
|
class PlaceConsumer(AsyncWebsocketConsumer):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
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"))
|
await self.send(text_data=encode("r", "a"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if place_settings.placement_ended:
|
||||||
|
await self.send(text_data=encode("r", "d"))
|
||||||
|
return
|
||||||
|
|
||||||
parts = text_data.split("|")
|
parts = text_data.split("|")
|
||||||
if len(parts) != 4:
|
if len(parts) != 4:
|
||||||
await self.send(text_data=encode("r", "i"))
|
await self.send(text_data=encode("r", "i"))
|
||||||
@@ -142,6 +150,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
|||||||
next_placement_at,
|
next_placement_at,
|
||||||
place_settings.width,
|
place_settings.width,
|
||||||
place_settings.height,
|
place_settings.height,
|
||||||
|
_epoch_ms_or_none(place_settings.placement_ends_at),
|
||||||
)
|
)
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
@@ -152,4 +161,14 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
|||||||
await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
|
await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
|
||||||
|
|
||||||
async def settings_changed(self, event):
|
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,240 @@
|
|||||||
|
"""Renders one event's pixel placement history as a heatmap.
|
||||||
|
|
||||||
|
Same source as gif.py (the durable PixelPlacement history), but instead of
|
||||||
|
replaying colors, counts how many times each pixel was placed and maps that
|
||||||
|
count to a white -> red gradient, so an admin can see
|
||||||
|
where the action was - either as a single PNG of the final counts, as an
|
||||||
|
animated GIF of the counts building up over time, or as an animated GIF
|
||||||
|
where the heat decays so only recent activity stays red.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from django.db.models import Count
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
from .gif import FINAL_FRAME_HOLD_MS, FRAME_DURATION_MS, MAX_FRAMES
|
||||||
|
from .models import PixelPlacement, PlaceSettings
|
||||||
|
|
||||||
|
SCALE = 8
|
||||||
|
|
||||||
|
# Gradient stops, from "never placed" to "most placed"
|
||||||
|
_GRADIENT = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(193, 1, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_heatmap_png(event: Event, usernames: list[str] | None = None) -> bytes:
|
||||||
|
"""Renders `event`'s placement counts per pixel as a PNG heatmap,
|
||||||
|
upscaled for visibility. Returns PNG bytes, even if there are zero
|
||||||
|
placements (all white).
|
||||||
|
|
||||||
|
If `usernames` is given, only those players' placements are counted
|
||||||
|
(combined on the same board).
|
||||||
|
|
||||||
|
Counts are log-scaled, otherwise a few heavily fought-over pixels make
|
||||||
|
everything else look white. Placements outside the *current* grid
|
||||||
|
dimensions are skipped, like in gif.py."""
|
||||||
|
place_settings = PlaceSettings.current(event)
|
||||||
|
width, height = place_settings.width, place_settings.height
|
||||||
|
|
||||||
|
placements = _placements(event, usernames)
|
||||||
|
counts = placements.values("x", "y").annotate(count=Count("id")).order_by()
|
||||||
|
|
||||||
|
grid = [0] * (width * height)
|
||||||
|
for row in counts:
|
||||||
|
if row["x"] >= width or row["y"] >= height:
|
||||||
|
continue
|
||||||
|
grid[row["y"] * width + row["x"]] = row["count"]
|
||||||
|
|
||||||
|
colors = _color_table(max(grid, default=0))
|
||||||
|
buffer = BytesIO()
|
||||||
|
_render_frame(grid, width, height, colors).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_heatmap_evolution_gif(
|
||||||
|
event: Event, usernames: list[str] | None = None
|
||||||
|
) -> bytes:
|
||||||
|
"""Same as build_heatmap_png, but as an animated GIF where the counts
|
||||||
|
accumulate placement by placement (sampled down to MAX_FRAMES, like
|
||||||
|
gif.py). Returns GIF bytes, even if there are zero placements.
|
||||||
|
|
||||||
|
Every frame uses the *final* max count for its color scale, so a pixel's
|
||||||
|
color only ever heats up - otherwise early frames would look as hot as
|
||||||
|
the end."""
|
||||||
|
place_settings = PlaceSettings.current(event)
|
||||||
|
width, height = place_settings.width, place_settings.height
|
||||||
|
|
||||||
|
placements = [
|
||||||
|
(x, y)
|
||||||
|
for x, y in _placements(event, usernames)
|
||||||
|
.order_by("placed_at")
|
||||||
|
.values_list("x", "y")
|
||||||
|
if x < width and y < height
|
||||||
|
]
|
||||||
|
|
||||||
|
final_grid = [0] * (width * height)
|
||||||
|
for x, y in placements:
|
||||||
|
final_grid[y * width + x] += 1
|
||||||
|
colors = _color_table(max(final_grid, default=0))
|
||||||
|
|
||||||
|
grid = [0] * (width * height)
|
||||||
|
frames: list[Image.Image] = []
|
||||||
|
|
||||||
|
step = max(1, len(placements) // MAX_FRAMES)
|
||||||
|
for i, (x, y) in enumerate(placements):
|
||||||
|
grid[y * width + x] += 1
|
||||||
|
if i % step == 0 or i == len(placements) - 1:
|
||||||
|
frames.append(_render_frame(grid, width, height, colors))
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
frames.append(_render_frame(grid, width, height, colors))
|
||||||
|
|
||||||
|
return _save_gif(frames)
|
||||||
|
|
||||||
|
|
||||||
|
DecayMode = Literal["placements", "time"]
|
||||||
|
|
||||||
|
DEFAULT_HALF_LIFE_MINUTES = 60
|
||||||
|
|
||||||
|
|
||||||
|
def build_heatmap_decay_gif(
|
||||||
|
event: Event,
|
||||||
|
usernames: list[str] | None = None,
|
||||||
|
half_life: float | None = None,
|
||||||
|
mode: DecayMode = "placements",
|
||||||
|
) -> bytes:
|
||||||
|
"""Like build_heatmap_evolution_gif, but every pixel's heat decays
|
||||||
|
exponentially, so a pixel nobody touches anymore fades back to white
|
||||||
|
and the GIF shows where the activity is *right now*.
|
||||||
|
|
||||||
|
`mode` picks what drives the decay (and the frame spacing):
|
||||||
|
- "placements": `half_life` is in placements (after that many more
|
||||||
|
placements anywhere on the board, a pixel's heat is halved), defaults
|
||||||
|
to 10 frames' worth. Frames are one every N placements, so quiet
|
||||||
|
periods take almost no frames and nothing fades during them.
|
||||||
|
- "time": `half_life` is in minutes, defaults to an hour. Frames are
|
||||||
|
evenly spaced in time between the first and last placement, so quiet
|
||||||
|
periods actually show the board fading out.
|
||||||
|
|
||||||
|
Every frame shares the same color scale (the hottest pixel across the
|
||||||
|
whole replay), so a quiet period actually looks quiet."""
|
||||||
|
place_settings = PlaceSettings.current(event)
|
||||||
|
width, height = place_settings.width, place_settings.height
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(placed_at, x, y)
|
||||||
|
for placed_at, x, y in _placements(event, usernames)
|
||||||
|
.order_by("placed_at")
|
||||||
|
.values_list("placed_at", "x", "y")
|
||||||
|
if x < width and y < height
|
||||||
|
]
|
||||||
|
|
||||||
|
# Both modes boil down to a "clock" per placement, the clock values to
|
||||||
|
# snapshot a frame at, and a half-life in that clock's unit.
|
||||||
|
if mode == "time":
|
||||||
|
clocks = [placed_at.timestamp() for placed_at, _, _ in rows]
|
||||||
|
frame_clocks = _evenly_spaced(clocks, MAX_FRAMES)
|
||||||
|
half_life_clock = (half_life or DEFAULT_HALF_LIFE_MINUTES) * 60
|
||||||
|
else:
|
||||||
|
clocks = list(range(len(rows)))
|
||||||
|
step = max(1, len(rows) // MAX_FRAMES)
|
||||||
|
frame_clocks = [i for i in clocks if i % step == 0 or i == len(rows) - 1]
|
||||||
|
half_life_clock = half_life or step * 10
|
||||||
|
decay = 0.5 ** (1 / max(1e-9, half_life_clock))
|
||||||
|
|
||||||
|
# Decay is applied lazily: each pixel stores its heat as of the clock it
|
||||||
|
# was last touched at, so a placement is O(1) instead of decaying the
|
||||||
|
# whole grid every time.
|
||||||
|
heat = [0.0] * (width * height)
|
||||||
|
touched_at = [0.0] * (width * height)
|
||||||
|
snapshots: list[list[float]] = []
|
||||||
|
placement_index = 0
|
||||||
|
for frame_clock in frame_clocks:
|
||||||
|
while placement_index < len(rows) and clocks[placement_index] <= frame_clock:
|
||||||
|
_, x, y = rows[placement_index]
|
||||||
|
clock = clocks[placement_index]
|
||||||
|
index = y * width + x
|
||||||
|
heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1
|
||||||
|
touched_at[index] = clock
|
||||||
|
placement_index += 1
|
||||||
|
snapshots.append(
|
||||||
|
[h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not snapshots:
|
||||||
|
snapshots.append(heat)
|
||||||
|
|
||||||
|
max_log = math.log1p(max(max(snapshot) for snapshot in snapshots)) or 1
|
||||||
|
colors = [_gradient(i / 255) for i in range(256)]
|
||||||
|
frames = []
|
||||||
|
for snapshot in snapshots:
|
||||||
|
image = Image.new("RGB", (width, height))
|
||||||
|
image.putdata([colors[round(math.log1p(h) / max_log * 255)] for h in snapshot])
|
||||||
|
frames.append(image.resize((width * SCALE, height * SCALE), Image.NEAREST))
|
||||||
|
|
||||||
|
return _save_gif(frames)
|
||||||
|
|
||||||
|
|
||||||
|
def _evenly_spaced(clocks: list[float], count: int) -> list[float]:
|
||||||
|
"""`count` clock values evenly spread from the first to the last one
|
||||||
|
(just the one if they're all the same, none if `clocks` is empty)."""
|
||||||
|
if not clocks:
|
||||||
|
return []
|
||||||
|
first, last = clocks[0], clocks[-1]
|
||||||
|
if first == last:
|
||||||
|
return [first]
|
||||||
|
return [first + (last - first) * i / (count - 1) for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def _save_gif(frames: list[Image.Image]) -> bytes:
|
||||||
|
buffer = BytesIO()
|
||||||
|
durations = [FRAME_DURATION_MS] * len(frames)
|
||||||
|
durations[-1] = FINAL_FRAME_HOLD_MS
|
||||||
|
frames[0].save(
|
||||||
|
buffer,
|
||||||
|
format="GIF",
|
||||||
|
save_all=True,
|
||||||
|
append_images=frames[1:],
|
||||||
|
duration=durations,
|
||||||
|
loop=0,
|
||||||
|
disposal=2,
|
||||||
|
)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _placements(event: Event, usernames: list[str] | None):
|
||||||
|
placements = PixelPlacement.objects.filter(event=event)
|
||||||
|
if usernames:
|
||||||
|
placements = placements.filter(player__username__in=usernames)
|
||||||
|
return placements
|
||||||
|
|
||||||
|
|
||||||
|
def _color_table(max_count: int) -> list[tuple[int, int, int]]:
|
||||||
|
"""Color for every count in [0, max_count], log-scaled."""
|
||||||
|
max_log = math.log1p(max_count) or 1
|
||||||
|
return [_gradient(math.log1p(count) / max_log) for count in range(max_count + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_frame(
|
||||||
|
grid: list[int], width: int, height: int, colors: list[tuple[int, int, int]]
|
||||||
|
) -> Image.Image:
|
||||||
|
image = Image.new("RGB", (width, height))
|
||||||
|
image.putdata([colors[count] for count in grid])
|
||||||
|
return image.resize((width * SCALE, height * SCALE), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
|
def _gradient(t: float) -> tuple[int, int, int]:
|
||||||
|
"""Linearly interpolates `_GRADIENT` at `t` in [0, 1]."""
|
||||||
|
position = t * (len(_GRADIENT) - 1)
|
||||||
|
index = min(int(position), len(_GRADIENT) - 2)
|
||||||
|
frac = position - index
|
||||||
|
start, end = _GRADIENT[index], _GRADIENT[index + 1]
|
||||||
|
r, g, b = (round(a + (z - a) * frac) for a, z in zip(start, end))
|
||||||
|
return r, g, b
|
||||||
@@ -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.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from events.scoping import EventScopedManager
|
from events.scoping import EventScopedManager
|
||||||
@@ -47,10 +48,22 @@ class PlaceSettings(models.Model):
|
|||||||
)
|
)
|
||||||
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
|
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
|
||||||
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT)
|
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):
|
def __str__(self):
|
||||||
return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
|
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
|
@classmethod
|
||||||
def current(cls, event: Event) -> "PlaceSettings":
|
def current(cls, event: Event) -> "PlaceSettings":
|
||||||
obj, _ = cls.objects.get_or_create(event=event)
|
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)
|
1. Rebuilding that event's Redis canvas from history at the (possibly new)
|
||||||
dimensions - a resize invalidates the old Redis bytes (wrong length,
|
dimensions - a resize invalidates the old Redis bytes (wrong length,
|
||||||
wrong meaning), so this keeps Redis self-healing instead of relying on
|
wrong meaning), so this keeps Redis self-healing instead of relying on
|
||||||
the admin remembering to click "Resync canvas from DB" after every
|
the admin remembering to click "Resync canvas from DB" after every
|
||||||
resize.
|
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
|
the clock app, place has no periodic heartbeat broadcast to piggyback
|
||||||
this onto, so it's broadcast explicitly here instead.
|
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
|
from asgiref.sync import async_to_sync
|
||||||
@@ -18,11 +19,14 @@ from django.dispatch import receiver
|
|||||||
from .broadcast import place_group
|
from .broadcast import place_group
|
||||||
from .canvas import rebuild_canvas_from_history
|
from .canvas import rebuild_canvas_from_history
|
||||||
from .models import PlaceSettings
|
from .models import PlaceSettings
|
||||||
|
from .tasks import schedule_wipe
|
||||||
|
from .wire import to_epoch_ms
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=PlaceSettings)
|
@receiver(post_save, sender=PlaceSettings)
|
||||||
def on_settings_saved(sender, instance, **kwargs):
|
def on_settings_saved(sender, instance, **kwargs):
|
||||||
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
|
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
|
||||||
|
schedule_wipe(instance)
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
if channel_layer is None:
|
if channel_layer is None:
|
||||||
@@ -33,5 +37,10 @@ def on_settings_saved(sender, instance, **kwargs):
|
|||||||
"type": "settings_changed",
|
"type": "settings_changed",
|
||||||
"width": instance.width,
|
"width": instance.width,
|
||||||
"height": instance.height,
|
"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
|
'|'-separated fields, in a fixed order the client already knows from this
|
||||||
table:
|
table:
|
||||||
|
|
||||||
s|<next>|<width>|<height> initial personal state, once per connection
|
s|<next>|<width>|<height>|<end>
|
||||||
(next = epoch ms cooldown ends, or empty if free now)
|
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,
|
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
|
||||||
including the placer (no separate ack needed)
|
including the placer (no separate ack needed)
|
||||||
k|<next> your own placement was accepted; carries your
|
k|<next> your own placement was accepted; carries your
|
||||||
@@ -15,10 +17,16 @@ table:
|
|||||||
r|c|<next> rejected: still on cooldown
|
r|c|<next> rejected: still on cooldown
|
||||||
r|a rejected: not logged in
|
r|a rejected: not logged in
|
||||||
r|i rejected: invalid x/y/color
|
r|i rejected: invalid x/y/color
|
||||||
e|<width>|<height> an admin resized the grid for this event -
|
r|d rejected: placement is over (past <end>)
|
||||||
broadcast to everyone. A client that sees this
|
e|<width>|<height>|<end>
|
||||||
should re-fetch /api/.../place/canvas rather than
|
an admin edited this event's settings (grid size
|
||||||
try to remap its local pixels.
|
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:
|
Client -> server has exactly one message shape, the same as the broadcast:
|
||||||
p|<x>|<y>|<c>
|
p|<x>|<y>|<c>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from "vue-i18n";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useTitle } from "@vueuse/core";
|
||||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||||
import { PALETTE } from "@/palette";
|
import { PALETTE } from "@/palette";
|
||||||
|
import { playWipeWave } from "@/placeWipe";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventPath: string;
|
eventPath: string;
|
||||||
@@ -40,14 +41,26 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
|||||||
pixelCtx.fillRect(x, y, 1, 1);
|
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 {
|
const {
|
||||||
connected,
|
connected,
|
||||||
cooldownRemainingMs,
|
cooldownRemainingMs,
|
||||||
|
placementEndMs,
|
||||||
|
placementEnded,
|
||||||
cooldownDurationSeconds,
|
cooldownDurationSeconds,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
gridHeight,
|
gridHeight,
|
||||||
place,
|
place,
|
||||||
} = usePlaceSocket(props.eventPath, drawPixel);
|
} = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe);
|
||||||
useLiveBadge("place", connected);
|
useLiveBadge("place", connected);
|
||||||
|
|
||||||
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
|
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 cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
|
||||||
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
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
|
// 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
|
// 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;
|
const { x, y } = hoveredCell.value;
|
||||||
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
|
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
|
||||||
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
|
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(() => {
|
onMounted(() => {
|
||||||
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
|
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
|
||||||
@@ -125,7 +144,7 @@ function handleMouseLeave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
if (onCooldown.value) return;
|
if (!canPlace.value) return;
|
||||||
const { x, y } = cellFromEvent(event);
|
const { x, y } = cellFromEvent(event);
|
||||||
place(x, y, selectedColorIndex.value);
|
place(x, y, selectedColorIndex.value);
|
||||||
}
|
}
|
||||||
@@ -142,7 +161,7 @@ function handleClick(event: MouseEvent) {
|
|||||||
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
|
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
|
||||||
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
|
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
|
||||||
imageRendering: 'pixelated',
|
imageRendering: 'pixelated',
|
||||||
cursor: onCooldown ? 'not-allowed' : 'crosshair',
|
cursor: canPlace ? 'crosshair' : 'not-allowed',
|
||||||
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
|
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
|
||||||
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
|
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
|
||||||
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
|
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">
|
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
|
||||||
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
|
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
|
||||||
</button>
|
</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>
|
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
|
||||||
<progress class="progress progress-primary w-48"
|
<progress class="progress progress-primary w-48"
|
||||||
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||||
</template>
|
</template>
|
||||||
|
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
|
||||||
|
{{ t("place.endsAt", { time: placementEndDisplay }) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||||
import { PALETTE } from "@/palette";
|
import { PALETTE } from "@/palette";
|
||||||
|
import { playWipeWave } from "@/placeWipe";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventPath: string;
|
eventPath: string;
|
||||||
@@ -22,7 +23,22 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
|||||||
ctx.fillRect(x, y, 1, 1);
|
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(() => {
|
onMounted(() => {
|
||||||
ctx = canvasRef.value?.getContext("2d") ?? null;
|
ctx = canvasRef.value?.getContext("2d") ?? null;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
|
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
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, placeApiPlacementsCsv, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.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, 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 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> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||||
@@ -876,7 +876,8 @@ export const placeApiCanvasQueryKey = (options: Options<PlaceApiCanvasData>) =>
|
|||||||
* Canvas
|
* Canvas
|
||||||
*
|
*
|
||||||
* The one-time full-grid load. Every placement after that arrives as a
|
* 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>>({
|
export const placeApiCanvasOptions = (options: Options<PlaceApiCanvasData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiCanvasQueryKey>>({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
@@ -914,6 +915,121 @@ export const placeApiEvolutionGifOptions = (options: Options<PlaceApiEvolutionGi
|
|||||||
queryKey: placeApiEvolutionGifQueryKey(options)
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heatmap Png
|
||||||
|
*
|
||||||
|
* Admin-only: how many times each pixel was placed, as a heatmap PNG.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapPngOptions = (options: Options<PlaceApiHeatmapPngData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapPngQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await placeApiHeatmapPng({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: placeApiHeatmapPngQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placeApiHeatmapEvolutionGifQueryKey = (options: Options<PlaceApiHeatmapEvolutionGifData>) => createQueryKey('placeApiHeatmapEvolutionGif', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heatmap Evolution Gif
|
||||||
|
*
|
||||||
|
* Admin-only: the heatmap building up over time, as an animated GIF.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapEvolutionGifOptions = (options: Options<PlaceApiHeatmapEvolutionGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapEvolutionGifQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await placeApiHeatmapEvolutionGif({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: placeApiHeatmapEvolutionGifQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placeApiHeatmapDecayGifQueryKey = (options: Options<PlaceApiHeatmapDecayGifData>) => createQueryKey('placeApiHeatmapDecayGif', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heatmap Decay Gif
|
||||||
|
*
|
||||||
|
* Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||||
|
* touches anymore fade back to white.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined). `mode` is "placements"
|
||||||
|
* (decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||||
|
* worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||||
|
* to 60).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapDecayGifOptions = (options: Options<PlaceApiHeatmapDecayGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapDecayGifQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await placeApiHeatmapDecayGif({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
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);
|
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 { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
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, 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> & {
|
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
|
* Canvas
|
||||||
*
|
*
|
||||||
* The one-time full-grid load. Every placement after that arrives as a
|
* 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 });
|
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,96 @@ export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(optio
|
|||||||
...options
|
...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
|
||||||
|
*
|
||||||
|
* Admin-only: how many times each pixel was placed, as a heatmap PNG.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapPngResponses, unknown, ThrowOnError>({
|
||||||
|
security: [{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}],
|
||||||
|
url: '/api/events/{event_path}/place/heatmap.png',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heatmap Evolution Gif
|
||||||
|
*
|
||||||
|
* Admin-only: the heatmap building up over time, as an animated GIF.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapEvolutionGifResponses, unknown, ThrowOnError>({
|
||||||
|
security: [{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}],
|
||||||
|
url: '/api/events/{event_path}/place/heatmap-evolution.gif',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heatmap Decay Gif
|
||||||
|
*
|
||||||
|
* Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||||
|
* touches anymore fade back to white.
|
||||||
|
*
|
||||||
|
* `players` is an optional comma-separated list of usernames to only
|
||||||
|
* count those players' placements (combined). `mode` is "placements"
|
||||||
|
* (decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||||
|
* worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||||
|
* to 60).
|
||||||
|
*/
|
||||||
|
export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
|
||||||
|
security: [{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}],
|
||||||
|
url: '/api/events/{event_path}/place/heatmap-decay.gif',
|
||||||
|
...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
|
* Placements Csv
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1264,6 +1264,144 @@ export type PlayerStatsOut = {
|
|||||||
points_below: number | null;
|
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
|
* MarketListSchema
|
||||||
*/
|
*/
|
||||||
@@ -2743,6 +2881,126 @@ export type PlaceApiEvolutionGifResponses = {
|
|||||||
200: unknown;
|
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: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Players
|
||||||
|
*/
|
||||||
|
players?: string;
|
||||||
|
};
|
||||||
|
url: '/api/events/{event_path}/place/heatmap.png';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiHeatmapPngResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiHeatmapEvolutionGifData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Players
|
||||||
|
*/
|
||||||
|
players?: string;
|
||||||
|
};
|
||||||
|
url: '/api/events/{event_path}/place/heatmap-evolution.gif';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiHeatmapEvolutionGifResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiHeatmapDecayGifData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Players
|
||||||
|
*/
|
||||||
|
players?: string;
|
||||||
|
/**
|
||||||
|
* Half Life
|
||||||
|
*/
|
||||||
|
half_life?: number | null;
|
||||||
|
/**
|
||||||
|
* Mode
|
||||||
|
*/
|
||||||
|
mode?: 'placements' | 'time';
|
||||||
|
};
|
||||||
|
url: '/api/events/{event_path}/place/heatmap-decay.gif';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaceApiHeatmapDecayGifResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
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 = {
|
export type PlaceApiPlacementsCsvData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PLACE_COOLDOWN_SECONDS = 30
|
|||||||
|
|
||||||
export type OnPixel = (x: number, y: number, colorIndex: number) => void
|
export type OnPixel = (x: number, y: number, colorIndex: number) => void
|
||||||
export type OnResize = (width: number, height: number) => void
|
export type OnResize = (width: number, height: number) => void
|
||||||
|
export type OnWipe = () => void
|
||||||
|
|
||||||
// Plain (non-reactive) - a several-thousand-cell reactive array is
|
// Plain (non-reactive) - a several-thousand-cell reactive array is
|
||||||
// pointless overhead. Kept module-level, alongside its dimensions, so the
|
// 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 gridWidth = ref(DEFAULT_GRID_SIZE)
|
||||||
const gridHeight = 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 cooldownEndMs = ref<number | null>(null)
|
||||||
const cooldownRemainingMs = ref(0)
|
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
|
let animationFrame: number | null = null
|
||||||
|
|
||||||
@@ -36,9 +46,23 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
|||||||
cooldownEndMs.value = nextMs ?? null
|
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() {
|
function tickCooldown() {
|
||||||
|
const now = Date.now()
|
||||||
cooldownRemainingMs.value =
|
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)
|
animationFrame = requestAnimationFrame(tickCooldown)
|
||||||
}
|
}
|
||||||
@@ -65,9 +89,10 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
|||||||
|
|
||||||
switch (parts[0]) {
|
switch (parts[0]) {
|
||||||
case 's': {
|
case 's': {
|
||||||
const [, next, width, height] = parts
|
const [, next, width, height, end] = parts
|
||||||
applyCooldown(next !== '' ? Number(next) : null)
|
applyCooldown(next !== '' ? Number(next) : null)
|
||||||
applyGridSize(Number(width), Number(height))
|
applyGridSize(Number(width), Number(height))
|
||||||
|
applyPlacementEnd(end)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'p': {
|
case 'p': {
|
||||||
@@ -81,14 +106,24 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
|||||||
}
|
}
|
||||||
case 'r': {
|
case 'r': {
|
||||||
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
|
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
|
||||||
|
if (parts[1] === 'd') placementEnded.value = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'e': {
|
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
|
// The server already rebuilt its canvas at the new size (see
|
||||||
// place/signals.py) - just re-fetch it rather than guess how to
|
// place/signals.py) - just re-fetch it rather than guess how to
|
||||||
// remap our local pixels.
|
// 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
|
break
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -151,6 +186,8 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
|||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
cooldownRemainingMs,
|
cooldownRemainingMs,
|
||||||
|
placementEndMs,
|
||||||
|
placementEnded,
|
||||||
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
|
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
gridHeight,
|
gridHeight,
|
||||||
|
|||||||
@@ -97,7 +97,9 @@
|
|||||||
"pageTitle": "Place",
|
"pageTitle": "Place",
|
||||||
"header": "Place",
|
"header": "Place",
|
||||||
"showGrid": "Show grid",
|
"showGrid": "Show grid",
|
||||||
"hideGrid": "Hide grid"
|
"hideGrid": "Hide grid",
|
||||||
|
"placementEnded": "Pixel placement is over",
|
||||||
|
"endsAt": "Ends at {time}"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"pageTitle": "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