139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
import csv
|
|
import struct
|
|
|
|
from django.db.models import Count
|
|
from django.http import HttpRequest, HttpResponse
|
|
from ninja import Router
|
|
from ninja.decorators import decorate_view
|
|
from ninja.security import django_auth_is_staff
|
|
|
|
from games.decorators import require_game_enabled
|
|
|
|
from .canvas import read_canvas
|
|
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 .schemas import PlaceStatsOut
|
|
from .stats import build_place_stats
|
|
|
|
router = Router()
|
|
|
|
PREFIX = "/events/{event_path}/place"
|
|
|
|
# 4-byte little-endian header (uint16 width, uint16 height) prefixed to the
|
|
# canvas payload - the grid can be resized by an admin, so the frontend
|
|
# can't hardcode dimensions and needs to read them off the payload itself.
|
|
HEADER_FORMAT = "<HH"
|
|
|
|
|
|
@router.get(f"{PREFIX}/canvas")
|
|
@decorate_view(require_game_enabled(path="place"))
|
|
def canvas(request: HttpRequest, event_path: str):
|
|
"""The one-time full-grid load. Every placement after that arrives as a
|
|
single websocket message, never a re-fetch."""
|
|
place_settings = PlaceSettings.current(request.event)
|
|
header = struct.pack(HEADER_FORMAT, 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")
|
|
|
|
|
|
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
|
|
@decorate_view(require_game_enabled(path="place"))
|
|
def evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
|
|
"""Admin-only: replays the full placement history as an animated GIF.
|
|
|
|
`players` is an optional comma-separated list of usernames to only
|
|
replay those players' placements (combined)."""
|
|
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
|
return HttpResponse(
|
|
build_evolution_gif(request.event, usernames), content_type="image/gif"
|
|
)
|
|
|
|
|
|
@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)
|
|
@decorate_view(require_game_enabled(path="place"))
|
|
def placements_csv(request: HttpRequest, event_path: str):
|
|
"""Admin-only: pixel placement count per player, most active first."""
|
|
rows = (
|
|
PixelPlacement.objects.filter(event=request.event)
|
|
.values("player__username")
|
|
.annotate(count=Count("id"))
|
|
.order_by("-count", "player__username")
|
|
)
|
|
# 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)
|
|
writer.writerow(["username", "placements"])
|
|
for row in rows:
|
|
writer.writerow(
|
|
[
|
|
row["player__username"],
|
|
row["count"],
|
|
]
|
|
)
|
|
return response
|